pythonprincess commited on
Commit
1844ac6
Β·
verified Β·
1 Parent(s): bf9604a

Upload 2 files

Browse files
Files changed (2) hide show
  1. app/location_utils.py +864 -0
  2. app/orchestrator.py +1442 -0
app/location_utils.py ADDED
@@ -0,0 +1,864 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # app/location_utils.py
2
+ """
3
+ πŸ—ΊοΈ Penny's Location Intelligence System
4
+ Handles city detection, tenant routing, and geographic data loading.
5
+
6
+ MISSION: Connect residents to the right local resources, regardless of how
7
+ they describe their location β€” whether it's "Atlanta", "ATL", "30303", or "near me".
8
+
9
+ CURRENT: Rule-based city matching with 7 supported cities
10
+ FUTURE: Will add ZIP→city mapping, geocoding API, and user location preferences
11
+ """
12
+
13
+ import re
14
+ import json
15
+ import os
16
+ import logging
17
+ from typing import Dict, Any, Optional, List, Tuple
18
+ from pathlib import Path
19
+ from dataclasses import dataclass
20
+ from enum import Enum
21
+
22
+ # --- LOGGING SETUP (Azure-friendly) ---
23
+ logger = logging.getLogger(__name__)
24
+
25
+ # --- BASE PATHS (OS-agnostic for Azure/Windows/Linux) ---
26
+ BASE_DIR = Path(__file__).parent.parent.resolve()
27
+ DATA_PATH = BASE_DIR / "data"
28
+ EVENTS_PATH = DATA_PATH / "events"
29
+ RESOURCES_PATH = DATA_PATH / "resources"
30
+
31
+ # Ensure critical directories exist (Azure deployment safety)
32
+ for path in [DATA_PATH, EVENTS_PATH, RESOURCES_PATH]:
33
+ path.mkdir(parents=True, exist_ok=True)
34
+
35
+
36
+ # ============================================================
37
+ # CITY REGISTRY (Penny's Supported Cities)
38
+ # ============================================================
39
+
40
+ @dataclass
41
+ class CityInfo:
42
+ """
43
+ Structured information about a city Penny supports.
44
+ Makes it easy to add new cities with metadata.
45
+ """
46
+ tenant_id: str # Standard format: cityname_state (e.g., "atlanta_ga")
47
+ full_name: str # Display name: "Atlanta, GA"
48
+ state: str # Two-letter state code
49
+ aliases: List[str] # Common variations users might say
50
+ timezone: str # IANA timezone (e.g., "America/New_York")
51
+ lat: Optional[float] = None # For weather API fallback
52
+ lon: Optional[float] = None
53
+
54
+ def __post_init__(self):
55
+ # Normalize all aliases to lowercase for matching
56
+ self.aliases = [alias.lower().strip() for alias in self.aliases]
57
+
58
+
59
+ class SupportedCities:
60
+ """
61
+ πŸ™οΈ Penny's city registry.
62
+ Each city gets standardized metadata for consistent routing.
63
+ """
64
+
65
+ ATLANTA = CityInfo(
66
+ tenant_id="atlanta_ga",
67
+ full_name="Atlanta, GA",
68
+ state="GA",
69
+ timezone="America/New_York",
70
+ lat=33.7490,
71
+ lon=-84.3880,
72
+ aliases=[
73
+ "atlanta", "atl", "atlanta ga", "atlanta, ga",
74
+ "city of atlanta", "hotlanta", "the atl"
75
+ ]
76
+ )
77
+
78
+ BIRMINGHAM = CityInfo(
79
+ tenant_id="birmingham_al",
80
+ full_name="Birmingham, AL",
81
+ state="AL",
82
+ timezone="America/Chicago",
83
+ lat=33.5207,
84
+ lon=-86.8025,
85
+ aliases=[
86
+ "birmingham", "birmingham al", "birmingham, al",
87
+ "city of birmingham", "bham"
88
+ ]
89
+ )
90
+
91
+ CHESTERFIELD = CityInfo(
92
+ tenant_id="chesterfield_va",
93
+ full_name="Chesterfield, VA",
94
+ state="VA",
95
+ timezone="America/New_York",
96
+ lat=37.3771,
97
+ lon=-77.5047,
98
+ aliases=[
99
+ "chesterfield", "chesterfield va", "chesterfield, va",
100
+ "chesterfield county"
101
+ ]
102
+ )
103
+
104
+ EL_PASO = CityInfo(
105
+ tenant_id="el_paso_tx",
106
+ full_name="El Paso, TX",
107
+ state="TX",
108
+ timezone="America/Denver",
109
+ lat=31.7619,
110
+ lon=-106.4850,
111
+ aliases=[
112
+ "el paso", "el paso tx", "el paso, tx",
113
+ "city of el paso", "elpaso"
114
+ ]
115
+ )
116
+
117
+ PROVIDENCE = CityInfo(
118
+ tenant_id="providence_ri",
119
+ full_name="Providence, RI",
120
+ state="RI",
121
+ timezone="America/New_York",
122
+ lat=41.8240,
123
+ lon=-71.4128,
124
+ aliases=[
125
+ "providence", "providence ri", "providence, ri",
126
+ "city of providence", "pvd"
127
+ ]
128
+ )
129
+
130
+ SEATTLE = CityInfo(
131
+ tenant_id="seattle_wa",
132
+ full_name="Seattle, WA",
133
+ state="WA",
134
+ timezone="America/Los_Angeles",
135
+ lat=47.6062,
136
+ lon=-122.3321,
137
+ aliases=[
138
+ "seattle", "seattle wa", "seattle, wa",
139
+ "city of seattle", "emerald city", "sea"
140
+ ]
141
+ )
142
+
143
+ NORFOLK = CityInfo(
144
+ tenant_id="norfolk_va",
145
+ full_name="Norfolk, VA",
146
+ state="VA",
147
+ timezone="America/New_York",
148
+ lat=36.8508,
149
+ lon=-76.2859,
150
+ aliases=[
151
+ "norfolk", "norfolk va", "norfolk, va",
152
+ "city of norfolk", "757", "norfolk virginia"
153
+ ]
154
+ )
155
+
156
+ @classmethod
157
+ def get_all_cities(cls) -> List[CityInfo]:
158
+ """Returns list of all supported cities."""
159
+ return [
160
+ cls.ATLANTA,
161
+ cls.BIRMINGHAM,
162
+ cls.CHESTERFIELD,
163
+ cls.EL_PASO,
164
+ cls.PROVIDENCE,
165
+ cls.SEATTLE,
166
+ cls.NORFOLK
167
+ ]
168
+
169
+ @classmethod
170
+ def get_city_by_tenant_id(cls, tenant_id: str) -> Optional[CityInfo]:
171
+ """Lookup city info by tenant ID."""
172
+ for city in cls.get_all_cities():
173
+ if city.tenant_id == tenant_id:
174
+ return city
175
+ return None
176
+
177
+
178
+ # ============================================================
179
+ # BUILD DYNAMIC CITY PATTERNS (from CityInfo registry)
180
+ # ============================================================
181
+
182
+ def _build_city_patterns() -> Dict[str, str]:
183
+ """
184
+ Generates city matching dictionary from the CityInfo registry.
185
+ This keeps the pattern matching backward-compatible with existing code.
186
+ """
187
+ patterns = {}
188
+ for city in SupportedCities.get_all_cities():
189
+ for alias in city.aliases:
190
+ patterns[alias] = city.tenant_id
191
+ return patterns
192
+
193
+
194
+ # Dynamic pattern dictionary (auto-generated from city registry)
195
+ REAL_CITY_PATTERNS = _build_city_patterns()
196
+
197
+
198
+ # ============================================================
199
+ # LOCATION DETECTION ENUMS
200
+ # ============================================================
201
+
202
+ class LocationStatus(str, Enum):
203
+ """
204
+ Status codes for location detection results.
205
+ """
206
+ FOUND = "found" # Valid city matched
207
+ ZIP_DETECTED = "zip_detected" # ZIP code found (needs mapping)
208
+ USER_LOCATION_NEEDED = "user_location_needed" # "near me" detected
209
+ UNKNOWN = "unknown" # No match found
210
+ AMBIGUOUS = "ambiguous" # Multiple possible matches
211
+
212
+
213
+ @dataclass
214
+ class LocationMatch:
215
+ """
216
+ Structured result from location detection.
217
+ Includes confidence and matched patterns for debugging.
218
+ """
219
+ status: LocationStatus
220
+ tenant_id: Optional[str] = None
221
+ city_info: Optional[CityInfo] = None
222
+ confidence: float = 0.0 # 0.0 - 1.0
223
+ matched_pattern: Optional[str] = None
224
+ alternatives: List[str] = None
225
+
226
+ def __post_init__(self):
227
+ if self.alternatives is None:
228
+ self.alternatives = []
229
+
230
+
231
+ # ============================================================
232
+ # ZIP CODE PATTERNS (for future expansion)
233
+ # ============================================================
234
+
235
+ ZIP_PATTERN = re.compile(r"\b\d{5}(?:-\d{4})?\b") # Matches 12345 or 12345-6789
236
+
237
+ # Future ZIP β†’ City mapping (placeholder)
238
+ ZIP_TO_CITY_MAP: Dict[str, str] = {
239
+ # Atlanta metro
240
+ "30303": "atlanta_ga",
241
+ "30318": "atlanta_ga",
242
+ "30309": "atlanta_ga",
243
+
244
+ # Birmingham metro
245
+ "35203": "birmingham_al",
246
+ "35233": "birmingham_al",
247
+
248
+ # Chesterfield County
249
+ "23832": "chesterfield_va",
250
+ "23838": "chesterfield_va",
251
+
252
+ # El Paso
253
+ "79901": "el_paso_tx",
254
+ "79936": "el_paso_tx",
255
+
256
+ # Providence
257
+ "02903": "providence_ri",
258
+ "02904": "providence_ri",
259
+
260
+ # Seattle metro
261
+ "98101": "seattle_wa",
262
+ "98104": "seattle_wa",
263
+ "98122": "seattle_wa",
264
+
265
+ # Norfolk
266
+ "23510": "norfolk_va",
267
+ "23517": "norfolk_va",
268
+ "23518": "norfolk_va",
269
+ "23523": "norfolk_va",
270
+ }
271
+
272
+
273
+ # ============================================================
274
+ # MAIN CITY EXTRACTION LOGIC (Enhanced)
275
+ # ============================================================
276
+
277
+ def extract_city_name(text: str) -> str:
278
+ """
279
+ 🎯 BACKWARD-COMPATIBLE location extraction (returns tenant_id string).
280
+
281
+ Extracts tenant ID (e.g., 'atlanta_ga') from user input.
282
+
283
+ Args:
284
+ text: User's location input (e.g., "Atlanta", "30303", "near me")
285
+
286
+ Returns:
287
+ Tenant ID string or status code:
288
+ - Valid tenant_id (e.g., "atlanta_ga")
289
+ - "zip_detected" (ZIP code found, needs mapping)
290
+ - "user_location_needed" ("near me" detected)
291
+ - "unknown" (no match)
292
+ """
293
+ result = extract_location_detailed(text)
294
+ return result.tenant_id or result.status.value
295
+
296
+
297
+ def extract_location_detailed(text: str) -> LocationMatch:
298
+ """
299
+ 🧠 ENHANCED location extraction with confidence scoring.
300
+
301
+ This function intelligently parses location references and returns
302
+ structured results with metadata for better error handling.
303
+
304
+ Args:
305
+ text: User's location input
306
+
307
+ Returns:
308
+ LocationMatch object with full detection details
309
+ """
310
+
311
+ if not text or not text.strip():
312
+ logger.warning("Empty text provided to location extraction")
313
+ return LocationMatch(
314
+ status=LocationStatus.UNKNOWN,
315
+ confidence=0.0
316
+ )
317
+
318
+ lowered = text.lower().strip()
319
+ logger.debug(f"Extracting location from: '{lowered}'")
320
+
321
+ # --- STEP 1: Check for "near me" / location services needed ---
322
+ near_me_phrases = [
323
+ "near me", "my area", "my city", "my neighborhood",
324
+ "where i am", "current location", "my location",
325
+ "around here", "locally", "in my town"
326
+ ]
327
+
328
+ if any(phrase in lowered for phrase in near_me_phrases):
329
+ logger.info("User location services required")
330
+ return LocationMatch(
331
+ status=LocationStatus.USER_LOCATION_NEEDED,
332
+ confidence=1.0,
333
+ matched_pattern="near_me_detected"
334
+ )
335
+
336
+ # --- STEP 2: Check for ZIP codes ---
337
+ zip_matches = ZIP_PATTERN.findall(text)
338
+ if zip_matches:
339
+ zip_code = zip_matches[0] # Take first ZIP if multiple
340
+
341
+ # Try to map ZIP to known city
342
+ if zip_code in ZIP_TO_CITY_MAP:
343
+ tenant_id = ZIP_TO_CITY_MAP[zip_code]
344
+ city_info = SupportedCities.get_city_by_tenant_id(tenant_id)
345
+ logger.info(f"ZIP {zip_code} mapped to {tenant_id}")
346
+ return LocationMatch(
347
+ status=LocationStatus.FOUND,
348
+ tenant_id=tenant_id,
349
+ city_info=city_info,
350
+ confidence=0.95,
351
+ matched_pattern=f"zip:{zip_code}"
352
+ )
353
+ else:
354
+ logger.info(f"ZIP code detected but not mapped: {zip_code}")
355
+ return LocationMatch(
356
+ status=LocationStatus.ZIP_DETECTED,
357
+ confidence=0.5,
358
+ matched_pattern=f"zip:{zip_code}"
359
+ )
360
+
361
+ # --- STEP 3: Match against city patterns ---
362
+ matches = []
363
+ for pattern, tenant_id in REAL_CITY_PATTERNS.items():
364
+ if pattern in lowered:
365
+ matches.append((pattern, tenant_id))
366
+
367
+ if not matches:
368
+ logger.info(f"No city match found for: '{lowered}'")
369
+ return LocationMatch(
370
+ status=LocationStatus.UNKNOWN,
371
+ confidence=0.0
372
+ )
373
+
374
+ # If multiple matches, pick the longest pattern (most specific)
375
+ # Example: "atlanta" vs "city of atlanta" β€” pick the longer one
376
+ matches.sort(key=lambda x: len(x[0]), reverse=True)
377
+ best_pattern, best_tenant_id = matches[0]
378
+
379
+ city_info = SupportedCities.get_city_by_tenant_id(best_tenant_id)
380
+
381
+ # Calculate confidence based on match specificity
382
+ confidence = min(len(best_pattern) / len(lowered), 1.0)
383
+
384
+ result = LocationMatch(
385
+ status=LocationStatus.FOUND,
386
+ tenant_id=best_tenant_id,
387
+ city_info=city_info,
388
+ confidence=confidence,
389
+ matched_pattern=best_pattern
390
+ )
391
+
392
+ # Check for ambiguity (multiple different cities matched)
393
+ unique_tenant_ids = set(tid for _, tid in matches)
394
+ if len(unique_tenant_ids) > 1:
395
+ result.status = LocationStatus.AMBIGUOUS
396
+ result.alternatives = [tid for _, tid in matches if tid != best_tenant_id]
397
+ logger.warning(f"Ambiguous location match: {unique_tenant_ids}")
398
+
399
+ logger.info(f"Location matched: {best_tenant_id} (confidence: {confidence:.2f})")
400
+ return result
401
+
402
+
403
+ # ============================================================
404
+ # DATA LOADING UTILITIES (Enhanced with error handling)
405
+ # ============================================================
406
+
407
+ def load_city_data(directory: Path, tenant_id: str) -> Dict[str, Any]:
408
+ """
409
+ πŸ—„οΈ Generic utility to load JSON data for a given tenant ID.
410
+
411
+ Args:
412
+ directory: Base path (EVENTS_PATH or RESOURCES_PATH)
413
+ tenant_id: City identifier (e.g., 'atlanta_ga')
414
+
415
+ Returns:
416
+ Parsed JSON content as dictionary
417
+
418
+ Raises:
419
+ FileNotFoundError: If the JSON file doesn't exist
420
+ json.JSONDecodeError: If the file is malformed
421
+ """
422
+
423
+ file_path = directory / f"{tenant_id}.json"
424
+
425
+ if not file_path.exists():
426
+ logger.error(f"Data file not found: {file_path}")
427
+ raise FileNotFoundError(f"Data file not found: {file_path}")
428
+
429
+ try:
430
+ with open(file_path, 'r', encoding='utf-8') as f:
431
+ data = json.load(f)
432
+ logger.debug(f"Loaded data from {file_path}")
433
+ return data
434
+ except json.JSONDecodeError as e:
435
+ logger.error(f"Invalid JSON in {file_path}: {e}")
436
+ raise
437
+ except Exception as e:
438
+ logger.error(f"Error reading {file_path}: {e}", exc_info=True)
439
+ raise
440
+
441
+
442
+ def load_city_events(tenant_id: str) -> Dict[str, Any]:
443
+ """
444
+ πŸ“… Loads structured event data for a given city.
445
+
446
+ Args:
447
+ tenant_id: City identifier (e.g., 'atlanta_ga')
448
+
449
+ Returns:
450
+ Event data structure with 'events' key containing list of events
451
+
452
+ Example:
453
+ {
454
+ "city": "Atlanta, GA",
455
+ "events": [
456
+ {"name": "Jazz Festival", "category": "outdoor", ...},
457
+ ...
458
+ ]
459
+ }
460
+ """
461
+ logger.info(f"Loading events for {tenant_id}")
462
+ return load_city_data(EVENTS_PATH, tenant_id)
463
+
464
+
465
+ def load_city_resources(tenant_id: str) -> Dict[str, Any]:
466
+ """
467
+ ��️ Loads civic resource data for a given city.
468
+
469
+ Args:
470
+ tenant_id: City identifier (e.g., 'atlanta_ga')
471
+
472
+ Returns:
473
+ Resource data structure with categorized resources
474
+
475
+ Example:
476
+ {
477
+ "city": "Atlanta, GA",
478
+ "resources": {
479
+ "shelters": [...],
480
+ "food_banks": [...],
481
+ "libraries": [...]
482
+ }
483
+ }
484
+ """
485
+ logger.info(f"Loading resources for {tenant_id}")
486
+ return load_city_data(RESOURCES_PATH, tenant_id)
487
+
488
+
489
+ # ============================================================
490
+ # UTILITY FUNCTIONS
491
+ # ============================================================
492
+
493
+ def normalize_location_name(text: str) -> str:
494
+ """
495
+ 🧹 Normalize location names into consistent format.
496
+ Removes spaces, hyphens, and special characters.
497
+
498
+ Example:
499
+ "El Paso, TX" β†’ "elpasotx"
500
+ "Chesterfield County" β†’ "chesterfieldcounty"
501
+ """
502
+ if not text:
503
+ return ""
504
+
505
+ # Remove punctuation and spaces
506
+ normalized = re.sub(r"[\s\-,\.]+", "", text.lower().strip())
507
+ return normalized
508
+
509
+
510
+ def get_city_coordinates(tenant_id: str) -> Optional[Dict[str, float]]:
511
+ """
512
+ πŸ—ΊοΈ Returns coordinates for a city as a dictionary.
513
+ Useful for weather API calls.
514
+
515
+ Args:
516
+ tenant_id: City identifier
517
+
518
+ Returns:
519
+ Dictionary with "lat" and "lon" keys, or None if not found
520
+
521
+ Note: This function returns a dict for consistency with orchestrator usage.
522
+ Use tuple unpacking: coords = get_city_coordinates(tenant_id); lat, lon = coords["lat"], coords["lon"]
523
+ """
524
+ city_info = SupportedCities.get_city_by_tenant_id(tenant_id)
525
+ if city_info and city_info.lat is not None and city_info.lon is not None:
526
+ return {"lat": city_info.lat, "lon": city_info.lon}
527
+ return None
528
+
529
+
530
+ def get_city_info(tenant_id: str) -> Optional[Dict[str, Any]]:
531
+ """
532
+ πŸ™οΈ Returns city information dictionary.
533
+
534
+ Args:
535
+ tenant_id: City identifier
536
+
537
+ Returns:
538
+ Dictionary with city information (name, state, coordinates, etc.) or None
539
+ """
540
+ city_info = SupportedCities.get_city_by_tenant_id(tenant_id)
541
+ if city_info:
542
+ return {
543
+ "tenant_id": city_info.tenant_id,
544
+ "full_name": city_info.full_name,
545
+ "state": city_info.state,
546
+ "timezone": city_info.timezone,
547
+ "lat": city_info.lat,
548
+ "lon": city_info.lon,
549
+ "aliases": city_info.aliases
550
+ }
551
+ return None
552
+
553
+
554
+ def detect_location_from_text(text: str) -> Dict[str, Any]:
555
+ """
556
+ πŸ” Detects location from text input.
557
+
558
+ Args:
559
+ text: User input text
560
+
561
+ Returns:
562
+ Dictionary with keys:
563
+ - found: bool (whether location was detected)
564
+ - tenant_id: str (if found)
565
+ - city_info: dict (if found)
566
+ - confidence: float (0.0-1.0)
567
+ """
568
+ result = extract_location_detailed(text)
569
+
570
+ return {
571
+ "found": result.status == LocationStatus.FOUND,
572
+ "tenant_id": result.tenant_id,
573
+ "city_info": {
574
+ "tenant_id": result.city_info.tenant_id,
575
+ "full_name": result.city_info.full_name,
576
+ "state": result.city_info.state
577
+ } if result.city_info else None,
578
+ "confidence": result.confidence,
579
+ "status": result.status.value
580
+ }
581
+
582
+
583
+ def validate_coordinates(lat: float, lon: float) -> Tuple[bool, Optional[str]]:
584
+ """
585
+ βœ… Validates latitude and longitude coordinates.
586
+
587
+ Args:
588
+ lat: Latitude (-90 to 90)
589
+ lon: Longitude (-180 to 180)
590
+
591
+ Returns:
592
+ Tuple of (is_valid, error_message)
593
+ - is_valid: True if coordinates are valid
594
+ - error_message: None if valid, error description if invalid
595
+ """
596
+ if not isinstance(lat, (int, float)) or not isinstance(lon, (int, float)):
597
+ return False, "Coordinates must be numeric values"
598
+
599
+ if not (-90 <= lat <= 90):
600
+ return False, f"Latitude must be between -90 and 90, got {lat}"
601
+
602
+ if not (-180 <= lon <= 180):
603
+ return False, f"Longitude must be between -180 and 180, got {lon}"
604
+
605
+ return True, None
606
+
607
+
608
+ def get_city_timezone(tenant_id: str) -> Optional[str]:
609
+ """
610
+ πŸ• Returns IANA timezone string for a city.
611
+ Useful for time-sensitive features (events, business hours).
612
+
613
+ Args:
614
+ tenant_id: City identifier
615
+
616
+ Returns:
617
+ IANA timezone string (e.g., "America/New_York") or None
618
+ """
619
+ city_info = SupportedCities.get_city_by_tenant_id(tenant_id)
620
+ return city_info.timezone if city_info else None
621
+
622
+
623
+ def validate_tenant_id(tenant_id: str) -> bool:
624
+ """
625
+ βœ… Checks if a tenant_id is valid and supported.
626
+
627
+ Args:
628
+ tenant_id: City identifier to validate
629
+
630
+ Returns:
631
+ True if valid and supported, False otherwise
632
+ """
633
+ city_info = SupportedCities.get_city_by_tenant_id(tenant_id)
634
+ return city_info is not None
635
+
636
+
637
+ def get_all_supported_cities() -> List[Dict[str, str]]:
638
+ """
639
+ πŸ“‹ Returns list of all supported cities for API responses.
640
+
641
+ Returns:
642
+ List of city info dictionaries with tenant_id and display name
643
+
644
+ Example:
645
+ [
646
+ {"tenant_id": "atlanta_ga", "name": "Atlanta, GA"},
647
+ {"tenant_id": "seattle_wa", "name": "Seattle, WA"},
648
+ ...
649
+ ]
650
+ """
651
+ return [
652
+ {
653
+ "tenant_id": city.tenant_id,
654
+ "name": city.full_name,
655
+ "state": city.state
656
+ }
657
+ for city in SupportedCities.get_all_cities()
658
+ ]
659
+
660
+
661
+ # ============================================================
662
+ # DATA VALIDATION (For startup checks)
663
+ # ============================================================
664
+
665
+ def validate_city_data_files() -> Dict[str, Dict[str, bool]]:
666
+ """
667
+ πŸ§ͺ Validates that all expected data files exist.
668
+ Useful for startup checks and deployment verification.
669
+
670
+ Returns:
671
+ Dictionary mapping tenant_id to file existence status
672
+
673
+ Example:
674
+ {
675
+ "atlanta_ga": {"events": True, "resources": True},
676
+ "seattle_wa": {"events": False, "resources": True}
677
+ }
678
+ """
679
+ validation_results = {}
680
+
681
+ for city in SupportedCities.get_all_cities():
682
+ tenant_id = city.tenant_id
683
+ events_file = EVENTS_PATH / f"{tenant_id}.json"
684
+ resources_file = RESOURCES_PATH / f"{tenant_id}.json"
685
+
686
+ validation_results[tenant_id] = {
687
+ "events": events_file.exists(),
688
+ "resources": resources_file.exists()
689
+ }
690
+
691
+ if not events_file.exists():
692
+ logger.warning(f"Missing events file for {tenant_id}")
693
+ if not resources_file.exists():
694
+ logger.warning(f"Missing resources file for {tenant_id}")
695
+
696
+ return validation_results
697
+
698
+
699
+ # ============================================================
700
+ # INITIALIZATION CHECK (Call on app startup)
701
+ # ============================================================
702
+
703
+ def initialize_location_system() -> bool:
704
+ """
705
+ πŸš€ Validates location system is ready.
706
+ Should be called during app startup.
707
+
708
+ Returns:
709
+ True if system is ready, False if critical files missing
710
+ """
711
+ logger.info("πŸ—ΊοΈ Initializing Penny's location system...")
712
+
713
+ # Check directories exist
714
+ if not DATA_PATH.exists():
715
+ logger.error(f"Data directory not found: {DATA_PATH}")
716
+ return False
717
+
718
+ # Validate city data files
719
+ validation = validate_city_data_files()
720
+
721
+ total_cities = len(SupportedCities.get_all_cities())
722
+ cities_with_events = sum(1 for v in validation.values() if v["events"])
723
+ cities_with_resources = sum(1 for v in validation.values() if v["resources"])
724
+
725
+ logger.info(f"βœ… {total_cities} cities registered")
726
+ logger.info(f"βœ… {cities_with_events}/{total_cities} cities have event data")
727
+ logger.info(f"βœ… {cities_with_resources}/{total_cities} cities have resource data")
728
+
729
+ # Warn about missing data but don't fail
730
+ missing_data = [tid for tid, status in validation.items()
731
+ if not status["events"] or not status["resources"]]
732
+
733
+ if missing_data:
734
+ logger.warning(f"⚠️ Incomplete data for cities: {missing_data}")
735
+
736
+ logger.info("πŸ—ΊοΈ Location system initialized successfully")
737
+ return True
738
+
739
+
740
+ # ============================================================
741
+ # GEOCODING FUNCTIONS (Azure Maps Integration)
742
+ # ============================================================
743
+
744
+ AZURE_MAPS_KEY = os.getenv("AZURE_MAPS_KEY")
745
+
746
+
747
+ async def geocode_address(address: str) -> Dict[str, Any]:
748
+ """
749
+ πŸ—ΊοΈ Convert address to coordinates using Azure Maps Search API.
750
+
751
+ Args:
752
+ address: Human-readable address or city name
753
+
754
+ Returns:
755
+ Dictionary with lat/lon or error
756
+
757
+ Example:
758
+ result = await geocode_address("Atlanta, GA")
759
+ # Returns: {"lat": 33.749, "lon": -84.388}
760
+ """
761
+ if not AZURE_MAPS_KEY:
762
+ logger.error("AZURE_MAPS_KEY not configured")
763
+ return {"error": "Azure Maps key not configured"}
764
+
765
+ url = "https://atlas.microsoft.com/search/address/json"
766
+ params = {
767
+ "api-version": "1.0",
768
+ "subscription-key": AZURE_MAPS_KEY,
769
+ "query": address,
770
+ "limit": 1
771
+ }
772
+
773
+ try:
774
+ import httpx
775
+ async with httpx.AsyncClient(timeout=10.0) as client:
776
+ response = await client.get(url, params=params)
777
+ response.raise_for_status()
778
+ data = response.json()
779
+
780
+ if data.get("results") and len(data["results"]) > 0:
781
+ position = data["results"][0]["position"]
782
+ logger.info(f"Geocoded '{address}' to ({position['lat']}, {position['lon']})")
783
+ return {
784
+ "lat": position["lat"],
785
+ "lon": position["lon"]
786
+ }
787
+ else:
788
+ logger.warning(f"No results found for address: {address}")
789
+ return {"error": "Address not found"}
790
+
791
+ except Exception as e:
792
+ logger.error(f"Geocoding error: {e}", exc_info=True)
793
+ return {"error": f"Geocoding failed: {str(e)}"}
794
+
795
+
796
+ def get_user_location(city: str) -> Dict[str, Any]:
797
+ """
798
+ 🌍 Simple wrapper to geocode a city name.
799
+
800
+ Args:
801
+ city: City name (e.g., "Atlanta")
802
+
803
+ Returns:
804
+ Dictionary with lat/lon or error
805
+
806
+ Note: This is a synchronous wrapper for backward compatibility.
807
+ Consider using geocode_address() directly for async code.
808
+ """
809
+ import asyncio
810
+
811
+ try:
812
+ # Run the async geocode_address in a new event loop
813
+ loop = asyncio.new_event_loop()
814
+ asyncio.set_event_loop(loop)
815
+ result = loop.run_until_complete(geocode_address(city))
816
+ loop.close()
817
+ return result
818
+ except Exception as e:
819
+ logger.error(f"get_user_location error: {e}", exc_info=True)
820
+ return {"error": str(e)}
821
+
822
+
823
+ # ============================================================
824
+ # TESTING
825
+ # ============================================================
826
+
827
+ if __name__ == "__main__":
828
+ """πŸ§ͺ Test location utilities"""
829
+
830
+ print("=" * 60)
831
+ print("πŸ§ͺ Testing Location Utils")
832
+ print("=" * 60)
833
+
834
+ # Initialize system
835
+ print("\n--- System Initialization ---")
836
+ initialize_location_system()
837
+
838
+ # Test location extraction
839
+ print("\n--- Location Extraction Tests ---")
840
+ test_inputs = [
841
+ "What's the weather in Atlanta?",
842
+ "Events near me",
843
+ "Seattle, WA",
844
+ "30303",
845
+ "Show me Birmingham",
846
+ "Norfolk events this weekend",
847
+ "What's happening in 757?"
848
+ ]
849
+
850
+ for test in test_inputs:
851
+ result = extract_location_detailed(test)
852
+ print(f"\nInput: '{test}'")
853
+ print(f"Status: {result.status.value}")
854
+ print(f"Tenant: {result.tenant_id}")
855
+ print(f"Confidence: {result.confidence:.2f}")
856
+
857
+ # Test coordinate lookup
858
+ print("\n--- Coordinate Lookup Tests ---")
859
+ for city in ["atlanta_ga", "norfolk_va", "seattle_wa"]:
860
+ coords = get_city_coordinates(city)
861
+ print(f"{city}: {coords}")
862
+
863
+ print("\n" + "=" * 60)
864
+ print("βœ… Tests complete")
app/orchestrator.py ADDED
@@ -0,0 +1,1442 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ return await _handle_emergency(
320
+ message=message,
321
+ context=context,
322
+ start_time=start_time
323
+ )
324
+
325
+ # === STEP 4: ROUTE TO APPROPRIATE HANDLER ===
326
+
327
+ # Translation
328
+ if intent == IntentType.TRANSLATION:
329
+ result = await _handle_translation(message, context)
330
+
331
+ # Sentiment Analysis
332
+ elif intent == IntentType.SENTIMENT_ANALYSIS:
333
+ result = await _handle_sentiment(message, context)
334
+
335
+ # Bias Detection
336
+ elif intent == IntentType.BIAS_DETECTION:
337
+ result = await _handle_bias(message, context)
338
+
339
+ # Document Processing
340
+ elif intent == IntentType.DOCUMENT_PROCESSING:
341
+ result = await _handle_document(message, context)
342
+
343
+ # Weather (includes compound weather+events handling)
344
+ async def _handle_weather(
345
+ message: str,
346
+ context: Dict[str, Any],
347
+ tenant_id: Optional[str],
348
+ lat: Optional[float],
349
+ lon: Optional[float],
350
+ intent_result: IntentMatch
351
+ ) -> OrchestrationResult:
352
+ """
353
+ 🌀️ Weather handler with compound intent support.
354
+
355
+ Handles both simple weather queries and compound weather+events queries.
356
+ Uses enhanced weather_agent.py with caching and performance tracking.
357
+ """
358
+ logger.info("🌀️ Processing weather request")
359
+
360
+ # Check service availability first
361
+ if not WEATHER_AGENT_AVAILABLE:
362
+ logger.warning("Weather agent not available")
363
+ return OrchestrationResult(
364
+ intent=IntentType.WEATHER.value,
365
+ reply="Weather service isn't available right now. Try again soon! 🌀️",
366
+ success=False,
367
+ error="Weather agent not loaded",
368
+ fallback_used=True
369
+ )
370
+
371
+ # Check for compound intent (weather + events)
372
+ is_compound = intent_result.is_compound or IntentType.EVENTS in intent_result.secondary_intents
373
+
374
+ # === ENHANCED LOCATION RESOLUTION ===
375
+
376
+ # Step 1: Try to extract location from the query itself
377
+ if not tenant_id or (lat is None or lon is None):
378
+ location_result = extract_location_detailed(message)
379
+
380
+ if location_result.status == LocationStatus.FOUND:
381
+ tenant_id = location_result.tenant_id
382
+ logger.info(f"πŸ“ Location extracted from query: {tenant_id}")
383
+
384
+ # Get coordinates for extracted location
385
+ coords = get_city_coordinates(tenant_id)
386
+ if coords:
387
+ lat, lon = coords["lat"], coords["lon"]
388
+ logger.info(f"βœ… Coordinates found: {lat}, {lon}")
389
+
390
+ # Step 2: If still no coordinates, try to get from tenant_id
391
+ if (lat is None or lon is None) and tenant_id:
392
+ coords = get_city_coordinates(tenant_id)
393
+ if coords:
394
+ lat, lon = coords["lat"], coords["lon"]
395
+ logger.info(f"βœ… Coordinates from tenant_id: {lat}, {lon}")
396
+
397
+ # Step 3: If still no coordinates, ask for location
398
+ if lat is None or lon is None:
399
+ logger.warning("❌ No coordinates available for weather request")
400
+ return OrchestrationResult(
401
+ intent=IntentType.WEATHER.value,
402
+ reply=(
403
+ "I need to know your location to check the weather! πŸ“ "
404
+ "You can tell me your city, or share your location."
405
+ ),
406
+ success=False,
407
+ error="Location required"
408
+ )
409
+
410
+ try:
411
+ # Use combined weather + events if compound intent detected
412
+ if is_compound and tenant_id and EVENT_WEATHER_AVAILABLE:
413
+ logger.info("Using weather+events combined handler")
414
+ result = await get_event_recommendations_with_weather(tenant_id, lat, lon)
415
+
416
+ # Build response
417
+ weather = result.get("weather", {})
418
+ weather_summary = result.get("weather_summary", "Weather unavailable")
419
+ suggestions = result.get("suggestions", [])
420
+
421
+ reply_lines = [f"🌀️ **Weather Update:**\n{weather_summary}\n"]
422
+
423
+ if suggestions:
424
+ reply_lines.append("\nπŸ“… **Event Suggestions Based on Weather:**")
425
+ for suggestion in suggestions[:5]: # Top 5 suggestions
426
+ reply_lines.append(f"β€’ {suggestion}")
427
+
428
+ reply = "\n".join(reply_lines)
429
+
430
+ return OrchestrationResult(
431
+ intent=IntentType.WEATHER.value,
432
+ reply=reply,
433
+ success=True,
434
+ data=result,
435
+ model_id="weather_events_combined"
436
+ )
437
+
438
+ else:
439
+ # Simple weather query using enhanced weather_agent
440
+ logger.info(f"🌀️ Fetching weather for coordinates: {lat}, {lon}")
441
+ weather = await get_weather_for_location(lat, lon)
442
+
443
+ # Use enhanced weather_agent's format_weather_summary
444
+ weather_text = format_weather_summary(weather)
445
+
446
+ # Get outfit recommendation from enhanced weather_agent
447
+ temp = weather.get("temperature", {}).get("value", 70)
448
+ condition = weather.get("phrase", "Clear")
449
+ outfit = recommend_outfit(temp, condition)
450
+
451
+ # Build friendly response with location name if available
452
+ location_name = tenant_id.replace("_", " ").title() if tenant_id else "your location"
453
+ reply = f"🌀️ **Weather for {location_name}:**\n\n{weather_text}\n\nπŸ‘• {outfit}"
454
+
455
+ logger.info(f"βœ… Weather fetched successfully for {location_name}")
456
+
457
+ return OrchestrationResult(
458
+ intent=IntentType.WEATHER.value,
459
+ reply=reply,
460
+ success=True,
461
+ data=weather,
462
+ model_id="azure-maps-weather",
463
+ tenant_id=tenant_id
464
+ )
465
+
466
+ except Exception as e:
467
+ logger.error(f"❌ Weather error: {e}", exc_info=True)
468
+ return OrchestrationResult(
469
+ intent=IntentType.WEATHER.value,
470
+ reply=(
471
+ "I'm having trouble getting weather data right now. "
472
+ "Can I help you with something else? πŸ’›"
473
+ ),
474
+ success=False,
475
+ error=str(e),
476
+ fallback_used=True
477
+ )
478
+
479
+ # Events
480
+ elif intent == IntentType.EVENTS:
481
+ result = await _handle_events(
482
+ message=message,
483
+ context=context,
484
+ tenant_id=tenant_id,
485
+ lat=lat,
486
+ lon=lon,
487
+ intent_result=intent_result
488
+ )
489
+
490
+ # Local Resources
491
+ elif intent == IntentType.LOCAL_RESOURCES:
492
+ result = await _handle_local_resources(
493
+ message=message,
494
+ context=context,
495
+ tenant_id=tenant_id,
496
+ lat=lat,
497
+ lon=lon
498
+ )
499
+
500
+ # Greeting, Help, Unknown
501
+ elif intent in [IntentType.GREETING, IntentType.HELP, IntentType.UNKNOWN]:
502
+ result = await _handle_conversational(
503
+ message=message,
504
+ intent=intent,
505
+ context=context
506
+ )
507
+
508
+ else:
509
+ # Unhandled intent type (shouldn't happen, but safety net)
510
+ result = await _handle_fallback(message, intent, context)
511
+
512
+ # === STEP 5: ADD METADATA & LOG INTERACTION ===
513
+ response_time = (time.time() - start_time) * 1000
514
+ result.response_time_ms = round(response_time, 2)
515
+ result.confidence = confidence
516
+ result.tenant_id = tenant_id
517
+
518
+ # Log the interaction with structured logging
519
+ log_interaction(
520
+ tenant_id=tenant_id or "unknown",
521
+ interaction_type="orchestration",
522
+ intent=intent.value,
523
+ response_time_ms=response_time,
524
+ success=result.success,
525
+ metadata={
526
+ "confidence": confidence,
527
+ "fallback_used": result.fallback_used,
528
+ "model_id": result.model_id,
529
+ "orchestration_count": _orchestration_count
530
+ }
531
+ )
532
+
533
+ # Log slow responses
534
+ if response_time > MAX_RESPONSE_TIME_MS:
535
+ logger.warning(
536
+ f"⚠️ Slow response: {response_time:.0f}ms "
537
+ f"(intent: {intent.value})"
538
+ )
539
+
540
+ logger.info(
541
+ f"βœ… Orchestration complete: {intent.value} "
542
+ f"({response_time:.0f}ms)"
543
+ )
544
+
545
+ return result.to_dict()
546
+
547
+ except Exception as e:
548
+ # === CATASTROPHIC FAILURE HANDLER ===
549
+ response_time = (time.time() - start_time) * 1000
550
+ logger.error(
551
+ f"❌ Orchestrator error: {e} "
552
+ f"(response_time: {response_time:.0f}ms)",
553
+ exc_info=True
554
+ )
555
+
556
+ # Log failed interaction
557
+ log_interaction(
558
+ tenant_id=context.get("tenant_id", "unknown"),
559
+ interaction_type="orchestration_error",
560
+ intent="error",
561
+ response_time_ms=response_time,
562
+ success=False,
563
+ metadata={
564
+ "error": str(e),
565
+ "error_type": type(e).__name__
566
+ }
567
+ )
568
+
569
+ error_result = OrchestrationResult(
570
+ intent="error",
571
+ reply=(
572
+ "I'm having trouble processing your request right now. "
573
+ "Please try again in a moment, or let me know if you need "
574
+ "immediate assistance! πŸ’›"
575
+ ),
576
+ success=False,
577
+ error=str(e),
578
+ model_id="orchestrator",
579
+ fallback_used=True,
580
+ response_time_ms=round(response_time, 2)
581
+ )
582
+
583
+ return error_result.to_dict()
584
+
585
+
586
+ # ============================================================
587
+ # SPECIALIZED INTENT HANDLERS (ENHANCED)
588
+ # ============================================================
589
+
590
+ async def _handle_emergency(
591
+ message: str,
592
+ context: Dict[str, Any],
593
+ start_time: float
594
+ ) -> OrchestrationResult:
595
+ """
596
+ 🚨 CRITICAL: Emergency intent handler.
597
+
598
+ This function handles crisis situations with immediate routing
599
+ to appropriate services. All emergency interactions are logged
600
+ for compliance and safety tracking.
601
+
602
+ IMPORTANT: This is a compliance-critical function. All emergency
603
+ interactions must be logged and handled with priority.
604
+ """
605
+ global _emergency_count
606
+ _emergency_count += 1
607
+
608
+ # Sanitize message for logging (but keep full context for safety review)
609
+ safe_message = sanitize_for_logging(message)
610
+ logger.warning(f"🚨 EMERGENCY INTENT DETECTED (#{_emergency_count}): {safe_message[:100]}")
611
+
612
+ # TODO: Integrate with safety_utils.py when enhanced
613
+ # from app.safety_utils import route_emergency
614
+ # result = await route_emergency(message, context)
615
+
616
+ # For now, provide crisis resources
617
+ reply = (
618
+ "🚨 **If this is a life-threatening emergency, please call 911 immediately.**\n\n"
619
+ "For crisis support:\n"
620
+ "- **National Suicide Prevention Lifeline:** 988\n"
621
+ "- **Crisis Text Line:** Text HOME to 741741\n"
622
+ "- **National Domestic Violence Hotline:** 1-800-799-7233\n\n"
623
+ "I'm here to help connect you with local resources. "
624
+ "What kind of support do you need right now?"
625
+ )
626
+
627
+ # Log emergency interaction for compliance (CRITICAL)
628
+ response_time = (time.time() - start_time) * 1000
629
+ log_interaction(
630
+ tenant_id=context.get("tenant_id", "emergency"),
631
+ interaction_type="emergency",
632
+ intent=IntentType.EMERGENCY.value,
633
+ response_time_ms=response_time,
634
+ success=True,
635
+ metadata={
636
+ "emergency_number": _emergency_count,
637
+ "message_length": len(message),
638
+ "timestamp": datetime.now().isoformat(),
639
+ "action": "crisis_resources_provided"
640
+ }
641
+ )
642
+
643
+ logger.critical(
644
+ f"EMERGENCY LOG #{_emergency_count}: Resources provided "
645
+ f"({response_time:.0f}ms)"
646
+ )
647
+
648
+ return OrchestrationResult(
649
+ intent=IntentType.EMERGENCY.value,
650
+ reply=reply,
651
+ success=True,
652
+ model_id="emergency_router",
653
+ data={"crisis_resources_provided": True},
654
+ response_time_ms=round(response_time, 2)
655
+ )
656
+
657
+
658
+ async def _handle_translation(
659
+ message: str,
660
+ context: Dict[str, Any]
661
+ ) -> OrchestrationResult:
662
+ """
663
+ 🌍 Translation handler - 27 languages supported.
664
+
665
+ Handles translation requests with graceful fallback if service
666
+ is unavailable.
667
+ """
668
+ logger.info("🌍 Processing translation request")
669
+
670
+ # Check service availability first
671
+ if not TRANSLATION_AVAILABLE:
672
+ logger.warning("Translation service not available")
673
+ return OrchestrationResult(
674
+ intent=IntentType.TRANSLATION.value,
675
+ reply="Translation isn't available right now. Try again soon! 🌍",
676
+ success=False,
677
+ error="Service not loaded",
678
+ fallback_used=True
679
+ )
680
+
681
+ try:
682
+ # Extract language parameters from context
683
+ source_lang = context.get("source_lang", "eng_Latn")
684
+ target_lang = context.get("target_lang", "spa_Latn")
685
+
686
+ # TODO: Parse languages from message when enhanced
687
+ # Example: "Translate 'hello' to Spanish"
688
+
689
+ result = await translate_text(message, source_lang, target_lang)
690
+
691
+ # Use compatibility helper to check result
692
+ success, error = _check_result_success(result, ["translated_text"])
693
+
694
+ if success:
695
+ translated = result.get("translated_text", "")
696
+ reply = (
697
+ f"Here's the translation:\n\n"
698
+ f"**{translated}**\n\n"
699
+ f"(Translated from {source_lang} to {target_lang})"
700
+ )
701
+
702
+ return OrchestrationResult(
703
+ intent=IntentType.TRANSLATION.value,
704
+ reply=reply,
705
+ success=True,
706
+ data=result,
707
+ model_id="penny-translate-agent"
708
+ )
709
+ else:
710
+ raise Exception(error or "Translation failed")
711
+
712
+ except Exception as e:
713
+ logger.error(f"Translation error: {e}", exc_info=True)
714
+ return OrchestrationResult(
715
+ intent=IntentType.TRANSLATION.value,
716
+ reply=(
717
+ "I had trouble translating that. Could you rephrase? πŸ’¬"
718
+ ),
719
+ success=False,
720
+ error=str(e),
721
+ fallback_used=True
722
+ )
723
+
724
+
725
+ async def _handle_sentiment(
726
+ message: str,
727
+ context: Dict[str, Any]
728
+ ) -> OrchestrationResult:
729
+ """
730
+ 😊 Sentiment analysis handler.
731
+
732
+ Analyzes the emotional tone of text with graceful fallback
733
+ if service is unavailable.
734
+ """
735
+ logger.info("😊 Processing sentiment analysis")
736
+
737
+ # Check service availability first
738
+ if not SENTIMENT_AVAILABLE:
739
+ logger.warning("Sentiment service not available")
740
+ return OrchestrationResult(
741
+ intent=IntentType.SENTIMENT_ANALYSIS.value,
742
+ reply="Sentiment analysis isn't available right now. Try again soon! 😊",
743
+ success=False,
744
+ error="Service not loaded",
745
+ fallback_used=True
746
+ )
747
+
748
+ try:
749
+ result = await get_sentiment_analysis(message)
750
+
751
+ # Use compatibility helper to check result
752
+ success, error = _check_result_success(result, ["label", "score"])
753
+
754
+ if success:
755
+ sentiment = result.get("label", "neutral")
756
+ confidence = result.get("score", 0.0)
757
+
758
+ reply = (
759
+ f"The overall sentiment detected is: **{sentiment}**\n"
760
+ f"Confidence: {confidence:.1%}"
761
+ )
762
+
763
+ return OrchestrationResult(
764
+ intent=IntentType.SENTIMENT_ANALYSIS.value,
765
+ reply=reply,
766
+ success=True,
767
+ data=result,
768
+ model_id="penny-sentiment-agent"
769
+ )
770
+ else:
771
+ raise Exception(error or "Sentiment analysis failed")
772
+
773
+ except Exception as e:
774
+ logger.error(f"Sentiment analysis error: {e}", exc_info=True)
775
+ return OrchestrationResult(
776
+ intent=IntentType.SENTIMENT_ANALYSIS.value,
777
+ reply="I couldn't analyze the sentiment right now. Try again? 😊",
778
+ success=False,
779
+ error=str(e),
780
+ fallback_used=True
781
+ )
782
+
783
+ async def _handle_bias(
784
+ message: str,
785
+ context: Dict[str, Any]
786
+ ) -> OrchestrationResult:
787
+ """
788
+ βš–οΈ Bias detection handler.
789
+
790
+ Analyzes text for potential bias patterns with graceful fallback
791
+ if service is unavailable.
792
+ """
793
+ logger.info("βš–οΈ Processing bias detection")
794
+
795
+ # Check service availability first
796
+ if not BIAS_AVAILABLE:
797
+ logger.warning("Bias detection service not available")
798
+ return OrchestrationResult(
799
+ intent=IntentType.BIAS_DETECTION.value,
800
+ reply="Bias detection isn't available right now. Try again soon! βš–οΈ",
801
+ success=False,
802
+ error="Service not loaded",
803
+ fallback_used=True
804
+ )
805
+
806
+ try:
807
+ result = await check_bias(message)
808
+
809
+ # Use compatibility helper to check result
810
+ success, error = _check_result_success(result, ["analysis"])
811
+
812
+ if success:
813
+ analysis = result.get("analysis", [])
814
+
815
+ if analysis:
816
+ top_result = analysis[0]
817
+ label = top_result.get("label", "unknown")
818
+ score = top_result.get("score", 0.0)
819
+
820
+ reply = (
821
+ f"Bias analysis complete:\n\n"
822
+ f"**Most likely category:** {label}\n"
823
+ f"**Confidence:** {score:.1%}"
824
+ )
825
+ else:
826
+ reply = "The text appears relatively neutral. βš–οΈ"
827
+
828
+ return OrchestrationResult(
829
+ intent=IntentType.BIAS_DETECTION.value,
830
+ reply=reply,
831
+ success=True,
832
+ data=result,
833
+ model_id="penny-bias-checker"
834
+ )
835
+ else:
836
+ raise Exception(error or "Bias detection failed")
837
+
838
+ except Exception as e:
839
+ logger.error(f"Bias detection error: {e}", exc_info=True)
840
+ return OrchestrationResult(
841
+ intent=IntentType.BIAS_DETECTION.value,
842
+ reply="I couldn't check for bias right now. Try again? βš–οΈ",
843
+ success=False,
844
+ error=str(e),
845
+ fallback_used=True
846
+ )
847
+
848
+
849
+ async def _handle_document(
850
+ message: str,
851
+ context: Dict[str, Any]
852
+ ) -> OrchestrationResult:
853
+ """
854
+ πŸ“„ Document processing handler.
855
+
856
+ Note: Actual file upload happens in router.py via FastAPI.
857
+ This handler just provides instructions.
858
+ """
859
+ logger.info("πŸ“„ Document processing requested")
860
+
861
+ reply = (
862
+ "I can help you process documents! πŸ“„\n\n"
863
+ "Please upload your document (PDF or image) using the "
864
+ "`/upload-document` endpoint. I can extract text, analyze forms, "
865
+ "and help you understand civic documents.\n\n"
866
+ "What kind of document do you need help with?"
867
+ )
868
+
869
+ return OrchestrationResult(
870
+ intent=IntentType.DOCUMENT_PROCESSING.value,
871
+ reply=reply,
872
+ success=True,
873
+ model_id="document_router"
874
+ )
875
+
876
+
877
+ async def _handle_weather(
878
+ message: str,
879
+ context: Dict[str, Any],
880
+ tenant_id: Optional[str],
881
+ lat: Optional[float],
882
+ lon: Optional[float],
883
+ intent_result: IntentMatch
884
+ ) -> OrchestrationResult:
885
+ """
886
+ 🌀️ Weather handler with compound intent support.
887
+
888
+ Handles both simple weather queries and compound weather+events queries.
889
+ Uses enhanced weather_agent.py with caching and performance tracking.
890
+ """
891
+ logger.info("🌀️ Processing weather request")
892
+
893
+ # Check service availability first
894
+ if not WEATHER_AGENT_AVAILABLE:
895
+ logger.warning("Weather agent not available")
896
+ return OrchestrationResult(
897
+ intent=IntentType.WEATHER.value,
898
+ reply="Weather service isn't available right now. Try again soon! 🌀️",
899
+ success=False,
900
+ error="Weather agent not loaded",
901
+ fallback_used=True
902
+ )
903
+
904
+ # Check for compound intent (weather + events)
905
+ is_compound = intent_result.is_compound or IntentType.EVENTS in intent_result.secondary_intents
906
+
907
+ # Validate location
908
+ if lat is None or lon is None:
909
+ # Try to get coordinates from tenant_id
910
+ if tenant_id:
911
+ coords = get_city_coordinates(tenant_id)
912
+ if coords and lat is None and lon is None:
913
+ lat, lon = coords["lat"], coords["lon"]
914
+ logger.info(f"Using city coordinates for {tenant_id}: {lat}, {lon}")
915
+
916
+ if lat is None or lon is None:
917
+ return OrchestrationResult(
918
+ intent=IntentType.WEATHER.value,
919
+ reply=(
920
+ "I need to know your location to check the weather! πŸ“ "
921
+ "You can tell me your city, or share your location."
922
+ ),
923
+ success=False,
924
+ error="Location required"
925
+ )
926
+
927
+ try:
928
+ # Use combined weather + events if compound intent detected
929
+ if is_compound and tenant_id and EVENT_WEATHER_AVAILABLE:
930
+ logger.info("Using weather+events combined handler")
931
+ result = await get_event_recommendations_with_weather(tenant_id, lat, lon)
932
+
933
+ # Build response
934
+ weather = result.get("weather", {})
935
+ weather_summary = result.get("weather_summary", "Weather unavailable")
936
+ suggestions = result.get("suggestions", [])
937
+
938
+ reply_lines = [f"🌀️ **Weather Update:**\n{weather_summary}\n"]
939
+
940
+ if suggestions:
941
+ reply_lines.append("\nπŸ“… **Event Suggestions Based on Weather:**")
942
+ for suggestion in suggestions[:5]: # Top 5 suggestions
943
+ reply_lines.append(f"β€’ {suggestion}")
944
+
945
+ reply = "\n".join(reply_lines)
946
+
947
+ return OrchestrationResult(
948
+ intent=IntentType.WEATHER.value,
949
+ reply=reply,
950
+ success=True,
951
+ data=result,
952
+ model_id="weather_events_combined"
953
+ )
954
+
955
+ else:
956
+ # Simple weather query using enhanced weather_agent
957
+ weather = await get_weather_for_location(lat, lon)
958
+
959
+ # Use enhanced weather_agent's format_weather_summary
960
+ if format_weather_summary:
961
+ weather_text = format_weather_summary(weather)
962
+ else:
963
+ # Fallback formatting
964
+ temp = weather.get("temperature", {}).get("value")
965
+ phrase = weather.get("phrase", "Conditions unavailable")
966
+ if temp:
967
+ weather_text = f"{phrase}, {int(temp)}Β°F"
968
+ else:
969
+ weather_text = phrase
970
+
971
+ # Get outfit recommendation from enhanced weather_agent
972
+ if recommend_outfit:
973
+ temp = weather.get("temperature", {}).get("value", 70)
974
+ condition = weather.get("phrase", "Clear")
975
+ outfit = recommend_outfit(temp, condition)
976
+ reply = f"🌀️ {weather_text}\n\nπŸ‘• {outfit}"
977
+ else:
978
+ reply = f"🌀️ {weather_text}"
979
+
980
+ return OrchestrationResult(
981
+ intent=IntentType.WEATHER.value,
982
+ reply=reply,
983
+ success=True,
984
+ data=weather,
985
+ model_id="azure-maps-weather"
986
+ )
987
+
988
+ except Exception as e:
989
+ logger.error(f"Weather error: {e}", exc_info=True)
990
+ return OrchestrationResult(
991
+ intent=IntentType.WEATHER.value,
992
+ reply=(
993
+ "I'm having trouble getting weather data right now. "
994
+ "Can I help you with something else? πŸ’›"
995
+ ),
996
+ success=False,
997
+ error=str(e),
998
+ fallback_used=True
999
+ )
1000
+
1001
+
1002
+ async def _handle_events(
1003
+ message: str,
1004
+ context: Dict[str, Any],
1005
+ tenant_id: Optional[str],
1006
+ lat: Optional[float],
1007
+ lon: Optional[float],
1008
+ intent_result: IntentMatch
1009
+ ) -> OrchestrationResult:
1010
+ """
1011
+ πŸ“… Events handler.
1012
+
1013
+ Routes event queries to tool_agent with proper error handling
1014
+ and graceful degradation.
1015
+ """
1016
+ logger.info("πŸ“… Processing events request")
1017
+
1018
+ if not tenant_id:
1019
+ return OrchestrationResult(
1020
+ intent=IntentType.EVENTS.value,
1021
+ reply=(
1022
+ "I'd love to help you find events! πŸ“… "
1023
+ "Which city are you interested in? "
1024
+ "I have information for Atlanta, Birmingham, Chesterfield, "
1025
+ "El Paso, Providence, and Seattle."
1026
+ ),
1027
+ success=False,
1028
+ error="City required"
1029
+ )
1030
+
1031
+ # Check tool agent availability
1032
+ if not TOOL_AGENT_AVAILABLE:
1033
+ logger.warning("Tool agent not available")
1034
+ return OrchestrationResult(
1035
+ intent=IntentType.EVENTS.value,
1036
+ reply=(
1037
+ "Event information isn't available right now. "
1038
+ "Try again soon! πŸ“…"
1039
+ ),
1040
+ success=False,
1041
+ error="Tool agent not loaded",
1042
+ fallback_used=True
1043
+ )
1044
+
1045
+ try:
1046
+ # FIXED: Add role parameter (compatibility fix)
1047
+ tool_response = await handle_tool_request(
1048
+ user_input=message,
1049
+ role=context.get("role", "resident"), # ← ADDED
1050
+ lat=lat,
1051
+ lon=lon,
1052
+ context=context
1053
+ )
1054
+
1055
+ reply = tool_response.get("response", "Events information retrieved.")
1056
+
1057
+ return OrchestrationResult(
1058
+ intent=IntentType.EVENTS.value,
1059
+ reply=reply,
1060
+ success=True,
1061
+ data=tool_response,
1062
+ model_id="events_tool"
1063
+ )
1064
+
1065
+ except Exception as e:
1066
+ logger.error(f"Events error: {e}", exc_info=True)
1067
+ return OrchestrationResult(
1068
+ intent=IntentType.EVENTS.value,
1069
+ reply=(
1070
+ "I'm having trouble loading event information right now. "
1071
+ "Check back soon! πŸ“…"
1072
+ ),
1073
+ success=False,
1074
+ error=str(e),
1075
+ fallback_used=True
1076
+ )
1077
+
1078
+ async def _handle_local_resources(
1079
+ message: str,
1080
+ context: Dict[str, Any],
1081
+ tenant_id: Optional[str],
1082
+ lat: Optional[float],
1083
+ lon: Optional[float]
1084
+ ) -> OrchestrationResult:
1085
+ """
1086
+ πŸ›οΈ Local resources handler (shelters, libraries, food banks, etc.).
1087
+
1088
+ Routes resource queries to tool_agent with proper error handling.
1089
+ """
1090
+ logger.info("πŸ›οΈ Processing local resources request")
1091
+
1092
+ if not tenant_id:
1093
+ return OrchestrationResult(
1094
+ intent=IntentType.LOCAL_RESOURCES.value,
1095
+ reply=(
1096
+ "I can help you find local resources! πŸ›οΈ "
1097
+ "Which city do you need help in? "
1098
+ "I cover Atlanta, Birmingham, Chesterfield, El Paso, "
1099
+ "Providence, and Seattle."
1100
+ ),
1101
+ success=False,
1102
+ error="City required"
1103
+ )
1104
+
1105
+ # Check tool agent availability
1106
+ if not TOOL_AGENT_AVAILABLE:
1107
+ logger.warning("Tool agent not available")
1108
+ return OrchestrationResult(
1109
+ intent=IntentType.LOCAL_RESOURCES.value,
1110
+ reply=(
1111
+ "Resource information isn't available right now. "
1112
+ "Try again soon! πŸ›οΈ"
1113
+ ),
1114
+ success=False,
1115
+ error="Tool agent not loaded",
1116
+ fallback_used=True
1117
+ )
1118
+
1119
+ try:
1120
+ # FIXED: Add role parameter (compatibility fix)
1121
+ tool_response = await handle_tool_request(
1122
+ user_input=message,
1123
+ role=context.get("role", "resident"), # ← ADDED
1124
+ lat=lat,
1125
+ lon=lon,
1126
+ context=context
1127
+ )
1128
+
1129
+ reply = tool_response.get("response", "Resource information retrieved.")
1130
+
1131
+ return OrchestrationResult(
1132
+ intent=IntentType.LOCAL_RESOURCES.value,
1133
+ reply=reply,
1134
+ success=True,
1135
+ data=tool_response,
1136
+ model_id="resources_tool"
1137
+ )
1138
+
1139
+ except Exception as e:
1140
+ logger.error(f"Resources error: {e}", exc_info=True)
1141
+ return OrchestrationResult(
1142
+ intent=IntentType.LOCAL_RESOURCES.value,
1143
+ reply=(
1144
+ "I'm having trouble finding resource information right now. "
1145
+ "Would you like to try a different search? πŸ’›"
1146
+ ),
1147
+ success=False,
1148
+ error=str(e),
1149
+ fallback_used=True
1150
+ )
1151
+
1152
+
1153
+ async def _handle_conversational(
1154
+ message: str,
1155
+ intent: IntentType,
1156
+ context: Dict[str, Any]
1157
+ ) -> OrchestrationResult:
1158
+ """
1159
+ πŸ’¬ Handles conversational intents (greeting, help, unknown).
1160
+ Uses Penny's core LLM for natural responses with graceful fallback.
1161
+ """
1162
+ logger.info(f"πŸ’¬ Processing conversational intent: {intent.value}")
1163
+
1164
+ # Check LLM availability
1165
+ use_llm = LLM_AVAILABLE
1166
+
1167
+ try:
1168
+ if use_llm:
1169
+ # Build prompt based on intent
1170
+ if intent == IntentType.GREETING:
1171
+ prompt = (
1172
+ f"The user greeted you with: '{message}'\n\n"
1173
+ "Respond warmly as Penny, introduce yourself briefly, "
1174
+ "and ask how you can help them with civic services today."
1175
+ )
1176
+
1177
+ elif intent == IntentType.HELP:
1178
+ prompt = (
1179
+ f"The user asked for help: '{message}'\n\n"
1180
+ "Explain Penny's main features:\n"
1181
+ "- Finding local resources (shelters, libraries, food banks)\n"
1182
+ "- Community events and activities\n"
1183
+ "- Weather information\n"
1184
+ "- 27-language translation\n"
1185
+ "- Document processing help\n\n"
1186
+ "Ask which city they need assistance in."
1187
+ )
1188
+
1189
+ else: # UNKNOWN
1190
+ prompt = (
1191
+ f"The user said: '{message}'\n\n"
1192
+ "You're not sure what they need help with. "
1193
+ "Respond kindly, acknowledge their request, and ask them to "
1194
+ "clarify or rephrase. Mention a few things you can help with."
1195
+ )
1196
+
1197
+ # Call Penny's core LLM
1198
+ llm_result = await generate_response(prompt=prompt, max_new_tokens=200)
1199
+
1200
+ # Use compatibility helper to check result
1201
+ success, error = _check_result_success(llm_result, ["response"])
1202
+
1203
+ if success:
1204
+ reply = llm_result.get("response", "")
1205
+
1206
+ return OrchestrationResult(
1207
+ intent=intent.value,
1208
+ reply=reply,
1209
+ success=True,
1210
+ data=llm_result,
1211
+ model_id=CORE_MODEL_ID
1212
+ )
1213
+ else:
1214
+ raise Exception(error or "LLM generation failed")
1215
+
1216
+ else:
1217
+ # LLM not available, use fallback directly
1218
+ logger.info("LLM not available, using fallback responses")
1219
+ raise Exception("LLM service not loaded")
1220
+
1221
+ except Exception as e:
1222
+ logger.warning(f"Conversational handler using fallback: {e}")
1223
+
1224
+ # Hardcoded fallback responses (Penny's friendly voice)
1225
+ fallback_replies = {
1226
+ IntentType.GREETING: (
1227
+ "Hi there! πŸ‘‹ I'm Penny, your civic assistant. "
1228
+ "I can help you find local resources, events, weather, and more. "
1229
+ "What city are you in?"
1230
+ ),
1231
+ IntentType.HELP: (
1232
+ "I'm Penny! πŸ’› I can help you with:\n\n"
1233
+ "πŸ›οΈ Local resources (shelters, libraries, food banks)\n"
1234
+ "πŸ“… Community events\n"
1235
+ "🌀️ Weather updates\n"
1236
+ "🌍 Translation (27 languages)\n"
1237
+ "πŸ“„ Document help\n\n"
1238
+ "What would you like to know about?"
1239
+ ),
1240
+ IntentType.UNKNOWN: (
1241
+ "I'm not sure I understood that. Could you rephrase? "
1242
+ "I'm best at helping with local services, events, weather, "
1243
+ "and translation! πŸ’¬"
1244
+ )
1245
+ }
1246
+
1247
+ return OrchestrationResult(
1248
+ intent=intent.value,
1249
+ reply=fallback_replies.get(intent, "How can I help you today? πŸ’›"),
1250
+ success=True,
1251
+ model_id="fallback",
1252
+ fallback_used=True
1253
+ )
1254
+
1255
+
1256
+ async def _handle_fallback(
1257
+ message: str,
1258
+ intent: IntentType,
1259
+ context: Dict[str, Any]
1260
+ ) -> OrchestrationResult:
1261
+ """
1262
+ πŸ†˜ Ultimate fallback handler for unhandled intents.
1263
+
1264
+ This is a safety net that should rarely trigger, but ensures
1265
+ users always get a helpful response.
1266
+ """
1267
+ logger.warning(f"⚠️ Fallback triggered for intent: {intent.value}")
1268
+
1269
+ reply = (
1270
+ "I've processed your request, but I'm not sure how to help with that yet. "
1271
+ "I'm still learning! πŸ€–\n\n"
1272
+ "I'm best at:\n"
1273
+ "πŸ›οΈ Finding local resources\n"
1274
+ "πŸ“… Community events\n"
1275
+ "🌀️ Weather updates\n"
1276
+ "🌍 Translation\n\n"
1277
+ "Could you rephrase your question? πŸ’›"
1278
+ )
1279
+
1280
+ return OrchestrationResult(
1281
+ intent=intent.value,
1282
+ reply=reply,
1283
+ success=False,
1284
+ error="Unhandled intent",
1285
+ fallback_used=True
1286
+ )
1287
+
1288
+
1289
+ # ============================================================
1290
+ # HEALTH CHECK & DIAGNOSTICS (ENHANCED)
1291
+ # ============================================================
1292
+
1293
+ def get_orchestrator_health() -> Dict[str, Any]:
1294
+ """
1295
+ πŸ“Š Returns comprehensive orchestrator health status.
1296
+
1297
+ Used by the main application health check endpoint to monitor
1298
+ the orchestrator and all its service dependencies.
1299
+
1300
+ Returns:
1301
+ Dictionary with health information including:
1302
+ - status: operational/degraded
1303
+ - service_availability: which services are loaded
1304
+ - statistics: orchestration counts
1305
+ - supported_intents: list of all intent types
1306
+ - features: available orchestrator features
1307
+ """
1308
+ # Get service availability
1309
+ services = get_service_availability()
1310
+
1311
+ # Determine overall status
1312
+ # Orchestrator is operational even if some services are down (graceful degradation)
1313
+ critical_services = ["weather", "tool_agent"] # Must have these
1314
+ critical_available = all(services.get(svc, False) for svc in critical_services)
1315
+
1316
+ status = "operational" if critical_available else "degraded"
1317
+
1318
+ return {
1319
+ "status": status,
1320
+ "core_model": CORE_MODEL_ID,
1321
+ "max_response_time_ms": MAX_RESPONSE_TIME_MS,
1322
+ "statistics": {
1323
+ "total_orchestrations": _orchestration_count,
1324
+ "emergency_interactions": _emergency_count
1325
+ },
1326
+ "service_availability": services,
1327
+ "supported_intents": [intent.value for intent in IntentType],
1328
+ "features": {
1329
+ "emergency_routing": True,
1330
+ "compound_intents": True,
1331
+ "fallback_handling": True,
1332
+ "performance_tracking": True,
1333
+ "context_aware": True,
1334
+ "multi_language": TRANSLATION_AVAILABLE,
1335
+ "sentiment_analysis": SENTIMENT_AVAILABLE,
1336
+ "bias_detection": BIAS_AVAILABLE,
1337
+ "weather_integration": WEATHER_AGENT_AVAILABLE,
1338
+ "event_recommendations": EVENT_WEATHER_AVAILABLE
1339
+ }
1340
+ }
1341
+
1342
+
1343
+ def get_orchestrator_stats() -> Dict[str, Any]:
1344
+ """
1345
+ πŸ“ˆ Returns orchestrator statistics.
1346
+
1347
+ Useful for monitoring and analytics.
1348
+ """
1349
+ return {
1350
+ "total_orchestrations": _orchestration_count,
1351
+ "emergency_interactions": _emergency_count,
1352
+ "services_available": sum(1 for v in get_service_availability().values() if v),
1353
+ "services_total": len(get_service_availability())
1354
+ }
1355
+
1356
+
1357
+ # ============================================================
1358
+ # TESTING & DEBUGGING (ENHANCED)
1359
+ # ============================================================
1360
+
1361
+ if __name__ == "__main__":
1362
+ """
1363
+ πŸ§ͺ Test the orchestrator with sample queries.
1364
+ Run with: python -m app.orchestrator
1365
+ """
1366
+ import asyncio
1367
+
1368
+ print("=" * 60)
1369
+ print("πŸ§ͺ Testing Penny's Orchestrator")
1370
+ print("=" * 60)
1371
+
1372
+ # Display service availability first
1373
+ print("\nπŸ“Š Service Availability Check:")
1374
+ services = get_service_availability()
1375
+ for service, available in services.items():
1376
+ status = "βœ…" if available else "❌"
1377
+ print(f" {status} {service}: {'Available' if available else 'Not loaded'}")
1378
+
1379
+ print("\n" + "=" * 60)
1380
+
1381
+ test_queries = [
1382
+ {
1383
+ "name": "Greeting",
1384
+ "message": "Hi Penny!",
1385
+ "context": {}
1386
+ },
1387
+ {
1388
+ "name": "Weather with location",
1389
+ "message": "What's the weather?",
1390
+ "context": {"lat": 33.7490, "lon": -84.3880}
1391
+ },
1392
+ {
1393
+ "name": "Events in city",
1394
+ "message": "Events in Atlanta",
1395
+ "context": {"tenant_id": "atlanta_ga"}
1396
+ },
1397
+ {
1398
+ "name": "Help request",
1399
+ "message": "I need help",
1400
+ "context": {}
1401
+ },
1402
+ {
1403
+ "name": "Translation",
1404
+ "message": "Translate hello",
1405
+ "context": {"source_lang": "eng_Latn", "target_lang": "spa_Latn"}
1406
+ }
1407
+ ]
1408
+
1409
+ async def run_tests():
1410
+ for i, query in enumerate(test_queries, 1):
1411
+ print(f"\n--- Test {i}: {query['name']} ---")
1412
+ print(f"Query: {query['message']}")
1413
+
1414
+ try:
1415
+ result = await run_orchestrator(query["message"], query["context"])
1416
+ print(f"Intent: {result['intent']}")
1417
+ print(f"Success: {result['success']}")
1418
+ print(f"Fallback: {result.get('fallback_used', False)}")
1419
+
1420
+ # Truncate long replies
1421
+ reply = result['reply']
1422
+ if len(reply) > 150:
1423
+ reply = reply[:150] + "..."
1424
+ print(f"Reply: {reply}")
1425
+
1426
+ if result.get('response_time_ms'):
1427
+ print(f"Response time: {result['response_time_ms']:.0f}ms")
1428
+
1429
+ except Exception as e:
1430
+ print(f"❌ Error: {e}")
1431
+
1432
+ asyncio.run(run_tests())
1433
+
1434
+ print("\n" + "=" * 60)
1435
+ print("πŸ“Š Final Statistics:")
1436
+ stats = get_orchestrator_stats()
1437
+ for key, value in stats.items():
1438
+ print(f" {key}: {value}")
1439
+
1440
+ print("\n" + "=" * 60)
1441
+ print("βœ… Tests complete")
1442
+ print("=" * 60)