Files
library-desk/src/services/consolidation_service.py
T
jpmschweitzerandClaude Opus 4.5 910b289c9e feat: integrate external scheduler for prefetch task registration
Add SchedulerClient to communicate with external scheduler service for
registering volatile prefetch tasks discovered during HybridRAG searches.

- Add scheduler_client.py with full REST API for task CRUD operations
- Add scheduler_url config setting (default: http://scheduler:8090)
- Update consolidation service to use scheduler for prefetch registration
- Add scheduler health checks to startup/shutdown lifecycle

When HybridRAG classifies web content as prefetch-worthy, it now creates
scheduled tasks that periodically refresh the volatile cache via the
external scheduler service.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-29 20:42:37 +01:00

1315 lines
50 KiB
Python

"""
Knowledge Consolidation Service (Librarian Logic)
Processes unprocessed SearchQuery nodes from HybridRAG searches
to consolidate new knowledge into wiki pages.
This service:
1. Queries Neo4j for unprocessed SearchQuery nodes
2. Analyzes web results with Ollama for novel information
3. Creates/updates wiki pages with new facts
4. Updates knowledge graph with new entities
5. Marks SearchQuery nodes as processed
"""
import logging
import json
from datetime import datetime, timedelta
from typing import List, Dict, Any, Optional
from src.clients.neo4j_client import Neo4jClient
from src.clients.ollama_client import OllamaClient
from src.clients.wikijs_client import WikiJSClient
from src.services.wiki_page_writer import WikiPageWriter
from src.models.consolidation import (
SearchQueryInfo,
ConsolidationResult,
ConsolidationResponse,
MemoryRouteClassification,
MemoryRoutingResult,
)
from src.config import Settings
logger = logging.getLogger(__name__)
class ConsolidationService:
"""
Service for consolidating knowledge from search results.
"""
def __init__(
self,
neo4j: Neo4jClient,
ollama: OllamaClient,
wiki: WikiJSClient,
settings: Settings,
ingestion_service: Optional["IngestionService"] = None,
volatile_service: Optional["VolatileCacheService"] = None,
settings_client: Optional["SettingsClient"] = None,
scheduler_client: Optional["SchedulerClient"] = None,
):
self.neo4j = neo4j
self.ollama = ollama
self.wiki = wiki
self.settings = settings
self.wiki_page_writer = WikiPageWriter(ollama_client=ollama, settings=settings)
self.ingestion_service = ingestion_service # Optional to avoid circular dependency
self.volatile_service = volatile_service # For ephemeral data caching
self.settings_client = settings_client # For prefetch registration (fallback)
self.scheduler_client = scheduler_client # For scheduler-driven prefetch
async def consolidate_knowledge(
self,
process_limit: int = 10,
lookback_days: int = 7,
min_web_results: int = 2,
dry_run: bool = False
) -> ConsolidationResponse:
"""
Process unprocessed search queries and consolidate knowledge.
Args:
process_limit: Maximum searches to process
lookback_days: Only process searches from last N days
min_web_results: Minimum web results required to consolidate
dry_run: If True, analyze but don't create pages
Returns:
ConsolidationResponse with processing results
"""
logger.info(f"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")
# Find unprocessed searches
unprocessed = await self._find_unprocessed_searches(lookback_days, process_limit)
if not unprocessed:
logger.info("No unprocessed searches found")
return ConsolidationResponse(
total_found=0,
processed_count=0,
pages_created=0,
pages_updated=0,
entities_added=0,
errors=[],
results=[],
dry_run=dry_run
)
logger.info(f"Found {len(unprocessed)} unprocessed searches")
# Process each search
results: List[ConsolidationResult] = []
total_pages_created = 0
total_pages_updated = 0
total_entities_added = 0
total_volatile_cached = 0
total_files_queued = 0
total_prefetch_registered = 0
errors: List[str] = []
for search in unprocessed:
try:
result = await self._process_search(
search=search,
min_web_results=min_web_results,
dry_run=dry_run
)
if result:
results.append(result)
total_pages_created += result.pages_created
total_pages_updated += result.pages_updated
total_entities_added += result.entities_added
total_volatile_cached += result.volatile_cached
total_files_queued += result.files_queued
total_prefetch_registered += result.prefetch_registered
# Mark as processed if not dry run (even if skipped)
# This prevents searches from accumulating when they don't meet criteria
if not dry_run:
await self._mark_search_processed(search['id'])
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)
errors.append(error_msg)
results.append(ConsolidationResult(
search_id=search['id'],
query=search['query'],
error=str(e)
))
# Mark as processed even on error (to avoid retrying failed searches forever)
if not dry_run:
await self._mark_search_processed(search['id'])
# Build response
processed_count = len([r for r in results if not r.error])
response = ConsolidationResponse(
total_found=len(unprocessed),
processed_count=processed_count,
pages_created=total_pages_created,
pages_updated=total_pages_updated,
entities_added=total_entities_added,
volatile_cached=total_volatile_cached,
files_queued=total_files_queued,
prefetch_registered=total_prefetch_registered,
errors=errors,
results=results,
dry_run=dry_run
)
logger.info(
f"Consolidation complete: {processed_count}/{len(unprocessed)} searches, "
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"
)
return response
async def _find_unprocessed_searches(
self,
lookback_days: int,
limit: int
) -> List[Dict[str, Any]]:
"""
Find unprocessed SearchQuery nodes from Neo4j.
"""
lookback_date = datetime.now() - timedelta(days=lookback_days)
query = """
MATCH (sq:SearchQuery {processed: false})
WHERE sq.timestamp > datetime($lookback_date)
RETURN sq.id as id,
sq.query as query,
sq.user as user,
sq.timestamp as timestamp,
sq.total_results as total_results,
sq.web_count as web_count,
sq.keywords as keywords
ORDER BY sq.timestamp DESC
LIMIT $limit
"""
try:
results = await self.neo4j.execute_query(
query,
{
"lookback_date": lookback_date.isoformat(),
"limit": limit
}
)
searches = []
for record in results:
searches.append({
'id': record['id'],
'query': record['query'],
'user': record['user'],
'timestamp': record['timestamp'],
'total_results': record.get('total_results', 0),
'web_count': record.get('web_count', 0),
'keywords': record.get('keywords', [])
})
return searches
except Exception as e:
logger.error(f"Failed to find unprocessed searches: {e}")
return []
async def _process_search(
self,
search: Dict[str, Any],
min_web_results: int,
dry_run: bool
) -> Optional[ConsolidationResult]:
"""
Process a single search query for knowledge consolidation.
Uses unified memory routing to classify each web result and route to:
- wiki: Stable reference content → wiki page creation/update
- volatile: Ephemeral data → volatile cache
- file: Downloadable documents → Paperless queue
- prefetch: Regular updates → scheduler registration
- skip: Low value content → discard
"""
search_id = search['id']
query = search['query']
user = search['user']
web_count = search.get('web_count', 0)
logger.info(f"Processing: '{query}' (user: {user}, web: {web_count})")
# Skip if insufficient web results
if web_count < min_web_results:
logger.info(f"Skipping - insufficient web results ({web_count} < {min_web_results})")
return None
# Get web results from SearchQuery
web_results = await self._get_web_results(search_id)
if not web_results:
logger.info("No web results found in database")
return None
logger.info(f"Retrieved {len(web_results)} web results")
# Unified classification of all web results
routing_result = await self._classify_web_results_unified(
query=query,
web_results=web_results,
keywords=search.get('keywords', []),
user=user
)
if not routing_result.classifications:
logger.info("No classifications returned")
return ConsolidationResult(
search_id=search_id,
query=query
)
logger.info(
f"Routing: {routing_result.wiki_routed} wiki, "
f"{routing_result.volatile_cached} volatile, "
f"{routing_result.files_queued} files, "
f"{routing_result.prefetch_registered} prefetch, "
f"{routing_result.skipped} skipped"
)
if dry_run:
logger.info("[DRY RUN] Would route results to destinations")
return ConsolidationResult(
search_id=search_id,
query=query,
pages_created=routing_result.wiki_routed,
volatile_cached=routing_result.volatile_cached,
files_queued=routing_result.files_queued,
prefetch_registered=routing_result.prefetch_registered,
)
# Process each classification
pages_created = 0
pages_updated = 0
entities_added = 0
volatile_cached = 0
files_queued = 0
prefetch_registered = 0
# Create URL-to-web_result lookup
url_to_result = {r['url']: r for r in web_results}
for classification in routing_result.classifications:
web_result = url_to_result.get(classification.url, {})
if classification.route_type == 'wiki':
# Route to wiki page creation/update
try:
if classification.wiki_action == 'create':
await self._create_or_consolidate_page(
user=user,
title=classification.title,
path=classification.wiki_path or f"reference/{classification.title.lower().replace(' ', '-')}",
summary=classification.wiki_summary or '',
source_query=query,
web_results=[web_result] if web_result else web_results[:3]
)
pages_created += 1
logger.info(f"Created wiki page: {classification.title}")
elif classification.wiki_action == 'update':
await self._update_page_with_facts(
title=classification.title,
new_facts=[classification.wiki_summary] if classification.wiki_summary else [],
source_url=classification.url,
user=user
)
pages_updated += 1
logger.info(f"Updated wiki page: {classification.title}")
except Exception as e:
logger.error(f"Failed wiki routing for {classification.title}: {e}")
elif classification.route_type == 'volatile':
# Route to volatile cache
if await self._route_to_volatile(classification, web_result, user):
volatile_cached += 1
elif classification.route_type == 'file':
# Route to Paperless queue
if await self._route_to_files(classification, web_result, user):
files_queued += 1
elif classification.route_type == 'prefetch':
# Register prefetch pattern
if await self._register_prefetch(classification, web_result, user):
prefetch_registered += 1
# 'skip' route type - do nothing
return ConsolidationResult(
search_id=search_id,
query=query,
pages_created=pages_created,
pages_updated=pages_updated,
entities_added=entities_added,
volatile_cached=volatile_cached,
files_queued=files_queued,
prefetch_registered=prefetch_registered,
)
async def _get_web_results(self, search_id: str) -> List[Dict[str, Any]]:
"""Get web results for a search from Neo4j."""
query = """
MATCH (sq:SearchQuery {id: $search_id})-[f:FOUND]->(wr:WebResult)
RETURN wr.url as url,
wr.title as title,
wr.content as content,
f.rank as rank,
f.rrf_score as rrf_score
ORDER BY f.rank
LIMIT 20
"""
try:
results = await self.neo4j.execute_query(query, {"search_id": search_id})
web_results = []
for record in results:
web_results.append({
'url': record['url'],
'title': record['title'],
'content': record['content'],
'rank': record['rank'],
'rrf_score': record['rrf_score']
})
return web_results
except Exception as e:
logger.error(f"Failed to get web results: {e}")
return []
async def _analyze_web_results(
self,
query: str,
web_results: List[Dict[str, Any]],
keywords: List[str],
user: str = "jpmschweitzer"
) -> Optional[Dict[str, Any]]:
"""
Analyze web results with Ollama for novel information.
Returns analysis with has_novel_info, new_pages, update_pages, new_entities.
"""
# Fetch existing taxonomy structure for this user
try:
taxonomy_structure = await self.wiki.get_taxonomy_structure(f"users/{user}")
existing_paths_info = self._format_taxonomy_for_prompt(taxonomy_structure)
logger.info(f"Fetched taxonomy with {len(taxonomy_structure)} categories for user {user}")
except Exception as e:
logger.warning(f"Failed to fetch taxonomy structure: {e}")
existing_paths_info = ""
# Build analysis prompt
web_summary = "\n\n".join([
f"[{i+1}] {r['title']}\n{r['url']}\n{r['content'][:300]}..."
for i, r in enumerate(web_results[:5])
])
prompt = f"""You are a Librarian helping build a personal knowledge base and extended memory system.
Analyze these web search results for information worth documenting in our personal wiki.
Query: "{query}"
Keywords: {', '.join(keywords) if keywords else 'none'}
Web Results:
{web_summary}
This is a PERSONAL knowledge base using Schema.org-aligned taxonomy that captures:
- People: Family members, friends, colleagues, public figures (Schema.org: Person)
- Companies: Businesses, organizations, institutions (Schema.org: Organization)
- Places: Locations, restaurants, travel destinations (Schema.org: Place)
- Entertainment: Books, movies, TV, music, games (Schema.org: CreativeWork)
- Recipes: Food, cooking techniques, ingredients (Schema.org: CreativeWork/Recipe)
- Products: Purchased items, gear, tools, equipment (Schema.org: Product)
- Technology: Software, applications, infrastructure (Schema.org: SoftwareApplication)
- Health: Medical info, fitness, wellness (Schema.org: MedicalEntity)
- Events: Concerts, travel, appointments, important dates (Schema.org: Event)
- Hobbies: Personal interests, activities, pastimes (Custom extension)
- Projects: Work projects, personal projects (Schema.org: Project)
- Reference: General knowledge, how-tos (Custom extension)
ANALYSIS STEPS:
1. Read each web result carefully for substantive, factual content
2. Identify genuinely novel information not likely already known
3. Match topics to appropriate taxonomy categories
4. Generate valid paths following the exact format below
RULES:
- Do NOT suggest pages for topics with insufficient information in results
- Do NOT invent entities not explicitly mentioned in results
- Do NOT suggest paths that don't match the taxonomy exactly
- Do NOT suggest generic or vague page topics
- Be CONSERVATIVE - fewer high-quality suggestions is better than many low-quality ones
- ONLY suggest documentation for substantive, specific information
**CRITICAL: Use ONLY these Schema.org-aligned path prefixes (case-sensitive):**
- People: `people/<name>` (Schema.org: Person)
- Companies: `companies/<company-name>` (Schema.org: Organization)
- Places: `places/<location>` (Schema.org: Place)
- Entertainment (Schema.org: CreativeWork):
- Books: `entertainment/books/<title>`
- Movies: `entertainment/movies/<title>`
- TV: `entertainment/tv/<title>`
- Music: `entertainment/music/<artist-or-album>`
- Games: `entertainment/games/<title>`
- Recipes: `recipes/<cuisine-or-category>/<dish>` (Schema.org: Recipe)
- Products: `products/<category>/<product-name>` (Schema.org: Product)
- Technology: `technology/<category>/<topic>` (Schema.org: SoftwareApplication)
- Health: `health/<category>/<topic>` (Schema.org: MedicalEntity)
- Events: `events/<event-type>/<event-name>` (Schema.org: Event)
- Hobbies: `hobbies/<hobby-name>` (Custom extension)
- Projects: `projects/<project-name>` (Schema.org: Project)
- Reference: `reference/<category>/<topic>` (Custom extension)
**Path Rules:**
- Use lowercase with hyphens (kebab-case): "machine-learning" not "Machine_Learning"
- Keep paths 2-3 levels deep maximum
- Be consistent with existing paths when possible
{existing_paths_info}
Return ONLY valid JSON:
{{
"has_novel_info": true,
"new_pages": [
{{"title": "Page Title", "path": "companies/example-company", "summary": "What information to include"}}
],
"update_pages": [
{{"title": "Existing Page", "new_facts": ["fact 1"], "source_url": "url"}}
],
"new_entities": [
{{"name": "Entity Name", "type": "person/place/thing/concept/recipe/media", "description": "Brief description"}}
]
}}
JSON:"""
try:
# Call Ollama for analysis (temperature=0.0 for consistent classification)
response = await self.ollama.generate_text(
prompt=prompt,
model=self.settings.ollama_model,
stream=False,
temperature=0.0
)
if not response:
logger.warning("Empty response from Ollama")
return None
# Extract JSON from response
response_clean = response.strip()
if '{' in response_clean:
json_start = response_clean.find('{')
json_end = response_clean.rfind('}') + 1
response_clean = response_clean[json_start:json_end]
analysis = json.loads(response_clean)
return analysis
except json.JSONDecodeError as e:
logger.error(f"Failed to parse Ollama response as JSON: {e}")
logger.debug(f"Response was: {response[:500]}")
return None
except Exception as e:
logger.error(f"Analysis failed: {e}", exc_info=True)
return None
def _format_taxonomy_for_prompt(self, taxonomy: Dict[str, List[str]]) -> str:
"""
Format taxonomy structure for inclusion in LLM prompt.
Args:
taxonomy: Dict mapping categories to subcategories
Returns:
Formatted string showing existing paths
"""
if not taxonomy:
return ""
lines = ["**Existing paths in your wiki (PREFER these over creating new ones):**"]
for category, subcategories in taxonomy.items():
if subcategories:
lines.append(f"- {category}/")
for sub in subcategories:
lines.append(f" - {category}/{sub}/")
else:
lines.append(f"- {category}/")
lines.append("")
lines.append("**IMPORTANT:** If a suitable existing path exists, use it instead of creating a new category.")
lines.append("Example: NATO should go in `reference/political-entities/` not a new `reference/military-alliances/`")
return "\n".join(lines)
async def _mark_search_processed(self, search_id: str):
"""Mark SearchQuery node as processed."""
query = """
MATCH (sq:SearchQuery {id: $search_id})
SET sq.processed = true,
sq.processed_at = datetime()
RETURN sq.id
"""
try:
await self.neo4j.execute_query(query, {"search_id": search_id})
logger.debug(f"Marked search {search_id} as processed")
except Exception as e:
logger.error(f"Failed to mark search as processed: {e}")
async def _apply_bidirectional_entity_linking(
self,
page_id: int,
page_title: str,
user: str
) -> Dict[str, int]:
"""
Apply bidirectional entity linking after page creation/update.
This runs AFTER ingestion so entities are extracted and in the graph.
Steps:
1. Link entities in the new page (forward links to existing entities)
2. Find pages that mention the new entity (reverse references)
3. Link entities in those pages (backward links to the new entity)
Args:
page_id: Wiki page ID
page_title: Page title (used to find reverse references)
user: User identifier
Returns:
Dict with link counts: {
"forward_links": int, # Links added to the new page
"backward_links": int, # Links added to other pages pointing to new page
"pages_updated": int # Number of other pages updated
}
"""
from src.core.multi_tenancy import get_neo4j_user_base_label
forward_links = 0
backward_links = 0
pages_updated = 0
try:
# Import here to avoid circular dependency
from src.routers.entity_linking import link_entities_in_page, EntityLinkingRequest
from src.core.dependencies import get_wiki_service, get_graph_service
wiki_service = get_wiki_service()
graph_service = get_graph_service()
# STEP 1: Forward linking - link entities in the new page
logger.info(f"Step 1/3: Linking entities in page {page_id} ('{page_title}')")
try:
forward_result = await link_entities_in_page(
request=EntityLinkingRequest(
user=user,
page_id=page_id,
create_relationships=True,
re_index_if_changed=False # Already indexed, no need to re-index
),
wiki_service=wiki_service,
graph_service=graph_service,
ingestion_service=self.ingestion_service,
api_key="" # Internal call, no auth needed
)
forward_links = forward_result.content_links_added
logger.info(f"Added {forward_links} forward links in page {page_id}")
except Exception as e:
logger.error(f"Failed to add forward links: {e}")
# STEP 2: Find reverse references - which pages mention this new entity?
logger.info(f"Step 2/3: Finding pages that mention '{page_title}'")
user_base_label = get_neo4j_user_base_label(user)
# Query to find documents that mention entities with this page's title
reverse_query = f"""
// Find entities with the same name as the page title
MATCH (e:{user_base_label})
WHERE toLower(e.name) = toLower($title)
AND NOT e:Document
// Find documents that mention those entities
MATCH (d:Document)-[r:MENTIONS]->(e)
WHERE d.page_id <> $page_id // Exclude the page itself
RETURN DISTINCT d.page_id as page_id, d.title as title
LIMIT 50
"""
try:
reverse_refs = await self.neo4j.execute_query(
reverse_query,
{"title": page_title, "page_id": page_id}
)
logger.info(f"Found {len(reverse_refs)} pages that mention '{page_title}'")
except Exception as e:
logger.error(f"Failed to find reverse references: {e}")
reverse_refs = []
# STEP 3: Backward linking - add links in those pages to the new entity
if reverse_refs:
logger.info(f"Step 3/3: Adding backward links in {len(reverse_refs)} pages")
for ref in reverse_refs:
try:
backward_result = await link_entities_in_page(
request=EntityLinkingRequest(
user=user,
page_id=ref['page_id'],
create_relationships=False, # Relationships already exist
re_index_if_changed=False # Don't re-index for link updates
),
wiki_service=wiki_service,
graph_service=graph_service,
ingestion_service=self.ingestion_service,
api_key=""
)
if backward_result.content_links_added > 0:
backward_links += backward_result.content_links_added
pages_updated += 1
logger.info(
f"Added {backward_result.content_links_added} links "
f"in page {ref['page_id']} ('{ref['title']}')"
)
except Exception as e:
logger.error(f"Failed to add backward links in page {ref['page_id']}: {e}")
else:
logger.info("Step 3/3: No reverse references found, skipping backward linking")
return {
"forward_links": forward_links,
"backward_links": backward_links,
"pages_updated": pages_updated
}
except Exception as e:
logger.error(f"Bidirectional entity linking failed: {e}", exc_info=True)
return {
"forward_links": 0,
"backward_links": 0,
"pages_updated": 0
}
async def _create_or_consolidate_page(
self,
user: str,
title: str,
path: str,
summary: str,
source_query: str,
web_results: List[Dict[str, Any]]
):
"""
Create wiki page or consolidate with existing synonym page.
Uses WikiPageWriter for intelligent LLM-based content generation:
- For new pages: Holistic structured content creation
- For existing pages: Zero-loss reconstruction with conflict detection
"""
# Normalize path to user namespace
if not path.startswith(f"users/{user}"):
path = f"users/{user}/{path.lstrip('/')}"
# Format web results as source information
source_information = [
{
'title': r['title'],
'url': r['url'],
'content': r['content']
}
for r in web_results[:5] # Top 5 web results
]
# Search for existing pages with similar titles (synonym consolidation)
existing_pages = await self.wiki.search_pages(title, path_prefix=f"users/{user}")
if existing_pages:
# Page exists - reconstruct with new information using LLM
logger.info(f"Found existing page for '{title}', will reconstruct with new info")
page_id = existing_pages[0]['id']
# Get current content
existing_page = await self.wiki.get_page(page_id)
if existing_page:
# Build new information text from summary and web results
new_information = f"{summary}\n\n"
for r in web_results[:3]:
new_information += f"- {r['title']}: {r['content'][:200]}...\n"
# Use WikiPageWriter to reconstruct with LLM
reconstructed_content, conflicts = await self.wiki_page_writer.reconstruct_page(
title=title,
existing_content=existing_page['content'],
new_information=new_information,
new_sources=source_information,
detect_conflicts=True
)
if conflicts:
logger.warning(
f"Detected {len(conflicts)} conflicts when updating '{title}' - "
"LLM chose most authoritative sources"
)
await self.wiki.update_page(
page_id=page_id,
content=reconstructed_content
)
logger.info(f"Reconstructed existing page: {title}")
# Trigger ingestion to update vectors and graph
if self.ingestion_service:
try:
await self.ingestion_service.ingest_page(
page_id=page_id,
user=user,
force_refresh=True
)
logger.info(f"Ingested updated page {page_id} into knowledge base")
# Apply bidirectional entity linking after ingestion
link_stats = await self._apply_bidirectional_entity_linking(
page_id=page_id,
page_title=title,
user=user
)
logger.info(
f"Entity linking complete: {link_stats['forward_links']} forward links, "
f"{link_stats['backward_links']} backward links "
f"({link_stats['pages_updated']} pages updated)"
)
except Exception as e:
logger.error(f"Failed to ingest updated page {page_id}: {e}")
return
# Create new page with LLM-generated structured content
logger.info(f"Creating new page: {title}")
# Use WikiPageWriter to create structured content
content = await self.wiki_page_writer.create_page(
title=title,
topic_summary=summary,
source_information=source_information,
entities=None, # Could extract from keywords if available
related_docs=None
)
# Extract tags from path for dossier organization
path_parts = path.split('/')
tags = [part for part in path_parts if part and part not in ['users', user]]
created_page = await self.wiki.create_page(
path=path,
title=title,
content=content,
description=f"Consolidated from search: {source_query}",
tags=tags[:3], # Limit to 3 tags
is_published=True
)
page_id = created_page.get("id") if created_page else None
logger.info(f"Created new page: {path} (page_id: {page_id})")
# Trigger ingestion to update vectors and graph
if self.ingestion_service and page_id:
try:
await self.ingestion_service.ingest_page(
page_id=page_id,
user=user,
force_refresh=False # New page, no need to force
)
logger.info(f"Ingested new page {page_id} into knowledge base")
# Apply bidirectional entity linking after ingestion
link_stats = await self._apply_bidirectional_entity_linking(
page_id=page_id,
page_title=title,
user=user
)
logger.info(
f"Entity linking complete: {link_stats['forward_links']} forward links, "
f"{link_stats['backward_links']} backward links "
f"({link_stats['pages_updated']} pages updated)"
)
except Exception as e:
logger.error(f"Failed to ingest new page {page_id}: {e}")
async def _update_page_with_facts(
self,
title: str,
new_facts: List[str],
source_url: str,
user: str
):
"""
Update existing page with new facts using LLM reconstruction.
Uses WikiPageWriter to intelligently merge facts with zero loss.
"""
# Search for page
pages = await self.wiki.search_pages(title, path_prefix=f"users/{user}")
if not pages:
logger.warning(f"Page '{title}' not found for update")
return
page_id = pages[0]['id']
existing_page = await self.wiki.get_page(page_id)
if not existing_page:
return
# Build new information from facts
new_information = "\n".join([f"- {fact}" for fact in new_facts])
# Format source
source_information = [{
'title': source_url,
'url': source_url,
'content': new_information
}]
# Use WikiPageWriter to reconstruct with LLM
reconstructed_content, conflicts = await self.wiki_page_writer.reconstruct_page(
title=title,
existing_content=existing_page['content'],
new_information=new_information,
new_sources=source_information,
detect_conflicts=True
)
if conflicts:
logger.warning(
f"Detected {len(conflicts)} conflicts when updating '{title}' with new facts"
)
await self.wiki.update_page(
page_id=page_id,
content=reconstructed_content
)
# Trigger ingestion to update vectors and graph
logger.debug(f"ingestion_service available: {self.ingestion_service is not None}")
if self.ingestion_service:
try:
logger.info(f"Starting ingestion for updated page {page_id}")
await self.ingestion_service.ingest_page(
page_id=page_id,
user=user,
force_refresh=True
)
logger.info(f"Ingested updated page {page_id} into knowledge base")
# Apply bidirectional entity linking after ingestion
link_stats = await self._apply_bidirectional_entity_linking(
page_id=page_id,
page_title=title,
user=user
)
logger.info(
f"Entity linking complete: {link_stats['forward_links']} forward links, "
f"{link_stats['backward_links']} backward links "
f"({link_stats['pages_updated']} pages updated)"
)
except Exception as e:
logger.error(f"Failed to ingest updated page {page_id}: {e}")
async def _add_entity_to_graph(
self,
user: str,
entity_name: str,
entity_type: str,
description: str,
source_search_id: str
):
"""Add new entity to knowledge graph."""
from src.core.multi_tenancy import get_neo4j_user_base_label
user_base_label = get_neo4j_user_base_label(user)
# Create entity node with appropriate type label
type_label = entity_type.capitalize() if entity_type else "Entity"
query = f"""
MERGE (e:{user_base_label}:{type_label} {{name: $name}})
ON CREATE SET
e.description = $description,
e.created_at = datetime(),
e.source = 'librarian_consolidation',
e.source_search_id = $search_id
ON MATCH SET
e.updated_at = datetime()
RETURN e
"""
try:
await self.neo4j.execute_query(query, {
"name": entity_name,
"description": description,
"search_id": source_search_id
})
logger.debug(f"Added entity to graph: {entity_name} ({entity_type})")
except Exception as e:
logger.error(f"Failed to add entity to graph: {e}")
async def _classify_web_results_unified(
self,
query: str,
web_results: List[Dict[str, Any]],
keywords: List[str],
user: str = "jpmschweitzer"
) -> MemoryRoutingResult:
"""
Unified classification of web results for memory routing.
Each web result is classified into exactly one destination:
- wiki: Stable reference content → wiki page creation/update
- volatile: Ephemeral data (weather, news, prices) → volatile cache
- file: Downloadable file (PDF, doc, xls, images) → Paperless
- prefetch: Regularly updated source → scheduler registration
- skip: Low value, ads, errors → discard
Returns:
MemoryRoutingResult with classifications for each web result
"""
# Fetch existing taxonomy structure for wiki path suggestions
try:
taxonomy_structure = await self.wiki.get_taxonomy_structure(f"users/{user}")
existing_paths_info = self._format_taxonomy_for_prompt(taxonomy_structure)
logger.info(f"Fetched taxonomy with {len(taxonomy_structure)} categories for user {user}")
except Exception as e:
logger.warning(f"Failed to fetch taxonomy structure: {e}")
existing_paths_info = ""
# Build classification prompt
web_summary = "\n\n".join([
f"[{i+1}] Title: {r['title']}\n URL: {r['url']}\n Content: {r['content'][:400]}..."
for i, r in enumerate(web_results[:10])
])
prompt = f"""You are a Memory Router for a personal knowledge system. Classify each web result into ONE destination.
Query: "{query}"
Keywords: {', '.join(keywords) if keywords else 'none'}
Web Results:
{web_summary}
CLASSIFICATION RULES:
**wiki** - Stable reference content worth documenting permanently:
- Factual information about people, places, companies, products
- How-to guides, tutorials, technical documentation
- Historical facts, biographies, definitions
- Content that won't change frequently
**volatile** - Ephemeral data that changes frequently:
- Current weather conditions or forecasts
- Latest news headlines or breaking news
- Stock prices, exchange rates, crypto prices
- Sports scores, live results
- Traffic conditions, transit delays
- Social media trends, notifications
Use namespaces: weather, news, financial, transit, traffic, sports, social, system
**file** - Downloadable documents:
- PDF files (URLs ending in .pdf or containing /pdf/)
- Office documents (.doc, .docx, .xls, .xlsx, .ppt)
- Images (.jpg, .png, .gif when they're primary content)
- CSV/data files
- Any direct download link
**prefetch** - Sources worth checking regularly:
- News feeds or RSS sources
- API endpoints with live data
- Dashboards or status pages
- Only if not already captured by volatile
**skip** - Low value content:
- Ads, paywalled content
- Error pages, 404s
- Duplicate or redundant results
- Content not answering the query
{existing_paths_info}
Return ONLY valid JSON array:
[
{{
"url": "...",
"title": "...",
"route_type": "wiki|volatile|file|prefetch|skip",
"wiki_action": "create|update",
"wiki_path": "category/subcategory/page-name",
"wiki_summary": "What to document",
"volatile_namespace": "weather|news|financial|...",
"volatile_key": "cache-key",
"volatile_ttl_hours": 1,
"prefetch_cron": "0 * * * *",
"prefetch_endpoint": "/volatile/fetch/...",
"confidence": 0.9,
"reason": "Why this classification"
}}
]
Only include fields relevant to the route_type. Set irrelevant fields to null.
JSON:"""
try:
response = await self.ollama.generate_text(
prompt=prompt,
model=self.settings.ollama_model,
stream=False,
temperature=0.0
)
if not response:
logger.warning("Empty response from Ollama for classification")
return MemoryRoutingResult()
# Extract JSON array from response
response_clean = response.strip()
if '[' in response_clean:
json_start = response_clean.find('[')
json_end = response_clean.rfind(']') + 1
response_clean = response_clean[json_start:json_end]
classifications_raw = json.loads(response_clean)
# Parse into MemoryRouteClassification objects
result = MemoryRoutingResult()
for item in classifications_raw:
try:
classification = MemoryRouteClassification(
url=item.get('url', ''),
title=item.get('title', ''),
route_type=item.get('route_type', 'skip'),
wiki_action=item.get('wiki_action'),
wiki_path=item.get('wiki_path'),
wiki_summary=item.get('wiki_summary'),
volatile_namespace=item.get('volatile_namespace'),
volatile_key=item.get('volatile_key'),
volatile_ttl_hours=item.get('volatile_ttl_hours'),
prefetch_cron=item.get('prefetch_cron'),
prefetch_endpoint=item.get('prefetch_endpoint'),
confidence=item.get('confidence', 0.5),
reason=item.get('reason', ''),
)
result.classifications.append(classification)
# Count by route type
if classification.route_type == 'wiki':
result.wiki_routed += 1
elif classification.route_type == 'volatile':
result.volatile_cached += 1
elif classification.route_type == 'file':
result.files_queued += 1
elif classification.route_type == 'prefetch':
result.prefetch_registered += 1
else:
result.skipped += 1
except Exception as e:
logger.warning(f"Failed to parse classification item: {e}")
logger.info(
f"Classification complete: {result.wiki_routed} wiki, "
f"{result.volatile_cached} volatile, {result.files_queued} files, "
f"{result.prefetch_registered} prefetch, {result.skipped} skipped"
)
return result
except json.JSONDecodeError as e:
logger.error(f"Failed to parse classification response as JSON: {e}")
return MemoryRoutingResult()
except Exception as e:
logger.error(f"Classification failed: {e}", exc_info=True)
return MemoryRoutingResult()
async def _route_to_volatile(
self,
classification: MemoryRouteClassification,
web_result: Dict[str, Any],
user: str,
) -> bool:
"""
Route a web result to volatile cache.
Args:
classification: The classification with volatile routing info
web_result: The original web result data
user: User identifier
Returns:
True if successfully cached, False otherwise
"""
if not self.volatile_service:
logger.warning("Volatile service not configured, skipping volatile routing")
return False
namespace = classification.volatile_namespace or "custom"
key = classification.volatile_key or web_result['url'].split('/')[-1]
ttl = (classification.volatile_ttl_hours or 1) * 3600 # Convert hours to seconds
try:
# Store the web result content in volatile cache
data = {
"title": web_result.get('title', ''),
"content": web_result.get('content', ''),
"url": web_result.get('url', ''),
"text": f"{web_result.get('title', '')}: {web_result.get('content', '')[:500]}",
}
await self.volatile_service.store(
user=user,
namespace=namespace,
key=key,
data=data,
source=web_result.get('url', 'web_search'),
ttl=ttl,
)
logger.info(f"Cached to volatile: {namespace}/{key} (ttl={ttl}s)")
return True
except Exception as e:
logger.error(f"Failed to cache to volatile: {e}")
return False
async def _route_to_files(
self,
classification: MemoryRouteClassification,
web_result: Dict[str, Any],
user: str,
) -> bool:
"""
Queue a file for Paperless ingestion.
Args:
classification: The classification with file info
web_result: The original web result data
user: User identifier
Returns:
True if successfully queued, False otherwise
"""
# For now, log the file for manual review or future Paperless integration
url = web_result.get('url', '')
title = web_result.get('title', '')
logger.info(f"File detected for Paperless: {title} ({url})")
# TODO: Implement actual Paperless file upload
# This would involve:
# 1. Download the file
# 2. Upload to Paperless via API
# 3. Add tags based on classification
return True # Placeholder - count as queued
async def _register_prefetch(
self,
classification: MemoryRouteClassification,
web_result: Dict[str, Any],
user: str,
) -> bool:
"""
Register a prefetch pattern with the external scheduler service.
Args:
classification: The classification with prefetch info
web_result: The original web result data
user: User identifier
Returns:
True if successfully registered, False otherwise
"""
if not self.scheduler_client:
logger.warning("Scheduler client not configured, skipping prefetch registration")
return False
# Parse cron pattern into scheduler schedule format
# Format: "minute hour day_of_month month day_of_week"
# Scheduler uses -1 for "every"
cron = classification.prefetch_cron or "0 * * * *"
schedule = self._parse_cron_to_schedule(cron)
# Determine namespace and key from classification
namespace = classification.volatile_namespace or "custom"
key = classification.volatile_key or web_result.get('url', '').split('/')[-1].split('?')[0]
if not key:
logger.warning(f"Could not determine prefetch key for {web_result.get('url')}")
return False
try:
# Use the scheduler client's convenience method to register volatile fetch
success = await self.scheduler_client.register_volatile_fetch(
namespace=namespace,
key=key,
user=user,
schedule=schedule,
description=f"Auto-prefetch: {classification.title or web_result.get('title', 'Unknown')}",
)
if success:
logger.info(f"Registered scheduler task: volatile_{namespace}_{key}_{user}")
return success
except Exception as e:
logger.error(f"Failed to register prefetch with scheduler: {e}")
return False
def _parse_cron_to_schedule(self, cron: str) -> dict:
"""
Parse cron string to scheduler schedule dict.
Args:
cron: Cron-style string (e.g., "0 6 * * *" = 6:00 AM daily)
Returns:
Dict with minute, hour, day_of_month, month, day_of_week
where -1 means "every"
"""
parts = cron.strip().split()
if len(parts) != 5:
# Default to hourly if invalid
return {"minute": 0, "hour": -1}
def parse_part(part: str) -> int:
if part == "*":
return -1
try:
return int(part)
except ValueError:
return -1
return {
"minute": parse_part(parts[0]),
"hour": parse_part(parts[1]),
"day_of_month": parse_part(parts[2]),
"month": parse_part(parts[3]),
"day_of_week": parse_part(parts[4]),
}