"""Decoding Parliament — HTTP API (FastAPI). Runs on a Hugging Face Space (Docker SDK, free CPU) or locally. uvicorn server:app --port 7860 # local POST /ask {"question": "...", "k": 5} → {"answer", "model", "retriever", "passages": [{n, citation, text, speech_id, score}], "seconds"} GET /health → {"status": "ok", "passages": 158945, ...} Configuration by environment: GEMINI_API_KEY (required; a Space secret), DP_EMBED (sealion), DP_MODE (hybrid), DP_PROVIDER (gemini), DP_MODEL, DP_ORIGINS (comma-separated allowed origins; default *), DP_RATE (requests per hour per IP, default 30). Nothing is logged except request counts for rate limiting (in memory, per IP, reset hourly). """ import os, time, threading from collections import defaultdict, deque from fastapi import FastAPI, HTTPException, Request from fastapi.middleware.cors import CORSMiddleware from pydantic import BaseModel, Field EMBED = os.environ.get("DP_EMBED", "sealion"); MODE = os.environ.get("DP_MODE", "hybrid") PROVIDER = os.environ.get("DP_PROVIDER", "gemini"); MODEL = os.environ.get("DP_MODEL") or None ORIGINS = [o.strip() for o in os.environ.get("DP_ORIGINS", "*").split(",")] RATE = int(os.environ.get("DP_RATE", "30")) K_MAX, Q_MAX = 8, 400 app = FastAPI(title="Decoding Parliament API", version="1.0") app.add_middleware(CORSMiddleware, allow_origins=ORIGINS, allow_methods=["POST", "GET"], allow_headers=["*"]) STATE = {"ret": None, "gen": None, "error": None} _hits = defaultdict(deque); _lock = threading.Lock() def load(): try: from retrieval import Retriever from rag import Generator STATE["ret"] = Retriever(model=EMBED, mode=MODE) STATE["gen"] = Generator(PROVIDER, MODEL) except SystemExit as e: STATE["error"] = f"generator not configured: {e}" except Exception as e: STATE["error"] = f"load failed: {e}" @app.on_event("startup") def _startup(): threading.Thread(target=load, daemon=True).start() # answer /health while the index loads def rate_ok(ip): now = time.time() with _lock: q = _hits[ip] while q and q[0] < now - 3600: q.popleft() if len(q) >= RATE: return False q.append(now); return True class Ask(BaseModel): question: str = Field(..., min_length=2, max_length=Q_MAX) k: int = Field(5, ge=1, le=K_MAX) lang: str = Field("auto", pattern="^(auto|my|en)$") # answer language: question's language, Burmese, or English @app.get("/health") def health(): r = STATE["ret"] return {"status": "ok" if r else ("error" if STATE["error"] else "loading"), "error": STATE["error"], "passages": len(r.meta) if r else None, "retriever": r.name if r else None, "generator": f"{STATE['gen'].provider}/{STATE['gen'].model}" if STATE["gen"] else None} @app.post("/ask") def ask(body: Ask, request: Request): if STATE["error"]: raise HTTPException(503, STATE["error"]) if not STATE["ret"]: raise HTTPException(503, "index still loading — try again in a minute") ip = request.headers.get("x-forwarded-for", request.client.host if request.client else "?").split(",")[0].strip() if not rate_ok(ip): raise HTTPException(429, f"rate limit: {RATE} questions per hour") from rag import answer t = time.time() res = answer(body.question.strip(), STATE["ret"], STATE["gen"], k=body.k, per_speech=2, lang=None if body.lang == "auto" else body.lang) return {"question": res["question"], "answer": res["answer"], "model": res["model"], "retriever": res["retriever"], "lang": res["lang"], "query_my": res.get("query_my"), "passages": [{"n": p["n"], "citation": p["citation"], "text": p["text"], "speech_id": p["speech_id"], "score": p["score"]} for p in res["passages"]], "seconds": round(time.time() - t, 2)} class Search(BaseModel): question: str = Field(..., min_length=2, max_length=Q_MAX) k: int = Field(5, ge=1, le=K_MAX) mode: str = Field("bm25", pattern="^(bm25|dense|hybrid)$") @app.post("/search") def search(body: Search, request: Request): """Passages only, no generated answer. mode=bm25 is the SQ3 comparison condition ("keyword search over the archive").""" if not STATE["ret"]: raise HTTPException(503, "index still loading — try again in a minute") ip = request.headers.get("x-forwarded-for", request.client.host if request.client else "?").split(",")[0].strip() if not rate_ok(ip): raise HTTPException(429, f"rate limit: {RATE} questions per hour") from retrieval import Retriever r = STATE["ret"] ret = r if body.mode == r.mode else Retriever(model=r.model_key, mode=body.mode, meta=r.meta, dense=r.dense, bm25=r.bm25, bm25_weight=r.bm25_weight) from common import citation_of t = time.time(); hits = ret.search(body.question.strip(), k=body.k, per_speech=2) return {"question": body.question, "mode": body.mode, "retriever": ret.name, "passages": [{"n": i, "citation": citation_of(m), "text": m["text"], "speech_id": m["speech_id"], "score": round(float(sc), 4)} for i, (sc, m) in enumerate(hits, 1)], "seconds": round(time.time() - t, 2)} @app.get("/") def root(): return {"name": "Decoding Parliament API", "docs": "/docs", "health": "/health", "ask": "POST /ask {question, k, lang}", "search": "POST /search {question, k, mode}"}