Files
library-desk/tests/test_consolidation_repair.py
T
jpmschweitzerandClaude Fable 5 9ceec1464a fix: WS5 hazards batch - CORS, scheduler auth, reranker dedupe, wiring, write txns
- CORS: drop allow_credentials (wildcard origin + credentials told
  browsers to attach credentials for any site); origins configurable via
  CORS_ALLOW_ORIGINS (default * is safe without credentials). Verified
  live: preflight no longer advertises access-control-allow-credentials.
- Scheduler tasks: auth moved from a plain Authorization header (which
  the Scheduler's rest_api_executor does NOT env-substitute) to its
  auth {type: bearer, token: ${LIBRARY_API_KEY}} block, substituted from
  the Scheduler's own environment at execution time. The registrar no
  longer resolves the real key client-side, so it can never be persisted
  into the scheduled_tasks.config JSONB column. Also fixed: JSON bodies
  moved from the ignored "body" key to "payload" (the executor only
  reads config["payload"], so the tasks would have POSTed empty bodies
  and failed required-user validation).
- Reranker: parsed ranking indices are deduplicated preserving first
  occurrence (an LLM answer like "3,3,1" duplicated a result).
- HybridRAG wiring consolidated into dependencies.get_hybrid_rag_service
  (now including volatile_service); the inline copies in /query/hybrid
  and /wiki/pages/smart-create are gone - smart-create previously ran
  without the volatile leg, and the singleton was unused.
- Remaining Neo4j writes (GraphService ingestion/deletes/purges/entity
  mentions, webhook rename+delete cleanup, document-sync _index_graph,
  consolidation mark-processed/add-entity) moved from auto-commit
  execute_query to execute_write managed transactions with retry.

Verified end-to-end on the local dev server as llm_tester: /query/hybrid
200 with all five legs ok (volatile now active), background persistence
landed as one transaction.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QbFZyDvYksazX6nYQYZ67L
2026-07-14 14:50:03 +02:00

226 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)
neo4j.execute_write = 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")