fix: stop consolidation from consuming searches when the LLM is down
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
This commit is contained in:
@@ -50,9 +50,14 @@ class ConsolidationResponse(BaseModel):
|
||||
volatile_cached: int = Field(default=0, description="Items cached to volatile storage")
|
||||
files_queued: int = Field(default=0, description="Files queued for Paperless")
|
||||
prefetch_registered: int = Field(default=0, description="Prefetch patterns registered")
|
||||
searches_deferred: int = Field(
|
||||
default=0,
|
||||
description="Searches left unprocessed for the next run because the generation LLM was unavailable"
|
||||
)
|
||||
errors: List[str] = Field(default=[], description="Error messages")
|
||||
results: List[ConsolidationResult] = Field(description="Per-search results")
|
||||
dry_run: bool = Field(description="Whether this was a dry run")
|
||||
duration_ms: float = Field(default=0.0, description="Run duration in milliseconds")
|
||||
|
||||
|
||||
class MemoryRouteClassification(BaseModel):
|
||||
|
||||
@@ -13,7 +13,8 @@ This service:
|
||||
"""
|
||||
import logging
|
||||
import json
|
||||
from datetime import datetime, timedelta
|
||||
import time
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import List, Dict, Any, Optional
|
||||
|
||||
from src.clients.neo4j_client import Neo4jClient
|
||||
@@ -32,6 +33,18 @@ from src.config import Settings
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ConsolidationLLMUnavailableError(Exception):
|
||||
"""
|
||||
The generation LLM produced no output (infrastructure failure).
|
||||
|
||||
Raised instead of silently returning an empty classification so the
|
||||
caller can leave the affected SearchQuery nodes UNPROCESSED for the
|
||||
next run. Marking them processed on LLM failure permanently drains
|
||||
the consolidation queue with zero output (the exact failure mode that
|
||||
made every run log 'No unprocessed searches found' in production).
|
||||
"""
|
||||
|
||||
|
||||
class ConsolidationService:
|
||||
"""
|
||||
Service for consolidating knowledge from search results.
|
||||
@@ -77,7 +90,8 @@ class ConsolidationService:
|
||||
Returns:
|
||||
ConsolidationResponse with processing results
|
||||
"""
|
||||
logger.info(f"Starting knowledge consolidation")
|
||||
run_start = time.time()
|
||||
logger.info("Starting knowledge consolidation")
|
||||
logger.info(f"Limits: process={process_limit}, lookback={lookback_days}d, min_web={min_web_results}")
|
||||
if dry_run:
|
||||
logger.warning("DRY RUN MODE - will not create wiki pages")
|
||||
@@ -86,7 +100,12 @@ class ConsolidationService:
|
||||
unprocessed = await self._find_unprocessed_searches(lookback_days, process_limit)
|
||||
|
||||
if not unprocessed:
|
||||
logger.info("No unprocessed searches found")
|
||||
duration_ms = (time.time() - run_start) * 1000
|
||||
logger.info(
|
||||
f"Consolidation run complete: searches_processed=0 "
|
||||
f"searches_deferred=0 duration_ms={duration_ms:.0f} "
|
||||
f"(no unprocessed searches found)"
|
||||
)
|
||||
return ConsolidationResponse(
|
||||
total_found=0,
|
||||
processed_count=0,
|
||||
@@ -95,7 +114,8 @@ class ConsolidationService:
|
||||
entities_added=0,
|
||||
errors=[],
|
||||
results=[],
|
||||
dry_run=dry_run
|
||||
dry_run=dry_run,
|
||||
duration_ms=duration_ms
|
||||
)
|
||||
|
||||
logger.info(f"Found {len(unprocessed)} unprocessed searches")
|
||||
@@ -108,9 +128,10 @@ class ConsolidationService:
|
||||
total_volatile_cached = 0
|
||||
total_files_queued = 0
|
||||
total_prefetch_registered = 0
|
||||
searches_deferred = 0
|
||||
errors: List[str] = []
|
||||
|
||||
for search in unprocessed:
|
||||
for index, search in enumerate(unprocessed):
|
||||
try:
|
||||
result = await self._process_search(
|
||||
search=search,
|
||||
@@ -132,6 +153,22 @@ class ConsolidationService:
|
||||
if not dry_run:
|
||||
await self._mark_search_processed(search['id'])
|
||||
|
||||
except ConsolidationLLMUnavailableError as e:
|
||||
# Infrastructure failure: the generation LLM is unavailable.
|
||||
# Do NOT consume the search - leave it (and the rest of this
|
||||
# batch) unprocessed so the next run retries. Consuming
|
||||
# searches here is what silently drained the queue in
|
||||
# production ('No unprocessed searches found' with zero
|
||||
# pages ever created).
|
||||
searches_deferred = len(unprocessed) - index
|
||||
error_msg = (
|
||||
f"Generation LLM unavailable ({e}); deferring "
|
||||
f"{searches_deferred} search(es) to the next run"
|
||||
)
|
||||
logger.error(error_msg)
|
||||
errors.append(error_msg)
|
||||
break
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"Search {search['id'][:8]}: {str(e)}"
|
||||
logger.error(f"Failed to process search: {error_msg}", exc_info=True)
|
||||
@@ -148,6 +185,7 @@ class ConsolidationService:
|
||||
|
||||
# Build response
|
||||
processed_count = len([r for r in results if not r.error])
|
||||
duration_ms = (time.time() - run_start) * 1000
|
||||
|
||||
response = ConsolidationResponse(
|
||||
total_found=len(unprocessed),
|
||||
@@ -158,13 +196,16 @@ class ConsolidationService:
|
||||
volatile_cached=total_volatile_cached,
|
||||
files_queued=total_files_queued,
|
||||
prefetch_registered=total_prefetch_registered,
|
||||
searches_deferred=searches_deferred,
|
||||
errors=errors,
|
||||
results=results,
|
||||
dry_run=dry_run
|
||||
dry_run=dry_run,
|
||||
duration_ms=duration_ms
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Consolidation complete: {processed_count}/{len(unprocessed)} searches, "
|
||||
f"Consolidation run complete: searches_processed={processed_count} "
|
||||
f"searches_deferred={searches_deferred} duration_ms={duration_ms:.0f} | "
|
||||
f"{total_pages_created} pages created, {total_pages_updated} updated, "
|
||||
f"{total_entities_added} entities, {total_volatile_cached} volatile, "
|
||||
f"{total_files_queued} files, {total_prefetch_registered} prefetch"
|
||||
@@ -180,7 +221,9 @@ class ConsolidationService:
|
||||
"""
|
||||
Find unprocessed SearchQuery nodes from Neo4j.
|
||||
"""
|
||||
lookback_date = datetime.now() - timedelta(days=lookback_days)
|
||||
# UTC-aware: sq.timestamp is stored via Neo4j datetime() (UTC), and a
|
||||
# naive local isoformat would be misread as UTC by datetime($param).
|
||||
lookback_date = datetime.now(timezone.utc) - timedelta(days=lookback_days)
|
||||
|
||||
query = """
|
||||
MATCH (sq:SearchQuery {processed: false})
|
||||
@@ -1084,9 +1127,16 @@ JSON:"""
|
||||
temperature=0.0
|
||||
)
|
||||
|
||||
# generate_text returns None on any transport/HTTP failure (e.g.
|
||||
# model missing, Ollama down) and Ollama never legitimately
|
||||
# returns an empty completion for this prompt: both mean the LLM
|
||||
# is unavailable, NOT that there is nothing to route. Raise so
|
||||
# the search is retried next run instead of being consumed.
|
||||
if not response:
|
||||
logger.warning("Empty response from Ollama for classification")
|
||||
return MemoryRoutingResult()
|
||||
raise ConsolidationLLMUnavailableError(
|
||||
f"no output from generation model "
|
||||
f"'{self.settings.ollama_llm_model}' for classification"
|
||||
)
|
||||
|
||||
# Extract JSON array from response
|
||||
response_clean = response.strip()
|
||||
@@ -1140,7 +1190,12 @@ JSON:"""
|
||||
)
|
||||
return result
|
||||
|
||||
except ConsolidationLLMUnavailableError:
|
||||
# Infrastructure failure: propagate so the search is NOT consumed
|
||||
raise
|
||||
except json.JSONDecodeError as e:
|
||||
# The model responded but with unparseable output: consume the
|
||||
# search (empty routing) to avoid retrying a bad prompt forever.
|
||||
logger.error(f"Failed to parse classification response as JSON: {e}")
|
||||
return MemoryRoutingResult()
|
||||
except Exception as e:
|
||||
|
||||
Reference in New Issue
Block a user