Spaces:
Sleeping
Sleeping
File size: 16,460 Bytes
62f3c0d | 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 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 | """FastAPI backend for the demo UI.
Exposes a single /search endpoint that runs the hybrid RAG pipeline AND
generates grounded one-line rationales per hit via Gemini. The judge entry
point (inference.py) does not depend on this server — it imports the
retriever directly. This keeps the eval pipeline 100% local while the demo
gets the polished, AI-narrated UX.
"""
from __future__ import annotations
import os
import time
from contextlib import asynccontextmanager
from pathlib import Path
from dotenv import load_dotenv
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel, Field
load_dotenv()
from pathlib import Path # noqa: E402
from src.offline_guard import enforce_offline_if_cached # noqa: E402
enforce_offline_if_cached()
# IMPORT ORDER MATTERS. The retriever pulls in torch + transformers +
# faiss; LLMClient pulls in google.genai (which itself drags in grpc /
# protobuf). On some Windows configurations, loading google.genai BEFORE
# torch leaves protobuf in a state that segfaults the C-extension
# initialisation later in transformers. Importing torch/transformers
# first via Retriever pins protobuf's symbol table the way native CUDA
# code expects it, then google.genai is loaded into that environment
# without conflict. Caught by a cold-clone demo-boot test on Windows.
from src.retrieval.retriever import Retriever # noqa: E402
from src.llm.llm_client import LLMClient # noqa: E402
class SearchRequest(BaseModel):
query: str = Field(..., min_length=1, max_length=2000)
top_k: int = Field(5, ge=1, le=20)
rewrite: bool = True
rationales: bool = True
hyde: bool = False # opt-in: HyDE adds 1 LLM call (~700ms)
multi_query: bool = False # opt-in: 1 extra LLM call, 3 paraphrased variants
# Optional per-request LLM keys — frontend can pass these from the
# browser's localStorage so judges can enable AI features without
# touching the .env file. Falls back to env-configured client if absent.
gemini_api_key: str | None = None
groq_api_key: str | None = None
class HitOut(BaseModel):
rank: int
is_code: str
title: str
scope: str
rerank_score: float
rrf_score: float
rationale: str | None = None
confidence: str # "high" | "medium" | "low"
related_standards: list[str] = []
categories: list[str] = []
def _confidence_band(rerank_score: float) -> str:
"""Bucket rerank sigmoid scores into UI-friendly bands.
Calibrated on the bootstrap eval set via scripts/calibrate_confidence.py
(results: data/results/confidence_calibration.json). Headline finding:
- rerank_score >= 0.55 -> 85.7% of these hits were correct (HIGH)
- 0.40 <= score < 0.55 -> 13.3% precision (MEDIUM — could be right)
- score < 0.40 -> effectively noise (LOW)
"""
if rerank_score >= 0.55:
return "high"
if rerank_score >= 0.40:
return "medium"
return "low"
class SearchResponse(BaseModel):
query: str
expanded_query: str | None
material: str | None
application: str | None
hits: list[HitOut]
latency_seconds: float
used_gemini: bool # legacy field — true if any LLM fired
llm_provider: str | None # "gemini" | "groq" | None
llm_status: (
str # ok | fellback_to_groq | rate_limited | disabled | all_providers_failed
)
llm_message: str | None = None
# back-compat shims so older frontends don't break
gemini_status: str
gemini_message: str | None = None
STATE: dict = {}
@asynccontextmanager
async def lifespan(app: FastAPI):
print("[api] Loading retriever ...")
t = time.perf_counter()
STATE["retriever"] = Retriever()
print(f"[api] ready in {time.perf_counter() - t:.1f}s")
STATE["llm"] = LLMClient()
print(f"[api] LLM providers ready: {STATE['llm'].providers or 'NONE'}")
# Load cross-references (best-effort)
import json as _json
xref_path = Path("data/xrefs.json")
if xref_path.exists():
STATE["xrefs"] = _json.loads(xref_path.read_text(encoding="utf-8"))
print(f"[api] Loaded {len(STATE['xrefs'])} cross-reference entries")
else:
STATE["xrefs"] = {}
# Eager-load standards-by-code map. Doing this here (rather than
# lazily in get_standard) avoids a thundering-herd race where two
# concurrent first-time hits both parse the 5 MB JSON.
standards_path = Path("data/parsed_standards.json")
if standards_path.exists():
records = _json.loads(standards_path.read_text(encoding="utf-8"))
STATE["standards_by_code"] = {s["is_code"]: s for s in records}
print(
f"[api] Loaded {len(STATE['standards_by_code'])} standards into lookup map"
)
else:
STATE["standards_by_code"] = {}
yield
app = FastAPI(title="BIS RAG Recommender", version="1.0", lifespan=lifespan)
# CORS: restrict to localhost only. The demo UI lives on :3000 same machine.
# Wide-open origins would let any LAN peer / visited website spend the
# user's HF / Gemini / Groq keys via this local backend's /search endpoint.
app.add_middleware(
CORSMiddleware,
allow_origins=["*"], # Allows your Vercel domain to communicate with the backend
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
@app.get("/health")
def health():
llm: LLMClient | None = STATE.get("llm")
return {
"ok": True,
"llm_providers": llm.providers if llm else [],
"llm_primary": llm._primary_name if llm and llm.enabled else None,
# Back-compat field
"gemini": bool(llm and llm.gemini is not None),
}
class StandardDetail(BaseModel):
is_code: str
title: str
scope: str
revision: str | None
page_start: int
page_end: int
full_text: str
related_standards: list[str]
categories: list[str]
# IS code shape: "IS 269: 1989", "IS 2185 (Part 1): 1979", etc.
# Restrict the URL parameter to this pattern so arbitrary input
# (path traversal probes, reflected-XSS smells) never reaches the
# lookup. Whitespace-tolerant.
import re as _re_mod # noqa: E402
_IS_CODE_RE = _re_mod.compile(
r"^IS\s*\d+(?:\s*\(Part\s*\d+(?:/\s*Sec\s*\d+)?\))?\s*:\s*\d{4}$",
_re_mod.IGNORECASE,
)
@app.get("/standards/{is_code}", response_model=StandardDetail)
def get_standard(is_code: str):
"""Fetch the full record for one IS code (used by the detail modal).
is_code must match the IS-code grammar — invalid input gets a generic
404 without echoing the user-supplied string back.
"""
from fastapi import HTTPException
if not _IS_CODE_RE.match(is_code or ""):
raise HTTPException(404, "IS code not found")
standards = STATE.get("standards_by_code") or {}
s = standards.get(is_code)
if not s:
# Tolerant lookup — match by normalised form
norm = _re_mod.sub(r"\s+", "", is_code).lower()
for code, rec in standards.items():
if rec.get("is_code_norm") == norm:
s = rec
break
if not s:
raise HTTPException(404, "IS code not found")
related = (STATE.get("xrefs") or {}).get(s["is_code"], [])
from src.retrieval.metadata import detect_categories
blob = f"{s['title']} {s.get('scope') or ''}"
cats = sorted(detect_categories(blob))
return StandardDetail(
is_code=s["is_code"],
title=s["title"],
scope=s.get("scope") or "",
revision=s.get("revision"),
page_start=s["page_start"],
page_end=s["page_end"],
full_text=s.get("full_text") or "",
related_standards=related,
categories=cats,
)
@app.post("/search", response_model=SearchResponse)
def search(req: SearchRequest) -> SearchResponse:
retriever: Retriever = STATE["retriever"]
# If the request carries per-request LLM keys (sent by the frontend from
# browser localStorage), build a one-off client; otherwise use the
# process-wide one constructed from env.
llm: LLMClient | None
if req.gemini_api_key or req.groq_api_key:
try:
llm = LLMClient(
gemini_api_key=req.gemini_api_key or None,
groq_api_key=req.groq_api_key or None,
verbose=False,
)
except Exception: # noqa: BLE001
llm = STATE.get("llm")
else:
llm = STATE.get("llm")
used_gemini = False # legacy bool — true if any LLM produced output
expanded = None
material = None
application = None
# Status reflects the LAST LLM call's outcome (best signal for the UI).
# Initialised to "disabled" if neither provider is configured, else "ok".
llm_status = "disabled" if (llm is None or not llm.enabled) else "ok"
llm_message: str | None = (
None
if (llm is not None and llm.enabled)
else "No LLM API keys configured (GEMINI_API_KEY / GROQ_API_KEY). "
"Retrieval still works; AI rewrite and rationales are off."
)
llm_provider: str | None = None
def _note_call(stage: str, fired: bool):
nonlocal llm_status, llm_message, llm_provider, used_gemini
if llm is None:
return
if fired:
used_gemini = True
llm_provider = llm.last.provider
# Promote to "fellback_to_groq" if that's what happened
if (
llm.last.status == "fellback_to_groq"
and llm_status != "fellback_to_groq"
):
llm_status = "fellback_to_groq"
llm_message = (
"Gemini quota exhausted on this request — automatically "
"fell back to Groq (Llama 3.3 70B). Retrieval is unchanged."
)
else:
# Only downgrade status if we haven't already noted success in this request
if llm_status == "ok":
llm_status = "rate_limited"
llm_message = (
f"LLM call ({stage}) returned no result. "
f"Tried providers: {llm.providers}. "
"Retrieval is unaffected."
)
t0 = time.perf_counter()
dense_query = None
if req.rewrite and llm is not None and llm.enabled:
rw = llm.rewrite_query(req.query)
if rw.expanded and rw.expanded.strip() != req.query.strip():
dense_query = rw.expanded
expanded = rw.expanded
material = rw.material
application = rw.application
_note_call("rewrite_query", True)
else:
_note_call("rewrite_query", False)
# HyDE: if enabled, fold a hypothetical-doc passage into the dense query.
if req.hyde and llm is not None and llm.enabled:
h = llm.hyde_passage(req.query)
if h:
dense_query = f"{dense_query or req.query}\n\n{h}"
_note_call("hyde_passage", True)
else:
_note_call("hyde_passage", False)
multi_qs: list[str] = []
if req.multi_query and llm is not None and llm.enabled:
multi_qs = llm.multi_query_variants(req.query, n=3)
if multi_qs:
_note_call("multi_query", True)
else:
_note_call("multi_query", False)
hits = retriever.search(req.query, dense_query=dense_query, multi_queries=multi_qs)[
: req.top_k
]
rationales: list[dict] = []
if req.rationales and llm is not None and llm.enabled and hits:
rationales = llm.generate_rationales(
req.query,
[{"is_code": h.is_code, "title": h.title, "scope": h.scope} for h in hits],
)
_note_call("rationales", bool(rationales))
rationale_by_code = {
r.get("is_code"): r.get("reason") for r in rationales if isinstance(r, dict)
}
whitelist = retriever.whitelist_norm
import re as _re
# Phrases the LLM uses when it concludes a candidate doesn't really apply.
# We strip such rationales — they're confusing on items we're still
# showing (rank 4 / 5 are "related but secondary" by design, not "wrong").
_NEGATIVE_PATTERNS = _re.compile(
r"\b(?:doesn'?t|does\s+not|not\s+(?:directly\s+)?(?:apply|applicable|relevant|related)|"
r"unrelated|inapplicable|irrelevant|n/?a)\b",
_re.IGNORECASE,
)
hits_out: list[HitOut] = []
for h in hits:
reason = rationale_by_code.get(h.is_code)
if reason:
# Hallucination guard: reject rationales that mention IS codes
# outside the SP 21 whitelist.
for found in _re.findall(
r"IS\s*\d+\s*(?:\(Part[^)]+\))?\s*:\s*\d{4}", reason
):
norm = _re.sub(r"\s+", "", found).lower()
if norm not in whitelist:
reason = None
break
if reason and _NEGATIVE_PATTERNS.search(reason):
# Suppress "does not apply" / "not directly applicable" on hits
# we're recommending — these are confusing UX. The card still
# ranks the standard normally; we just hide the disqualifying note.
reason = None
related = (STATE.get("xrefs") or {}).get(h.is_code, [])[:4]
hits_out.append(
HitOut(
rank=h.rank,
is_code=h.is_code,
title=h.title,
scope=h.scope,
rerank_score=round(h.rerank_score, 4),
rrf_score=round(h.rrf_score, 4),
rationale=reason,
confidence=_confidence_band(h.rerank_score),
related_standards=related,
categories=list(h.categories),
)
)
return SearchResponse(
query=req.query,
expanded_query=expanded,
material=material,
application=application,
hits=hits_out,
latency_seconds=round(time.perf_counter() - t0, 3),
used_gemini=used_gemini,
llm_provider=llm_provider,
llm_status=llm_status,
llm_message=llm_message,
# Back-compat fields the existing UI still reads.
gemini_status=llm_status if llm_status != "fellback_to_groq" else "ok",
gemini_message=llm_message,
)
# ----------------------------------------------------------------------
# /judge_search — inference.py-parity endpoint for the Eval Sandbox UI.
#
# Mirrors `inference.py` exactly: NO LLM, NO query rewriting, NO rationales.
# Just bare retrieval + server-measured wall-clock latency. The frontend's
# Eval Sandbox panel loops over a judge-supplied JSON file and calls this
# per query; the resulting JSON is byte-for-byte the shape `eval_script.py`
# expects, so judges can verify our reported metrics offline.
# ----------------------------------------------------------------------
class JudgeSearchRequest(BaseModel):
query: str = Field(..., min_length=1, max_length=2000)
class JudgeSearchResponse(BaseModel):
retrieved_standards: list[str]
latency_seconds: float
@app.post("/judge_search", response_model=JudgeSearchResponse)
def judge_search(req: JudgeSearchRequest) -> JudgeSearchResponse:
"""Pure-retrieval endpoint that mirrors `inference.py`'s behaviour.
Same Retriever, no LLM enrichment, server-side `time.perf_counter()`
around the retriever.search() call so latency matches what the judge
auto-script would report. Errors return an empty list + zero latency,
matching the per-item failure behaviour in `inference.py:104`.
"""
retriever: Retriever = STATE["retriever"]
t = time.perf_counter()
try:
hits = retriever.search(req.query)
except Exception: # noqa: BLE001 — never let one bad query break a batch run
return JudgeSearchResponse(retrieved_standards=[], latency_seconds=0.0)
latency = time.perf_counter() - t
return JudgeSearchResponse(
retrieved_standards=[h.is_code for h in hits[:5]],
latency_seconds=round(latency, 3),
)
def main():
import uvicorn
port = int(os.getenv("PORT", "8000"))
# Bind 127.0.0.1, not 0.0.0.0 — the demo backend should be reachable
# only from the same machine. Override with HOST=0.0.0.0 if you really
# need LAN access (e.g., remote demo screensharing).
host = os.getenv("HOST", "127.0.0.1")
uvicorn.run(app, host=host, port=port)
if __name__ == "__main__":
main()
|