|
2 | 2 |
|
3 | 3 | import logging |
4 | 4 | import re |
| 5 | +import threading |
5 | 6 | from collections.abc import AsyncGenerator |
6 | 7 | from contextlib import asynccontextmanager |
7 | 8 | from html import escape as html_escape |
|
13 | 14 | from fastapi.responses import ( |
14 | 15 | FileResponse, |
15 | 16 | HTMLResponse, |
| 17 | + JSONResponse, |
16 | 18 | PlainTextResponse, |
17 | 19 | RedirectResponse, |
18 | 20 | Response, |
|
31 | 33 |
|
32 | 34 | # Module-level state set during lifespan |
33 | 35 | pipeline: dict[str, Any] = {} |
| 36 | +pipeline_ready = threading.Event() |
34 | 37 |
|
35 | 38 | templates = Jinja2Templates(directory=Path(__file__).parent / "templates") |
36 | 39 |
|
@@ -153,24 +156,36 @@ def nl2br(value: str) -> Markup: |
153 | 156 | return Markup(escaped.replace("\n", "<br>")) |
154 | 157 |
|
155 | 158 |
|
| 159 | +def _load_pipeline_background() -> None: |
| 160 | + """Load the retrieval pipeline in a background thread.""" |
| 161 | + try: |
| 162 | + index, mapping, embed_model, cross_encoder = _load_pipeline() |
| 163 | + pipeline["index"] = index |
| 164 | + pipeline["mapping"] = mapping |
| 165 | + pipeline["embed_model"] = embed_model |
| 166 | + pipeline["cross_encoder"] = cross_encoder |
| 167 | + |
| 168 | + verse_idx: dict[tuple[str, str, str], int] = {} |
| 169 | + for i, entry in enumerate(mapping): |
| 170 | + key = (entry["book_title"], entry["chapter"], entry["verse"]) |
| 171 | + verse_idx[key] = i |
| 172 | + pipeline["verse_index"] = verse_idx |
| 173 | + |
| 174 | + pipeline["loaded"] = True |
| 175 | + pipeline_ready.set() |
| 176 | + logger.info("Pipeline loaded successfully") |
| 177 | + except Exception: |
| 178 | + logger.exception("Failed to load pipeline") |
| 179 | + |
| 180 | + |
156 | 181 | @asynccontextmanager |
157 | 182 | async def lifespan(_app: FastAPI) -> AsyncGenerator[None, None]: |
158 | | - """Load models once at startup.""" |
159 | | - index, mapping, embed_model, cross_encoder = _load_pipeline() |
160 | | - pipeline["index"] = index |
161 | | - pipeline["mapping"] = mapping |
162 | | - pipeline["embed_model"] = embed_model |
163 | | - pipeline["cross_encoder"] = cross_encoder |
164 | | - |
165 | | - verse_idx: dict[tuple[str, str, str], int] = {} |
166 | | - for i, entry in enumerate(mapping): |
167 | | - key = (entry["book_title"], entry["chapter"], entry["verse"]) |
168 | | - verse_idx[key] = i |
169 | | - pipeline["verse_index"] = verse_idx |
170 | | - |
171 | | - pipeline["loaded"] = True |
| 183 | + """Spawn background pipeline loading so HTTP is available immediately.""" |
| 184 | + thread = threading.Thread(target=_load_pipeline_background, daemon=True) |
| 185 | + thread.start() |
172 | 186 | yield |
173 | 187 | pipeline.clear() |
| 188 | + pipeline_ready.clear() |
174 | 189 |
|
175 | 190 |
|
176 | 191 | app = FastAPI(title="RAG Bible", lifespan=lifespan) |
@@ -257,14 +272,23 @@ def sitemap_xml() -> Response: |
257 | 272 |
|
258 | 273 |
|
259 | 274 | @app.get("/health") # type: ignore[misc] |
260 | | -def health() -> dict[str, str]: |
261 | | - """Health check endpoint.""" |
262 | | - return {"status": "ok"} |
| 275 | +def health() -> Response: |
| 276 | + """Health check endpoint. Returns 503 while pipeline is loading.""" |
| 277 | + if not pipeline_ready.is_set(): |
| 278 | + return JSONResponse({"status": "loading"}, status_code=503) |
| 279 | + return JSONResponse({"status": "ok"}) |
263 | 280 |
|
264 | 281 |
|
265 | 282 | @app.post("/search", response_class=HTMLResponse) # type: ignore[misc] |
266 | 283 | def search_endpoint(request: Request, query: str = Form("")) -> HTMLResponse: |
267 | 284 | """Search the Bible and return an HTML fragment.""" |
| 285 | + if not pipeline_ready.is_set(): |
| 286 | + return templates.TemplateResponse( |
| 287 | + request=request, |
| 288 | + name="loading.html", |
| 289 | + context={"query": query}, |
| 290 | + ) |
| 291 | + |
268 | 292 | cleaned = sanitize_query(query) |
269 | 293 |
|
270 | 294 | if not cleaned: |
|
0 commit comments