Build and Push / build (release) Successful in 54s
- Add Paperless document search to HybridRAG pipeline - Add volatile cache (weather, forecast, news, stocks) to HybridRAG - Add include_documents and include_volatile params to hybrid_search - Add 📑 and ⚡ icons for document/volatile sources - Update Librarian prompt with new data source awareness - Fix Biographer routing: personal memory queries now route correctly - Add location keywords to Steward pre-fetch logic 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
403 lines
13 KiB
Python
403 lines
13 KiB
Python
"""
|
|
Steward service layer.
|
|
|
|
Provides high-level interface for request analysis with logging
|
|
and error handling.
|
|
|
|
Parses plain text recommendations into structured data.
|
|
Includes memory pre-fetch for user context injection.
|
|
"""
|
|
import re
|
|
from typing import Any, Optional
|
|
|
|
from src.core.household_registry import get_household_registry
|
|
from src.core.logging_config import get_logger, log_operation
|
|
from src.core.memory_service import memory_service
|
|
from .agent import get_steward_agent
|
|
from .schemas import ConversationContext, StewardRecommendation
|
|
|
|
logger = get_logger(__name__)
|
|
|
|
|
|
def _extract_capabilities(text: str) -> list[str]:
|
|
"""
|
|
Extract capability names from Steward's text response.
|
|
|
|
Uses keyword matching to find mentioned capabilities.
|
|
|
|
Args:
|
|
text: Steward's plain text analysis
|
|
|
|
Returns:
|
|
List of capability names (e.g., ['tatlock_core'])
|
|
"""
|
|
text_lower = text.lower()
|
|
registry = get_household_registry()
|
|
capabilities = registry.get_all_capabilities()
|
|
|
|
found_caps = []
|
|
|
|
for cap in capabilities:
|
|
# Check if capability name is mentioned
|
|
if cap.name.lower() in text_lower:
|
|
found_caps.append(cap.name)
|
|
continue
|
|
|
|
# Check if any domains are mentioned
|
|
for domain in cap.domains:
|
|
if domain.lower() in text_lower:
|
|
found_caps.append(cap.name)
|
|
break
|
|
|
|
return found_caps
|
|
|
|
|
|
def _extract_complexity(text: str) -> str:
|
|
"""
|
|
Extract complexity assessment from text.
|
|
|
|
Args:
|
|
text: Steward's plain text analysis
|
|
|
|
Returns:
|
|
One of: "simple", "moderate", "complex"
|
|
"""
|
|
text_lower = text.lower()
|
|
|
|
if "complex" in text_lower:
|
|
return "complex"
|
|
elif "moderate" in text_lower:
|
|
return "moderate"
|
|
else:
|
|
return "simple" # Default to simple
|
|
|
|
|
|
def _extract_conversation_context(
|
|
text: str,
|
|
conversation_history: list[dict]
|
|
) -> ConversationContext:
|
|
"""
|
|
Extract conversation context analysis from text.
|
|
|
|
Args:
|
|
text: Steward's plain text analysis
|
|
conversation_history: Previous conversation turns
|
|
|
|
Returns:
|
|
ConversationContext with relevant turn analysis
|
|
"""
|
|
text_lower = text.lower()
|
|
|
|
# Check if conversation history is referenced
|
|
has_context = bool(conversation_history) and any([
|
|
"previous" in text_lower,
|
|
"earlier" in text_lower,
|
|
"context" in text_lower,
|
|
"turn" in text_lower,
|
|
"history" in text_lower,
|
|
])
|
|
|
|
# Extract turn numbers if mentioned (e.g., "turn 0", "turn 1")
|
|
relevant_turns = []
|
|
turn_pattern = r"turn\s+(\d+)"
|
|
matches = re.findall(turn_pattern, text_lower)
|
|
relevant_turns = [int(m) for m in matches]
|
|
|
|
# Create summary from relevant portion of text
|
|
context_summary = ""
|
|
if has_context:
|
|
# Extract sentence(s) mentioning context
|
|
sentences = text.split('.')
|
|
context_sentences = [s for s in sentences if any(
|
|
word in s.lower() for word in ["previous", "earlier", "context", "history"]
|
|
)]
|
|
if context_sentences:
|
|
context_summary = context_sentences[0].strip()
|
|
|
|
return ConversationContext(
|
|
has_previous_context=has_context,
|
|
relevant_turns=relevant_turns,
|
|
context_summary=context_summary
|
|
)
|
|
|
|
|
|
def _extract_missing_capabilities(text: str) -> Optional[str]:
|
|
"""
|
|
Extract missing capability notes from text.
|
|
|
|
Args:
|
|
text: Steward's plain text analysis
|
|
|
|
Returns:
|
|
Description of missing capabilities, or None
|
|
"""
|
|
text_lower = text.lower()
|
|
|
|
# Look for indicators of missing capabilities
|
|
if any(word in text_lower for word in [
|
|
"missing", "unavailable", "not available", "don't have", "doesn't have"
|
|
]):
|
|
# Find the sentence mentioning missing capabilities
|
|
sentences = text.split('.')
|
|
for sentence in sentences:
|
|
if any(word in sentence.lower() for word in [
|
|
"missing", "unavailable", "not available"
|
|
]):
|
|
return sentence.strip()
|
|
|
|
return None
|
|
|
|
|
|
def _build_enriched_query(user_request: str, memory_context: dict[str, Any]) -> str:
|
|
"""
|
|
Build an enriched query by appending user context when not specified.
|
|
|
|
When the user asks location-dependent questions (weather, nearby, etc.)
|
|
without specifying a location, this appends their known location.
|
|
Similarly for timezone-dependent queries.
|
|
|
|
Args:
|
|
user_request: The user's original request
|
|
memory_context: Pre-fetched memory context with profile/preferences
|
|
|
|
Returns:
|
|
str: Query with context appended, or original query if no enrichment needed
|
|
|
|
Example:
|
|
>>> query = _build_enriched_query(
|
|
... "What's the weather?",
|
|
... {"profile": {"location": "Amsterdam", "timezone": "Europe/Amsterdam"}}
|
|
... )
|
|
>>> query
|
|
"What's the weather?\n\n[User Context: location=Amsterdam, timezone=Europe/Amsterdam]"
|
|
"""
|
|
if not memory_context:
|
|
return user_request
|
|
|
|
request_lower = user_request.lower()
|
|
profile = memory_context.get("profile", {})
|
|
preferences = memory_context.get("preferences", {})
|
|
|
|
context_parts = []
|
|
|
|
# Check if location is needed and not specified
|
|
location_keywords = ["weather", "temperature", "forecast", "nearby", "local", "here"]
|
|
# Use word boundary pattern to avoid false positives like "at" in "what"
|
|
location_prepositions = [r'\bin\b', r'\bat\b', r'\bnear\b', r'\baround\b', r'\bfor\b']
|
|
location_specified = any(re.search(p, request_lower) for p in location_prepositions)
|
|
|
|
if any(word in request_lower for word in location_keywords):
|
|
if not location_specified and profile.get("location"):
|
|
context_parts.append(f"location={profile['location']}")
|
|
|
|
# Check if timezone is needed and not specified
|
|
time_keywords = ["time", "schedule", "meeting", "appointment", "when", "today", "tomorrow"]
|
|
timezone_specified = any(word in request_lower for word in ["timezone", "tz", "utc", "gmt"])
|
|
|
|
if any(word in request_lower for word in time_keywords):
|
|
if not timezone_specified and profile.get("timezone"):
|
|
context_parts.append(f"timezone={profile['timezone']}")
|
|
|
|
# Add preferences if relevant
|
|
if preferences.get("temperature_unit") and "weather" in request_lower:
|
|
context_parts.append(f"temperature_unit={preferences['temperature_unit']}")
|
|
|
|
# Build enriched query
|
|
if context_parts:
|
|
context_str = ", ".join(context_parts)
|
|
return f"{user_request}\n\n[User Context: {context_str}]"
|
|
|
|
return user_request
|
|
|
|
|
|
async def _prefetch_memory_context(user_request: str) -> dict[str, Any]:
|
|
"""
|
|
Pre-fetch user context that might be needed for this request.
|
|
|
|
This is the "direct access" layer - fast lookups without LLM overhead.
|
|
Uses simple keyword matching to determine what context to fetch.
|
|
|
|
Args:
|
|
user_request: The user's request text
|
|
|
|
Returns:
|
|
Dict with profile and/or preferences data
|
|
|
|
Example:
|
|
>>> ctx = await _prefetch_memory_context("What's the weather?")
|
|
>>> ctx
|
|
{"profile": {"location": "Amsterdam"}}
|
|
"""
|
|
request_lower = user_request.lower()
|
|
|
|
# Determine what context might be needed based on keywords
|
|
profile_keys = []
|
|
|
|
# Location-related queries
|
|
if any(word in request_lower for word in [
|
|
"weather", "temperature", "forecast", "nearby", "local",
|
|
"directions", "distance", "map", "here",
|
|
# Direct location questions
|
|
"live", "where", "home", "reside", "location", "address",
|
|
]):
|
|
profile_keys.append("location")
|
|
|
|
# Time-related queries
|
|
if any(word in request_lower for word in [
|
|
"time", "schedule", "meeting", "appointment", "reminder",
|
|
"alarm", "when", "today", "tomorrow"
|
|
]):
|
|
profile_keys.append("timezone")
|
|
|
|
# Personal queries
|
|
if any(word in request_lower for word in [
|
|
"my name", "who am i", "about me"
|
|
]):
|
|
profile_keys.append("name")
|
|
|
|
# Always fetch preferences if they might affect response format
|
|
include_preferences = any(word in request_lower for word in [
|
|
"temperature", "weather", "convert", "unit", "format",
|
|
"celsius", "fahrenheit", "metric", "imperial"
|
|
])
|
|
|
|
try:
|
|
return await memory_service.prefetch_context(
|
|
include_profile=bool(profile_keys),
|
|
include_preferences=include_preferences,
|
|
profile_keys=profile_keys if profile_keys else None,
|
|
)
|
|
except Exception as e:
|
|
logger.warning(
|
|
"steward_prefetch_memory_failed",
|
|
error=str(e),
|
|
)
|
|
return {}
|
|
|
|
|
|
async def analyze_request(
|
|
user_request: str,
|
|
conversation_history: list[dict],
|
|
conversation_id: Optional[str] = None,
|
|
) -> StewardRecommendation:
|
|
"""
|
|
Analyze user request with full conversation context.
|
|
|
|
This is the main entry point for Steward analysis. It:
|
|
1. Calls the Steward agent with full conversation history
|
|
2. Logs the operation with timing
|
|
3. Returns structured recommendations
|
|
|
|
Args:
|
|
user_request: The current user message to analyze
|
|
conversation_history: Full conversation history (all previous turns)
|
|
conversation_id: Optional conversation ID for tracking
|
|
|
|
Returns:
|
|
StewardRecommendation with capability recommendations and context analysis
|
|
|
|
Example:
|
|
>>> recommendation = await analyze_request(
|
|
... "What's sqrt(144)?",
|
|
... conversation_history=[],
|
|
... )
|
|
>>> print(recommendation.recommended_capabilities)
|
|
['tatlock_core']
|
|
"""
|
|
async with log_operation(
|
|
"steward_analysis",
|
|
{
|
|
"request_preview": user_request[:100],
|
|
"conversation_id": conversation_id,
|
|
"history_length": len(conversation_history),
|
|
}
|
|
) as log_ctx:
|
|
try:
|
|
# Pre-fetch user context from memory (fast, no LLM)
|
|
memory_context = await _prefetch_memory_context(user_request)
|
|
log_ctx["memory_context_keys"] = list(memory_context.keys())
|
|
|
|
# Get Steward agent
|
|
steward = get_steward_agent()
|
|
|
|
logger.debug(
|
|
"steward_analyzing_request",
|
|
request=user_request,
|
|
history_turns=len(conversation_history),
|
|
memory_context=bool(memory_context),
|
|
)
|
|
|
|
# Get plain text analysis from Steward
|
|
analysis_text = await steward.analyze(
|
|
user_request,
|
|
conversation_history=conversation_history
|
|
)
|
|
|
|
# Parse plain text into structured recommendation
|
|
capabilities = _extract_capabilities(analysis_text)
|
|
complexity = _extract_complexity(analysis_text)
|
|
context = _extract_conversation_context(analysis_text, conversation_history)
|
|
missing = _extract_missing_capabilities(analysis_text)
|
|
|
|
# Build enriched query with auto-filled context
|
|
enriched_query = _build_enriched_query(user_request, memory_context)
|
|
|
|
recommendation = StewardRecommendation(
|
|
recommended_capabilities=capabilities,
|
|
reasoning=analysis_text,
|
|
estimated_complexity=complexity,
|
|
conversation_context=context,
|
|
missing_capabilities=missing,
|
|
memory_context=memory_context,
|
|
enriched_query=enriched_query,
|
|
)
|
|
|
|
# Update log context with results
|
|
log_ctx["recommendation_count"] = len(recommendation.recommended_capabilities)
|
|
log_ctx["complexity"] = recommendation.estimated_complexity
|
|
log_ctx["has_context"] = recommendation.conversation_context.has_previous_context
|
|
log_ctx["missing_capabilities"] = recommendation.missing_capabilities is not None
|
|
|
|
logger.info(
|
|
"steward_analysis_complete",
|
|
recommended=recommendation.recommended_capabilities,
|
|
complexity=recommendation.estimated_complexity,
|
|
reasoning=analysis_text[:200], # First 200 chars
|
|
)
|
|
|
|
return recommendation
|
|
|
|
except Exception as e:
|
|
logger.error(
|
|
"steward_analysis_failed",
|
|
error=str(e),
|
|
error_type=type(e).__name__,
|
|
exc_info=True,
|
|
)
|
|
raise
|
|
|
|
|
|
async def format_steward_note(recommendation: StewardRecommendation) -> str:
|
|
"""
|
|
Format Steward's recommendation as a note for the Butler.
|
|
|
|
This creates a structured message that will be prepended to the user's
|
|
request when sent to Tatlock, providing context and guidance.
|
|
|
|
Args:
|
|
recommendation: Steward's analysis and recommendations
|
|
|
|
Returns:
|
|
Formatted note string for the Butler
|
|
|
|
Example:
|
|
>>> note = await format_steward_note(recommendation)
|
|
>>> print(note)
|
|
📋 Steward's Analysis
|
|
========================================
|
|
Complexity: SIMPLE
|
|
Recommended tools: tatlock_core
|
|
========================================
|
|
"""
|
|
return recommendation.format_for_butler()
|