haiku.rag/tests/test_doctor.py
Yiorgis Gozadinos 61df6b65a6
Guard against an FTS index that covers no rows
lance serves unsorted results with matching rows dropped when a
declared FTS index has indexed nothing, or when a populated table has
no FTS index at all. doctor fails on both: vacuum remediation for an
existing index, rebuild --embed-only for a missing one, since optimize
never creates an index. The chunk repository warns once per repository
on the first FTS or hybrid search against either state; a failing
coverage check is logged and never fails the search. Removes
_ensure_fts_index, which had no callers.
2026-08-31 18:42:31 +03:00

1335 lines
43 KiB
Python

import json
from importlib import metadata
from unittest.mock import AsyncMock, MagicMock
import lancedb
import numpy as np
import pytest
import yaml
from typer.testing import CliRunner
from haiku.rag.cli import _cli as cli
from haiku.rag.config.models import (
AppConfig,
ConversionOptions,
DoclingServeConfig,
DoctorConfig,
DuplicateDetectionConfig,
EmbeddingModelConfig,
EmbeddingsConfig,
ModelConfig,
PictureDescriptionConfig,
ProcessingConfig,
ProvidersConfig,
)
from haiku.rag.doctor import (
CheckResult,
DoctorReport,
Severity,
_active_models,
_check_api_keys,
_check_duplicate_documents,
_check_embedding_drift,
_check_vector_index,
_duplicate_families,
_model_present,
_probe_endpoint,
_provider_targets,
_resolve_endpoint,
_sample,
run_doctor,
run_provider_checks,
)
from haiku.rag.store.schema import (
DocumentItemRecord,
DocumentMetaRecord,
DocumentRecord,
SettingsRecord,
create_chunk_model,
)
runner = CliRunner()
CURRENT_VERSION = metadata.version("haiku.rag-slim")
VECTOR_DIM = 4
ChunkRecord = create_chunk_model(VECTOR_DIM)
def _config(
provider: str = "ollama",
name: str = "test",
vector_dim: int = VECTOR_DIM,
multimodal: bool = False,
):
return AppConfig(
embeddings=EmbeddingsConfig(
model=EmbeddingModelConfig(
provider=provider,
name=name,
vector_dim=vector_dim,
multimodal=multimodal,
)
)
)
async def _build_db(
path,
*,
version: str = CURRENT_VERSION,
provider: str = "ollama",
name: str = "test",
vector_dim: int = VECTOR_DIM,
stored_vector_dim: int | None = None,
fts_index: str | None = "covering",
):
"""Create a consistent single-document database without touching an embedder.
``stored_vector_dim`` records a different dimension in settings than the
chunks table actually uses, to exercise the vector-dimension check.
``fts_index`` places the chunks FTS index: "covering" builds it over the
rows, "empty" builds it before them, None never builds it.
"""
from lancedb.index import FTS
db = await lancedb.connect_async(path)
settings_tbl = await db.create_table("settings", schema=SettingsRecord)
docs_tbl = await db.create_table("documents", schema=DocumentRecord)
meta_tbl = await db.create_table("document_meta", schema=DocumentMetaRecord)
chunks_tbl = await db.create_table("chunks", schema=create_chunk_model(vector_dim))
items_tbl = await db.create_table("document_items", schema=DocumentItemRecord)
if fts_index == "empty":
await chunks_tbl.create_index("content_fts", config=FTS())
await settings_tbl.add(
[
SettingsRecord(
id="settings",
settings=json.dumps(
{
"version": version,
"embeddings": {
"model": {
"provider": provider,
"name": name,
"vector_dim": stored_vector_dim or vector_dim,
}
},
}
),
)
]
)
await docs_tbl.add([DocumentRecord(id="d1", content="hello")])
await meta_tbl.add([DocumentMetaRecord(id="d1", uri="test://d1")])
await items_tbl.add(
[
DocumentItemRecord(
document_id="d1", position=0, self_ref="#/texts/0", text="x"
)
]
)
chunk_model = create_chunk_model(vector_dim)
await chunks_tbl.add(
[
chunk_model(
id="c1",
document_id="d1",
content="hello",
metadata=json.dumps({"doc_item_refs": ["#/texts/0"]}),
vector=[0.1] * vector_dim,
)
]
)
if fts_index == "covering":
await chunks_tbl.create_index("content_fts", config=FTS())
return db
def _result(report: DoctorReport, name: str) -> CheckResult:
return next(r for r in report.results if r.name == name)
@pytest.fixture(autouse=True)
def _stub_provider_probe(monkeypatch):
"""Default every provider probe to reachable with the test models present,
so database-integrity tests don't depend on a live Ollama. Provider tests
re-patch this with their own behavior."""
async def probe(_client, _url, _headers):
return (
True,
None,
{
"models": [
{"name": "test"},
{"name": "gpt-oss:latest"},
{"name": "qwen3-embedding:4b"},
]
},
)
monkeypatch.setattr("haiku.rag.doctor._probe_endpoint", probe)
@pytest.mark.asyncio
async def test_healthy_db_all_ok(temp_db_path):
await _build_db(temp_db_path)
report = await run_doctor(_config(), temp_db_path, {})
assert not report.failed
assert report.count(Severity.WARN) == 0
assert all(r.severity is Severity.OK for r in report.results)
@pytest.mark.asyncio
async def test_doctor_reports_progress(temp_db_path):
await _build_db(temp_db_path)
labels: list[str] = []
await run_doctor(_config(), temp_db_path, {}, on_progress=labels.append)
assert "Inspecting tables" in labels
assert "Scanning chunk vectors" in labels
assert "Detecting near-duplicate documents" in labels
assert "Probing provider endpoints" in labels
@pytest.mark.asyncio
async def test_empty_db_fails(temp_db_path):
report = await run_doctor(_config(), temp_db_path, {})
assert report.failed
assert _result(report, "tables_present").message == "Database is empty."
@pytest.mark.asyncio
async def test_missing_table_fails_without_opening_store(temp_db_path):
db = await lancedb.connect_async(temp_db_path)
await db.create_table("settings", schema=SettingsRecord)
report = await run_doctor(_config(), temp_db_path, {})
assert report.failed
tables = _result(report, "tables_present")
assert tables.severity is Severity.FAIL
assert "documents" in tables.details
@pytest.mark.asyncio
async def test_missing_fts_index_fails(temp_db_path):
"""optimize indexes the rows of an index that exists; it never creates a
missing one, so the remediation has to build it."""
await _build_db(temp_db_path, fts_index=None)
report = await run_doctor(_config(), temp_db_path, {})
result = _result(report, "fts_index_coverage")
assert result.severity is Severity.FAIL
assert "chunks.content_fts" in result.details[0]
assert "no index over 1 rows" in result.details[0]
assert "rebuild" in (result.remediation or "")
assert "vacuum" not in (result.remediation or "")
assert report.failed
@pytest.mark.asyncio
async def test_fts_index_covering_no_rows_fails(temp_db_path):
"""The state a bulk write without a closing vacuum leaves behind."""
await _build_db(temp_db_path, fts_index="empty")
report = await run_doctor(_config(), temp_db_path, {})
result = _result(report, "fts_index_coverage")
assert result.severity is Severity.FAIL
assert "0 of 1 rows indexed" in result.details[0]
assert "vacuum" in (result.remediation or "")
@pytest.mark.asyncio
async def test_fts_coverage_passes_an_empty_table(temp_db_path):
db = await _build_db(temp_db_path)
chunks_tbl = await db.open_table("chunks")
await chunks_tbl.delete("id = 'c1'")
report = await run_doctor(_config(), temp_db_path, {})
assert _result(report, "fts_index_coverage").severity is Severity.OK
@pytest.mark.asyncio
async def test_orphaned_chunk_fails(temp_db_path):
db = await _build_db(temp_db_path)
chunks_tbl = await db.open_table("chunks")
await chunks_tbl.add(
[
ChunkRecord(
id="orphan",
document_id="ghost",
content="x",
vector=[0.2] * VECTOR_DIM,
)
]
)
report = await run_doctor(_config(), temp_db_path, {})
result = _result(report, "orphaned_chunks")
assert result.severity is Severity.FAIL
assert "ghost" in result.details
assert report.failed
@pytest.mark.asyncio
async def test_orphaned_document_item_fails(temp_db_path):
db = await _build_db(temp_db_path)
items_tbl = await db.open_table("document_items")
await items_tbl.add(
[DocumentItemRecord(document_id="ghost", position=0, self_ref="#/texts/0")]
)
report = await run_doctor(_config(), temp_db_path, {})
assert _result(report, "orphaned_document_items").severity is Severity.FAIL
async def _add_doc(db, doc_id, *, items, metadata=None, chunks=None):
docs_tbl = await db.open_table("documents")
meta_tbl = await db.open_table("document_meta")
await docs_tbl.add([DocumentRecord(id=doc_id, content="x")])
await meta_tbl.add(
[
DocumentMetaRecord(
id=doc_id,
uri=f"test://{doc_id}",
metadata=json.dumps(metadata or {}),
)
]
)
if items:
items_tbl = await db.open_table("document_items")
await items_tbl.add(items)
if chunks:
chunks_tbl = await db.open_table("chunks")
await chunks_tbl.add(chunks)
@pytest.mark.asyncio
async def test_document_with_text_but_no_chunks_warns(temp_db_path):
db = await _build_db(temp_db_path)
await _add_doc(
db,
"d2",
items=[
DocumentItemRecord(
document_id="d2",
position=0,
self_ref="#/texts/0",
label="text",
text="real content",
)
],
)
report = await run_doctor(_config(), temp_db_path, {})
result = _result(report, "documents_text_no_chunks")
assert result.severity is Severity.WARN
assert "d2" in result.details
assert report.count(Severity.FAIL) == 0
@pytest.mark.asyncio
async def test_empty_document_no_chunks_is_ok(temp_db_path):
db = await _build_db(temp_db_path)
await _add_doc(db, "d2", items=[])
report = await run_doctor(_config(), temp_db_path, {})
assert _result(report, "documents_without_chunks").severity is Severity.OK
@pytest.mark.asyncio
async def test_heading_only_document_no_chunks_is_ok(temp_db_path):
db = await _build_db(temp_db_path)
await _add_doc(
db,
"d2",
items=[
DocumentItemRecord(
document_id="d2",
position=0,
self_ref="#/texts/0",
label="section_header",
text="title: haiku.rag",
)
],
)
report = await run_doctor(_config(), temp_db_path, {})
assert _result(report, "documents_without_chunks").severity is Severity.OK
assert all(r.name != "documents_text_no_chunks" for r in report.results)
@pytest.mark.asyncio
async def test_image_only_document_text_embedder_warns(temp_db_path):
db = await _build_db(temp_db_path)
await _add_doc(
db,
"d2",
items=[
DocumentItemRecord(
document_id="d2", position=0, self_ref="#/pictures/0", label="picture"
)
],
)
report = await run_doctor(_config(), temp_db_path, {})
result = _result(report, "documents_images_unsearchable")
assert result.severity is Severity.WARN
assert "d2" in result.details
@pytest.mark.asyncio
async def test_image_only_document_multimodal_embedder_warns(temp_db_path):
db = await _build_db(temp_db_path, provider="vllm", name="qwen-vl")
await _add_doc(
db,
"d2",
items=[
DocumentItemRecord(
document_id="d2", position=0, self_ref="#/pictures/0", label="picture"
)
],
)
report = await run_doctor(
_config(provider="vllm", name="qwen-vl", multimodal=True), temp_db_path, {}
)
result = _result(report, "documents_pictures_no_chunks")
assert result.severity is Severity.WARN
assert "d2" in result.details
@pytest.mark.asyncio
async def test_document_meta_parity_fails(temp_db_path):
db = await _build_db(temp_db_path)
docs_tbl = await db.open_table("documents")
await docs_tbl.add([DocumentRecord(id="d2", content="no meta")])
report = await run_doctor(_config(), temp_db_path, {})
result = _result(report, "document_meta_parity")
assert result.severity is Severity.FAIL
assert any("d2" in d for d in result.details)
@pytest.mark.asyncio
async def test_dangling_doc_item_ref_fails(temp_db_path):
db = await _build_db(temp_db_path)
chunks_tbl = await db.open_table("chunks")
await chunks_tbl.add(
[
ChunkRecord(
id="c2",
document_id="d1",
content="x",
metadata=json.dumps({"doc_item_refs": ["#/texts/999"]}),
vector=[0.3] * VECTOR_DIM,
)
]
)
report = await run_doctor(_config(), temp_db_path, {})
result = _result(report, "dangling_doc_item_refs")
assert result.severity is Severity.FAIL
assert "c2" in result.details
@pytest.mark.asyncio
async def test_unembedded_chunk_warns(temp_db_path):
db = await _build_db(temp_db_path)
chunks_tbl = await db.open_table("chunks")
await chunks_tbl.add(
[
ChunkRecord(
id="zero",
document_id="d1",
content="x",
metadata=json.dumps({"doc_item_refs": ["#/texts/0"]}),
vector=[0.0] * VECTOR_DIM,
)
]
)
report = await run_doctor(_config(), temp_db_path, {})
result = _result(report, "unembedded_chunks")
assert result.severity is Severity.WARN
assert "zero" in result.details
assert not report.failed
@pytest.mark.asyncio
async def test_chunked_document_without_items_warns(temp_db_path):
db = await _build_db(temp_db_path)
await _add_doc(
db,
"d2",
items=[],
chunks=[
ChunkRecord(
id="c2", document_id="d2", content="x", vector=[0.1] * VECTOR_DIM
)
],
)
report = await run_doctor(_config(), temp_db_path, {})
result = _result(report, "documents_without_items")
assert result.severity is Severity.WARN
assert "d2" in result.details
@pytest.mark.asyncio
async def test_empty_document_without_items_is_ok(temp_db_path):
db = await _build_db(temp_db_path)
await _add_doc(db, "d2", items=[])
report = await run_doctor(_config(), temp_db_path, {})
assert _result(report, "documents_without_items").severity is Severity.OK
@pytest.mark.asyncio
async def test_missing_picture_data_in_text_document_is_ok(temp_db_path):
db = await _build_db(temp_db_path)
await _add_doc(
db,
"d2",
metadata={"content_type": "text/markdown"},
items=[
DocumentItemRecord(
document_id="d2",
position=0,
self_ref="#/pictures/0",
label="picture",
picture_data=None,
)
],
)
report = await run_doctor(_config(), temp_db_path, {})
assert _result(report, "picture_data").severity is Severity.OK
@pytest.mark.asyncio
async def test_missing_picture_data_in_pdf_document_warns(temp_db_path):
db = await _build_db(temp_db_path)
await _add_doc(
db,
"d2",
metadata={"content_type": "application/pdf"},
items=[
DocumentItemRecord(
document_id="d2",
position=0,
self_ref="#/pictures/0",
label="picture",
picture_data=None,
)
],
)
report = await run_doctor(_config(), temp_db_path, {})
result = _result(report, "picture_data")
assert result.severity is Severity.WARN
assert "d2" in result.details
@pytest.mark.asyncio
async def test_missing_picture_data_warns(temp_db_path):
db = await _build_db(temp_db_path)
items_tbl = await db.open_table("document_items")
await items_tbl.add(
[
DocumentItemRecord(
document_id="d1",
position=1,
self_ref="#/pictures/0",
label="picture",
picture_data=None,
)
]
)
report = await run_doctor(_config(), temp_db_path, {})
assert _result(report, "picture_data").severity is Severity.WARN
assert not report.failed
@pytest.mark.asyncio
async def test_picture_with_data_ok(temp_db_path):
db = await _build_db(temp_db_path)
items_tbl = await db.open_table("document_items")
await items_tbl.add(
[
DocumentItemRecord(
document_id="d1",
position=1,
self_ref="#/pictures/0",
label="picture",
picture_data=b"\x89PNG",
)
]
)
report = await run_doctor(_config(), temp_db_path, {})
assert _result(report, "picture_data").severity is Severity.OK
@pytest.mark.asyncio
async def test_embedding_name_drift_warns(temp_db_path):
await _build_db(temp_db_path, name="test")
report = await run_doctor(_config(name="different"), temp_db_path, {})
result = _result(report, "embedding_drift")
assert result.severity is Severity.WARN
assert not report.failed
@pytest.mark.asyncio
async def test_embedding_dim_drift_fails(temp_db_path):
await _build_db(temp_db_path, vector_dim=VECTOR_DIM)
report = await run_doctor(_config(vector_dim=VECTOR_DIM + 1), temp_db_path, {})
assert _result(report, "embedding_drift").severity is Severity.FAIL
assert report.failed
@pytest.mark.asyncio
async def test_embedding_provider_drift_warns(temp_db_path):
await _build_db(temp_db_path, provider="ollama")
report = await run_doctor(_config(provider="vllm"), temp_db_path, {})
result = _result(report, "embedding_drift")
assert result.severity is Severity.WARN
assert any("provider" in d for d in result.details)
@pytest.mark.asyncio
async def test_vector_dimension_mismatch_fails(temp_db_path):
await _build_db(
temp_db_path, vector_dim=VECTOR_DIM, stored_vector_dim=VECTOR_DIM + 1
)
report = await run_doctor(_config(vector_dim=VECTOR_DIM + 1), temp_db_path, {})
result = _result(report, "vector_dimension")
assert result.severity is Severity.FAIL
assert report.failed
@pytest.mark.asyncio
async def test_pending_migration_warns(temp_db_path):
await _build_db(temp_db_path, version="0.40.0")
report = await run_doctor(_config(), temp_db_path, {})
assert _result(report, "pending_migrations").severity is Severity.WARN
assert not report.failed
@pytest.mark.asyncio
async def test_missing_api_key_fails(temp_db_path):
await _build_db(temp_db_path, provider="openai", name="text-embedding-3-small")
config = _config(provider="openai", name="text-embedding-3-small")
report = await run_doctor(config, temp_db_path, environ={})
result = _result(report, "api_keys")
assert result.severity is Severity.FAIL
assert any("OPENAI_API_KEY" in d for d in result.details)
@pytest.mark.asyncio
async def test_present_api_key_ok(temp_db_path):
await _build_db(temp_db_path, provider="openai", name="text-embedding-3-small")
config = _config(provider="openai", name="text-embedding-3-small")
report = await run_doctor(config, temp_db_path, environ={"OPENAI_API_KEY": "sk-x"})
assert _result(report, "api_keys").severity is Severity.OK
@pytest.mark.asyncio
async def test_settings_row_missing_fails(temp_db_path):
db = await _build_db(temp_db_path)
settings_tbl = await db.open_table("settings")
await settings_tbl.delete("id = 'settings'")
report = await run_doctor(_config(), temp_db_path, {})
assert _result(report, "settings_row").severity is Severity.FAIL
assert report.failed
@pytest.mark.asyncio
async def test_many_orphans_are_sampled(temp_db_path):
db = await _build_db(temp_db_path)
chunks_tbl = await db.open_table("chunks")
await chunks_tbl.add(
[
ChunkRecord(
id=f"o{i}",
document_id=f"ghost{i}",
content="x",
vector=[0.2] * VECTOR_DIM,
)
for i in range(8)
]
)
report = await run_doctor(_config(), temp_db_path, {})
details = _result(report, "orphaned_chunks").details
assert len(details) == 6
assert details[-1] == "... (+3 more)"
def test_sample_returns_all_within_limit():
assert _sample(["a", "b"]) == ["a", "b"]
def test_embedding_drift_ok_without_stored_identity():
assert _check_embedding_drift({}, _config()).severity is Severity.OK
def test_vector_index_ok_without_index_on_small_collection():
stats = {"chunks": {"num_rows": 10, "has_vector_index": False}}
assert _check_vector_index(stats).severity is Severity.OK
def test_vector_index_ok_without_index_on_moderate_collection():
stats = {"chunks": {"num_rows": 300, "has_vector_index": False}}
result = _check_vector_index(stats)
assert result.severity is Severity.OK
assert result.remediation is None
def test_vector_index_warns_for_large_collection_without_index():
stats = {"chunks": {"num_rows": 100_000, "has_vector_index": False}}
result = _check_vector_index(stats)
assert result.severity is Severity.WARN
assert result.remediation == "haiku-rag create-index"
def test_vector_index_warns_on_unindexed_backlog():
stats = {
"chunks": {"num_rows": 300, "has_vector_index": True, "num_unindexed_rows": 5}
}
assert _check_vector_index(stats).severity is Severity.WARN
def test_vector_index_ok_when_fully_indexed():
stats = {
"chunks": {"num_rows": 300, "has_vector_index": True, "num_unindexed_rows": 0}
}
assert _check_vector_index(stats).severity is Severity.OK
def test_cli_doctor_nonexistent_db_exits_1(tmp_path):
result = runner.invoke(cli, ["doctor", "--db", str(tmp_path / "nope.lancedb")])
assert result.exit_code == 1
assert "does not exist" in result.output
def test_cli_doctor_exits_0_when_healthy(monkeypatch):
app = MagicMock()
app.doctor = AsyncMock(return_value=False)
monkeypatch.setattr("haiku.rag.cli.create_app", lambda *_a, **_k: app)
result = runner.invoke(cli, ["doctor", "--db", "/tmp/whatever.lancedb"])
assert result.exit_code == 0
def test_cli_doctor_exits_1_on_failure(monkeypatch):
app = MagicMock()
app.doctor = AsyncMock(return_value=True)
monkeypatch.setattr("haiku.rag.cli.create_app", lambda *_a, **_k: app)
result = runner.invoke(cli, ["doctor", "--db", "/tmp/whatever.lancedb"])
assert result.exit_code == 1
# --- Active models / API keys ---
def test_api_key_not_required_for_custom_openai_base_url():
config = AppConfig(
embeddings=EmbeddingsConfig(
model=EmbeddingModelConfig(
provider="openai",
name="x",
vector_dim=4,
base_url="http://localhost:1234/v1",
)
)
)
assert _check_api_keys(config, {}).severity is Severity.OK
def test_api_key_required_for_openai_without_base_url():
config = AppConfig(
embeddings=EmbeddingsConfig(
model=EmbeddingModelConfig(provider="openai", name="x", vector_dim=4)
)
)
result = _check_api_keys(config, {})
assert result.severity is Severity.FAIL
assert any("OPENAI_API_KEY" in d for d in result.details)
def test_api_key_not_required_when_config_supplies_it():
"""A key in the config is the point of the field; doctor must not demand
the environment variable as well."""
config = AppConfig(
embeddings=EmbeddingsConfig(
model=EmbeddingModelConfig(
provider="openai", name="x", vector_dim=4, api_key="sk-inline"
)
)
)
assert _check_api_keys(config, {}).severity is Severity.OK
def test_active_models_includes_picture_description_when_enabled():
config = AppConfig(processing=ProcessingConfig(pictures="description"))
names = [model.name for model in _active_models(config)]
assert "ministral-3" in names
def test_active_models_excludes_picture_description_by_default():
names = [model.name for model in _active_models(AppConfig())]
assert "ministral-3" not in names
def test_active_models_includes_title_model_when_auto_title():
base = _active_models(AppConfig())
with_title = _active_models(AppConfig(processing=ProcessingConfig(auto_title=True)))
assert len(with_title) == len(base) + 1
def test_picture_description_model_checked_for_api_key():
config = AppConfig(
processing=ProcessingConfig(
pictures="description",
conversion_options=ConversionOptions(
picture_description=PictureDescriptionConfig(
model=ModelConfig(provider="openai", name="gpt-4o")
)
),
)
)
result = _check_api_keys(config, {})
assert result.severity is Severity.FAIL
assert any("OPENAI_API_KEY" in d for d in result.details)
# --- Provider connectivity ---
def test_resolve_endpoint_ollama_strips_v1():
assert _resolve_endpoint("ollama", "http://h:1/v1", "http://fallback") == (
"http://h:1/api/tags",
"ollama",
"http://h:1",
)
def test_resolve_endpoint_ollama_uses_provider_fallback():
assert _resolve_endpoint("ollama", None, "http://fallback:11434") == (
"http://fallback:11434/api/tags",
"ollama",
"http://fallback:11434",
)
def test_resolve_endpoint_vllm_default_and_models_path():
assert _resolve_endpoint("vllm", None, "http://o") == (
"http://localhost:8000/v1/models",
"openai",
"http://localhost:8000/v1",
)
def test_resolve_endpoint_vllm_appends_v1():
assert _resolve_endpoint("vllm", "http://vllm:8000", "http://o") == (
"http://vllm:8000/v1/models",
"openai",
"http://vllm:8000/v1",
)
def test_resolve_endpoint_openai_saas_is_skipped():
assert _resolve_endpoint("openai", None, "http://o") is None
def test_resolve_endpoint_openai_with_base_url():
assert _resolve_endpoint("openai", "http://lmstudio:1234/v1", "http://o") == (
"http://lmstudio:1234/v1/models",
"openai",
"http://lmstudio:1234/v1",
)
def test_resolve_endpoint_local_provider():
assert _resolve_endpoint("sentence-transformers", None, "http://o") == "local"
def test_model_present_tag_insensitive():
assert _model_present("gpt-oss", {"gpt-oss:latest"})
assert _model_present("qwen:4b", {"qwen:4b"})
assert not _model_present("qwen:4b", {"qwen:8b"})
def test_provider_targets_default_groups_ollama_models():
targets, local = _provider_targets(AppConfig())
assert not local
assert len(targets) == 1
entry = next(iter(targets.values()))
assert entry["kind"] == "ollama"
assert {"qwen3-embedding:4b", "gpt-oss"} <= entry["models"]
def test_provider_targets_includes_docling_serve():
config = AppConfig(
processing=ProcessingConfig(converter="docling-serve"),
providers=ProvidersConfig(
docling_serve=DoclingServeConfig(base_url="http://docling:5001")
),
)
targets, _ = _provider_targets(config)
assert "http://docling:5001/health" in targets
assert targets["http://docling:5001/health"]["kind"] == "docling-serve"
def test_provider_targets_carries_model_api_key():
"""A secured endpoint answers the probe only with its key."""
config = AppConfig(
embeddings=EmbeddingsConfig(
model=EmbeddingModelConfig(
provider="openai",
name="x",
vector_dim=4,
base_url="http://vllm:8000/v1",
api_key="sk-probe",
)
)
)
targets, _ = _provider_targets(config)
entry = targets["http://vllm:8000/v1/models"]
assert entry["headers"] == {"Authorization": "Bearer sk-probe"}
def test_provider_targets_carries_docling_serve_api_key():
config = AppConfig(
processing=ProcessingConfig(converter="docling-serve"),
providers=ProvidersConfig(
docling_serve=DoclingServeConfig(
base_url="http://docling:5001", api_key="ds-key"
)
),
)
targets, _ = _provider_targets(config)
assert targets["http://docling:5001/health"]["headers"] == {"X-Api-Key": "ds-key"}
def test_provider_targets_collects_local_providers():
config = AppConfig(
embeddings=EmbeddingsConfig(
model=EmbeddingModelConfig(
provider="sentence-transformers", name="x", vector_dim=4
)
)
)
_, local = _provider_targets(config)
assert "sentence-transformers" in local
def _fake_probe(result):
async def probe(_client, _url, _headers):
return result
return probe
@pytest.mark.asyncio
async def test_provider_check_ok_when_models_present(monkeypatch):
monkeypatch.setattr(
"haiku.rag.doctor._probe_endpoint",
_fake_probe(
(
True,
None,
{
"models": [
{"name": "qwen3-embedding:4b"},
{"name": "gpt-oss:latest"},
]
},
)
),
)
results = await run_provider_checks(AppConfig())
assert all(r.severity is Severity.OK for r in results)
@pytest.mark.asyncio
async def test_provider_check_warns_on_missing_model(monkeypatch):
monkeypatch.setattr(
"haiku.rag.doctor._probe_endpoint",
_fake_probe((True, None, {"models": [{"name": "something-else"}]})),
)
results = await run_provider_checks(AppConfig())
result = next(r for r in results if r.name.startswith("provider:"))
assert result.severity is Severity.WARN
assert result.details
@pytest.mark.asyncio
async def test_provider_check_fails_when_unreachable(monkeypatch):
monkeypatch.setattr(
"haiku.rag.doctor._probe_endpoint",
_fake_probe((False, "Connection refused", None)),
)
results = await run_provider_checks(AppConfig())
result = next(r for r in results if r.name.startswith("provider:"))
assert result.severity is Severity.FAIL
assert "Connection refused" in result.details
@pytest.mark.asyncio
async def test_provider_check_reports_local_provider(monkeypatch):
monkeypatch.setattr(
"haiku.rag.doctor._probe_endpoint",
_fake_probe((True, None, {"models": [{"name": "gpt-oss:latest"}]})),
)
config = AppConfig(
embeddings=EmbeddingsConfig(
model=EmbeddingModelConfig(
provider="sentence-transformers", name="x", vector_dim=4
)
)
)
results = await run_provider_checks(config)
local = next(r for r in results if r.name == "provider:sentence-transformers")
assert local.severity is Severity.OK
assert "local" in local.message
@pytest.mark.asyncio
async def test_run_doctor_includes_provider_results(temp_db_path, monkeypatch):
await _build_db(temp_db_path)
monkeypatch.setattr(
"haiku.rag.doctor._probe_endpoint",
_fake_probe(
(True, None, {"models": [{"name": "test"}, {"name": "gpt-oss:latest"}]})
),
)
report = await run_doctor(_config(), temp_db_path, {})
assert any(r.name.startswith("provider:") for r in report.results)
assert not report.failed
async def _probe_with_handler(handler, headers: dict[str, str] | None = None):
import httpx
transport = httpx.MockTransport(handler)
async with httpx.AsyncClient(transport=transport) as client:
return await _probe_endpoint(client, "http://x", headers or {})
@pytest.mark.asyncio
async def test_probe_endpoint_success_with_json():
import httpx
reachable, error, payload = await _probe_with_handler(
lambda _request: httpx.Response(200, json={"models": []})
)
assert reachable and error is None and payload == {"models": []}
@pytest.mark.asyncio
async def test_probe_endpoint_success_non_json():
import httpx
reachable, _, payload = await _probe_with_handler(
lambda _request: httpx.Response(200, content=b"not json")
)
assert reachable and payload is None
@pytest.mark.asyncio
async def test_probe_endpoint_http_error_status():
import httpx
reachable, error, _ = await _probe_with_handler(
lambda _request: httpx.Response(503)
)
assert not reachable
assert error is not None and "503" in error
@pytest.mark.asyncio
async def test_probe_endpoint_sends_headers():
"""A secured endpoint needs its key on the probe request too."""
import httpx
seen: dict[str, str] = {}
def handler(request):
seen.update(request.headers)
return httpx.Response(200, json={})
await _probe_with_handler(handler, {"Authorization": "Bearer sk-probe"})
assert seen["authorization"] == "Bearer sk-probe"
@pytest.mark.asyncio
async def test_probe_endpoint_connection_error():
import httpx
def handler(_request):
raise httpx.ConnectError("refused")
reachable, error, _ = await _probe_with_handler(handler)
assert not reachable
assert error is not None and "refused" in error
# --- Duplicate-document detection ----------------------------------------
def _centroids(
spec: dict[str, list[int]], dim: int = 8
) -> tuple[list[str], np.ndarray, np.ndarray]:
"""Summed one-hot centroids + chunk counts per document, as
``_duplicate_families`` consumes them.
Orthogonal one-hot chunks make the centroid cosine of two documents equal to
``shared / sqrt(len(a) * len(b))``: identical documents score 1.0, fully
distinct documents score 0.0.
"""
eye = np.eye(dim)
doc_ids = list(spec)
centroids = np.array(
[eye[idxs].sum(axis=0) for idxs in spec.values()], dtype=np.float32
)
counts = np.array([len(idxs) for idxs in spec.values()], dtype=np.int64)
return doc_ids, centroids, counts
def test_duplicate_families_identical_docs_flagged():
families = _duplicate_families(
*_centroids({"a": [0, 1, 2, 3], "b": [0, 1, 2, 3]}), DuplicateDetectionConfig()
)
assert len(families) == 1
assert set(families[0].members) == {"a", "b"}
assert families[0].similarity == {"a": pytest.approx(1.0), "b": pytest.approx(1.0)}
def test_duplicate_families_append_only_not_flagged():
# A is fully contained in the larger B, but their centroids diverge
# (cosine sqrt(3/6) ~= 0.71), so it stays below the similarity cutoff.
families = _duplicate_families(
*_centroids({"a": [0, 1, 2], "b": [0, 1, 2, 3, 4, 5]}),
DuplicateDetectionConfig(),
)
assert families == []
def test_duplicate_families_distinct_docs_none():
families = _duplicate_families(
*_centroids({"a": [0, 1, 2], "b": [3, 4, 5]}), DuplicateDetectionConfig()
)
assert families == []
def test_duplicate_families_three_way_one_family():
families = _duplicate_families(
*_centroids({"a": [0, 1, 2, 3], "b": [0, 1, 2, 3], "c": [0, 1, 2, 3]}),
DuplicateDetectionConfig(),
)
assert len(families) == 1
assert set(families[0].members) == {"a", "b", "c"}
# Equal sizes -> smallest id is kept.
assert families[0].keep == "a"
def test_duplicate_families_clique_single_family():
# A self-similar corpus (all identical) is one clique. Union-find collapses
# it to a single family without materializing every pair.
spec = {chr(ord("a") + k): [0, 1, 2, 3] for k in range(8)}
families = _duplicate_families(*_centroids(spec), DuplicateDetectionConfig())
assert len(families) == 1
assert set(families[0].members) == set(spec)
assert all(s == pytest.approx(1.0) for s in families[0].similarity.values())
def test_duplicate_families_tiny_docs_ignored():
# min_chunks = 3 excludes the one-chunk documents.
families = _duplicate_families(
*_centroids({"a": [0], "b": [0]}), DuplicateDetectionConfig()
)
assert families == []
def test_duplicate_families_threshold_is_configurable():
# Share 3 of 4 chunks each -> centroid cosine 0.75.
spec = {"a": [0, 1, 2, 3], "b": [0, 1, 2, 4]}
assert _duplicate_families(*_centroids(spec), DuplicateDetectionConfig()) == []
flagged = _duplicate_families(
*_centroids(spec), DuplicateDetectionConfig(similarity_threshold=0.7)
)
assert len(flagged) == 1
assert set(flagged[0].members) == {"a", "b"}
def test_duplicate_documents_report_truncates_summary():
pairs = 7 # more than the terminal detail cap of 5
spec: dict[str, list[int]] = {}
for k in range(pairs):
idx = [3 * k, 3 * k + 1, 3 * k + 2]
spec[f"a{k}"] = idx
spec[f"b{k}"] = list(idx)
uris = {d: f"file:///srv/shared/library/docs/{d}.pdf" for d in spec}
result = _check_duplicate_documents(
*_centroids(spec, dim=3 * pairs), uris, {}, DuplicateDetectionConfig()
)
assert result.severity is Severity.WARN
# The summary message still reports the full total.
assert f"{pairs} group(s)" in result.message
# The terminal detail shows only the first few groups and points at export.
assert sum(1 for d in result.details if d.startswith("group ")) == 5
assert any("more groups" in d and "--duplicates-out" in d for d in result.details)
assert any("keep #" in d for d in result.details)
def test_duplicate_documents_report_factors_common_path():
base = "file:///srv/shared/library/docs/"
uris = {"a": base + "alpha.pdf", "b": base + "beta.pdf"}
result = _check_duplicate_documents(
*_centroids({"a": [0, 1, 2], "b": [0, 1, 2]}, dim=3),
uris,
{},
DuplicateDetectionConfig(),
)
assert f"common path: {base}" in result.details
member_lines = [d for d in result.details if d.lstrip().startswith("#")]
assert {d.strip() for d in member_lines} == {"#1 alpha.pdf", "#2 beta.pdf"}
assert not any(base in d for d in member_lines)
def test_duplicate_documents_writes_yaml(tmp_path):
# a,b identical (a 4-chunk duplicate); c distinct and excluded.
spec = {"a": [0, 1, 2, 3], "b": [0, 1, 2, 3], "c": [4, 5, 6]}
uris = {"a": "file:///x/a.pdf", "b": "file:///x/b.pdf", "c": "file:///x/c.pdf"}
out = tmp_path / "dups.yaml"
_check_duplicate_documents(
*_centroids(spec, dim=8), uris, {}, DuplicateDetectionConfig(), yaml_path=out
)
data = yaml.safe_load(out.read_text())
assert len(data["groups"]) == 1
group = data["groups"][0]
assert group["group"] == 1 and group["keep"] == "a"
docs_out = group["documents"]
assert [d["document_id"] for d in docs_out] == ["a", "b"]
assert [d["document"] for d in docs_out] == ["file:///x/a.pdf", "file:///x/b.pdf"]
assert all(d["chunks"] == 4 for d in docs_out)
assert all(d["similarity"] == pytest.approx(1.0) for d in docs_out)
assert {d["document_id"]: d["keep_suggested"] for d in docs_out} == {
"a": True,
"b": False,
}
def test_duplicate_documents_writes_empty_yaml_when_none(tmp_path):
spec = {"a": [0, 1, 2], "b": [3, 4, 5]} # distinct
out = tmp_path / "dups.yaml"
_check_duplicate_documents(
*_centroids(spec, dim=6),
{"a": "u", "b": "v"},
{},
DuplicateDetectionConfig(),
yaml_path=out,
)
assert yaml.safe_load(out.read_text()) == {"groups": []}
async def _build_dup_db(path, docs: dict[str, list[int]], *, vector_dim: int = 8):
"""Build a multi-document database with one-hot chunk vectors."""
eye = np.eye(vector_dim)
db = await lancedb.connect_async(path)
settings_tbl = await db.create_table("settings", schema=SettingsRecord)
docs_tbl = await db.create_table("documents", schema=DocumentRecord)
meta_tbl = await db.create_table("document_meta", schema=DocumentMetaRecord)
chunk_model = create_chunk_model(vector_dim)
chunks_tbl = await db.create_table("chunks", schema=chunk_model)
items_tbl = await db.create_table("document_items", schema=DocumentItemRecord)
await settings_tbl.add(
[
SettingsRecord(
id="settings",
settings=json.dumps(
{
"version": CURRENT_VERSION,
"embeddings": {
"model": {
"provider": "ollama",
"name": "test",
"vector_dim": vector_dim,
}
},
}
),
)
]
)
for doc_id, idxs in docs.items():
await docs_tbl.add([DocumentRecord(id=doc_id, content="x")])
await meta_tbl.add([DocumentMetaRecord(id=doc_id, uri=f"test://{doc_id}")])
await items_tbl.add(
[
DocumentItemRecord(
document_id=doc_id, position=0, self_ref="#/texts/0", text="x"
)
]
)
await chunks_tbl.add(
[
chunk_model(
id=f"{doc_id}-c{n}",
document_id=doc_id,
content="x",
metadata=json.dumps({"doc_item_refs": ["#/texts/0"]}),
vector=eye[i].tolist(),
)
for n, i in enumerate(idxs)
]
)
return db
@pytest.mark.asyncio
async def test_duplicate_documents_check_warns_end_to_end(temp_db_path):
await _build_dup_db(temp_db_path, {"a": [0, 1, 2, 3], "b": [0, 1, 2, 3]})
report = await run_doctor(_config(vector_dim=8), temp_db_path, {})
result = _result(report, "duplicate_documents")
assert result.severity is Severity.WARN
blob = " ".join(result.details)
assert "test://a" in blob and "test://b" in blob
@pytest.mark.asyncio
async def test_duplicate_documents_check_ok_when_distinct(temp_db_path):
await _build_dup_db(temp_db_path, {"a": [0, 1, 2], "b": [3, 4, 5]})
report = await run_doctor(_config(vector_dim=8), temp_db_path, {})
assert _result(report, "duplicate_documents").severity is Severity.OK
@pytest.mark.asyncio
async def test_duplicate_documents_check_reads_config(temp_db_path):
# Share 3 of 5 -> centroid cosine 0.6, below the default 0.97 cutoff.
await _build_dup_db(temp_db_path, {"a": [0, 1, 2, 3, 4], "b": [0, 1, 2, 5, 6]})
base = _config(vector_dim=8)
assert (
_result(
await run_doctor(base, temp_db_path, {}), "duplicate_documents"
).severity
is Severity.OK
)
tuned = _config(vector_dim=8)
tuned.doctor = DoctorConfig(
duplicates=DuplicateDetectionConfig(similarity_threshold=0.5)
)
assert (
_result(
await run_doctor(tuned, temp_db_path, {}), "duplicate_documents"
).severity
is Severity.WARN
)
@pytest.mark.asyncio
async def test_many_unembedded_chunks_are_sampled(temp_db_path):
"""Beyond the sample limit the detail list ends with a count of the rest."""
db = await _build_db(temp_db_path)
chunks_tbl = await db.open_table("chunks")
await chunks_tbl.add(
[
ChunkRecord(
id=f"z{i}",
document_id="d1",
content="x",
metadata=json.dumps({"doc_item_refs": ["#/texts/0"]}),
vector=[0.0] * VECTOR_DIM,
)
for i in range(8)
]
)
report = await run_doctor(_config(), temp_db_path, {})
details = _result(report, "unembedded_chunks").details
assert len(details) == 6
assert details[-1] == "... (+3 more)"