""" 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