pythonprincess commited on
Commit
15b7077
Β·
verified Β·
1 Parent(s): 441c9e7

Upload orchestrator.py

Browse files
Files changed (1) hide show
  1. app/orchestrator.py +1359 -0
app/orchestrator.py ADDED
@@ -0,0 +1,1359 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ 🎭 PENNY Orchestrator - Request Routing & Coordination Engine
3
+
4
+ This is Penny's decision-making brain. She analyzes each request, determines
5
+ the best way to help, and coordinates between her specialized AI models and
6
+ civic data tools.
7
+
8
+ MISSION: Route every resident request to the right resource while maintaining
9
+ Penny's warm, helpful personality and ensuring fast, accurate responses.
10
+
11
+ FEATURES:
12
+ - Enhanced intent classification with confidence scoring
13
+ - Compound intent handling (weather + events)
14
+ - Graceful fallbacks when services are unavailable
15
+ - Performance tracking for all operations
16
+ - Context-aware responses
17
+ - Emergency routing with immediate escalation
18
+
19
+ ENHANCEMENTS (Phase 1):
20
+ - βœ… Structured logging with performance tracking
21
+ - βœ… Safe imports with availability flags
22
+ - βœ… Result format checking helper
23
+ - βœ… Enhanced error handling patterns
24
+ - βœ… Service availability tracking
25
+ - βœ… Fixed function signature mismatches
26
+ - βœ… Integration with enhanced modules
27
+ """
28
+
29
+ import logging
30
+ import time
31
+ from typing import Dict, Any, Optional, List, Tuple
32
+ from datetime import datetime
33
+ from dataclasses import dataclass, field
34
+ from enum import Enum
35
+
36
+ # --- ENHANCED MODULE IMPORTS ---
37
+ from app.intents import classify_intent_detailed, IntentType, IntentMatch
38
+ from app.location_utils import (
39
+ extract_location_detailed,
40
+ LocationMatch,
41
+ LocationStatus,
42
+ get_city_coordinates
43
+ )
44
+ from app.logging_utils import (
45
+ log_interaction,
46
+ sanitize_for_logging,
47
+ LogLevel
48
+ )
49
+
50
+ # --- AGENT IMPORTS (with availability tracking) ---
51
+ try:
52
+ from app.weather_agent import (
53
+ get_weather_for_location,
54
+ recommend_outfit,
55
+ weather_to_event_recommendations,
56
+ format_weather_summary
57
+ )
58
+ WEATHER_AGENT_AVAILABLE = True
59
+ except ImportError as e:
60
+ logger = logging.getLogger(__name__)
61
+ logger.warning(f"Weather agent not available: {e}")
62
+ WEATHER_AGENT_AVAILABLE = False
63
+
64
+ try:
65
+ from app.event_weather import get_event_recommendations_with_weather
66
+ EVENT_WEATHER_AVAILABLE = True
67
+ except ImportError as e:
68
+ logger = logging.getLogger(__name__)
69
+ logger.warning(f"Event weather integration not available: {e}")
70
+ EVENT_WEATHER_AVAILABLE = False
71
+
72
+ try:
73
+ from app.tool_agent import handle_tool_request
74
+ TOOL_AGENT_AVAILABLE = True
75
+ except ImportError as e:
76
+ logger = logging.getLogger(__name__)
77
+ logger.warning(f"Tool agent not available: {e}")
78
+ TOOL_AGENT_AVAILABLE = False
79
+
80
+ # --- MODEL IMPORTS (with availability tracking) ---
81
+ try:
82
+ from models.translation.translation_utils import translate_text
83
+ TRANSLATION_AVAILABLE = True
84
+ except ImportError as e:
85
+ logger = logging.getLogger(__name__)
86
+ logger.warning(f"Translation service not available: {e}")
87
+ TRANSLATION_AVAILABLE = False
88
+
89
+ try:
90
+ from models.sentiment.sentiment_utils import get_sentiment_analysis
91
+ SENTIMENT_AVAILABLE = True
92
+ except ImportError as e:
93
+ logger = logging.getLogger(__name__)
94
+ logger.warning(f"Sentiment service not available: {e}")
95
+ SENTIMENT_AVAILABLE = False
96
+
97
+ try:
98
+ from models.bias.bias_utils import check_bias
99
+ BIAS_AVAILABLE = True
100
+ except ImportError as e:
101
+ logger = logging.getLogger(__name__)
102
+ logger.warning(f"Bias detection service not available: {e}")
103
+ BIAS_AVAILABLE = False
104
+
105
+ try:
106
+ from models.gemma.gemma_utils import generate_response
107
+ LLM_AVAILABLE = True
108
+ except ImportError as e:
109
+ logger = logging.getLogger(__name__)
110
+ logger.warning(f"LLM service not available: {e}")
111
+ LLM_AVAILABLE = False
112
+
113
+ # --- LOGGING SETUP ---
114
+ logger = logging.getLogger(__name__)
115
+
116
+ # --- CONFIGURATION ---
117
+ CORE_MODEL_ID = "penny-core-agent"
118
+ MAX_RESPONSE_TIME_MS = 5000 # 5 seconds - log if exceeded
119
+
120
+ # --- TRACKING COUNTERS ---
121
+ _orchestration_count = 0
122
+ _emergency_count = 0
123
+
124
+
125
+ # ============================================================
126
+ # COMPATIBILITY HELPER - Result Format Checking
127
+ # ============================================================
128
+
129
+ def _check_result_success(
130
+ result: Dict[str, Any],
131
+ expected_keys: List[str]
132
+ ) -> Tuple[bool, Optional[str]]:
133
+ """
134
+ βœ… Check if a utility function result indicates success.
135
+
136
+ Handles multiple return format patterns:
137
+ - Explicit "success" key (preferred)
138
+ - Presence of expected data keys (implicit success)
139
+ - Presence of "error" key (explicit failure)
140
+
141
+ This helper fixes compatibility issues where different utility
142
+ functions return different result formats.
143
+
144
+ Args:
145
+ result: Dictionary returned from utility function
146
+ expected_keys: List of keys that indicate successful data
147
+
148
+ Returns:
149
+ Tuple of (is_success, error_message)
150
+
151
+ Example:
152
+ result = await translate_text(message, "en", "es")
153
+ success, error = _check_result_success(result, ["translated_text"])
154
+ if success:
155
+ text = result.get("translated_text")
156
+ """
157
+ # Check for explicit success key
158
+ if "success" in result:
159
+ return result["success"], result.get("error")
160
+
161
+ # Check for explicit error (presence = failure)
162
+ if "error" in result and result["error"]:
163
+ return False, result["error"]
164
+
165
+ # Check for expected data keys (implicit success)
166
+ has_data = any(key in result for key in expected_keys)
167
+ if has_data:
168
+ return True, None
169
+
170
+ # Unknown format - assume failure
171
+ return False, "Unexpected response format"
172
+
173
+
174
+ # ============================================================
175
+ # SERVICE AVAILABILITY CHECK
176
+ # ============================================================
177
+
178
+ def get_service_availability() -> Dict[str, bool]:
179
+ """
180
+ πŸ“Š Returns which services are currently available.
181
+
182
+ Used for health checks, debugging, and deciding whether
183
+ to attempt service calls or use fallbacks.
184
+
185
+ Returns:
186
+ Dictionary mapping service names to availability status
187
+ """
188
+ return {
189
+ "translation": TRANSLATION_AVAILABLE,
190
+ "sentiment": SENTIMENT_AVAILABLE,
191
+ "bias_detection": BIAS_AVAILABLE,
192
+ "llm": LLM_AVAILABLE,
193
+ "tool_agent": TOOL_AGENT_AVAILABLE,
194
+ "weather": WEATHER_AGENT_AVAILABLE,
195
+ "event_weather": EVENT_WEATHER_AVAILABLE
196
+ }
197
+
198
+
199
+ # ============================================================
200
+ # ORCHESTRATION RESULT STRUCTURE
201
+ # ============================================================
202
+
203
+ @dataclass
204
+ class OrchestrationResult:
205
+ """
206
+ πŸ“¦ Structured result from orchestration pipeline.
207
+
208
+ This format is used throughout the system for consistency
209
+ and makes it easy to track what happened during request processing.
210
+ """
211
+ intent: str # Detected intent
212
+ reply: str # User-facing response
213
+ success: bool # Whether request succeeded
214
+ tenant_id: Optional[str] = None # City/location identifier
215
+ data: Optional[Dict[str, Any]] = None # Raw data from services
216
+ model_id: Optional[str] = None # Which model/service was used
217
+ error: Optional[str] = None # Error message if failed
218
+ response_time_ms: Optional[float] = None
219
+ confidence: Optional[float] = None # Intent confidence score
220
+ fallback_used: bool = False # True if fallback logic triggered
221
+
222
+ def to_dict(self) -> Dict[str, Any]:
223
+ """Converts to dictionary for API responses."""
224
+ return {
225
+ "intent": self.intent,
226
+ "reply": self.reply,
227
+ "success": self.success,
228
+ "tenant_id": self.tenant_id,
229
+ "data": self.data,
230
+ "model_id": self.model_id,
231
+ "error": self.error,
232
+ "response_time_ms": self.response_time_ms,
233
+ "confidence": self.confidence,
234
+ "fallback_used": self.fallback_used
235
+ }
236
+
237
+
238
+ # ============================================================
239
+ # MAIN ORCHESTRATOR FUNCTION (ENHANCED)
240
+ # ============================================================
241
+
242
+ async def run_orchestrator(
243
+ message: str,
244
+ context: Dict[str, Any] = None
245
+ ) -> Dict[str, Any]:
246
+ """
247
+ 🧠 Main decision-making brain of Penny.
248
+
249
+ This function:
250
+ 1. Analyzes the user's message to determine intent
251
+ 2. Extracts location/city information
252
+ 3. Routes to the appropriate specialized service
253
+ 4. Handles errors gracefully with helpful fallbacks
254
+ 5. Tracks performance and logs the interaction
255
+
256
+ Args:
257
+ message: User's input text
258
+ context: Additional context (tenant_id, lat, lon, session_id, etc.)
259
+
260
+ Returns:
261
+ Dictionary with response and metadata
262
+
263
+ Example:
264
+ result = await run_orchestrator(
265
+ message="What's the weather in Atlanta?",
266
+ context={"lat": 33.7490, "lon": -84.3880}
267
+ )
268
+ """
269
+ global _orchestration_count
270
+ _orchestration_count += 1
271
+
272
+ start_time = time.time()
273
+
274
+ # Initialize context if not provided
275
+ if context is None:
276
+ context = {}
277
+
278
+ # Sanitize message for logging (PII protection)
279
+ safe_message = sanitize_for_logging(message)
280
+ logger.info(f"🎭 Orchestrator processing: '{safe_message[:50]}...'")
281
+
282
+ try:
283
+ # === STEP 1: CLASSIFY INTENT (Enhanced) ===
284
+ intent_result = classify_intent_detailed(message)
285
+ intent = intent_result.intent
286
+ confidence = intent_result.confidence
287
+
288
+ logger.info(
289
+ f"Intent detected: {intent.value} "
290
+ f"(confidence: {confidence:.2f})"
291
+ )
292
+
293
+ # === STEP 2: EXTRACT LOCATION ===
294
+ tenant_id = context.get("tenant_id")
295
+ lat = context.get("lat")
296
+ lon = context.get("lon")
297
+
298
+ # If tenant_id not provided, try to extract from message
299
+ if not tenant_id or tenant_id == "unknown":
300
+ location_result = extract_location_detailed(message)
301
+
302
+ if location_result.status == LocationStatus.FOUND:
303
+ tenant_id = location_result.tenant_id
304
+ logger.info(f"Location extracted: {tenant_id}")
305
+
306
+ # Get coordinates for this tenant if available
307
+ coords = get_city_coordinates(tenant_id)
308
+ if coords and lat is None and lon is None:
309
+ lat, lon = coords["lat"], coords["lon"]
310
+ logger.info(f"Coordinates loaded: {lat}, {lon}")
311
+
312
+ elif location_result.status == LocationStatus.USER_LOCATION_NEEDED:
313
+ logger.info("User location services needed")
314
+ else:
315
+ logger.info(f"No location detected: {location_result.status}")
316
+
317
+ # === STEP 3: HANDLE EMERGENCY INTENTS (CRITICAL) ===
318
+ if intent == IntentType.EMERGENCY:
319
+ result = await _handle_emergency(
320
+ message=message,
321
+ context=context,
322
+ start_time=start_time
323
+ )
324
+ return result.to_dict()
325
+
326
+ # === STEP 4: ROUTE TO APPROPRIATE HANDLER ===
327
+
328
+ # Translation
329
+ if intent == IntentType.TRANSLATION:
330
+ result = await _handle_translation(message, context)
331
+
332
+ # Sentiment Analysis
333
+ elif intent == IntentType.SENTIMENT_ANALYSIS:
334
+ result = await _handle_sentiment(message, context)
335
+
336
+ # Bias Detection
337
+ elif intent == IntentType.BIAS_DETECTION:
338
+ result = await _handle_bias(message, context)
339
+
340
+ # Document Processing
341
+ elif intent == IntentType.DOCUMENT_PROCESSING:
342
+ result = await _handle_document(message, context)
343
+
344
+ # Weather (includes compound weather+events handling)
345
+ elif intent == IntentType.WEATHER:
346
+ result = await _handle_weather(
347
+ message=message,
348
+ context=context,
349
+ tenant_id=tenant_id,
350
+ lat=lat,
351
+ lon=lon,
352
+ intent_result=intent_result
353
+ )
354
+
355
+ # Events
356
+ elif intent == IntentType.EVENTS:
357
+ result = await _handle_events(
358
+ message=message,
359
+ context=context,
360
+ tenant_id=tenant_id,
361
+ lat=lat,
362
+ lon=lon,
363
+ intent_result=intent_result
364
+ )
365
+
366
+ # Local Resources
367
+ elif intent == IntentType.LOCAL_RESOURCES:
368
+ result = await _handle_local_resources(
369
+ message=message,
370
+ context=context,
371
+ tenant_id=tenant_id,
372
+ lat=lat,
373
+ lon=lon
374
+ )
375
+
376
+ # Greeting, Help, Unknown
377
+ elif intent in [IntentType.GREETING, IntentType.HELP, IntentType.UNKNOWN]:
378
+ result = await _handle_conversational(
379
+ message=message,
380
+ intent=intent,
381
+ context=context
382
+ )
383
+
384
+ else:
385
+ # Unhandled intent type (shouldn't happen, but safety net)
386
+ result = await _handle_fallback(message, intent, context)
387
+
388
+ # === STEP 5: ADD METADATA & LOG INTERACTION ===
389
+ response_time = (time.time() - start_time) * 1000
390
+ result.response_time_ms = round(response_time, 2)
391
+ result.confidence = confidence
392
+ result.tenant_id = tenant_id
393
+
394
+ # Log the interaction with structured logging
395
+ log_interaction(
396
+ tenant_id=tenant_id or "unknown",
397
+ interaction_type="orchestration",
398
+ intent=intent.value,
399
+ response_time_ms=response_time,
400
+ success=result.success,
401
+ metadata={
402
+ "confidence": confidence,
403
+ "fallback_used": result.fallback_used,
404
+ "model_id": result.model_id,
405
+ "orchestration_count": _orchestration_count
406
+ }
407
+ )
408
+
409
+ # Log slow responses
410
+ if response_time > MAX_RESPONSE_TIME_MS:
411
+ logger.warning(
412
+ f"⚠️ Slow response: {response_time:.0f}ms "
413
+ f"(intent: {intent.value})"
414
+ )
415
+
416
+ logger.info(
417
+ f"βœ… Orchestration complete: {intent.value} "
418
+ f"({response_time:.0f}ms)"
419
+ )
420
+
421
+ return result.to_dict()
422
+
423
+ except Exception as e:
424
+ # === CATASTROPHIC FAILURE HANDLER ===
425
+ response_time = (time.time() - start_time) * 1000
426
+ logger.error(
427
+ f"❌ Orchestrator error: {e} "
428
+ f"(response_time: {response_time:.0f}ms)",
429
+ exc_info=True
430
+ )
431
+
432
+ # Log failed interaction
433
+ log_interaction(
434
+ tenant_id=context.get("tenant_id", "unknown"),
435
+ interaction_type="orchestration_error",
436
+ intent="error",
437
+ response_time_ms=response_time,
438
+ success=False,
439
+ metadata={
440
+ "error": str(e),
441
+ "error_type": type(e).__name__
442
+ }
443
+ )
444
+
445
+ error_result = OrchestrationResult(
446
+ intent="error",
447
+ reply=(
448
+ "I'm having trouble processing your request right now. "
449
+ "Please try again in a moment, or let me know if you need "
450
+ "immediate assistance! πŸ’›"
451
+ ),
452
+ success=False,
453
+ error=str(e),
454
+ model_id="orchestrator",
455
+ fallback_used=True,
456
+ response_time_ms=round(response_time, 2)
457
+ )
458
+
459
+ return error_result.to_dict()
460
+
461
+
462
+ # ============================================================
463
+ # SPECIALIZED INTENT HANDLERS (ENHANCED)
464
+ # ============================================================
465
+
466
+ async def _handle_emergency(
467
+ message: str,
468
+ context: Dict[str, Any],
469
+ start_time: float
470
+ ) -> OrchestrationResult:
471
+ """
472
+ 🚨 CRITICAL: Emergency intent handler.
473
+
474
+ This function handles crisis situations with immediate routing
475
+ to appropriate services. All emergency interactions are logged
476
+ for compliance and safety tracking.
477
+
478
+ IMPORTANT: This is a compliance-critical function. All emergency
479
+ interactions must be logged and handled with priority.
480
+ """
481
+ global _emergency_count
482
+ _emergency_count += 1
483
+
484
+ # Sanitize message for logging (but keep full context for safety review)
485
+ safe_message = sanitize_for_logging(message)
486
+ logger.warning(f"🚨 EMERGENCY INTENT DETECTED (#{_emergency_count}): {safe_message[:100]}")
487
+
488
+ # TODO: Integrate with safety_utils.py when enhanced
489
+ # from app.safety_utils import route_emergency
490
+ # result = await route_emergency(message, context)
491
+
492
+ # For now, provide crisis resources
493
+ reply = (
494
+ "🚨 **If this is a life-threatening emergency, please call 911 immediately.**\n\n"
495
+ "For crisis support:\n"
496
+ "- **National Suicide Prevention Lifeline:** 988\n"
497
+ "- **Crisis Text Line:** Text HOME to 741741\n"
498
+ "- **National Domestic Violence Hotline:** 1-800-799-7233\n\n"
499
+ "I'm here to help connect you with local resources. "
500
+ "What kind of support do you need right now?"
501
+ )
502
+
503
+ # Log emergency interaction for compliance (CRITICAL)
504
+ response_time = (time.time() - start_time) * 1000
505
+ log_interaction(
506
+ tenant_id=context.get("tenant_id", "emergency"),
507
+ interaction_type="emergency",
508
+ intent=IntentType.EMERGENCY.value,
509
+ response_time_ms=response_time,
510
+ success=True,
511
+ metadata={
512
+ "emergency_number": _emergency_count,
513
+ "message_length": len(message),
514
+ "timestamp": datetime.now().isoformat(),
515
+ "action": "crisis_resources_provided"
516
+ }
517
+ )
518
+
519
+ logger.critical(
520
+ f"EMERGENCY LOG #{_emergency_count}: Resources provided "
521
+ f"({response_time:.0f}ms)"
522
+ )
523
+
524
+ return OrchestrationResult(
525
+ intent=IntentType.EMERGENCY.value,
526
+ reply=reply,
527
+ success=True,
528
+ model_id="emergency_router",
529
+ data={"crisis_resources_provided": True},
530
+ response_time_ms=round(response_time, 2)
531
+ )
532
+
533
+
534
+ async def _handle_translation(
535
+ message: str,
536
+ context: Dict[str, Any]
537
+ ) -> OrchestrationResult:
538
+ """
539
+ 🌍 Translation handler - 27 languages supported.
540
+
541
+ Handles translation requests with graceful fallback if service
542
+ is unavailable.
543
+ """
544
+ logger.info("🌍 Processing translation request")
545
+
546
+ # Check service availability first
547
+ if not TRANSLATION_AVAILABLE:
548
+ logger.warning("Translation service not available")
549
+ return OrchestrationResult(
550
+ intent=IntentType.TRANSLATION.value,
551
+ reply="Translation isn't available right now. Try again soon! 🌍",
552
+ success=False,
553
+ error="Service not loaded",
554
+ fallback_used=True
555
+ )
556
+
557
+ try:
558
+ # Extract language parameters from context
559
+ source_lang = context.get("source_lang", "eng_Latn")
560
+ target_lang = context.get("target_lang", "spa_Latn")
561
+
562
+ # TODO: Parse languages from message when enhanced
563
+ # Example: "Translate 'hello' to Spanish"
564
+
565
+ result = await translate_text(message, source_lang, target_lang)
566
+
567
+ # Use compatibility helper to check result
568
+ success, error = _check_result_success(result, ["translated_text"])
569
+
570
+ if success:
571
+ translated = result.get("translated_text", "")
572
+ reply = (
573
+ f"Here's the translation:\n\n"
574
+ f"**{translated}**\n\n"
575
+ f"(Translated from {source_lang} to {target_lang})"
576
+ )
577
+
578
+ return OrchestrationResult(
579
+ intent=IntentType.TRANSLATION.value,
580
+ reply=reply,
581
+ success=True,
582
+ data=result,
583
+ model_id="penny-translate-agent"
584
+ )
585
+ else:
586
+ raise Exception(error or "Translation failed")
587
+
588
+ except Exception as e:
589
+ logger.error(f"Translation error: {e}", exc_info=True)
590
+ return OrchestrationResult(
591
+ intent=IntentType.TRANSLATION.value,
592
+ reply=(
593
+ "I had trouble translating that. Could you rephrase? πŸ’¬"
594
+ ),
595
+ success=False,
596
+ error=str(e),
597
+ fallback_used=True
598
+ )
599
+
600
+
601
+ async def _handle_sentiment(
602
+ message: str,
603
+ context: Dict[str, Any]
604
+ ) -> OrchestrationResult:
605
+ """
606
+ 😊 Sentiment analysis handler.
607
+
608
+ Analyzes the emotional tone of text with graceful fallback
609
+ if service is unavailable.
610
+ """
611
+ logger.info("😊 Processing sentiment analysis")
612
+
613
+ # Check service availability first
614
+ if not SENTIMENT_AVAILABLE:
615
+ logger.warning("Sentiment service not available")
616
+ return OrchestrationResult(
617
+ intent=IntentType.SENTIMENT_ANALYSIS.value,
618
+ reply="Sentiment analysis isn't available right now. Try again soon! 😊",
619
+ success=False,
620
+ error="Service not loaded",
621
+ fallback_used=True
622
+ )
623
+
624
+ try:
625
+ result = await get_sentiment_analysis(message)
626
+
627
+ # Use compatibility helper to check result
628
+ success, error = _check_result_success(result, ["label", "score"])
629
+
630
+ if success:
631
+ sentiment = result.get("label", "neutral")
632
+ confidence = result.get("score", 0.0)
633
+
634
+ reply = (
635
+ f"The overall sentiment detected is: **{sentiment}**\n"
636
+ f"Confidence: {confidence:.1%}"
637
+ )
638
+
639
+ return OrchestrationResult(
640
+ intent=IntentType.SENTIMENT_ANALYSIS.value,
641
+ reply=reply,
642
+ success=True,
643
+ data=result,
644
+ model_id="penny-sentiment-agent"
645
+ )
646
+ else:
647
+ raise Exception(error or "Sentiment analysis failed")
648
+
649
+ except Exception as e:
650
+ logger.error(f"Sentiment analysis error: {e}", exc_info=True)
651
+ return OrchestrationResult(
652
+ intent=IntentType.SENTIMENT_ANALYSIS.value,
653
+ reply="I couldn't analyze the sentiment right now. Try again? 😊",
654
+ success=False,
655
+ error=str(e),
656
+ fallback_used=True
657
+ )
658
+
659
+ async def _handle_bias(
660
+ message: str,
661
+ context: Dict[str, Any]
662
+ ) -> OrchestrationResult:
663
+ """
664
+ βš–οΈ Bias detection handler.
665
+
666
+ Analyzes text for potential bias patterns with graceful fallback
667
+ if service is unavailable.
668
+ """
669
+ logger.info("βš–οΈ Processing bias detection")
670
+
671
+ # Check service availability first
672
+ if not BIAS_AVAILABLE:
673
+ logger.warning("Bias detection service not available")
674
+ return OrchestrationResult(
675
+ intent=IntentType.BIAS_DETECTION.value,
676
+ reply="Bias detection isn't available right now. Try again soon! βš–οΈ",
677
+ success=False,
678
+ error="Service not loaded",
679
+ fallback_used=True
680
+ )
681
+
682
+ try:
683
+ result = await check_bias(message)
684
+
685
+ # Use compatibility helper to check result
686
+ success, error = _check_result_success(result, ["analysis"])
687
+
688
+ if success:
689
+ analysis = result.get("analysis", [])
690
+
691
+ if analysis:
692
+ top_result = analysis[0]
693
+ label = top_result.get("label", "unknown")
694
+ score = top_result.get("score", 0.0)
695
+
696
+ reply = (
697
+ f"Bias analysis complete:\n\n"
698
+ f"**Most likely category:** {label}\n"
699
+ f"**Confidence:** {score:.1%}"
700
+ )
701
+ else:
702
+ reply = "The text appears relatively neutral. βš–οΈ"
703
+
704
+ return OrchestrationResult(
705
+ intent=IntentType.BIAS_DETECTION.value,
706
+ reply=reply,
707
+ success=True,
708
+ data=result,
709
+ model_id="penny-bias-checker"
710
+ )
711
+ else:
712
+ raise Exception(error or "Bias detection failed")
713
+
714
+ except Exception as e:
715
+ logger.error(f"Bias detection error: {e}", exc_info=True)
716
+ return OrchestrationResult(
717
+ intent=IntentType.BIAS_DETECTION.value,
718
+ reply="I couldn't check for bias right now. Try again? βš–οΈ",
719
+ success=False,
720
+ error=str(e),
721
+ fallback_used=True
722
+ )
723
+
724
+
725
+ async def _handle_document(
726
+ message: str,
727
+ context: Dict[str, Any]
728
+ ) -> OrchestrationResult:
729
+ """
730
+ πŸ“„ Document processing handler.
731
+
732
+ Note: Actual file upload happens in router.py via FastAPI.
733
+ This handler just provides instructions.
734
+ """
735
+ logger.info("πŸ“„ Document processing requested")
736
+
737
+ reply = (
738
+ "I can help you process documents! πŸ“„\n\n"
739
+ "Please upload your document (PDF or image) using the "
740
+ "`/upload-document` endpoint. I can extract text, analyze forms, "
741
+ "and help you understand civic documents.\n\n"
742
+ "What kind of document do you need help with?"
743
+ )
744
+
745
+ return OrchestrationResult(
746
+ intent=IntentType.DOCUMENT_PROCESSING.value,
747
+ reply=reply,
748
+ success=True,
749
+ model_id="document_router"
750
+ )
751
+
752
+
753
+ async def _handle_weather(
754
+ message: str,
755
+ context: Dict[str, Any],
756
+ tenant_id: Optional[str],
757
+ lat: Optional[float],
758
+ lon: Optional[float],
759
+ intent_result: IntentMatch
760
+ ) -> OrchestrationResult:
761
+ """
762
+ 🌀️ Weather handler with compound intent support.
763
+
764
+ Handles both simple weather queries and compound weather+events queries.
765
+ Uses enhanced weather_agent.py with caching and performance tracking.
766
+ """
767
+ logger.info("🌀️ Processing weather request")
768
+
769
+ # Check service availability first
770
+ if not WEATHER_AGENT_AVAILABLE:
771
+ logger.warning("Weather agent not available")
772
+ return OrchestrationResult(
773
+ intent=IntentType.WEATHER.value,
774
+ reply="Weather service isn't available right now. Try again soon! 🌀️",
775
+ success=False,
776
+ error="Weather agent not loaded",
777
+ fallback_used=True
778
+ )
779
+
780
+ # Check for compound intent (weather + events)
781
+ is_compound = intent_result.is_compound or IntentType.EVENTS in intent_result.secondary_intents
782
+
783
+ # === ENHANCED LOCATION RESOLUTION ===
784
+ # Try multiple strategies to get coordinates
785
+
786
+ # Strategy 1: Use provided coordinates
787
+ if lat is not None and lon is not None:
788
+ logger.info(f"Using provided coordinates: {lat}, {lon}")
789
+
790
+ # Strategy 2: Get coordinates from tenant_id (try multiple formats)
791
+ elif tenant_id:
792
+ # Try tenant_id as-is first
793
+ coords = get_city_coordinates(tenant_id)
794
+
795
+ # If that fails and tenant_id doesn't have state suffix, try adding common suffixes
796
+ if not coords and "_" not in tenant_id:
797
+ # Try common state abbreviations for known cities
798
+ state_suffixes = ["_va", "_ga", "_al", "_tx", "_ri", "_wa"]
799
+ for suffix in state_suffixes:
800
+ test_tenant_id = tenant_id + suffix
801
+ coords = get_city_coordinates(test_tenant_id)
802
+ if coords:
803
+ tenant_id = test_tenant_id # Update tenant_id to normalized form
804
+ logger.info(f"Normalized tenant_id to {tenant_id}")
805
+ break
806
+
807
+ if coords:
808
+ lat, lon = coords["lat"], coords["lon"]
809
+ logger.info(f"βœ… Using city coordinates for {tenant_id}: {lat}, {lon}")
810
+
811
+ # Strategy 3: Extract location from message if still no coordinates
812
+ if lat is None or lon is None:
813
+ logger.info("No coordinates from tenant_id, trying to extract from message")
814
+ location_result = extract_location_detailed(message)
815
+
816
+ if location_result.status == LocationStatus.FOUND:
817
+ extracted_tenant_id = location_result.tenant_id
818
+ logger.info(f"πŸ“ Location extracted from message: {extracted_tenant_id}")
819
+
820
+ # Update tenant_id if we extracted a better one
821
+ if not tenant_id or tenant_id != extracted_tenant_id:
822
+ tenant_id = extracted_tenant_id
823
+ logger.info(f"Updated tenant_id to {tenant_id}")
824
+
825
+ # Get coordinates for extracted location
826
+ coords = get_city_coordinates(tenant_id)
827
+ if coords:
828
+ lat, lon = coords["lat"], coords["lon"]
829
+ logger.info(f"βœ… Coordinates found from message extraction: {lat}, {lon}")
830
+
831
+ # Final check: if still no coordinates, return error
832
+ if lat is None or lon is None:
833
+ logger.warning(f"❌ No coordinates available for weather request (tenant_id: {tenant_id})")
834
+ return OrchestrationResult(
835
+ intent=IntentType.WEATHER.value,
836
+ reply=(
837
+ "I need to know your location to check the weather! πŸ“ "
838
+ "You can tell me your city, or share your location."
839
+ ),
840
+ success=False,
841
+ error="Location required"
842
+ )
843
+
844
+ try:
845
+ # Use combined weather + events if compound intent detected
846
+ if is_compound and tenant_id and EVENT_WEATHER_AVAILABLE:
847
+ logger.info("Using weather+events combined handler")
848
+ result = await get_event_recommendations_with_weather(tenant_id, lat, lon)
849
+
850
+ # Build response
851
+ weather = result.get("weather", {})
852
+ weather_summary = result.get("weather_summary", "Weather unavailable")
853
+ suggestions = result.get("suggestions", [])
854
+
855
+ reply_lines = [f"🌀️ **Weather Update:**\n{weather_summary}\n"]
856
+
857
+ if suggestions:
858
+ reply_lines.append("\nπŸ“… **Event Suggestions Based on Weather:**")
859
+ for suggestion in suggestions[:5]: # Top 5 suggestions
860
+ reply_lines.append(f"β€’ {suggestion}")
861
+
862
+ reply = "\n".join(reply_lines)
863
+
864
+ return OrchestrationResult(
865
+ intent=IntentType.WEATHER.value,
866
+ reply=reply,
867
+ success=True,
868
+ data=result,
869
+ model_id="weather_events_combined"
870
+ )
871
+
872
+ else:
873
+ # Simple weather query using enhanced weather_agent
874
+ weather = await get_weather_for_location(lat, lon)
875
+
876
+ # Use enhanced weather_agent's format_weather_summary
877
+ if format_weather_summary:
878
+ weather_text = format_weather_summary(weather)
879
+ else:
880
+ # Fallback formatting
881
+ temp = weather.get("temperature", {}).get("value")
882
+ phrase = weather.get("phrase", "Conditions unavailable")
883
+ if temp:
884
+ weather_text = f"{phrase}, {int(temp)}Β°F"
885
+ else:
886
+ weather_text = phrase
887
+
888
+ # Get outfit recommendation from enhanced weather_agent
889
+ if recommend_outfit:
890
+ temp = weather.get("temperature", {}).get("value", 70)
891
+ condition = weather.get("phrase", "Clear")
892
+ outfit = recommend_outfit(temp, condition)
893
+ reply = f"🌀️ {weather_text}\n\nπŸ‘• {outfit}"
894
+ else:
895
+ reply = f"🌀️ {weather_text}"
896
+
897
+ return OrchestrationResult(
898
+ intent=IntentType.WEATHER.value,
899
+ reply=reply,
900
+ success=True,
901
+ data=weather,
902
+ model_id="azure-maps-weather"
903
+ )
904
+
905
+ except Exception as e:
906
+ logger.error(f"Weather error: {e}", exc_info=True)
907
+ return OrchestrationResult(
908
+ intent=IntentType.WEATHER.value,
909
+ reply=(
910
+ "I'm having trouble getting weather data right now. "
911
+ "Can I help you with something else? πŸ’›"
912
+ ),
913
+ success=False,
914
+ error=str(e),
915
+ fallback_used=True
916
+ )
917
+
918
+
919
+ async def _handle_events(
920
+ message: str,
921
+ context: Dict[str, Any],
922
+ tenant_id: Optional[str],
923
+ lat: Optional[float],
924
+ lon: Optional[float],
925
+ intent_result: IntentMatch
926
+ ) -> OrchestrationResult:
927
+ """
928
+ πŸ“… Events handler.
929
+
930
+ Routes event queries to tool_agent with proper error handling
931
+ and graceful degradation.
932
+ """
933
+ logger.info("πŸ“… Processing events request")
934
+
935
+ if not tenant_id:
936
+ return OrchestrationResult(
937
+ intent=IntentType.EVENTS.value,
938
+ reply=(
939
+ "I'd love to help you find events! πŸ“… "
940
+ "Which city are you interested in? "
941
+ "I have information for Atlanta, Birmingham, Chesterfield, "
942
+ "El Paso, Providence, and Seattle."
943
+ ),
944
+ success=False,
945
+ error="City required"
946
+ )
947
+
948
+ # Check tool agent availability
949
+ if not TOOL_AGENT_AVAILABLE:
950
+ logger.warning("Tool agent not available")
951
+ return OrchestrationResult(
952
+ intent=IntentType.EVENTS.value,
953
+ reply=(
954
+ "Event information isn't available right now. "
955
+ "Try again soon! πŸ“…"
956
+ ),
957
+ success=False,
958
+ error="Tool agent not loaded",
959
+ fallback_used=True
960
+ )
961
+
962
+ try:
963
+ # FIXED: Add role parameter (compatibility fix)
964
+ tool_response = await handle_tool_request(
965
+ user_input=message,
966
+ role=context.get("role", "resident"), # ← ADDED
967
+ lat=lat,
968
+ lon=lon,
969
+ context=context
970
+ )
971
+
972
+ reply = tool_response.get("response", "Events information retrieved.")
973
+
974
+ return OrchestrationResult(
975
+ intent=IntentType.EVENTS.value,
976
+ reply=reply,
977
+ success=True,
978
+ data=tool_response,
979
+ model_id="events_tool"
980
+ )
981
+
982
+ except Exception as e:
983
+ logger.error(f"Events error: {e}", exc_info=True)
984
+ return OrchestrationResult(
985
+ intent=IntentType.EVENTS.value,
986
+ reply=(
987
+ "I'm having trouble loading event information right now. "
988
+ "Check back soon! πŸ“…"
989
+ ),
990
+ success=False,
991
+ error=str(e),
992
+ fallback_used=True
993
+ )
994
+
995
+ async def _handle_local_resources(
996
+ message: str,
997
+ context: Dict[str, Any],
998
+ tenant_id: Optional[str],
999
+ lat: Optional[float],
1000
+ lon: Optional[float]
1001
+ ) -> OrchestrationResult:
1002
+ """
1003
+ πŸ›οΈ Local resources handler (shelters, libraries, food banks, etc.).
1004
+
1005
+ Routes resource queries to tool_agent with proper error handling.
1006
+ """
1007
+ logger.info("πŸ›οΈ Processing local resources request")
1008
+
1009
+ if not tenant_id:
1010
+ return OrchestrationResult(
1011
+ intent=IntentType.LOCAL_RESOURCES.value,
1012
+ reply=(
1013
+ "I can help you find local resources! πŸ›οΈ "
1014
+ "Which city do you need help in? "
1015
+ "I cover Atlanta, Birmingham, Chesterfield, El Paso, "
1016
+ "Providence, and Seattle."
1017
+ ),
1018
+ success=False,
1019
+ error="City required"
1020
+ )
1021
+
1022
+ # Check tool agent availability
1023
+ if not TOOL_AGENT_AVAILABLE:
1024
+ logger.warning("Tool agent not available")
1025
+ return OrchestrationResult(
1026
+ intent=IntentType.LOCAL_RESOURCES.value,
1027
+ reply=(
1028
+ "Resource information isn't available right now. "
1029
+ "Try again soon! πŸ›οΈ"
1030
+ ),
1031
+ success=False,
1032
+ error="Tool agent not loaded",
1033
+ fallback_used=True
1034
+ )
1035
+
1036
+ try:
1037
+ # FIXED: Add role parameter (compatibility fix)
1038
+ tool_response = await handle_tool_request(
1039
+ user_input=message,
1040
+ role=context.get("role", "resident"), # ← ADDED
1041
+ lat=lat,
1042
+ lon=lon,
1043
+ context=context
1044
+ )
1045
+
1046
+ reply = tool_response.get("response", "Resource information retrieved.")
1047
+
1048
+ return OrchestrationResult(
1049
+ intent=IntentType.LOCAL_RESOURCES.value,
1050
+ reply=reply,
1051
+ success=True,
1052
+ data=tool_response,
1053
+ model_id="resources_tool"
1054
+ )
1055
+
1056
+ except Exception as e:
1057
+ logger.error(f"Resources error: {e}", exc_info=True)
1058
+ return OrchestrationResult(
1059
+ intent=IntentType.LOCAL_RESOURCES.value,
1060
+ reply=(
1061
+ "I'm having trouble finding resource information right now. "
1062
+ "Would you like to try a different search? πŸ’›"
1063
+ ),
1064
+ success=False,
1065
+ error=str(e),
1066
+ fallback_used=True
1067
+ )
1068
+
1069
+
1070
+ async def _handle_conversational(
1071
+ message: str,
1072
+ intent: IntentType,
1073
+ context: Dict[str, Any]
1074
+ ) -> OrchestrationResult:
1075
+ """
1076
+ πŸ’¬ Handles conversational intents (greeting, help, unknown).
1077
+ Uses Penny's core LLM for natural responses with graceful fallback.
1078
+ """
1079
+ logger.info(f"πŸ’¬ Processing conversational intent: {intent.value}")
1080
+
1081
+ # Check LLM availability
1082
+ use_llm = LLM_AVAILABLE
1083
+
1084
+ try:
1085
+ if use_llm:
1086
+ # Build prompt based on intent
1087
+ if intent == IntentType.GREETING:
1088
+ prompt = (
1089
+ f"The user greeted you with: '{message}'\n\n"
1090
+ "Respond warmly as Penny, introduce yourself briefly, "
1091
+ "and ask how you can help them with civic services today."
1092
+ )
1093
+
1094
+ elif intent == IntentType.HELP:
1095
+ prompt = (
1096
+ f"The user asked for help: '{message}'\n\n"
1097
+ "Explain Penny's main features:\n"
1098
+ "- Finding local resources (shelters, libraries, food banks)\n"
1099
+ "- Community events and activities\n"
1100
+ "- Weather information\n"
1101
+ "- 27-language translation\n"
1102
+ "- Document processing help\n\n"
1103
+ "Ask which city they need assistance in."
1104
+ )
1105
+
1106
+ else: # UNKNOWN
1107
+ prompt = (
1108
+ f"The user said: '{message}'\n\n"
1109
+ "You're not sure what they need help with. "
1110
+ "Respond kindly, acknowledge their request, and ask them to "
1111
+ "clarify or rephrase. Mention a few things you can help with."
1112
+ )
1113
+
1114
+ # Call Penny's core LLM
1115
+ llm_result = await generate_response(prompt=prompt, max_new_tokens=200)
1116
+
1117
+ # Use compatibility helper to check result
1118
+ success, error = _check_result_success(llm_result, ["response"])
1119
+
1120
+ if success:
1121
+ reply = llm_result.get("response", "")
1122
+
1123
+ return OrchestrationResult(
1124
+ intent=intent.value,
1125
+ reply=reply,
1126
+ success=True,
1127
+ data=llm_result,
1128
+ model_id=CORE_MODEL_ID
1129
+ )
1130
+ else:
1131
+ raise Exception(error or "LLM generation failed")
1132
+
1133
+ else:
1134
+ # LLM not available, use fallback directly
1135
+ logger.info("LLM not available, using fallback responses")
1136
+ raise Exception("LLM service not loaded")
1137
+
1138
+ except Exception as e:
1139
+ logger.warning(f"Conversational handler using fallback: {e}")
1140
+
1141
+ # Hardcoded fallback responses (Penny's friendly voice)
1142
+ fallback_replies = {
1143
+ IntentType.GREETING: (
1144
+ "Hi there! πŸ‘‹ I'm Penny, your civic assistant. "
1145
+ "I can help you find local resources, events, weather, and more. "
1146
+ "What city are you in?"
1147
+ ),
1148
+ IntentType.HELP: (
1149
+ "I'm Penny! πŸ’› I can help you with:\n\n"
1150
+ "πŸ›οΈ Local resources (shelters, libraries, food banks)\n"
1151
+ "πŸ“… Community events\n"
1152
+ "🌀️ Weather updates\n"
1153
+ "🌍 Translation (27 languages)\n"
1154
+ "πŸ“„ Document help\n\n"
1155
+ "What would you like to know about?"
1156
+ ),
1157
+ IntentType.UNKNOWN: (
1158
+ "I'm not sure I understood that. Could you rephrase? "
1159
+ "I'm best at helping with local services, events, weather, "
1160
+ "and translation! πŸ’¬"
1161
+ )
1162
+ }
1163
+
1164
+ return OrchestrationResult(
1165
+ intent=intent.value,
1166
+ reply=fallback_replies.get(intent, "How can I help you today? πŸ’›"),
1167
+ success=True,
1168
+ model_id="fallback",
1169
+ fallback_used=True
1170
+ )
1171
+
1172
+
1173
+ async def _handle_fallback(
1174
+ message: str,
1175
+ intent: IntentType,
1176
+ context: Dict[str, Any]
1177
+ ) -> OrchestrationResult:
1178
+ """
1179
+ πŸ†˜ Ultimate fallback handler for unhandled intents.
1180
+
1181
+ This is a safety net that should rarely trigger, but ensures
1182
+ users always get a helpful response.
1183
+ """
1184
+ logger.warning(f"⚠️ Fallback triggered for intent: {intent.value}")
1185
+
1186
+ reply = (
1187
+ "I've processed your request, but I'm not sure how to help with that yet. "
1188
+ "I'm still learning! πŸ€–\n\n"
1189
+ "I'm best at:\n"
1190
+ "πŸ›οΈ Finding local resources\n"
1191
+ "πŸ“… Community events\n"
1192
+ "🌀️ Weather updates\n"
1193
+ "🌍 Translation\n\n"
1194
+ "Could you rephrase your question? πŸ’›"
1195
+ )
1196
+
1197
+ return OrchestrationResult(
1198
+ intent=intent.value,
1199
+ reply=reply,
1200
+ success=False,
1201
+ error="Unhandled intent",
1202
+ fallback_used=True
1203
+ )
1204
+
1205
+
1206
+ # ============================================================
1207
+ # HEALTH CHECK & DIAGNOSTICS (ENHANCED)
1208
+ # ============================================================
1209
+
1210
+ def get_orchestrator_health() -> Dict[str, Any]:
1211
+ """
1212
+ πŸ“Š Returns comprehensive orchestrator health status.
1213
+
1214
+ Used by the main application health check endpoint to monitor
1215
+ the orchestrator and all its service dependencies.
1216
+
1217
+ Returns:
1218
+ Dictionary with health information including:
1219
+ - status: operational/degraded
1220
+ - service_availability: which services are loaded
1221
+ - statistics: orchestration counts
1222
+ - supported_intents: list of all intent types
1223
+ - features: available orchestrator features
1224
+ """
1225
+ # Get service availability
1226
+ services = get_service_availability()
1227
+
1228
+ # Determine overall status
1229
+ # Orchestrator is operational even if some services are down (graceful degradation)
1230
+ critical_services = ["weather", "tool_agent"] # Must have these
1231
+ critical_available = all(services.get(svc, False) for svc in critical_services)
1232
+
1233
+ status = "operational" if critical_available else "degraded"
1234
+
1235
+ return {
1236
+ "status": status,
1237
+ "core_model": CORE_MODEL_ID,
1238
+ "max_response_time_ms": MAX_RESPONSE_TIME_MS,
1239
+ "statistics": {
1240
+ "total_orchestrations": _orchestration_count,
1241
+ "emergency_interactions": _emergency_count
1242
+ },
1243
+ "service_availability": services,
1244
+ "supported_intents": [intent.value for intent in IntentType],
1245
+ "features": {
1246
+ "emergency_routing": True,
1247
+ "compound_intents": True,
1248
+ "fallback_handling": True,
1249
+ "performance_tracking": True,
1250
+ "context_aware": True,
1251
+ "multi_language": TRANSLATION_AVAILABLE,
1252
+ "sentiment_analysis": SENTIMENT_AVAILABLE,
1253
+ "bias_detection": BIAS_AVAILABLE,
1254
+ "weather_integration": WEATHER_AGENT_AVAILABLE,
1255
+ "event_recommendations": EVENT_WEATHER_AVAILABLE
1256
+ }
1257
+ }
1258
+
1259
+
1260
+ def get_orchestrator_stats() -> Dict[str, Any]:
1261
+ """
1262
+ πŸ“ˆ Returns orchestrator statistics.
1263
+
1264
+ Useful for monitoring and analytics.
1265
+ """
1266
+ return {
1267
+ "total_orchestrations": _orchestration_count,
1268
+ "emergency_interactions": _emergency_count,
1269
+ "services_available": sum(1 for v in get_service_availability().values() if v),
1270
+ "services_total": len(get_service_availability())
1271
+ }
1272
+
1273
+
1274
+ # ============================================================
1275
+ # TESTING & DEBUGGING (ENHANCED)
1276
+ # ============================================================
1277
+
1278
+ if __name__ == "__main__":
1279
+ """
1280
+ πŸ§ͺ Test the orchestrator with sample queries.
1281
+ Run with: python -m app.orchestrator
1282
+ """
1283
+ import asyncio
1284
+
1285
+ print("=" * 60)
1286
+ print("πŸ§ͺ Testing Penny's Orchestrator")
1287
+ print("=" * 60)
1288
+
1289
+ # Display service availability first
1290
+ print("\nπŸ“Š Service Availability Check:")
1291
+ services = get_service_availability()
1292
+ for service, available in services.items():
1293
+ status = "βœ…" if available else "❌"
1294
+ print(f" {status} {service}: {'Available' if available else 'Not loaded'}")
1295
+
1296
+ print("\n" + "=" * 60)
1297
+
1298
+ test_queries = [
1299
+ {
1300
+ "name": "Greeting",
1301
+ "message": "Hi Penny!",
1302
+ "context": {}
1303
+ },
1304
+ {
1305
+ "name": "Weather with location",
1306
+ "message": "What's the weather?",
1307
+ "context": {"lat": 33.7490, "lon": -84.3880}
1308
+ },
1309
+ {
1310
+ "name": "Events in city",
1311
+ "message": "Events in Atlanta",
1312
+ "context": {"tenant_id": "atlanta_ga"}
1313
+ },
1314
+ {
1315
+ "name": "Help request",
1316
+ "message": "I need help",
1317
+ "context": {}
1318
+ },
1319
+ {
1320
+ "name": "Translation",
1321
+ "message": "Translate hello",
1322
+ "context": {"source_lang": "eng_Latn", "target_lang": "spa_Latn"}
1323
+ }
1324
+ ]
1325
+
1326
+ async def run_tests():
1327
+ for i, query in enumerate(test_queries, 1):
1328
+ print(f"\n--- Test {i}: {query['name']} ---")
1329
+ print(f"Query: {query['message']}")
1330
+
1331
+ try:
1332
+ result = await run_orchestrator(query["message"], query["context"])
1333
+ print(f"Intent: {result['intent']}")
1334
+ print(f"Success: {result['success']}")
1335
+ print(f"Fallback: {result.get('fallback_used', False)}")
1336
+
1337
+ # Truncate long replies
1338
+ reply = result['reply']
1339
+ if len(reply) > 150:
1340
+ reply = reply[:150] + "..."
1341
+ print(f"Reply: {reply}")
1342
+
1343
+ if result.get('response_time_ms'):
1344
+ print(f"Response time: {result['response_time_ms']:.0f}ms")
1345
+
1346
+ except Exception as e:
1347
+ print(f"❌ Error: {e}")
1348
+
1349
+ asyncio.run(run_tests())
1350
+
1351
+ print("\n" + "=" * 60)
1352
+ print("πŸ“Š Final Statistics:")
1353
+ stats = get_orchestrator_stats()
1354
+ for key, value in stats.items():
1355
+ print(f" {key}: {value}")
1356
+
1357
+ print("\n" + "=" * 60)
1358
+ print("βœ… Tests complete")
1359
+ print("=" * 60)