ROOT CAUSE (investigated read-only against prod): the production
container sets OLLAMA_MODEL=nomic-embed-text (the embedding model), which
the pre-rename generation setting also read, so every consolidation
/api/generate call failed with HTTP 400 ('"nomic-embed-text" does not
support generate' - confirmed in prod logs and by a direct Ollama probe).
_classify_web_results_unified swallowed that as an empty classification,
and consolidate_knowledge marked EVERY SearchQuery processed anyway -
permanently draining the queue with zero pages ever created. Live Neo4j
shows 197/200 SearchQuery nodes processed=true with no output; every
subsequent 30-minute run then logged 'No unprocessed searches found'.
The label/tenant scoping was NOT at fault: persistence writes both the
tenant label and the plain :SearchQuery label the loop matches on.
The model resolution itself was already fixed in Phase A (94482bc,
ollama_llm_model / OLLAMA_LLM_MODEL). This commit repairs the pipeline
defect that masked it:
- LLM infrastructure failure (no output from generate) now raises
ConsolidationLLMUnavailableError instead of returning an empty routing
- consolidate_knowledge leaves those searches UNPROCESSED for the next
run, aborts the rest of the batch (the LLM is down for all of them),
and reports searches_deferred
- unparseable-but-present model output is still consumed (avoids
retrying a bad prompt forever); low-web skips unchanged
- every run logs 'Consolidation run complete: searches_processed=N
searches_deferred=M duration_ms=X'; both fields added to the response
- lookback boundary is now timezone-aware UTC (Neo4j datetime() reads
naive strings as UTC, shifting the window on CET hosts)
10 new offline regression tests.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QbFZyDvYksazX6nYQYZ67L
225 lines
7.9 KiB
Python
225 lines
7.9 KiB
Python
"""
|
|
Offline regression tests for the consolidation-loop repair (Phase C item 4).
|
|
|
|
Production failure mode being locked in:
|
|
- The generation LLM was unavailable (OLLAMA_MODEL env collision made every
|
|
/api/generate call 400), classification returned empty, and the loop
|
|
STILL marked every SearchQuery processed - permanently draining the queue
|
|
with zero output. Every subsequent 30-minute run then logged
|
|
'No unprocessed searches found'.
|
|
|
|
The repaired behavior:
|
|
- LLM-infrastructure failure raises ConsolidationLLMUnavailableError, the
|
|
affected searches stay UNPROCESSED (retried next run), and the run reports
|
|
searches_deferred.
|
|
- Successful classification still consumes searches.
|
|
- Every run logs searches_processed and duration_ms.
|
|
|
|
All clients are mocked - no shared services are contacted.
|
|
"""
|
|
|
|
import json
|
|
from unittest.mock import AsyncMock, MagicMock
|
|
|
|
import pytest
|
|
|
|
from src.services.consolidation_service import (
|
|
ConsolidationLLMUnavailableError,
|
|
ConsolidationService,
|
|
)
|
|
|
|
TEST_USER = "llm_tester"
|
|
|
|
|
|
def _search_row(search_id: str, query: str = "test query", web_count: int = 3):
|
|
return {
|
|
"id": search_id,
|
|
"query": query,
|
|
"user": TEST_USER,
|
|
"timestamp": "2026-07-14T00:00:00Z",
|
|
"total_results": web_count,
|
|
"web_count": web_count,
|
|
"keywords": ["test"],
|
|
}
|
|
|
|
|
|
def _web_result_row(url: str = "https://example.com/a"):
|
|
return {
|
|
"url": url,
|
|
"title": "Example",
|
|
"content": "Example content about the query.",
|
|
"rank": 1,
|
|
"rrf_score": 0.5,
|
|
}
|
|
|
|
|
|
def _make_service(searches, ollama_response):
|
|
"""ConsolidationService with a scripted Neo4j and Ollama."""
|
|
neo4j = AsyncMock()
|
|
executed = []
|
|
|
|
async def fake_query(cypher, params=None):
|
|
executed.append((cypher, params or {}))
|
|
if "processed: false" in cypher:
|
|
return searches
|
|
if "FOUND]->(wr:WebResult)" in cypher:
|
|
return [_web_result_row()]
|
|
return []
|
|
|
|
neo4j.execute_query = AsyncMock(side_effect=fake_query)
|
|
|
|
ollama = AsyncMock()
|
|
ollama.generate_text = AsyncMock(return_value=ollama_response)
|
|
|
|
wiki = AsyncMock()
|
|
wiki.get_taxonomy_structure = AsyncMock(return_value={})
|
|
|
|
settings = MagicMock()
|
|
settings.ollama_llm_model = "gemma4:e2b"
|
|
|
|
service = ConsolidationService(
|
|
neo4j=neo4j, ollama=ollama, wiki=wiki, settings=settings
|
|
)
|
|
return service, executed
|
|
|
|
|
|
def _mark_processed_calls(executed):
|
|
return [(c, p) for c, p in executed if "SET sq.processed = true" in c]
|
|
|
|
|
|
class TestLLMUnavailableDoesNotConsumeSearches:
|
|
@pytest.mark.asyncio
|
|
async def test_searches_stay_unprocessed_when_llm_returns_none(self):
|
|
"""generate_text -> None (transport/HTTP failure): defer, don't consume."""
|
|
searches = [_search_row("aaaa1111"), _search_row("bbbb2222")]
|
|
service, executed = _make_service(searches, ollama_response=None)
|
|
|
|
response = await service.consolidate_knowledge()
|
|
|
|
assert response.total_found == 2
|
|
assert response.processed_count == 0
|
|
assert response.searches_deferred == 2
|
|
assert response.errors and "unavailable" in response.errors[0].lower()
|
|
# THE regression guard: no search was marked processed
|
|
assert _mark_processed_calls(executed) == []
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_searches_stay_unprocessed_when_llm_returns_empty(self):
|
|
"""An empty completion is an infra anomaly, not 'nothing to route'."""
|
|
service, executed = _make_service([_search_row("cccc3333")], "")
|
|
|
|
response = await service.consolidate_knowledge()
|
|
|
|
assert response.searches_deferred == 1
|
|
assert _mark_processed_calls(executed) == []
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_classification_raises_on_no_output(self):
|
|
service, _ = _make_service([], None)
|
|
with pytest.raises(ConsolidationLLMUnavailableError):
|
|
await service._classify_web_results_unified(
|
|
query="q", web_results=[_web_result_row()], keywords=[], user=TEST_USER
|
|
)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_batch_aborts_after_first_llm_failure(self):
|
|
"""When the LLM is down it is down for all searches: one probe, then stop."""
|
|
searches = [_search_row(f"id{i}", web_count=5) for i in range(5)]
|
|
service, executed = _make_service(searches, ollama_response=None)
|
|
|
|
response = await service.consolidate_knowledge()
|
|
|
|
assert response.searches_deferred == 5
|
|
# Only the first search's classification was attempted
|
|
assert service.ollama.generate_text.await_count == 1
|
|
|
|
|
|
class TestSuccessfulRunsStillConsume:
|
|
@pytest.mark.asyncio
|
|
async def test_valid_classification_marks_processed(self):
|
|
classification = json.dumps([{
|
|
"url": "https://example.com/a",
|
|
"title": "Example",
|
|
"route_type": "skip",
|
|
"confidence": 0.9,
|
|
"reason": "low value",
|
|
}])
|
|
service, executed = _make_service([_search_row("dddd4444")], classification)
|
|
|
|
response = await service.consolidate_knowledge()
|
|
|
|
assert response.total_found == 1
|
|
assert response.processed_count == 1
|
|
assert response.searches_deferred == 0
|
|
marked = _mark_processed_calls(executed)
|
|
assert len(marked) == 1
|
|
assert marked[0][1]["search_id"] == "dddd4444"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_unparseable_output_still_consumes_search(self):
|
|
"""Model responded with junk: consume (avoid retrying a bad prompt forever)."""
|
|
service, executed = _make_service(
|
|
[_search_row("eeee5555")], "not json at all"
|
|
)
|
|
|
|
response = await service.consolidate_knowledge()
|
|
|
|
assert response.searches_deferred == 0
|
|
assert len(_mark_processed_calls(executed)) == 1
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_skipped_low_web_search_still_consumed(self):
|
|
"""Insufficient web results: intentionally consumed (existing behavior)."""
|
|
service, executed = _make_service(
|
|
[_search_row("ffff6666", web_count=0)], None
|
|
)
|
|
|
|
response = await service.consolidate_knowledge()
|
|
|
|
assert response.searches_deferred == 0
|
|
assert len(_mark_processed_calls(executed)) == 1
|
|
# LLM never called for a skipped search
|
|
service.ollama.generate_text.assert_not_awaited()
|
|
|
|
|
|
class TestRunLogging:
|
|
@pytest.mark.asyncio
|
|
async def test_logs_searches_processed_and_duration(self, caplog):
|
|
service, _ = _make_service([], None)
|
|
|
|
with caplog.at_level("INFO"):
|
|
response = await service.consolidate_knowledge()
|
|
|
|
assert response.duration_ms >= 0
|
|
run_logs = [r.message for r in caplog.records
|
|
if "Consolidation run complete" in r.message]
|
|
assert run_logs, "every run must emit the run-complete log line"
|
|
assert "searches_processed=0" in run_logs[0]
|
|
assert "duration_ms=" in run_logs[0]
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_logs_on_deferred_run(self, caplog):
|
|
service, _ = _make_service([_search_row("gggg7777")], None)
|
|
|
|
with caplog.at_level("INFO"):
|
|
response = await service.consolidate_knowledge()
|
|
|
|
assert response.duration_ms >= 0
|
|
run_logs = [r.message for r in caplog.records
|
|
if "Consolidation run complete" in r.message]
|
|
assert run_logs
|
|
assert "searches_deferred=1" in run_logs[0]
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_lookback_parameter_is_utc_aware(self):
|
|
"""The lookback boundary must be timezone-aware UTC (Neo4j datetime()
|
|
interprets naive strings as UTC, shifting the window on CET hosts)."""
|
|
service, executed = _make_service([], None)
|
|
|
|
await service.consolidate_knowledge(lookback_days=7)
|
|
|
|
find_calls = [(c, p) for c, p in executed if "processed: false" in c]
|
|
assert find_calls
|
|
lookback = find_calls[0][1]["lookback_date"]
|
|
assert "+00:00" in lookback or lookback.endswith("Z")
|