Skip to content

Commit 4bd5341

Browse files
feat: background pipeline loading for instant startup
UI available immediately; /search returns loading fragment with HTMX auto-retry until models are ready; /health returns 503 while loading. Adds loading.html template and tests for loading state. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent d90198f commit 4bd5341

6 files changed

Lines changed: 105 additions & 26 deletions

File tree

CLAUDE.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,9 +31,9 @@ French Bible RAG with two-stage retrieval:
3131
2. **`rag/ingest.py`** -- ingestion pipeline: reads `bible.db` SQLite, filters short/non-content verses, encodes with SentenceTransformer, builds FAISS IndexFlatIP, writes `data/index.faiss` + `data/mapping.json`
3232
3. **`rag/retrieve.py`** -- two-stage search: FAISS top-K (cosine via inner product on L2-normalized vectors), then cross-encoder reranking with sigmoid score normalization
3333
4. **`config.py`** -- all tunable parameters (paths, model names, thresholds, retrieval K values)
34-
5. **`app.py`** -- FastAPI server: loads pipeline once at startup via lifespan, serves Jinja2 HTML fragments to HTMX frontend. Query sanitization, input validation, contextual verse display with surrounding verses bounded by book_id. Root URL serves SPA, SEO routes (`/robots.txt`, `/sitemap.xml`), static asset cache middleware (24h), HF-to-custom-domain redirect middleware
34+
5. **`app.py`** -- FastAPI server: loads pipeline in a background thread at startup (UI available immediately, `/search` returns a loading fragment with HTMX auto-retry until ready, `/health` returns 503 while loading). Query sanitization, input validation, contextual verse display with surrounding verses bounded by book_id. Root URL serves SPA, SEO routes (`/robots.txt`, `/sitemap.xml`), static asset cache middleware (24h), HF-to-custom-domain redirect middleware
3535

36-
Data flow: `bible.db` -> ingest -> `data/{index.faiss, mapping.json}` -> app startup loads into memory -> HTMX POST `/search` -> HTML fragment response. Root `/` serves the SPA entry point.
36+
Data flow: `bible.db` -> ingest -> `data/{index.faiss, mapping.json}` -> app startup spawns background thread to load into memory -> HTMX POST `/search` -> HTML fragment response (or loading fragment if pipeline not yet ready). Root `/` serves the SPA entry point.
3737

3838
## Frontend
3939

@@ -44,6 +44,7 @@ Custom design system with warm parchment aesthetic (`#f5f0e8` background, `#2a2a
4444
- **`static/app.js`** -- component initializers inside `DOMContentLoaded`: `initPageHeader`, `initSearchBar`, `initStatusMessages`, `initCarousel`, `initCarouselNavigation`, `initHistorySidebar`, `initOfflineDetection`. Shared state via `window.appState`
4545
- **`static/service-worker.js`** -- cache-first for static assets, network-only for `/search` API
4646
- **`templates/results.html`** -- Embla Carousel structure (viewport > track > slides) with score badges and context verses
47+
- **`templates/loading.html`** -- loading state with HTMX `hx-trigger="load delay:2s"` auto-retry
4748
- **`templates/error.html`** -- error message with "Reessayer" retry button
4849
- **`templates/no_results.html`** -- simple no-results feedback
4950

@@ -63,7 +64,8 @@ Custom design system with warm parchment aesthetic (`#f5f0e8` background, `#2a2a
6364
- Cross-encoder raw scores are sigmoid-normalized to [0, 1] (0.5 = decision boundary)
6465
- `data/` is gitignored -- regenerate with `make ingest` (requires `bible.db` in `data/`)
6566
- Tests use two markers: `unit` (fast, mocked, default) and `integration` (loads real models + data)
66-
- App tests use `mock_pipeline` fixture from `conftest.py` to avoid loading models
67+
- App tests use `mock_pipeline` fixture from `conftest.py` to avoid loading models; `mock_pipeline_loading` simulates the not-yet-ready state
68+
- `pipeline_ready` is a `threading.Event` in `app.py`; tests patch it with set/unset events
6769
- Docstrings follow numpy convention
6870
- Line length: 100 chars
6971
- Python 3.12+ (uses `X | Y` union syntax)

README.md

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ The entire system runs locally with no external API calls, no paid dependencies,
7474
- **Semantic search** -- find verses by meaning, not just keywords
7575
- **Two-stage retrieval** -- FAISS vector search for recall, cross-encoder reranking for precision
7676
- **Contextual results** -- each match includes surrounding verses for readable context
77+
- **Instant startup** -- background pipeline loading; UI available in < 1s, search auto-retries until models are ready
7778
- **Fast** -- sub-2s response times on CPU
7879
- **35,000+ verses** -- complete French Bible (AELF translation)
7980
- **PWA-ready** -- offline support via service worker, installable on mobile
@@ -107,7 +108,7 @@ Open [http://localhost:8000](http://localhost:8000) in your browser.
107108
|--------|----------------|------------------------------------------|
108109
| GET | `/` | Main SPA entry point |
109110
| POST | `/search` | Search (accepts `query` form field, returns HTML fragment) |
110-
| GET | `/health` | Health check (`{"status": "ok"}`) |
111+
| GET | `/health` | Health check (200 `ok` or 503 `loading`) |
111112
| GET | `/robots.txt` | Robots.txt for crawlers |
112113
| GET | `/sitemap.xml` | XML sitemap for crawlers |
113114

@@ -145,15 +146,17 @@ flowchart LR
145146

146147
```
147148
config.py # Central configuration (paths, models, thresholds)
148-
app.py # FastAPI application + lifespan setup
149+
app.py # FastAPI application + background pipeline loading
149150
rag/ # Core package
150151
embeddings.py # Model loading and text encoding
151152
ingest.py # Ingestion: filter, embed, index
152153
retrieve.py # Two-stage retrieval: FAISS + cross-encoder
153-
templates/ # Jinja2 HTML fragments
154-
results.html # Search results (Embla Carousel)
155-
error.html # Error display
156-
no_results.html # No results feedback
154+
templates/ # Jinja2 HTML fragments
155+
results.html # Search results (Embla Carousel)
156+
context_verses.html # Surrounding context verses
157+
loading.html # Loading state with HTMX auto-retry
158+
error.html # Error display
159+
no_results.html # No results feedback
157160
static/ # Frontend assets (no build step)
158161
index.html # SPA entry point (HTMX)
159162
styles.css # Design system (CSS custom properties)
@@ -175,6 +178,7 @@ data/ # Generated artifacts (gitignored)
175178
|-----------------|------------------------------------------------------|---------------------------------------|
176179
| Embeddings | `paraphrase-multilingual-MiniLM-L12-v2` | 384-dim multilingual sentence encoder |
177180
| Reranker | `mmarco-mMiniLMv2-L12-H384-v1` | Cross-encoder for precision reranking |
181+
| Inference | ONNX Runtime | Optimized CPU inference backend |
178182
| Vector index | FAISS (`IndexFlatIP`) | Fast inner-product similarity search |
179183
| Backend | FastAPI + Uvicorn | Async HTTP server |
180184
| Frontend | HTMX + Embla Carousel + vanilla CSS/JS | No-build interactive UI |

app.py

Lines changed: 41 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import logging
44
import re
5+
import threading
56
from collections.abc import AsyncGenerator
67
from contextlib import asynccontextmanager
78
from html import escape as html_escape
@@ -13,6 +14,7 @@
1314
from fastapi.responses import (
1415
FileResponse,
1516
HTMLResponse,
17+
JSONResponse,
1618
PlainTextResponse,
1719
RedirectResponse,
1820
Response,
@@ -31,6 +33,7 @@
3133

3234
# Module-level state set during lifespan
3335
pipeline: dict[str, Any] = {}
36+
pipeline_ready = threading.Event()
3437

3538
templates = Jinja2Templates(directory=Path(__file__).parent / "templates")
3639

@@ -153,24 +156,36 @@ def nl2br(value: str) -> Markup:
153156
return Markup(escaped.replace("\n", "<br>"))
154157

155158

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+
156181
@asynccontextmanager
157182
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()
172186
yield
173187
pipeline.clear()
188+
pipeline_ready.clear()
174189

175190

176191
app = FastAPI(title="RAG Bible", lifespan=lifespan)
@@ -257,14 +272,23 @@ def sitemap_xml() -> Response:
257272

258273

259274
@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"})
263280

264281

265282
@app.post("/search", response_class=HTMLResponse) # type: ignore[misc]
266283
def search_endpoint(request: Request, query: str = Form("")) -> HTMLResponse:
267284
"""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+
268292
cleaned = sanitize_query(query)
269293

270294
if not cleaned:

templates/loading.html

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
<div class="status-message loading-message" role="status" aria-live="polite"
2+
hx-post="/search" hx-trigger="load delay:2s"
3+
hx-target="#results" hx-swap="innerHTML"
4+
hx-vals='{"query": "{{ query }}"}'>
5+
<p>Chargement des modeles en cours...</p>
6+
</div>

tests/conftest.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,8 +202,13 @@ def _mock_context(result: dict[str, Any], *_args: Any, **_kwargs: Any) -> list[d
202202
@pytest.fixture
203203
def mock_pipeline() -> Generator[None, None, None]:
204204
"""Patch app pipeline so no models are loaded."""
205+
import threading
206+
207+
ready = threading.Event()
208+
ready.set()
205209
with (
206210
patch("app.pipeline", {"loaded": True, "mapping": [], "verse_index": {}}),
211+
patch("app.pipeline_ready", ready),
207212
patch("app._run_search", side_effect=lambda q: _mock_search_results(relevant=True)),
208213
patch("app.get_verse_context", side_effect=_mock_context),
209214
):
@@ -213,14 +218,32 @@ def mock_pipeline() -> Generator[None, None, None]:
213218
@pytest.fixture
214219
def mock_pipeline_low_scores() -> Generator[None, None, None]:
215220
"""Patch app pipeline with only low-score results."""
221+
import threading
222+
223+
ready = threading.Event()
224+
ready.set()
216225
with (
217226
patch("app.pipeline", {"loaded": True, "mapping": [], "verse_index": {}}),
227+
patch("app.pipeline_ready", ready),
218228
patch("app._run_search", side_effect=lambda q: _mock_search_results(relevant=False)),
219229
patch("app.get_verse_context", side_effect=_mock_context),
220230
):
221231
yield
222232

223233

234+
@pytest.fixture
235+
def mock_pipeline_loading() -> Generator[None, None, None]:
236+
"""Patch app pipeline in loading state (not ready)."""
237+
import threading
238+
239+
not_ready = threading.Event() # not set = still loading
240+
with (
241+
patch("app.pipeline", {}),
242+
patch("app.pipeline_ready", not_ready),
243+
):
244+
yield
245+
246+
224247
@pytest.fixture
225248
def client(mock_pipeline: None) -> TestClient:
226249
"""FastAPI test client with mocked pipeline."""

tests/test_app.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,26 @@ def test_returns_ok(self, client: TestClient) -> None:
6565
assert response.status_code == 200
6666
assert response.json() == {"status": "ok"}
6767

68+
def test_returns_503_while_loading(self, mock_pipeline_loading: None) -> None:
69+
from app import app as fastapi_app
70+
71+
loading_client = TestClient(fastapi_app, raise_server_exceptions=False)
72+
response = loading_client.get("/health")
73+
assert response.status_code == 503
74+
assert response.json() == {"status": "loading"}
75+
76+
77+
@pytest.mark.unit
78+
class TestSearchLoading:
79+
def test_search_returns_loading_fragment(self, mock_pipeline_loading: None) -> None:
80+
from app import app as fastapi_app
81+
82+
loading_client = TestClient(fastapi_app, raise_server_exceptions=False)
83+
response = loading_client.post("/search", data={"query": "amour"})
84+
assert response.status_code == 200
85+
assert "Chargement" in response.text
86+
assert "hx-post" in response.text
87+
6888

6989
@pytest.mark.unit
7090
class TestSearchEndpoint:

0 commit comments

Comments
 (0)