File size: 5,401 Bytes
44d743a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
15d54db
44d743a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
15d54db
17ee6fb
44d743a
 
 
6cfa7b8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
44d743a
 
6cfa7b8
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
"""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}"}