Why: - Plan 001 Phase 4 needs batched, concurrency-bounded embedding wired into the inline upload path, with process-wide capacity/timeout/chunk-limit guards (ADR-0017). - The BM25 analyzer and dense-model config are ported from the `emet` evaluation lab, which benchmarked them against the real Farsi corpus (bm25-fa-norm-stop; nomic-embed-text-v2-moe at 768-dim; text-embedding-3-large at native 3072-dim), closing open items in ADR-0001/ADR-0005. Changes: - New: embedding ports, orchestration (embed_chunks), request-bounds helpers, and dense/sparse adapters (analyzers.py, bm25.py, openai_compatible.py). - upload.py now parses/chunks/embeds inline behind INGESTION_MAX_CONCURRENCY (503), INGESTION_TIMEOUT_SECONDS (504), and the chunk-count ceiling (413); every failure path still writes a terminal job row. - Lifespan builds and warms both dense embedders at startup (fail-soft) and creates the sparse embedder and concurrency semaphore. - httpx moves from dev to main dependencies (adapters use it directly). Impact: - Qdrant point upserts are still Phase 5 -- chunks_indexed stays 0. - New EMBEDDING_* env vars documented in .env.example; safe defaults. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
51 lines
1.8 KiB
Python
51 lines
1.8 KiB
Python
import pytest
|
|
from fastapi import FastAPI
|
|
from httpx import AsyncClient
|
|
|
|
from src.bootstrap.lifespan import _warm_dense_embedders
|
|
from src.config import Settings
|
|
from tests.fakes import FakeDenseEmbedder
|
|
|
|
pytestmark = [pytest.mark.unit, pytest.mark.asyncio]
|
|
|
|
|
|
async def test_lifespan_starts_and_stops_without_docker(client: AsyncClient) -> None:
|
|
response = await client.get("/healthz")
|
|
assert response.status_code == 200
|
|
|
|
|
|
async def test_lifespan_binds_resources_to_app_state(app: FastAPI, client: AsyncClient) -> None:
|
|
await client.get("/healthz")
|
|
resources = app.state.resources
|
|
assert isinstance(resources.settings, Settings)
|
|
assert resources.minio_client is not None
|
|
assert len(resources.dense_embedders) == 2
|
|
assert {e.name for e in resources.dense_embedders} == {"dense_nomic", "dense_openai"}
|
|
assert resources.sparse_embedder.name == "sparse"
|
|
assert resources.ingestion_concurrency_limiter is not None
|
|
|
|
|
|
async def test_warm_dense_embedders_calls_every_embedder() -> None:
|
|
"""Pays the model-load cost at boot instead of on a user's first upload:
|
|
a cold nomic load outruns INGESTION_TIMEOUT_SECONDS entirely.
|
|
"""
|
|
embedders = [FakeDenseEmbedder(name="dense_nomic"), FakeDenseEmbedder(name="dense_openai")]
|
|
|
|
await _warm_dense_embedders(embedders)
|
|
|
|
assert all(e.calls for e in embedders)
|
|
|
|
|
|
async def test_warm_dense_embedders_survives_an_unreachable_embedder() -> None:
|
|
"""A down embedder must not stop the process booting -- otherwise the
|
|
service cannot come up to report its own health. `/readyz` owns that
|
|
signal, not startup.
|
|
"""
|
|
failing = FakeDenseEmbedder(name="dense_nomic", fail_next=True)
|
|
healthy = FakeDenseEmbedder(name="dense_openai")
|
|
|
|
await _warm_dense_embedders([failing, healthy])
|
|
|
|
# The failure is swallowed *and* does not abort the remaining warm-ups.
|
|
assert healthy.calls
|