Spaces:
Sleeping
Sleeping
File size: 5,486 Bytes
39ff835 | 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 | """FastAPI backend for the News Search Engine.
Endpoints
---------
GET /api/health -> liveness + index stats
GET /api/categories -> list of categories (for the UI filter)
GET /api/search -> run a search
The index is loaded once at startup from ``artifacts/index.pkl`` (build it with
``python scripts/build_index.py``). If that file is missing, the API builds an
index from ``data/sample_news.jsonl`` on the fly so the app still works.
In production the built React app (``frontend/dist``) is served as static files,
so the whole thing runs as a single service.
"""
from __future__ import annotations
import os
import pickle
import sys
from contextlib import asynccontextmanager
from pathlib import Path
from typing import List, Optional
from fastapi import FastAPI, HTTPException, Query
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "src"))
from news_search import InvertedIndex, SearchEngine, build_index, load_corpus # noqa: E402
from news_search.engine import METHODS # noqa: E402
INDEX_PATH = Path(os.getenv("INDEX_PATH", ROOT / "artifacts" / "index.pkl"))
SAMPLE_PATH = ROOT / "data" / "sample_news.jsonl"
DENSE_PATH = INDEX_PATH.with_name("dense.pkl")
state: dict = {}
def _load_engine() -> SearchEngine:
# NOTE: index.pkl / dense.pkl are loaded with ``pickle``, which executes
# arbitrary code on load. These artifacts are produced locally by
# ``scripts/build_index.py`` and are trusted; never point INDEX_PATH at a
# file from an untrusted source.
if INDEX_PATH.exists():
print(f"Loading index from {INDEX_PATH}")
index = InvertedIndex.load(INDEX_PATH)
else:
print(f"Index not found at {INDEX_PATH}; building from sample {SAMPLE_PATH}")
index = build_index(load_corpus(SAMPLE_PATH), verbose=False)
dense = None
if DENSE_PATH.exists():
try:
with DENSE_PATH.open("rb") as fh:
dense = pickle.load(fh)
print("Loaded dense (BERT) retriever.")
except Exception as exc: # pragma: no cover
print(f"Could not load dense retriever: {exc}")
return SearchEngine(index, dense=dense)
@asynccontextmanager
async def lifespan(app: FastAPI):
engine = _load_engine()
state["engine"] = engine
print(
f">>> Ready: {engine.index.num_docs:,} documents | "
f"{engine.index.vocabulary_size:,} terms | "
f"BERT {'ENABLED' if engine.dense is not None else 'disabled'}"
)
yield
state.clear()
app = FastAPI(title="News Search Engine API", version="1.0.0", lifespan=lifespan)
# CORS is only needed for the Vite dev server (npm run dev on :5173); the built
# app is served same-origin from :8000. Scope to localhost dev origins by default;
# override with a comma-separated CORS_ORIGINS env var if deploying elsewhere.
_CORS_ORIGINS = [
o.strip()
for o in os.getenv(
"CORS_ORIGINS", "http://localhost:5173,http://localhost:8000"
).split(",")
if o.strip()
]
app.add_middleware(
CORSMiddleware,
allow_origins=_CORS_ORIGINS,
allow_methods=["GET"],
allow_headers=["*"],
)
def _engine() -> SearchEngine:
"""Return the loaded engine or a clean 503 if startup hasn't finished."""
engine = state.get("engine")
if engine is None:
raise HTTPException(503, "Search engine is not ready yet.")
return engine
@app.get("/api/health")
def health():
engine = _engine()
return {
"status": "ok",
"documents": engine.index.num_docs,
"vocabulary": engine.index.vocabulary_size,
"bert_available": engine.dense is not None,
"methods": list(METHODS),
}
@app.get("/api/categories")
def categories():
engine = _engine()
return {"categories": engine.categories}
@app.get("/api/search")
def search(
q: str = Query(..., min_length=1, max_length=512, description="Search query"),
method: Optional[str] = Query(None, description=f"One of {METHODS}; defaults to Hybrid"),
top_k: int = Query(10, ge=1, le=50),
category: Optional[str] = Query(None, max_length=100),
relevant_ids: Optional[List[int]] = Query(
None, description="Doc ids the user marked relevant (relevance feedback for 'prf')."
),
):
engine = _engine()
# Default to Hybrid (BM25 + BERT fusion) when embeddings are available, else
# BM25 so a bare request never fails on a lite (no-BERT) deployment.
if method is None:
method = "hybrid" if engine.dense is not None else "bm25"
if method not in METHODS:
raise HTTPException(400, f"Unknown method '{method}'. Choose from {list(METHODS)}.")
if method in ("bert", "hybrid") and engine.dense is None:
raise HTTPException(
400,
"Dense BERT retrieval is not available on this deployment. "
"Rebuild the index with `--bert` to enable it.",
)
return engine.search(
q, method=method, top_k=top_k, category=category, relevant_ids=relevant_ids
).to_dict()
# --- Serve the built frontend (if present) -------------------------------- #
_DIST = ROOT / "frontend" / "dist"
if _DIST.exists():
app.mount("/assets", StaticFiles(directory=_DIST / "assets"), name="assets")
@app.get("/")
def _index():
return FileResponse(_DIST / "index.html")
|