""" 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()