pagepiper/tests/conftest.py
pyr0ball 89a58ec9b0 feat: replace nomic-embed-text retriever with Agent-ModernColBERT
Bi-encoder embeddings collapse a whole query into one vector, losing
multi-part reasoning structure — queries like "the procedure for setting
an IP on an AVC-X" or "what is the action economy for a fighter casting
a spell while prone" lose nuance. Agent-ModernColBERT is a late-interaction
retriever: per-token embeddings, scored via MaxSim at query time, built
specifically for agentic/multi-hop queries.

Implements Option A from the issue (in-process, via `pylate`) rather than
Option B (managed cf-orch service) — cf-orch already has `agent-moderncolbert`
registered in model_registry.yaml with a `pagepiper/retrieve` assignment
in assignments.yaml pointing at it and referencing this issue directly,
someone had already pre-wired that side.

- app/services/colbert_index.py: new ColBERTIndex class, mirrors
  BM25Index's dirty-flag/rebuild-from-SQLite pattern exactly — no
  separate per-shelve indexing step needed, just mark_dirty() on the
  same callback that already marks BM25 dirty.
- app/services/retriever.py: hybrid_search's semantic half now merges
  BM25 with ColBERT MaxSim scores (min-max normalized per-batch, since
  MaxSim is unbounded unlike the old sqlite-vec L2-distance path) instead
  of Ollama-embed + sqlite-vec cosine. BM25 merge/rank/per-doc-cap/
  adjacent-chunk-window logic is unchanged.
- app/main.py / app/deps.py: per-user ColBERTIndex registry, same
  pattern as the existing per-user BM25Index registry.
- Existing BYOK tier gate preserved exactly (llm is None check) — this
  is a retrieval-technology swap, not a tier/licensing change. The
  ColBERT model runs locally via pylate with no Ollama dependency, but
  gating still follows product tiering.
- 12 new tests. pylate is intentionally NOT installed in the dev/test
  env — see the cf-sysadmin skill's "Known Gotchas" for why (installing
  it directly into the shared `cf` conda env broke several other
  services' torch/transformers pins on 2026-07-10). Tests inject fake
  pylate modules via sys.modules instead.

Known follow-up (not addressed here): shelve scripts still compute and
store Ollama embeddings into `page_vecs` at shelve time — that table is
no longer read by search/chat now that retrieval uses the ColBERT index.
Removing the now-redundant embedding step is separate cleanup.

Closes: #8
2026-07-10 19:02:12 -07:00

72 lines
2.2 KiB
Python

# tests/conftest.py
"""Shared fixtures for pagepiper test suite."""
from __future__ import annotations
import sqlite3
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
@pytest.fixture
def test_db(tmp_path) -> str:
db_path = str(tmp_path / "test.db")
schema = Path("migrations/001_initial_schema.sql").read_text()
conn = sqlite3.connect(db_path)
conn.executescript(schema)
conn.commit()
conn.close()
return db_path
@pytest.fixture
def client(test_db, tmp_path, monkeypatch):
monkeypatch.setenv("PAGEPIPER_DATA_DIR", str(tmp_path))
monkeypatch.setenv("PAGEPIPER_WATCH_DIR", str(tmp_path / "books"))
(tmp_path / "books").mkdir(exist_ok=True)
import app.main as _main_module
from app.config import LOCAL_USER_ID
from app.deps import UserCtx, get_db, get_user_ctx
from app.main import app
from app.services.bm25_index import BM25Index
from app.services.colbert_index import ColBERTIndex
from app.startup import apply_migrations, check_and_rebuild_vec_schema
monkeypatch.setattr(_main_module, "_apply_migrations", lambda: None, raising=False)
monkeypatch.setattr(
"app.startup.apply_migrations", lambda *a, **kw: None
)
monkeypatch.setattr(
"app.startup.check_and_rebuild_vec_schema", lambda *a, **kw: None
)
test_bm25 = BM25Index()
test_bm25.mark_dirty()
test_colbert = ColBERTIndex(index_dir=str(tmp_path / "colbert_index"))
def override_user_ctx():
return UserCtx(
user_id=LOCAL_USER_ID,
db_path=test_db,
vec_db_path=str(tmp_path / "test_vecs.db"),
data_dir=Path(tmp_path),
watch_dir=Path(tmp_path) / "books",
bm25=test_bm25,
colbert=test_colbert,
)
def override_db():
conn = sqlite3.connect(test_db)
conn.execute("PRAGMA foreign_keys = ON")
conn.row_factory = sqlite3.Row
try:
yield conn
finally:
conn.close()
app.dependency_overrides[get_user_ctx] = override_user_ctx
app.dependency_overrides[get_db] = override_db
yield TestClient(app)
app.dependency_overrides.clear()