Mechanical only, and separated from the judgment calls that follow so the reviewable changes are not buried in a 98-file whitespace diff. 227 automatic fixes: 60 blank lines carrying whitespace, 60 unsorted import blocks, 34 Optional[X] to X | None, 28 unused imports, 16 deprecated typing imports, 12 datetime.timezone.utc to datetime.UTC, and assorted smaller modernisations. Then `ruff format` over src and tests: 98 files reformatted, 35 already conforming. No file among the unused-import findings defines __all__ or is an __init__.py, so nothing here removes a re-export. `make test`: 658 passed, unchanged from HEAD. Two things observed while verifying, neither addressed here: `pytest tests/` cannot collect — tests/e2e/test_orchestration_e2e.py uses an `e2e` marker that is not registered, and the config is strict about markers. This fails identically at HEAD, so it predates this change; `make test` passes because it ignores tests/e2e, tests/integration and tests/contracts. test_tatlock_tool_call_logging_calculator is flaky. It failed once in a full run with these changes and passed on the next, passes in isolation with them, and fails in isolation at HEAD. It is order- or timing-dependent, not a regression from this commit — established by running the full suite both ways rather than by reasoning about which change could have caused it. Co-Authored-By: Claude <noreply@anthropic.com>
329 lines
12 KiB
Python
329 lines
12 KiB
Python
"""
|
|
Contract tests for HybridRAG parsing against a recorded live response.
|
|
|
|
The fixture in fixtures/hybrid_query_recorded.json is a real (recorded)
|
|
response from library-desk's POST /query/hybrid. These tests pin the
|
|
field mapping (source_type/sources, rrf_score, context, per-item
|
|
related_dossiers, keywords dict with nested synonyms) so a drift in
|
|
either side shows up as a test failure instead of every result
|
|
rendering as "unknown (score: 0.00)".
|
|
"""
|
|
|
|
import json
|
|
from pathlib import Path
|
|
from unittest.mock import AsyncMock, MagicMock
|
|
|
|
import httpx
|
|
import pytest
|
|
|
|
from src.agents.librarian.client import HybridRAGResponse, LibraryDeskClient
|
|
from src.agents.librarian.tools import SOURCE_ICONS, _coverage_note, hybrid_search
|
|
|
|
FIXTURE_PATH = Path(__file__).parent / "fixtures" / "hybrid_query_recorded.json"
|
|
|
|
|
|
@pytest.fixture
|
|
def recorded_response() -> dict:
|
|
"""Load the recorded /query/hybrid response."""
|
|
return json.loads(FIXTURE_PATH.read_text())
|
|
|
|
|
|
@pytest.fixture
|
|
def client_with_recorded_response(recorded_response):
|
|
"""LibraryDeskClient whose httpx client replays the recorded response."""
|
|
mock_response = MagicMock()
|
|
mock_response.json.return_value = recorded_response
|
|
mock_response.raise_for_status = MagicMock()
|
|
|
|
mock_httpx = AsyncMock(spec=httpx.AsyncClient)
|
|
mock_httpx.post.return_value = mock_response
|
|
|
|
client = LibraryDeskClient(base_url="http://test:8089", api_key="test-key")
|
|
client._client = mock_httpx
|
|
return client
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestHybridRAGContract:
|
|
"""Contract tests for parsing the live /query/hybrid response shape."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_sources_are_not_unknown(self, client_with_recorded_response):
|
|
"""Every result maps source_type - nothing falls back to 'unknown'."""
|
|
response = await client_with_recorded_response.hybrid_search(
|
|
"home server infrastructure", user="testuser"
|
|
)
|
|
|
|
assert isinstance(response, HybridRAGResponse)
|
|
assert response.results, "recorded fixture must contain results"
|
|
for result in response.results:
|
|
assert result.source != "unknown"
|
|
assert result.source in {"wiki", "web", "volatile", "document"}
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_scores_are_non_zero(self, client_with_recorded_response):
|
|
"""rrf_score maps to score - no silent 0.00 fallback."""
|
|
response = await client_with_recorded_response.hybrid_search(
|
|
"home server infrastructure", user="testuser"
|
|
)
|
|
|
|
for result in response.results:
|
|
assert result.score > 0.0
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_sources_list_and_icons(self, client_with_recorded_response):
|
|
"""Per-item sources list is parsed and every value has an icon."""
|
|
response = await client_with_recorded_response.hybrid_search(
|
|
"home server infrastructure", user="testuser"
|
|
)
|
|
|
|
for result in response.results:
|
|
assert result.sources, f"result '{result.title}' has empty sources"
|
|
for source in result.sources:
|
|
assert source in SOURCE_ICONS, f"no icon for source '{source}'"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_context_maps_to_formatted_context(self, client_with_recorded_response):
|
|
"""Top-level 'context' field maps to formatted_context."""
|
|
response = await client_with_recorded_response.hybrid_search(
|
|
"home server infrastructure", user="testuser"
|
|
)
|
|
|
|
assert response.formatted_context != ""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_keywords_and_synonyms_from_dict(self, client_with_recorded_response):
|
|
"""keywords is a dict: core_keywords + nested synonyms map."""
|
|
response = await client_with_recorded_response.hybrid_search(
|
|
"home server infrastructure", user="testuser"
|
|
)
|
|
|
|
assert response.keywords, "core_keywords should be extracted"
|
|
assert all(isinstance(k, str) for k in response.keywords)
|
|
# synonyms map in the fixture is empty, but must parse to a list
|
|
assert isinstance(response.synonyms, list)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_per_item_related_dossiers(self, client_with_recorded_response):
|
|
"""related_dossiers live per result and aggregate to unique titles."""
|
|
response = await client_with_recorded_response.hybrid_search(
|
|
"home server infrastructure", user="testuser"
|
|
)
|
|
|
|
per_item = [d for r in response.results for d in r.related_dossiers]
|
|
assert per_item, "recorded fixture contains per-item related_dossiers"
|
|
for dossier in per_item:
|
|
assert "title" in dossier
|
|
assert "tag" in dossier
|
|
|
|
assert response.related_dossiers, "top-level titles are aggregated"
|
|
assert len(response.related_dossiers) == len(set(response.related_dossiers))
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_payload_never_sends_zero_limits(self, client_with_recorded_response):
|
|
"""The live service 422s on limits < 1; disabled legs use enable_* flags."""
|
|
await client_with_recorded_response.hybrid_search(
|
|
"home server infrastructure",
|
|
user="testuser",
|
|
web_limit=0,
|
|
document_limit=0,
|
|
volatile_limit=0,
|
|
)
|
|
|
|
payload = client_with_recorded_response._client.post.call_args.kwargs["json"]
|
|
config = payload["config"]
|
|
for key in (
|
|
"vector_limit",
|
|
"graph_limit",
|
|
"web_limit",
|
|
"document_limit",
|
|
"volatile_limit",
|
|
):
|
|
assert config[key] >= 1
|
|
assert config["enable_web"] is False
|
|
assert config["enable_documents"] is False
|
|
assert config["enable_volatile"] is False
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_user_always_sent_as_query_param(self, client_with_recorded_response):
|
|
"""The tenant is always sent explicitly - library-desk is removing
|
|
its server-side default, so a missing user would 422."""
|
|
await client_with_recorded_response.hybrid_search(
|
|
"home server infrastructure", user="testuser"
|
|
)
|
|
|
|
params = client_with_recorded_response._client.post.call_args.kwargs["params"]
|
|
assert params["user"] == "testuser"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_source_counts_and_timing_parsed(self, client_with_recorded_response):
|
|
"""source_counts and timing map into the response model."""
|
|
response = await client_with_recorded_response.hybrid_search(
|
|
"home server infrastructure", user="testuser"
|
|
)
|
|
|
|
assert response.source_counts == {"graph": 3, "web": 2}
|
|
assert response.timing.get("total_ms", 0) > 0
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_source_status_absent_is_tolerated(self, client_with_recorded_response):
|
|
"""Recorded response predates source_status/degraded - defaults apply."""
|
|
response = await client_with_recorded_response.hybrid_search(
|
|
"home server infrastructure", user="testuser"
|
|
)
|
|
|
|
assert response.source_status == {}
|
|
assert response.degraded is False
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_source_status_parsed_when_present(self, recorded_response):
|
|
"""Additive source_status/degraded fields parse when the service sends them."""
|
|
enriched = dict(recorded_response)
|
|
enriched["source_status"] = {
|
|
"vector": "ok",
|
|
"graph": "ok",
|
|
"web": "failed",
|
|
"volatile": "disabled",
|
|
"documents": "ok",
|
|
}
|
|
enriched["degraded"] = True
|
|
|
|
mock_response = MagicMock()
|
|
mock_response.json.return_value = enriched
|
|
mock_response.raise_for_status = MagicMock()
|
|
mock_httpx = AsyncMock(spec=httpx.AsyncClient)
|
|
mock_httpx.post.return_value = mock_response
|
|
|
|
client = LibraryDeskClient(base_url="http://test:8089", api_key="test-key")
|
|
client._client = mock_httpx
|
|
|
|
response = await client.hybrid_search("home server infrastructure", user="u")
|
|
|
|
assert response.degraded is True
|
|
assert response.source_status["web"] == "failed"
|
|
assert response.source_status["volatile"] == "disabled"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_tool_renders_no_unknown_results(
|
|
self, client_with_recorded_response, monkeypatch
|
|
):
|
|
"""The hybrid_search tool renders real sources and non-zero scores."""
|
|
|
|
class _Factory:
|
|
def __call__(self):
|
|
return self
|
|
|
|
async def __aenter__(self):
|
|
return client_with_recorded_response
|
|
|
|
async def __aexit__(self, *args):
|
|
return None
|
|
|
|
monkeypatch.setattr("src.agents.librarian.tools.LibraryDeskClient", _Factory())
|
|
|
|
output = await hybrid_search("home server infrastructure")
|
|
|
|
assert "unknown" not in output
|
|
assert "score: 0.00" not in output
|
|
assert "•" not in output, "every source value should map to an icon"
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestCoverageNote:
|
|
"""Coverage note makes degraded searches visible to model and user."""
|
|
|
|
def _response(self, **kwargs) -> HybridRAGResponse:
|
|
return HybridRAGResponse(**kwargs)
|
|
|
|
def test_no_note_when_all_legs_report(self):
|
|
response = self._response(
|
|
source_counts={
|
|
"vector": 2,
|
|
"graph": 1,
|
|
"web": 2,
|
|
"documents": 1,
|
|
"volatile": 1,
|
|
},
|
|
)
|
|
note = _coverage_note(
|
|
response, include_web=True, include_documents=True, include_volatile=True
|
|
)
|
|
assert note == ""
|
|
|
|
def test_note_when_enabled_leg_missing_from_counts(self):
|
|
response = self._response(source_counts={"graph": 3, "web": 2})
|
|
note = _coverage_note(
|
|
response, include_web=True, include_documents=True, include_volatile=True
|
|
)
|
|
assert "Coverage note" in note
|
|
assert "documents" in note
|
|
assert "volatile" in note
|
|
# Always-on wiki legs are never inferred from count absence
|
|
assert "vector" not in note
|
|
assert "graph" not in note
|
|
|
|
def test_wiki_leg_absence_is_not_degradation(self):
|
|
"""vector/graph missing from top-N counts is healthy ranking, not outage."""
|
|
response = self._response(source_counts={"web": 2, "documents": 1, "volatile": 1})
|
|
note = _coverage_note(
|
|
response, include_web=True, include_documents=True, include_volatile=True
|
|
)
|
|
assert note == ""
|
|
|
|
def test_disabled_legs_are_not_reported_missing(self):
|
|
response = self._response(source_counts={"vector": 2, "graph": 1})
|
|
note = _coverage_note(
|
|
response,
|
|
include_web=False,
|
|
include_documents=False,
|
|
include_volatile=False,
|
|
)
|
|
assert note == ""
|
|
|
|
def test_note_prefers_source_status_failures(self):
|
|
response = self._response(
|
|
source_counts={"vector": 2, "graph": 1},
|
|
source_status={
|
|
"vector": "ok",
|
|
"graph": "ok",
|
|
"web": "failed",
|
|
"volatile": "disabled",
|
|
"documents": "ok",
|
|
},
|
|
degraded=True,
|
|
)
|
|
note = _coverage_note(
|
|
response, include_web=True, include_documents=True, include_volatile=True
|
|
)
|
|
assert "failed" in note
|
|
assert "web" in note
|
|
# disabled legs are not reported as failures
|
|
assert "volatile" not in note
|
|
|
|
def test_no_note_when_status_all_ok(self):
|
|
response = self._response(
|
|
source_counts={"graph": 1},
|
|
source_status={
|
|
"vector": "ok",
|
|
"graph": "ok",
|
|
"web": "ok",
|
|
"volatile": "ok",
|
|
"documents": "ok",
|
|
},
|
|
degraded=False,
|
|
)
|
|
note = _coverage_note(
|
|
response, include_web=True, include_documents=True, include_volatile=True
|
|
)
|
|
assert note == ""
|
|
|
|
def test_degraded_without_named_failures(self):
|
|
response = self._response(
|
|
source_status={"vector": "ok", "graph": "ok"},
|
|
degraded=True,
|
|
)
|
|
note = _coverage_note(
|
|
response, include_web=True, include_documents=True, include_volatile=True
|
|
)
|
|
assert "partial" in note
|