AHV / RAG_Products /api.py
murtaza-2007
Allow multiple email domains for sign-in (altafhvali.com, gmail.com)
c00c685
Raw
History Blame Contribute Delete
10.1 kB
"""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,
)