| """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", |
| ) |
|
|
| |
| 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") |
|
|
|
|
| |
| |
| |
| 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 |
|
|
|
|
| |
| |
| |
| @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_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) |
|
|
| |
| 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 |
|
|
|
|
| |
| |
| |
| @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"]} |
|
|
|
|
| |
| |
| |
| @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: |
| |
| 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: |
| 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, |
| ) |
|
|