Files
library-desk/tests/test_hybrid_rag_degradation.py
T
jpmschweitzerandClaude Fable 5 a66d801abd test: add offline unit tests for Phase A reliability fixes
Mock-based tests (no live services) covering:
- ollama_llm_model resolution under the OLLAMA_MODEL env collision
- per-leg retrieval failure -> source_status/degraded signaling
- Phase 0/Phase 4 LLM timeout fallbacks
- /stats wiki page count using the users/{user} path prefix
- Wiki.js list_all_pages limit-growth pagination and
  list_pages limit-after-filter behavior

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 10:05:51 +02:00

232 lines
8.1 KiB
Python

"""
Unit tests for HybridRAG degradation signaling (offline, all clients mocked).
Covers:
- Per-leg failure -> source_status reports 'failed', degraded=True
- Disabled legs -> 'disabled', do not trigger degraded
- Healthy legs -> 'ok', degraded=False
- Reranker model resolution from settings.ollama_llm_model
- Phase 0 keyword-extraction timeout falls back gracefully
"""
import asyncio
from unittest.mock import AsyncMock, MagicMock
import pytest
import src.services.hybrid_rag_service as hybrid_rag_module
from src.services.hybrid_rag_service import HybridRAGService
from src.models.hybrid_rag import HybridRAGConfig
@pytest.fixture
def settings():
settings = MagicMock()
settings.ollama_llm_model = "gemma4:e2b"
settings.vector_similarity_threshold = 0.7
return settings
@pytest.fixture
def vector_service():
"""Vector service returning a single wiki hit."""
vector = MagicMock()
hit = MagicMock()
hit.page_id = 1
hit.page_title = "Test Page"
hit.content = "Test content"
hit.page_path = "users/jp/test"
hit.score = 0.9
response = MagicMock()
response.results = [hit]
vector.search = AsyncMock(return_value=response)
return vector
@pytest.fixture
def graph_service():
graph = MagicMock()
graph.search_documents = AsyncMock(return_value=[])
graph.get_related_documents = AsyncMock(return_value=[])
graph.neo4j.execute_query = AsyncMock(return_value=[{"id": "search-1"}])
return graph
@pytest.fixture
def searxng_client():
searxng = MagicMock()
searxng.search_general = AsyncMock(return_value=[])
return searxng
@pytest.fixture
def ollama_client():
ollama = MagicMock()
ollama.generate_text = AsyncMock(
return_value='{"core_keywords": ["test"], "synonyms": {}}'
)
return ollama
@pytest.fixture
def content_extractor():
extractor = MagicMock()
extractor.extract_batch = AsyncMock(return_value=[])
return extractor
@pytest.fixture
def service(settings, vector_service, graph_service, searxng_client, ollama_client, content_extractor):
return HybridRAGService(
vector_service=vector_service,
graph_service=graph_service,
searxng_client=searxng_client,
ollama_client=ollama_client,
content_extractor=content_extractor,
settings=settings,
volatile_service=None,
)
def make_config(**overrides):
"""Config with documents disabled (leg needs a real Qdrant client) and fast phases."""
defaults = {
"enable_documents": False,
"enable_volatile": False,
"enable_reranking": False,
"enable_enrichment": False,
}
defaults.update(overrides)
return HybridRAGConfig(**defaults)
@pytest.mark.unit
class TestSourceStatus:
"""source_status must report every leg as ok/failed/disabled."""
async def test_all_enabled_legs_ok(self, service):
response = await service.search("test query", "jp", make_config())
assert response.source_status == {
"vector": "ok",
"graph": "ok",
"web": "ok",
"volatile": "disabled",
"documents": "disabled",
}
assert response.degraded is False
async def test_failed_leg_reported_and_degraded(self, service, graph_service):
graph_service.search_documents = AsyncMock(side_effect=RuntimeError("neo4j down"))
response = await service.search("test query", "jp", make_config())
assert response.source_status["graph"] == "failed"
assert response.source_status["vector"] == "ok"
assert response.degraded is True
async def test_failed_leg_still_contributes_no_results(self, service, graph_service):
"""Existing behavior preserved: failure -> empty leg, other legs still work."""
graph_service.search_documents = AsyncMock(side_effect=RuntimeError("neo4j down"))
response = await service.search("test query", "jp", make_config())
# The vector hit still comes through
assert response.total_results == 1
assert response.results[0].page_id == 1
async def test_multiple_failures(self, service, graph_service, searxng_client):
graph_service.search_documents = AsyncMock(side_effect=RuntimeError("neo4j down"))
searxng_client.search_general = AsyncMock(side_effect=OSError("searxng unreachable"))
response = await service.search("test query", "jp", make_config())
assert response.source_status["graph"] == "failed"
assert response.source_status["web"] == "failed"
assert response.degraded is True
async def test_disabled_legs_do_not_degrade(self, service):
config = make_config(enable_graph=False, enable_web=False)
response = await service.search("test query", "jp", config)
assert response.source_status["graph"] == "disabled"
assert response.source_status["web"] == "disabled"
assert response.degraded is False
async def test_volatile_without_service_is_disabled(self, service):
"""enable_volatile=True but no volatile service wired -> disabled, not failed."""
response = await service.search("test query", "jp", make_config(enable_volatile=True))
assert response.source_status["volatile"] == "disabled"
assert response.degraded is False
async def test_status_fields_serialized(self, service, graph_service):
"""Contract: fields present in the serialized response for tatlock to parse."""
graph_service.search_documents = AsyncMock(side_effect=RuntimeError("boom"))
response = await service.search("test query", "jp", make_config())
payload = response.model_dump()
assert set(payload["source_status"].keys()) == {"vector", "graph", "web", "volatile", "documents"}
assert payload["degraded"] is True
@pytest.mark.unit
class TestModelResolution:
"""The reranker/keyword model must come from settings.ollama_llm_model."""
def test_reranker_model_from_llm_setting(self, service):
assert service.reranker_model == "gemma4:e2b"
async def test_generation_calls_use_llm_model(self, service, ollama_client):
await service.search("test query", "jp", make_config())
# Phase 0 keyword extraction ran with the generation model
assert ollama_client.generate_text.await_count >= 1
for call in ollama_client.generate_text.await_args_list:
assert call.kwargs["model"] == "gemma4:e2b"
@pytest.mark.unit
class TestLLMTimeout:
"""A hung LLM call must not gate retrieval: 12s wait_for with fallback."""
async def test_keyword_extraction_timeout_falls_back(self, service, ollama_client, monkeypatch):
monkeypatch.setattr(hybrid_rag_module, "LLM_CALL_TIMEOUT_SECONDS", 0.05)
async def hang(*args, **kwargs):
await asyncio.sleep(5)
ollama_client.generate_text = AsyncMock(side_effect=hang)
response = await service.search("test query", "jp", make_config())
# Fallback: raw query words as keywords, retrieval still ran
assert response.keywords.core_keywords == ["test", "query"]
assert response.source_status["vector"] == "ok"
async def test_rerank_timeout_keeps_rrf_order(self, service, ollama_client, monkeypatch, searxng_client, content_extractor):
monkeypatch.setattr(hybrid_rag_module, "LLM_CALL_TIMEOUT_SECONDS", 0.05)
# Two web results so re-ranking actually runs (needs > 1 result)
searxng_client.search_general = AsyncMock(return_value=[
{"url": "http://a.test", "title": "A", "content": "a"},
{"url": "http://b.test", "title": "B", "content": "b"},
])
keyword_json = '{"core_keywords": ["test"], "synonyms": {}}'
async def generate(prompt, **kwargs):
if "Rank these documents" in prompt:
await asyncio.sleep(5) # Hang only the re-rank call
return keyword_json
ollama_client.generate_text = AsyncMock(side_effect=generate)
response = await service.search("test query", "jp", make_config(enable_reranking=True))
# RRF order preserved despite the hung re-rank call
assert response.total_results >= 2
assert response.degraded is False