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>
232 lines
8.1 KiB
Python
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
|