voice_router.py
1 #!/usr/bin/env python3 2 """ 3 Voice Command Router 4 5 Parses natural language voice commands and routes to appropriate handlers. 6 7 Designed for ambient computing - commands come from: 8 - iPhone via Siri Shortcuts 9 - Apple Watch dictation 10 - AirPods "Hey Siri" relay 11 - Meta Glasses relay 12 13 Command patterns: 14 - "What was I working on?" → context query 15 - "Capture this: <content>" → store thought 16 - "Run correlation" → trigger analysis 17 - "Show me the aha moments" → query results 18 - "That's important" → boost recent content 19 - "Remind me about <topic>" → context search 20 21 Usage: 22 router = VoiceRouter() 23 result = router.process("what was I working on today") 24 # Returns: {action: 'context_query', response: '...', tts: '...'} 25 """ 26 27 import re 28 from dataclasses import dataclass, field 29 from datetime import datetime, timedelta 30 from typing import Dict, List, Optional, Any, Callable, Tuple 31 from enum import Enum 32 33 34 class CommandIntent(Enum): 35 """Recognized command intents.""" 36 # Queries 37 CONTEXT_QUERY = "context_query" # "What was I working on?" 38 STATUS_CHECK = "status_check" # "How am I doing?" 39 AHA_QUERY = "aha_query" # "What insights today?" 40 TOPIC_SEARCH = "topic_search" # "Tell me about X" 41 42 # Actions 43 CAPTURE = "capture" # "Capture this: ..." 44 MARK_IMPORTANT = "mark_important" # "That's important" 45 MARK_IGNORE = "mark_ignore" # "Scratch that" 46 RUN_CORRELATION = "run_correlation" # "Run correlation" 47 RUN_SYNC = "run_sync" # "Sync now" 48 49 # Navigation 50 SHOW_DASHBOARD = "show_dashboard" # "Show me..." 51 READ_BRIEF = "read_brief" # "What's the brief?" 52 53 # Meta 54 HELP = "help" # "What can you do?" 55 UNKNOWN = "unknown" 56 57 58 @dataclass 59 class CommandResult: 60 """Result of processing a voice command.""" 61 intent: CommandIntent 62 confidence: float # 0.0 to 1.0 63 64 # What to do 65 action: str # API endpoint or handler name 66 params: Dict[str, Any] = field(default_factory=dict) 67 68 # Response 69 response_text: str = "" # Full response for display 70 tts_text: str = "" # Shortened version for speech 71 72 # State 73 success: bool = True 74 error: Optional[str] = None 75 76 def to_dict(self) -> Dict[str, Any]: 77 return { 78 'intent': self.intent.value, 79 'confidence': self.confidence, 80 'action': self.action, 81 'params': self.params, 82 'response_text': self.response_text, 83 'tts_text': self.tts_text, 84 'success': self.success, 85 'error': self.error 86 } 87 88 89 class VoiceRouter: 90 """ 91 Routes voice commands to appropriate handlers. 92 93 Pattern matching is intentionally fuzzy to handle: 94 - Transcription errors 95 - Natural speech variations 96 - Incomplete sentences 97 """ 98 99 # Command patterns: (regex, intent, confidence_boost) 100 PATTERNS: List[Tuple[str, CommandIntent, float]] = [ 101 # Context queries 102 (r'what (was|were) (i|we) (working|doing|thinking)', CommandIntent.CONTEXT_QUERY, 0.9), 103 (r'remind me (what|about)', CommandIntent.CONTEXT_QUERY, 0.8), 104 (r'what.*(happened|going on)', CommandIntent.CONTEXT_QUERY, 0.7), 105 (r'catch me up', CommandIntent.CONTEXT_QUERY, 0.85), 106 (r'where (was|were|am) (i|we)', CommandIntent.CONTEXT_QUERY, 0.8), 107 108 # Status 109 (r'(how|what).*(status|doing|going)', CommandIntent.STATUS_CHECK, 0.7), 110 (r'system status', CommandIntent.STATUS_CHECK, 0.95), 111 (r'health check', CommandIntent.STATUS_CHECK, 0.9), 112 113 # Aha queries 114 (r'(aha|insight|breakthrough)', CommandIntent.AHA_QUERY, 0.85), 115 (r'what.*(learn|discover|realize)', CommandIntent.AHA_QUERY, 0.7), 116 (r'any.*(insight|important|significant)', CommandIntent.AHA_QUERY, 0.75), 117 (r'high resonance', CommandIntent.AHA_QUERY, 0.9), 118 119 # Topic search 120 (r'(tell|remind) me about (.+)', CommandIntent.TOPIC_SEARCH, 0.85), 121 (r'what (do|did) (i|we) (know|say|think) about (.+)', CommandIntent.TOPIC_SEARCH, 0.8), 122 (r'search (for )?(.+)', CommandIntent.TOPIC_SEARCH, 0.7), 123 (r'find (.+)', CommandIntent.TOPIC_SEARCH, 0.65), 124 125 # Capture 126 (r'capture (this|that)?:?\s*(.+)?', CommandIntent.CAPTURE, 0.9), 127 (r'(remember|note|save) (this|that)?:?\s*(.+)?', CommandIntent.CAPTURE, 0.85), 128 (r'(log|record) (this|that)?:?\s*(.+)?', CommandIntent.CAPTURE, 0.8), 129 (r"(here'?s|this is) (a )?(thought|idea|insight):?\s*(.+)?", CommandIntent.CAPTURE, 0.75), 130 131 # Mark important 132 (r"(that'?s|this is|mark (as )?)(important|significant|key)", CommandIntent.MARK_IMPORTANT, 0.9), 133 (r'boost (that|this|it)', CommandIntent.MARK_IMPORTANT, 0.85), 134 (r'flag (that|this|it)', CommandIntent.MARK_IMPORTANT, 0.8), 135 (r'weight (that|this)', CommandIntent.MARK_IMPORTANT, 0.75), 136 137 # Mark ignore 138 (r'(scratch|ignore|forget|discard|never ?mind) (that|this|it)?', CommandIntent.MARK_IGNORE, 0.9), 139 (r'(not|no) important', CommandIntent.MARK_IGNORE, 0.7), 140 141 # Run operations 142 (r'run (the )?correlation', CommandIntent.RUN_CORRELATION, 0.95), 143 (r'correlate', CommandIntent.RUN_CORRELATION, 0.85), 144 (r'mine (the )?(data|transcripts|sessions)', CommandIntent.RUN_CORRELATION, 0.8), 145 (r'analyze', CommandIntent.RUN_CORRELATION, 0.7), 146 147 (r'(sync|synchronize)( now)?', CommandIntent.RUN_SYNC, 0.9), 148 (r'(collect|gather)( events)?', CommandIntent.RUN_SYNC, 0.8), 149 (r'update( data)?', CommandIntent.RUN_SYNC, 0.65), 150 151 # Show/display 152 (r'show (me )?(.+)', CommandIntent.SHOW_DASHBOARD, 0.75), 153 (r'display (.+)', CommandIntent.SHOW_DASHBOARD, 0.8), 154 (r'open (the )?dashboard', CommandIntent.SHOW_DASHBOARD, 0.9), 155 156 # Brief 157 (r"(what'?s|give me) (the )?(brief|summary|overview)", CommandIntent.READ_BRIEF, 0.9), 158 (r'brief me', CommandIntent.READ_BRIEF, 0.95), 159 (r'(daily|morning|evening) (brief|summary)', CommandIntent.READ_BRIEF, 0.9), 160 161 # Help 162 (r'(what can you|help|commands|how do i)', CommandIntent.HELP, 0.8), 163 ] 164 165 def __init__(self): 166 # Compiled patterns 167 self._compiled_patterns = [ 168 (re.compile(pattern, re.IGNORECASE), intent, conf) 169 for pattern, intent, conf in self.PATTERNS 170 ] 171 172 # Recent context for "that" / "this" references 173 self._recent_content: List[str] = [] 174 self._recent_timestamps: List[datetime] = [] 175 176 def process(self, command: str) -> CommandResult: 177 """ 178 Process a voice command and return result. 179 180 Args: 181 command: Raw voice command text 182 183 Returns: 184 CommandResult with intent, action, and response 185 """ 186 command = command.strip() 187 if not command: 188 return CommandResult( 189 intent=CommandIntent.UNKNOWN, 190 confidence=0.0, 191 action='none', 192 success=False, 193 error='Empty command' 194 ) 195 196 # Find best matching intent 197 best_intent = CommandIntent.UNKNOWN 198 best_confidence = 0.0 199 best_match = None 200 201 for pattern, intent, conf_boost in self._compiled_patterns: 202 match = pattern.search(command) 203 if match: 204 # Calculate confidence based on match coverage 205 coverage = len(match.group(0)) / len(command) 206 confidence = min(1.0, coverage * conf_boost * 1.2) 207 208 if confidence > best_confidence: 209 best_confidence = confidence 210 best_intent = intent 211 best_match = match 212 213 # Route to handler 214 result = self._route_intent(best_intent, best_confidence, command, best_match) 215 216 # Track for context 217 self._recent_content.append(command) 218 self._recent_timestamps.append(datetime.now()) 219 self._prune_recent() 220 221 return result 222 223 def _route_intent( 224 self, 225 intent: CommandIntent, 226 confidence: float, 227 command: str, 228 match: Optional[re.Match] 229 ) -> CommandResult: 230 """Route to appropriate handler based on intent.""" 231 232 if intent == CommandIntent.CONTEXT_QUERY: 233 return self._handle_context_query(confidence, command) 234 235 elif intent == CommandIntent.STATUS_CHECK: 236 return self._handle_status_check(confidence) 237 238 elif intent == CommandIntent.AHA_QUERY: 239 return self._handle_aha_query(confidence) 240 241 elif intent == CommandIntent.TOPIC_SEARCH: 242 topic = self._extract_topic(command, match) 243 return self._handle_topic_search(confidence, topic) 244 245 elif intent == CommandIntent.CAPTURE: 246 content = self._extract_capture_content(command, match) 247 return self._handle_capture(confidence, content) 248 249 elif intent == CommandIntent.MARK_IMPORTANT: 250 return self._handle_mark_important(confidence) 251 252 elif intent == CommandIntent.MARK_IGNORE: 253 return self._handle_mark_ignore(confidence) 254 255 elif intent == CommandIntent.RUN_CORRELATION: 256 return self._handle_correlation(confidence) 257 258 elif intent == CommandIntent.RUN_SYNC: 259 return self._handle_sync(confidence) 260 261 elif intent == CommandIntent.SHOW_DASHBOARD: 262 target = self._extract_show_target(command, match) 263 return self._handle_show(confidence, target) 264 265 elif intent == CommandIntent.READ_BRIEF: 266 return self._handle_brief(confidence) 267 268 elif intent == CommandIntent.HELP: 269 return self._handle_help(confidence) 270 271 else: 272 return CommandResult( 273 intent=CommandIntent.UNKNOWN, 274 confidence=confidence, 275 action='none', 276 response_text=f"I didn't understand: {command}", 277 tts_text="I didn't catch that. Try 'help' for commands.", 278 success=False 279 ) 280 281 # ==================== Handlers ==================== 282 283 def _handle_context_query(self, confidence: float, command: str) -> CommandResult: 284 """Handle 'what was I working on' queries.""" 285 # Parse time window from command 286 hours = 24 # default 287 if 'today' in command.lower(): 288 hours = 12 289 elif 'yesterday' in command.lower(): 290 hours = 48 291 elif 'week' in command.lower(): 292 hours = 168 293 elif 'hour' in command.lower(): 294 hours = 2 295 296 return CommandResult( 297 intent=CommandIntent.CONTEXT_QUERY, 298 confidence=confidence, 299 action='/context/direct', 300 params={'input': command, 'hours': hours}, 301 response_text=f"Querying context for last {hours} hours...", 302 tts_text="Let me check what you were working on." 303 ) 304 305 def _handle_status_check(self, confidence: float) -> CommandResult: 306 return CommandResult( 307 intent=CommandIntent.STATUS_CHECK, 308 confidence=confidence, 309 action='/mobile/status', 310 params={}, 311 response_text="Fetching system status...", 312 tts_text="Checking status." 313 ) 314 315 def _handle_aha_query(self, confidence: float) -> CommandResult: 316 return CommandResult( 317 intent=CommandIntent.AHA_QUERY, 318 confidence=confidence, 319 action='/correlate/aha', 320 params={'limit': 10}, 321 response_text="Fetching recent aha moments...", 322 tts_text="Looking for insights." 323 ) 324 325 def _handle_topic_search(self, confidence: float, topic: str) -> CommandResult: 326 if not topic: 327 return CommandResult( 328 intent=CommandIntent.TOPIC_SEARCH, 329 confidence=confidence * 0.5, 330 action='none', 331 response_text="What topic should I search for?", 332 tts_text="What topic?", 333 success=False 334 ) 335 336 return CommandResult( 337 intent=CommandIntent.TOPIC_SEARCH, 338 confidence=confidence, 339 action='/graph/search', 340 params={'q': topic, 'limit': 20}, 341 response_text=f"Searching for '{topic}'...", 342 tts_text=f"Searching for {topic}." 343 ) 344 345 def _handle_capture(self, confidence: float, content: str) -> CommandResult: 346 if not content: 347 return CommandResult( 348 intent=CommandIntent.CAPTURE, 349 confidence=confidence * 0.5, 350 action='none', 351 response_text="What should I capture?", 352 tts_text="What should I capture?", 353 success=False 354 ) 355 356 return CommandResult( 357 intent=CommandIntent.CAPTURE, 358 confidence=confidence, 359 action='/mobile/capture', 360 params={'thought': content}, 361 response_text=f"Capturing: {content[:100]}...", 362 tts_text="Captured." 363 ) 364 365 def _handle_mark_important(self, confidence: float) -> CommandResult: 366 # Get most recent content 367 recent = self._get_recent_reference() 368 369 return CommandResult( 370 intent=CommandIntent.MARK_IMPORTANT, 371 confidence=confidence, 372 action='/mobile/capture', 373 params={ 374 'thought': f"[IMPORTANT] {recent}" if recent else "[IMPORTANT] Last 30 seconds", 375 'tags': ['important', 'flagged'] 376 }, 377 response_text="Marked as important.", 378 tts_text="Marked." 379 ) 380 381 def _handle_mark_ignore(self, confidence: float) -> CommandResult: 382 return CommandResult( 383 intent=CommandIntent.MARK_IGNORE, 384 confidence=confidence, 385 action='internal', 386 params={'action': 'ignore_recent'}, 387 response_text="Ignored.", 388 tts_text="Ignored." 389 ) 390 391 def _handle_correlation(self, confidence: float) -> CommandResult: 392 return CommandResult( 393 intent=CommandIntent.RUN_CORRELATION, 394 confidence=confidence, 395 action='/unified/report', 396 params={'hours': 24}, 397 response_text="Running correlation analysis...", 398 tts_text="Running correlation." 399 ) 400 401 def _handle_sync(self, confidence: float) -> CommandResult: 402 return CommandResult( 403 intent=CommandIntent.RUN_SYNC, 404 confidence=confidence, 405 action='/pipeline/collect', 406 params={}, 407 response_text="Syncing data sources...", 408 tts_text="Syncing." 409 ) 410 411 def _handle_show(self, confidence: float, target: str) -> CommandResult: 412 # Map targets to views 413 view_map = { 414 'dashboard': '/unified/report', 415 'aha': '/correlate/aha', 416 'graph': '/graph/status', 417 'atoms': '/graph/atoms', 418 'resonance': '/graph/resonance', 419 'gravity': '/graph/gravity-wells', 420 'timeline': '/unified/timeline', 421 } 422 423 action = view_map.get(target.lower(), '/unified/report') 424 425 return CommandResult( 426 intent=CommandIntent.SHOW_DASHBOARD, 427 confidence=confidence, 428 action=action, 429 params={'target': target}, 430 response_text=f"Showing {target}...", 431 tts_text=f"Showing {target}." 432 ) 433 434 def _handle_brief(self, confidence: float) -> CommandResult: 435 return CommandResult( 436 intent=CommandIntent.READ_BRIEF, 437 confidence=confidence, 438 action='/mobile/brief', 439 params={}, 440 response_text="Fetching your brief...", 441 tts_text="Here's your brief." 442 ) 443 444 def _handle_help(self, confidence: float) -> CommandResult: 445 help_text = """ 446 Available commands: 447 - "What was I working on?" - Get context summary 448 - "Capture this: [thought]" - Save a thought 449 - "That's important" - Mark recent content high-weight 450 - "Run correlation" - Analyze all sources 451 - "Show me [aha/dashboard/graph]" - Display view 452 - "Brief me" - Get daily summary 453 - "Search for [topic]" - Find related content 454 - "Sync now" - Update data 455 """ 456 return CommandResult( 457 intent=CommandIntent.HELP, 458 confidence=confidence, 459 action='none', 460 response_text=help_text, 461 tts_text="You can ask about your work, capture thoughts, run analysis, or get your brief." 462 ) 463 464 # ==================== Helpers ==================== 465 466 def _extract_topic(self, command: str, match: Optional[re.Match]) -> str: 467 """Extract search topic from command.""" 468 if match and match.lastindex: 469 # Get last captured group (usually the topic) 470 return match.group(match.lastindex).strip() 471 472 # Fallback: extract after keywords 473 for keyword in ['about', 'for', 'find']: 474 if keyword in command.lower(): 475 parts = command.lower().split(keyword) 476 if len(parts) > 1: 477 return parts[-1].strip() 478 479 return '' 480 481 def _extract_capture_content(self, command: str, match: Optional[re.Match]) -> str: 482 """Extract content to capture from command.""" 483 if match and match.lastindex: 484 for i in range(match.lastindex, 0, -1): 485 group = match.group(i) 486 if group and group.strip() and group.lower() not in ['this', 'that', '']: 487 return group.strip() 488 489 # Fallback: extract after colon 490 if ':' in command: 491 return command.split(':', 1)[-1].strip() 492 493 # Try after "capture this" etc 494 for trigger in ['capture this', 'capture that', 'remember this', 'note this']: 495 if trigger in command.lower(): 496 return command.lower().split(trigger)[-1].strip() 497 498 return '' 499 500 def _extract_show_target(self, command: str, match: Optional[re.Match]) -> str: 501 """Extract what to show from command.""" 502 if match and match.lastindex: 503 return match.group(match.lastindex).strip() 504 505 # Defaults 506 return 'dashboard' 507 508 def _get_recent_reference(self) -> str: 509 """Get content that 'that' or 'this' might refer to.""" 510 if self._recent_content: 511 return self._recent_content[-1] 512 return '' 513 514 def _prune_recent(self, max_age_seconds: int = 300): 515 """Remove old entries from recent context.""" 516 cutoff = datetime.now() - timedelta(seconds=max_age_seconds) 517 518 while self._recent_timestamps and self._recent_timestamps[0] < cutoff: 519 self._recent_timestamps.pop(0) 520 if self._recent_content: 521 self._recent_content.pop(0) 522 523 524 # Singleton 525 _voice_router: Optional[VoiceRouter] = None 526 527 528 def get_voice_router() -> VoiceRouter: 529 """Get or create voice router singleton.""" 530 global _voice_router 531 if _voice_router is None: 532 _voice_router = VoiceRouter() 533 return _voice_router 534 535 536 def process_voice_command(command: str) -> Dict[str, Any]: 537 """Quick function to process a voice command.""" 538 router = get_voice_router() 539 result = router.process(command) 540 return result.to_dict() 541 542 543 if __name__ == '__main__': 544 router = VoiceRouter() 545 546 # Test commands 547 tests = [ 548 "What was I working on today?", 549 "Capture this: The key insight is about attention systems", 550 "That's important", 551 "Run correlation", 552 "Show me the aha moments", 553 "Tell me about graph integration", 554 "Brief me", 555 "Scratch that", 556 "What can you do?", 557 "Sync now", 558 ] 559 560 print("=== Voice Router Tests ===\n") 561 for cmd in tests: 562 result = router.process(cmd) 563 print(f"Command: {cmd}") 564 print(f" Intent: {result.intent.value} ({result.confidence:.0%})") 565 print(f" Action: {result.action}") 566 print(f" TTS: {result.tts_text}") 567 print()