Files
library-desk/src/models/consolidation.py
T
jpmschweitzerandClaude Fable 5 51f9ce08ec 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
2026-07-14 12:26:14 +02:00

105 lines
4.2 KiB
Python

"""
Knowledge Consolidation models for Librarian processing.
Used by the consolidation endpoint to process SearchQuery nodes
and consolidate knowledge into wiki pages.
"""
from pydantic import BaseModel, Field
from typing import List, Optional, Dict, Any
class ConsolidationRequest(BaseModel):
"""Request for knowledge consolidation from search results."""
process_limit: int = Field(default=10, ge=1, le=100, description="Max searches to process")
lookback_days: int = Field(default=7, ge=1, le=90, description="Process searches from last N days")
min_web_results: int = Field(default=2, ge=1, le=20, description="Minimum web results needed")
dry_run: bool = Field(default=False, description="If true, analyze but don't create pages")
class SearchQueryInfo(BaseModel):
"""Information about a search query to process."""
id: str
query: str
user: str
timestamp: str
total_results: int
web_count: int
keywords: List[str] = []
class ConsolidationResult(BaseModel):
"""Result of processing a single search query."""
search_id: str
query: str
pages_created: int = 0
pages_updated: int = 0
entities_added: int = 0
volatile_cached: int = 0
files_queued: int = 0
prefetch_registered: int = 0
error: Optional[str] = None
class ConsolidationResponse(BaseModel):
"""Response from knowledge consolidation."""
total_found: int = Field(description="Total unprocessed searches found")
processed_count: int = Field(description="Successfully processed searches")
pages_created: int = Field(description="New wiki pages created")
pages_updated: int = Field(description="Existing pages updated")
entities_added: int = Field(description="New entities added to graph")
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):
"""
Unified classification of a web result for memory routing.
Route types:
- wiki: Stable reference content → wiki page creation/update
- volatile: Ephemeral data (weather, news, prices) → volatile cache
- file: Downloadable file (PDF, doc, xls, images) → Paperless ingestion
- prefetch: Regularly updated source → scheduler registration
- skip: Low value, ads, errors → discard
"""
url: str
title: str
route_type: str = Field(description="One of: wiki, volatile, file, prefetch, skip")
# Wiki routing fields
wiki_action: Optional[str] = Field(default=None, description="create or update")
wiki_path: Optional[str] = Field(default=None, description="Wiki path for page")
wiki_summary: Optional[str] = Field(default=None, description="Summary for wiki page")
# Volatile routing fields
volatile_namespace: Optional[str] = Field(default=None, description="weather, news, financial, etc.")
volatile_key: Optional[str] = Field(default=None, description="Cache key")
volatile_ttl_hours: Optional[int] = Field(default=None, description="TTL in hours")
# Prefetch routing fields
prefetch_cron: Optional[str] = Field(default=None, description="Cron expression for refresh")
prefetch_endpoint: Optional[str] = Field(default=None, description="API endpoint to call")
# Classification metadata
confidence: float = Field(default=0.0, ge=0.0, le=1.0)
reason: str = Field(default="")
class MemoryRoutingResult(BaseModel):
"""Aggregated result of memory routing for a search."""
wiki_routed: int = 0
volatile_cached: int = 0
files_queued: int = 0
prefetch_registered: int = 0
skipped: int = 0
classifications: List[MemoryRouteClassification] = []