import os import shutil import traceback import json import uuid import base64 import hmac import hashlib import time from datetime import datetime from fastapi import FastAPI, Form, File, UploadFile, HTTPException, Depends, Header from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse, StreamingResponse from typing import Optional, List from pydantic import BaseModel import queue import threading from openai import OpenAI from src.config import config, resolve_ai_client from src.agents.graph import graph from src.ingestion.build_index import build_index from src.ingestion.manifest import load_manifest app = FastAPI(title="Carja Intelligence API") app.add_middleware( CORSMiddleware, allow_origins=["*"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) @app.exception_handler(Exception) async def global_exception_handler(request, exc): """Catch unhandled errors so uvicorn doesn't return bare HTML.""" traceback.print_exc() return JSONResponse( status_code=500, content={ "review_id": None, "synthesis": "", "design_review": {}, "evidence": [], "validation_passed": False, "errors": [f"Unhandled server error: {str(exc)}"] } ) @app.get("/health") async def health_check(): return {"status": "ok"} # Authentication configuration & utilities JWT_SECRET_KEY = os.getenv("JWT_SECRET_KEY", "super-secret-key-artitude-change-me-in-prod") def encode_jwt(payload: dict, secret: str = JWT_SECRET_KEY, algorithm: str = "HS256") -> str: header = {"alg": algorithm, "typ": "JWT"} header_b64 = base64.urlsafe_b64encode(json.dumps(header).encode()).decode().rstrip("=") payload_b64 = base64.urlsafe_b64encode(json.dumps(payload).encode()).decode().rstrip("=") signature_input = f"{header_b64}.{payload_b64}".encode() if algorithm == "HS256": signature = hmac.new(secret.encode(), signature_input, hashlib.sha256).digest() else: raise ValueError("Unsupported algorithm") signature_b64 = base64.urlsafe_b64encode(signature).decode().rstrip("=") return f"{header_b64}.{payload_b64}.{signature_b64}" def decode_jwt(token: str, secret: str = JWT_SECRET_KEY) -> dict: try: parts = token.split(".") if len(parts) != 3: raise ValueError("Invalid token structure") header_b64, payload_b64, signature_b64 = parts signature_input = f"{header_b64}.{payload_b64}".encode() def pad(s): return s + "=" * (4 - len(s) % 4) expected_sig = hmac.new(secret.encode(), signature_input, hashlib.sha256).digest() expected_sig_b64 = base64.urlsafe_b64encode(expected_sig).decode().rstrip("=") if not hmac.compare_digest(signature_b64.encode(), expected_sig_b64.encode()): raise ValueError("Signature mismatch") payload = json.loads(base64.urlsafe_b64decode(pad(payload_b64)).decode()) if "exp" in payload and payload["exp"] < time.time(): raise ValueError("Token expired") return payload except Exception: raise ValueError("Invalid token") def hash_password(password: str) -> str: salt = os.urandom(16) key = hashlib.pbkdf2_hmac('sha256', password.encode('utf-8'), salt, 100000) return f"{salt.hex()}:{key.hex()}" def verify_password(stored_password: str, provided_password: str) -> bool: try: salt_hex, key_hex = stored_password.split(":") salt = bytes.fromhex(salt_hex) key = bytes.fromhex(key_hex) new_key = hashlib.pbkdf2_hmac('sha256', provided_password.encode('utf-8'), salt, 100000) return hmac.compare_digest(key, new_key) except Exception: return False TEMP_DIR = "data/temp" PROJECTS_DIR = "data/projects" PROJECTS_DB = "data/projects.json" USERS_DB = "data/users.json" os.makedirs(TEMP_DIR, exist_ok=True) os.makedirs(PROJECTS_DIR, exist_ok=True) class ProjectCreate(BaseModel): name: str class UserAuth(BaseModel): username: str password: str class BrandKit(BaseModel): primary_colors: List[str] secondary_colors: List[str] typography: List[str] clearance_rules: str class CompetitorKit(BaseModel): name: str primary_colors: List[str] secondary_colors: List[str] typography: List[str] def get_projects_db(): if not os.path.exists(PROJECTS_DB): return [] with open(PROJECTS_DB, "r") as f: try: return json.load(f) except Exception: return [] def save_projects_db(projects): with open(PROJECTS_DB, "w") as f: json.dump(projects, f, indent=2) def get_users_db(): if not os.path.exists(USERS_DB): return [] with open(USERS_DB, "r") as f: try: return json.load(f) except Exception: return [] def save_users_db(users): with open(USERS_DB, "w") as f: json.dump(users, f, indent=2) async def get_current_user(authorization: Optional[str] = Header(None)) -> str: if not authorization or not authorization.startswith("Bearer "): raise HTTPException(status_code=401, detail="Not authenticated") token = authorization.split(" ")[1] try: payload = decode_jwt(token) user_id = payload.get("sub") if not user_id: raise HTTPException(status_code=401, detail="Invalid token payload") users = get_users_db() if not any(u["id"] == user_id for u in users): raise HTTPException(status_code=401, detail="User not found") return user_id except Exception: raise HTTPException(status_code=401, detail="Invalid or expired token") def verify_project_ownership(project_id: str, user_id: str): projects = get_projects_db() for p in projects: if p["id"] == project_id: if "user_id" not in p: p["user_id"] = user_id save_projects_db(projects) if p["user_id"] == user_id: return p else: raise HTTPException(status_code=403, detail="Not authorized to access this project") raise HTTPException(status_code=404, detail="Project not found") def get_project_paths(project_id: str): base_dir = os.path.join(PROJECTS_DIR, project_id) guidelines_dir = os.path.join(base_dir, "guidelines") stats_file = os.path.join(base_dir, "stats.json") reviews_dir = os.path.join(base_dir, "reviews") os.makedirs(guidelines_dir, exist_ok=True) os.makedirs(reviews_dir, exist_ok=True) return base_dir, guidelines_dir, stats_file, reviews_dir def get_stats(stats_file: str): if not os.path.exists(stats_file): return { "designs_reviewed": 0, "enhancements_suggested": 0, "total_health_score": 0 } with open(stats_file, "r") as f: return json.load(f) # Auth Endpoints @app.post("/api/auth/register") async def register_user(data: UserAuth): username_clean = data.username.strip() if len(username_clean) < 3: raise HTTPException(status_code=400, detail="Username must be at least 3 characters") if len(data.password) < 6: raise HTTPException(status_code=400, detail="Password must be at least 6 characters") users = get_users_db() if any(u["username"].lower() == username_clean.lower() for u in users): raise HTTPException(status_code=400, detail="Username already exists") user_id = str(uuid.uuid4()) new_user = { "id": user_id, "username": username_clean, "password_hash": hash_password(data.password), "created_at": datetime.now().isoformat() } users.append(new_user) save_users_db(users) token = encode_jwt({"sub": user_id, "exp": time.time() + 7 * 24 * 3600}) return {"access_token": token, "token_type": "bearer", "username": username_clean} @app.post("/api/auth/login") async def login_user(data: UserAuth): username_clean = data.username.strip() users = get_users_db() for u in users: if u["username"].lower() == username_clean.lower(): if verify_password(u["password_hash"], data.password): token = encode_jwt({"sub": u["id"], "exp": time.time() + 7 * 24 * 3600}) return {"access_token": token, "token_type": "bearer", "username": u["username"]} break raise HTTPException(status_code=400, detail="Invalid username or password") @app.get("/api/auth/me") async def get_me(user_id: str = Depends(get_current_user)): users = get_users_db() for u in users: if u["id"] == user_id: return {"id": u["id"], "username": u["username"]} raise HTTPException(status_code=404, detail="User not found") # Projects Endpoints @app.post("/api/projects") async def create_project(data: ProjectCreate, user_id: str = Depends(get_current_user)): projects = get_projects_db() project_id = str(uuid.uuid4()) new_project = { "id": project_id, "name": data.name, "user_id": user_id, "created_at": datetime.now().isoformat() } projects.append(new_project) save_projects_db(projects) # set up the project dirs get_project_paths(project_id) return new_project @app.get("/api/projects") async def list_projects(user_id: str = Depends(get_current_user)): projects = get_projects_db() modified = False user_projects = [] for p in projects: if "user_id" not in p: p["user_id"] = user_id modified = True if p["user_id"] == user_id: user_projects.append(p) if modified: save_projects_db(projects) return {"projects": user_projects} class ProjectUpdate(BaseModel): name: str @app.put("/api/projects/{project_id}") async def rename_project(project_id: str, data: ProjectUpdate, user_id: str = Depends(get_current_user)): projects = get_projects_db() for p in projects: if p["id"] == project_id: if "user_id" not in p: p["user_id"] = user_id if p["user_id"] != user_id: raise HTTPException(status_code=403, detail="Not authorized to access this project") p["name"] = data.name save_projects_db(projects) return p raise HTTPException(status_code=404, detail="Project not found") @app.delete("/api/projects/{project_id}") async def delete_project(project_id: str, user_id: str = Depends(get_current_user)): projects = get_projects_db() target_project = None for p in projects: if p["id"] == project_id: target_project = p break if not target_project: raise HTTPException(status_code=404, detail="Project not found") if "user_id" not in target_project: target_project["user_id"] = user_id if target_project["user_id"] != user_id: raise HTTPException(status_code=403, detail="Not authorized to access this project") updated_projects = [p for p in projects if p["id"] != project_id] save_projects_db(updated_projects) base_dir = os.path.join(PROJECTS_DIR, project_id) if os.path.exists(base_dir): try: shutil.rmtree(base_dir) # also nuke the chroma collection from src.retrieval.chroma_store import ChromaStore try: client = ChromaStore.get_client() client.delete_collection(name=project_id) except Exception: pass except Exception as e: print(f"Failed to delete project directory: {e}") return {"status": "success"} @app.post("/api/projects/{project_id}/analyze") async def review_design( project_id: str, query: str = Form(""), image: Optional[UploadFile] = File(None), thread_id: Optional[str] = Form(None), ai_provider: Optional[str] = Form(None), ai_api_key: Optional[str] = Form(None), user_id: str = Depends(get_current_user), ): verify_project_ownership(project_id, user_id) _, _, stats_file, reviews_dir = get_project_paths(project_id) # grab the project name so the AI knows which brand it's looking at project_name = project_id # fallback projects = get_projects_db() for p in projects: if p["id"] == project_id: project_name = p["name"] break image_path = None if image and image.filename: image_path = os.path.join(TEMP_DIR, f"{uuid.uuid4()}_{image.filename}") with open(image_path, "wb") as buffer: shutil.copyfileobj(image.file, buffer) q = queue.Queue() def background_task(): try: state = { "query": query, "image_path": image_path, "project_id": project_id, "project_name": project_name, "recursion_count": 0, "errors": [] } # thread id for conversation memory actual_thread_id = thread_id if thread_id else str(uuid.uuid4()) config = { "configurable": { "thread_id": actual_thread_id, "queue": q, "ai_provider": ai_provider, "ai_api_key": ai_api_key, } } final_state = graph.invoke(state, config=config) design_review = final_state.get("design_review", {}) review_id = str(uuid.uuid4()) try: stats = get_stats(stats_file) stats["designs_reviewed"] += 1 stats["enhancements_suggested"] += len(design_review.get("enhancements", {}).get("suggestions", [])) score = design_review.get("brand_consistency", {}).get("consistency_score", 0) stats["total_health_score"] += score if "recent_scores" not in stats: stats["recent_scores"] = [] stats["recent_scores"].append(score) if len(stats["recent_scores"]) > 10: stats["recent_scores"] = stats["recent_scores"][-10:] top_issue = "No major issues" suggestions = design_review.get("enhancements", {}).get("suggestions", []) if suggestions: top_issue = suggestions[0].get("title", top_issue) import time stats["last_review"] = { "filename": image.filename if (image and hasattr(image, 'filename')) else "Pasted Image", "top_issue": top_issue, "timestamp": int(time.time()), "review_id": review_id } with open(stats_file, "w") as f: json.dump(stats, f) except Exception as e: print(f"Failed to update stats: {e}") response_data = { "review_id": review_id, "thread_id": actual_thread_id, "synthesis": final_state.get("raw_synthesis", ""), "design_review": design_review, "evidence": final_state.get("retrieved_context", []), "validation_passed": final_state.get("validation_passed", False), "errors": final_state.get("errors", []) } # save to review history with open(os.path.join(reviews_dir, f"{review_id}.json"), "w") as f: json.dump(response_data, f, indent=2) q.put({"type": "final", "data": response_data}) except Exception as e: traceback.print_exc() q.put({"type": "error", "data": str(e)}) finally: if image_path and os.path.exists(image_path): os.remove(image_path) q.put(None) threading.Thread(target=background_task).start() def event_generator(): while True: item = q.get() if item is None: break if item["type"] == "token": yield f"data: {json.dumps({'type': 'token', 'content': item['data']})}\n\n" elif item["type"] == "final": yield f"data: {json.dumps({'type': 'final', 'data': item['data']})}\n\n" elif item["type"] == "error": yield f"data: {json.dumps({'type': 'error', 'error': item['data']})}\n\n" return StreamingResponse(event_generator(), media_type="text/event-stream") @app.post("/api/projects/{project_id}/ingest") async def ingest_guideline(project_id: str, file: UploadFile = File(...), ai_provider: Optional[str] = Form(None), ai_api_key: Optional[str] = Form(None), user_id: str = Depends(get_current_user)): verify_project_ownership(project_id, user_id) if not file.filename: return JSONResponse(status_code=400, content={"error": "No file provided."}) allowed_extensions = ('.pdf', '.md', '.txt') ext = os.path.splitext(file.filename)[1].lower() if ext not in allowed_extensions: return JSONResponse( status_code=400, content={"error": f"Unsupported file type '{ext}'. Allowed: {', '.join(allowed_extensions)}"} ) base_dir, guidelines_dir, _, _ = get_project_paths(project_id) file_path = os.path.join(guidelines_dir, file.filename) try: with open(file_path, "wb") as buffer: shutil.copyfileobj(file.file, buffer) # pull text out of the doc for brand kit extraction text = "" if ext == '.pdf': import pypdf reader = pypdf.PdfReader(file_path) for page in reader.pages: text += page.extract_text() + "\n" else: with open(file_path, "r", encoding="utf-8") as f: text = f.read() # ask openai to pull out brand kit info try: ingest_client, ingest_model = resolve_ai_client(ai_provider, ai_api_key) response = ingest_client.beta.chat.completions.parse( model=ingest_model, messages=[ {"role": "system", "content": "Extract the brand kit information (primary colors, secondary colors, typography fonts, and clearance/logo spacing rules) from the following text. Colors must be hex codes."}, {"role": "user", "content": text[:30000]} ], response_format=BrandKit ) brand_kit_data = response.choices[0].message.parsed.model_dump() with open(os.path.join(base_dir, "brand_kit.json"), "w") as f: json.dump(brand_kit_data, f, indent=2) except Exception as extract_err: print(f"Failed to extract brand kit: {extract_err}") build_index(guidelines_dir, project_id) return { "status": "success", "message": f"Successfully ingested '{file.filename}' into project.", "filename": file.filename } except Exception as e: traceback.print_exc() return JSONResponse( status_code=500, content={"error": f"Ingestion failed: {str(e)}"} ) @app.get("/api/projects/{project_id}/guidelines") async def list_guidelines(project_id: str, user_id: str = Depends(get_current_user)): verify_project_ownership(project_id, user_id) try: base_dir, guidelines_dir, _, _ = get_project_paths(project_id) files = [] if os.path.exists(guidelines_dir): for filename in os.listdir(guidelines_dir): filepath = os.path.join(guidelines_dir, filename) if os.path.isfile(filepath): stat = os.stat(filepath) files.append({ "filename": filename, "size_bytes": stat.st_size, "modified_at": stat.st_mtime, }) return {"guidelines": files} except Exception as e: traceback.print_exc() return JSONResponse( status_code=500, content={"error": f"Failed to list guidelines: {str(e)}"} ) @app.get("/api/projects/{project_id}/brand_kit") async def get_brand_kit(project_id: str, user_id: str = Depends(get_current_user)): verify_project_ownership(project_id, user_id) try: base_dir, _, _, _ = get_project_paths(project_id) brand_kit_path = os.path.join(base_dir, "brand_kit.json") if os.path.exists(brand_kit_path): with open(brand_kit_path, "r") as f: return json.load(f) return JSONResponse(status_code=404, content={"error": "Brand kit not found."}) except Exception as e: traceback.print_exc() return JSONResponse( status_code=500, content={"error": f"Failed to get brand kit: {str(e)}"} ) @app.get("/api/projects/{project_id}/stats") async def get_dashboard_stats(project_id: str, user_id: str = Depends(get_current_user)): verify_project_ownership(project_id, user_id) try: base_dir, guidelines_dir, stats_file, _ = get_project_paths(project_id) stats = get_stats(stats_file) active_guidelines = 0 if os.path.exists(guidelines_dir): active_guidelines = len([name for name in os.listdir(guidelines_dir) if os.path.isfile(os.path.join(guidelines_dir, name))]) avg_health = 0 if stats["designs_reviewed"] > 0: avg_health = int(stats["total_health_score"] / stats["designs_reviewed"]) return { "active_guidelines": active_guidelines, "designs_reviewed": stats["designs_reviewed"], "enhancements_suggested": stats["enhancements_suggested"], "brand_health_score": avg_health, "recent_scores": stats.get("recent_scores", []), "last_review": stats.get("last_review", None) } except Exception as e: traceback.print_exc() return JSONResponse( status_code=500, content={"error": f"Failed to get stats: {str(e)}"} ) class ScrapeRequest(BaseModel): url: str ai_provider: Optional[str] = None ai_api_key: Optional[str] = None @app.post("/api/projects/{project_id}/scrape") async def scrape_website(project_id: str, payload: ScrapeRequest, user_id: str = Depends(get_current_user)): verify_project_ownership(project_id, user_id) import requests as http_requests import re from urllib.parse import urljoin, urlparse from bs4 import BeautifulSoup from src.ingestion.build_index import build_index from src.ingestion.manifest import load_manifest base_dir, guidelines_dir, _, _ = get_project_paths(project_id) # Derive brand name from domain as fallback parsed_url = urlparse(payload.url) domain = parsed_url.netloc.replace('www.', '') domain_brand = domain.split('.')[0].capitalize() # 1. Clean and force the protocol if the user forgot it target_url = payload.url.strip() if not target_url.startswith(("http://", "https://")): target_url = "https://" + target_url # 2. Use an aggressive browser User-Agent to dodge bot-blockers req_headers = { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36', 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8', 'Accept-Language': 'en-US,en;q=0.5', } try: # Changed payload.url to target_url response = http_requests.get(target_url, headers=req_headers, timeout=15, allow_redirects=True) response.raise_for_status() except Exception as e: # This will print the actual technical reason in your console logs print(f"[SCRAPE CRASH] URL: {target_url} | Error: {str(e)}") raise HTTPException(status_code=400, detail=f"Scraper blocked or invalid URL: {str(e)}") soup = BeautifulSoup(response.content, 'html.parser') # collect raw CSS from inline styles,