Spaces:
Sleeping
Sleeping
| """ | |
| 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 | |
| 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 | |
| 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 | |
| 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 | |
| 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 | |
| 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} | |
| def health(): | |
| return {"ok": True} | |
| def index(): | |
| return FileResponse("web/index.html") | |