"""FastAPI deployment layer for RAG_Products. Endpoints: GET /health -> liveness + index status POST /query -> grounded Q&A over the catalog POST /similar -> hybrid "similar products" recommendations Run locally: uvicorn RAG_Products.api:app --host 0.0.0.0 --port 8000 """ import json import uuid from pathlib import Path from fastapi import FastAPI, HTTPException, UploadFile, File, Form, Header from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import FileResponse from fastapi.staticfiles import StaticFiles from pydantic import BaseModel, Field from RAG_Products.config import SIMILAR_K, OPENROUTER_API_KEY, ALLOWED_ORIGINS, ALLOWED_EMAIL_DOMAINS from RAG_Products.graph import get_graph from RAG_Products.similar import find_similar, doc_to_dict from RAG_Products.models import llm_groq from RAG_Products.prompt import SIMILAR_INTRO_PROMPT from RAG_Products.chat import handle as chat_handle, get_catalog from RAG_Products.insights import build_insights from RAG_Products import storage, knowledge STATIC_DIR = Path(__file__).resolve().parent / "static" app = FastAPI( title="AHV Assistant API", description="Retrieval-augmented product assistant + similar-item recommender.", version="1.0.0", ) # CORS so the Vercel-hosted frontend can call this API cross-origin. app.add_middleware( CORSMiddleware, allow_origins=[o.strip() for o in ALLOWED_ORIGINS.split(",")] if ALLOWED_ORIGINS != "*" else ["*"], allow_methods=["*"], allow_headers=["*"], ) app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static") @app.get("/") def home(): return FileResponse(STATIC_DIR / "index.html") # --------------------------------------------------------------------------- # Schemas # --------------------------------------------------------------------------- class QueryRequest(BaseModel): query: str = Field(..., description="A question about products in the catalog.") class QueryResponse(BaseModel): answer: str sources: list class SimilarRequest(BaseModel): product: str = Field(..., description="Product name or description to match.") k: int = Field(SIMILAR_K, ge=1, le=20, description="How many similar items.") explain: bool = Field(True, description="Include an LLM-written recommendation.") class SimilarResponse(BaseModel): target: dict similar: list recommendation: str | None = None class ChatRequest(BaseModel): message: str = Field(..., description="Natural-language query from the chatbox.") chat_id: str | None = Field(None, description="Existing chat to append to.") user_id: str = Field("default", description="Owner of the chat (per browser).") session_id: str | None = Field(None, description="Deprecated alias for chat_id.") class ProductRequest(BaseModel): name: str brand: str = "" variant: str = "" mrp: int | None = None best_buy: int | None = None bulk_price: int | None = None # --------------------------------------------------------------------------- # Endpoints # --------------------------------------------------------------------------- @app.get("/health") def health(): index_ok = True try: from RAG_Products.models import get_vector_db get_vector_db() except Exception as e: # index not built yet index_ok = False detail = str(e) else: detail = "ok" return { "status": "up", "index_loaded": index_ok, "llm_key_set": bool(OPENROUTER_API_KEY), "detail": detail, "storage_backend": storage.backend(), "storage_init_error": storage.init_error(), } def _resolve_user(authorization, fallback): """Prefer the verified Firebase uid from the Bearer token; else the client-supplied id (used in local/no-auth mode). Enforces ALLOWED_EMAIL_DOMAINS: a signed-in user whose email isn't on one of those domains is rejected outright (403), never silently downgraded to anonymous. """ if authorization and authorization.lower().startswith("bearer "): uid, email = storage.verify_token(authorization[7:].strip()) if uid: if ALLOWED_EMAIL_DOMAINS and not any( (email or "").lower().endswith("@" + d) for d in ALLOWED_EMAIL_DOMAINS ): allowed = ", ".join("@" + d for d in ALLOWED_EMAIL_DOMAINS) raise HTTPException(403, f"Sign-in is restricted to {allowed} accounts.") return uid return fallback or "default" @app.post("/chat") def chat(req: ChatRequest, authorization: str | None = Header(None)): """Single-box product assistant: deterministic answer + recommendations. Returns a fixed-shape JSON object (answer, match, results, recommendations, confidence, verified). No LLM in the path — same input, same output. """ msg = req.message.strip() if not msg: raise HTTPException(400, "message must not be empty") chat_id = req.chat_id or req.session_id or uuid.uuid4().hex user_id = _resolve_user(authorization, req.user_id) existing = storage.get_chat(chat_id) context = existing.get("context") if existing else None result = chat_handle(msg, context=context) # carry kit memory forward across turns (kit edits update it; others keep it) new_context = dict(context or {}) if result.get("kit_categories"): new_context["kit_categories"] = result.get("kit_categories") new_context["kit_budget"] = result.get("kit_budget") if result.get("last_category"): new_context["last_category"] = result.get("last_category") storage.append_messages( chat_id, user_id, [{"role": "user", "text": msg}, {"role": "assistant", "data": result}], context=new_context, title=msg, ) result["chat_id"] = chat_id return result # --------------------------------------------------------------------------- # Chat history (Firestore-backed, local-JSON fallback) # --------------------------------------------------------------------------- @app.get("/chats") def list_chats(user_id: str = "default", authorization: str | None = Header(None)): uid = _resolve_user(authorization, user_id) return {"backend": storage.backend(), "chats": storage.list_chats(uid)} @app.get("/chats/{chat_id}") def get_chat(chat_id: str): chat = storage.get_chat(chat_id) if not chat: raise HTTPException(404, "chat not found") return chat @app.delete("/chats/{chat_id}") def delete_chat(chat_id: str): storage.delete_chat(chat_id) return {"deleted": chat_id} class RenameRequest(BaseModel): title: str @app.patch("/chats/{chat_id}") def rename_chat(chat_id: str, req: RenameRequest): chat = storage.rename_chat(chat_id, req.title) if not chat: raise HTTPException(404, "chat not found") return {"id": chat_id, "title": chat["title"]} # --------------------------------------------------------------------------- # Knowledge base: add products # --------------------------------------------------------------------------- @app.post("/products") def add_product(req: ProductRequest): try: meta = knowledge.add_single_product( req.name, req.brand, req.variant, req.mrp, req.best_buy, req.bulk_price) except ValueError as e: raise HTTPException(400, str(e)) return {"added": 1, "product": {k: meta.get(k) for k in ("name", "brand", "variant", "category", "mrp", "best_buy", "bulk_price")}} @app.post("/products/upload") async def upload_products( file: UploadFile = File(...), commit: bool = Form(False), mapping: str | None = Form(None), ): data = await file.read() if not data: raise HTTPException(400, "empty file") if not commit: # preview: detect headers + proposed (LLM) mapping + sample rows return knowledge.preview_table(data, file.filename) try: m = json.loads(mapping) if mapping else None return knowledge.ingest_table(data, file.filename, mapping=m) except ValueError as e: raise HTTPException(400, str(e)) @app.get("/insights") def insights(): """Owner-only business analytics (margins, discounts, category breakdown). NOTE: deliberately separate from /chat so margin/cost figures are never exposed to customers. """ return build_insights(get_catalog()) @app.get("/dashboard") def dashboard(): return FileResponse(STATIC_DIR / "dashboard.html") @app.post("/query", response_model=QueryResponse) def query(req: QueryRequest): if not req.query.strip(): raise HTTPException(400, "query must not be empty") try: result = get_graph().invoke({"user_query": req.query, "query": req.query}) except RuntimeError as e: # e.g. missing GROQ_API_KEY raise HTTPException(503, str(e)) return QueryResponse( answer=result.get("answer", ""), sources=result.get("metadata", []), ) @app.post("/similar", response_model=SimilarResponse) def similar(req: SimilarRequest): if not req.product.strip(): raise HTTPException(400, "product must not be empty") target, ranked = find_similar(req.product, k=req.k) if target is None: raise HTTPException(404, "No matching product found in the catalog.") target_dict = doc_to_dict(target) similar_list = [{**doc_to_dict(d), "score": round(s, 3)} for d, s in ranked] recommendation = None if req.explain and similar_list: cand_text = "\n".join( f"- {c['name']} ({c['brand']}), Best Buy: {c['best_buy']}" for c in similar_list ) prompt = SIMILAR_INTRO_PROMPT.format( target=f"{target_dict['name']} ({target_dict['brand']})", candidates=cand_text, ) recommendation = llm_groq.invoke(prompt).content.strip() return SimilarResponse( target=target_dict, similar=similar_list, recommendation=recommendation, )