""" server.py — FastAPI backend for the GIVA Discovery web app. Serves the single-page frontend and one JSON endpoint that reuses search.py + llm.py. Run it WITHOUT the blocked .exe launcher: python -m uvicorn server:app --reload --port 8000 then open http://localhost:8000 """ from typing import Optional from fastapi import FastAPI from fastapi.responses import FileResponse from pydantic import BaseModel import base64 from fastapi import Response from search import search from llm import explain_matches from understand import understand_query from vision import describe_image, analyze_design import search_images from design_gen import generate_design from designed_spec import build_designed_spec from quote import compute_quote, budget_levers, estimate_timeline, DEFAULT_RATES from bom import load_bom app = FastAPI(title="GIVA Discovery") class SearchRequest(BaseModel): prompt: str material: Optional[str] = None # "Silver" | "Gold" colour: Optional[str] = None shop_for: Optional[str] = None # "Women" | "Men" | "Kids" solid_gold_only: bool = False min_price: Optional[float] = None max_price: Optional[float] = None top_n: int = 12 explain: bool = True smart: bool = True # use Claude query understanding @app.post("/api/search") def api_search(req: SearchRequest): # Claude turns the free text into structured intent (product type, material, # recipient, price, sort, stone-exclusion). Falls back to regex if no key. intent = understand_query(req.prompt) if req.smart else { "query": req.prompt, "product_type": None, "material": None, "colour": None, "shop_for": None, "min_price": None, "max_price": None, "sort": None, "exclude_stones": False, } # Sidebar values always win over inferred intent. material = req.material or intent.get("material") colour = req.colour or intent.get("colour") shop_for = req.shop_for or intent.get("shop_for") min_price = req.min_price if req.min_price is not None else intent.get("min_price") max_price = req.max_price if req.max_price is not None else intent.get("max_price") filters = {} if material: filters["primary_category"] = material if colour: filters["colour"] = colour if shop_for: filters["shop_for"] = shop_for if intent.get("product_type"): filters["product_type"] = intent["product_type"] if intent.get("exclude_stones"): filters["exclude_stones"] = True if req.solid_gold_only: filters["solid_gold_only"] = True if min_price is not None: filters["min_price"] = min_price if max_price is not None: filters["max_price"] = max_price if intent.get("sort"): filters["sort"] = intent["sort"] query_text = intent.get("query") or req.prompt results = search(query_text, filters, top_n=req.top_n) if req.explain and results: results = explain_matches(req.prompt, results) return { "count": len(results), "results": results, "applied": { "prompt": query_text, "product_type": filters.get("product_type"), "material": material, "colour": colour, "shop_for": shop_for, "min_price": min_price, "max_price": max_price, "sort": filters.get("sort"), "exclude_stones": filters.get("exclude_stones", False), }, } class ImageSearchRequest(BaseModel): image: str # data URL (data:image/...;base64,...) material: Optional[str] = None colour: Optional[str] = None min_price: Optional[float] = None max_price: Optional[float] = None top_n: int = 12 explain: bool = True @app.post("/api/search_by_image") def api_search_by_image(req: ImageSearchRequest): filters = {} if req.material: filters["primary_category"] = req.material if req.colour: filters["colour"] = req.colour if req.min_price is not None: filters["min_price"] = req.min_price if req.max_price is not None: filters["max_price"] = req.max_price # Preferred: TRUE visual match via CLIP over the giva_images collection. if search_images.is_ready(): results = search_images.search_by_dataurl(req.image, req.top_n, filters) return { "count": len(results), "results": results, "description": "Visual match on your photo (CLIP)", "mode": "clip", "applied": {**filters}, } # Fallback: no image index yet -> Claude Vision describes -> text search. intent = describe_image(req.image) if intent.get("product_type"): filters["product_type"] = intent["product_type"] if not req.material and intent.get("material"): filters["primary_category"] = intent["material"] if not req.colour and intent.get("colour"): filters["colour"] = intent["colour"] query_text = intent.get("query") or "jewellery" results = search(query_text, filters, top_n=req.top_n) if req.explain and results: results = explain_matches(query_text, results) return { "count": len(results), "results": results, "description": intent.get("description", ""), "mode": "claude-vision", "applied": {"prompt": query_text, **filters}, } # --------------------------------------------------------------------------- # Sketch-to-Quote: design generation + BOM + quote # --------------------------------------------------------------------------- class DesignRequest(BaseModel): brief: str category: str = "Any" # Ring | Earrings | Neckwear | Bracelet | Nose Pin | Any color: str = "Y" # Y | R | W kt: int = 14 seed: int = 7 @app.post("/api/design") def api_design(req: DesignRequest): """Generate a catalogue-style render from a brief. Returns a JPEG.""" img_bytes, ctype = generate_design(req.brief, req.category, req.color, req.kt, req.seed) return Response(content=img_bytes, media_type=ctype, headers={"Cache-Control": "no-store"}) class QuoteRequest(BaseModel): brief: str = "" image: str # data URL of the design (generated or uploaded) category: str = "Any" color: str = "Y" kt: int = 14 ring_size: Optional[int] = None budget: Optional[float] = None anchor_sku: Optional[str] = None use_vision: bool = True # count stones off the render via Claude vision @app.post("/api/quote") def api_quote(req: QuoteRequest): if not search_images.gold_ready(): return {"error": "Gold anchor index not built. Run the full ingest_images.py"} # Embed the design ONCE, reuse for the gold anchor and the GIVA lookalikes. from image_embed import embed_image_dataurl emb = embed_image_dataurl(req.image) # 1. Anchor: nearest costable GOLD SKUs (for the quote). matches = search_images.match_gold_anchor(emb, top_n=18) if not matches: return {"error": "No visual anchor found in the gold index."} boms = load_bom() # 1b. Similar GIVA pieces (whole catalogue, for the shopper to browse). similar_giva = search_images.match_giva(emb, top_n=6) # 2. Optional vision pass: count + size stones off the render. vision = analyze_design(req.image, req.brief) if req.use_vision else None # 3. Designed spec (anchor reality + design quantities). spec = build_designed_spec( matches=matches, boms=boms, vision=vision, brief=req.brief, category=req.category, kt=req.kt, color=req.color, ring_size=req.ring_size, anchor_sku=req.anchor_sku) if not spec: return {"error": "Could not build a spec (no BOM-covered anchor)."} # 4. Quote + budget levers + timeline. q = compute_quote(spec, req.kt, DEFAULT_RATES) levers = budget_levers(spec, req.kt, DEFAULT_RATES, req.budget) if req.budget else [] timeline = estimate_timeline(spec) return {"spec": spec, "quote": q, "levers": levers, "timeline": timeline, "anchorMatches": matches[:6], "similarGiva": similar_giva} class CompetitorRequest(BaseModel): sku: str ptype: Optional[str] = None top_n: int = 6 @app.post("/api/competitors") def api_competitors(req: CompetitorRequest): """Visually-similar competitor pieces (with prices) for a GIVA SKU.""" if not search_images.competitors_ready(): return {"ready": False, "results": []} results = search_images.match_competitors_for_sku(req.sku, req.top_n, req.ptype) return {"ready": True, "count": len(results), "results": results} @app.get("/health") def health(): return {"ok": True} @app.get("/") def index(): return FileResponse("web/index.html")