pythonprincess commited on
Commit
e0f3474
Β·
verified Β·
1 Parent(s): a9b19b6

Upload tool_agent.py

Browse files
Files changed (1) hide show
  1. app/tool_agent.py +956 -0
app/tool_agent.py ADDED
@@ -0,0 +1,956 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # app/tool_agent.py
2
+ """
3
+ πŸ› οΈ PENNY Tool Agent - Civic Data & Services Handler
4
+
5
+ Routes requests to civic data sources (events, resources, transit, etc.)
6
+ and integrates with real-time weather information.
7
+
8
+ MISSION: Connect residents to local civic services by intelligently
9
+ processing their requests and returning relevant, actionable information.
10
+
11
+ FEATURES:
12
+ - Real-time weather integration with outfit recommendations
13
+ - Event discovery with weather-aware suggestions
14
+ - Resource lookup (trash, transit, emergency services)
15
+ - City-specific data routing
16
+ - Graceful fallback for missing data
17
+
18
+ ENHANCEMENTS (Phase 1):
19
+ - βœ… Structured logging with performance tracking
20
+ - βœ… Enhanced error handling with user-friendly messages
21
+ - βœ… Type hints for all functions
22
+ - βœ… Health check integration
23
+ - βœ… Service availability tracking
24
+ - βœ… Integration with enhanced modules
25
+ - βœ… Penny's friendly voice throughout
26
+ - βœ… Context-aware city detection (uses tenant_id from UI dropdown)
27
+ """
28
+
29
+ import logging
30
+ import time
31
+ from typing import Optional, Dict, Any
32
+
33
+ # --- ENHANCED MODULE IMPORTS ---
34
+ from app.logging_utils import log_interaction, sanitize_for_logging
35
+
36
+ # --- AGENT IMPORTS (with availability tracking) ---
37
+ try:
38
+ from app.weather_agent import (
39
+ get_weather_for_location,
40
+ weather_to_event_recommendations,
41
+ recommend_outfit,
42
+ format_weather_summary
43
+ )
44
+ WEATHER_AGENT_AVAILABLE = True
45
+ except ImportError as e:
46
+ logging.getLogger(__name__).warning(f"Weather agent not available: {e}")
47
+ WEATHER_AGENT_AVAILABLE = False
48
+
49
+ # --- UTILITY IMPORTS (with availability tracking) ---
50
+ try:
51
+ from app.location_utils import (
52
+ extract_city_name,
53
+ load_city_events,
54
+ load_city_resources,
55
+ get_city_coordinates,
56
+ extract_location_detailed,
57
+ SupportedCities,
58
+ LocationStatus
59
+ )
60
+ LOCATION_UTILS_AVAILABLE = True
61
+ except ImportError as e:
62
+ logging.getLogger(__name__).warning(f"Location utils not available: {e}")
63
+ LOCATION_UTILS_AVAILABLE = False
64
+
65
+ # --- LOGGING SETUP ---
66
+ logger = logging.getLogger(__name__)
67
+
68
+ # --- TRACKING COUNTERS ---
69
+ _tool_request_count = 0
70
+ _weather_request_count = 0
71
+ _event_request_count = 0
72
+ _resource_request_count = 0
73
+
74
+
75
+ # ============================================================
76
+ # CITY DETECTION FROM CONTEXT (NEW FUNCTION)
77
+ # ============================================================
78
+
79
+ def get_city_from_context(context: Dict[str, Any], user_input: str) -> tuple[str, str]:
80
+ """
81
+ πŸ™οΈ Extracts city name and tenant_id from context or message.
82
+
83
+ Priority order:
84
+ 1. tenant_id from context (from UI dropdown)
85
+ 2. location from context
86
+ 3. Extract from user message text
87
+
88
+ Args:
89
+ context: Request context dictionary
90
+ user_input: User's message text
91
+
92
+ Returns:
93
+ Tuple of (city_name, tenant_id)
94
+ Example: ("Atlanta", "atlanta_ga")
95
+ """
96
+ def _normalize_tenant_id(tenant_id: str) -> tuple[str, str]:
97
+ """
98
+ Normalizes tenant_id to proper format using city registry.
99
+ Returns (city_name, proper_tenant_id)
100
+ """
101
+ tenant_id_lower = tenant_id.lower()
102
+
103
+ # If already in proper format (has underscore), verify it exists
104
+ if "_" in tenant_id_lower:
105
+ city_info = SupportedCities.get_city_by_tenant_id(tenant_id_lower)
106
+ if city_info:
107
+ return city_info.full_name.split(",")[0], city_info.tenant_id
108
+
109
+ # Try to find city by partial match
110
+ for city in SupportedCities.get_all_cities():
111
+ # Check if tenant_id matches any alias
112
+ if tenant_id_lower in [alias.lower() for alias in city.aliases]:
113
+ return city.full_name.split(",")[0], city.tenant_id
114
+ # Check if tenant_id matches city name part
115
+ city_name_part = city.tenant_id.split("_")[0]
116
+ if tenant_id_lower == city_name_part:
117
+ return city.full_name.split(",")[0], city.tenant_id
118
+
119
+ # Fallback: return as-is (will be handled by caller)
120
+ return tenant_id.replace("_", " ").title(), tenant_id_lower
121
+
122
+ # Priority 1: Check tenant_id in context (from dropdown)
123
+ tenant_id = context.get("tenant_id", "").lower()
124
+ if tenant_id and tenant_id != "unknown":
125
+ city_name, proper_tenant_id = _normalize_tenant_id(tenant_id)
126
+ logger.info(f"βœ… City from context tenant_id: {city_name} ({proper_tenant_id})")
127
+ return city_name, proper_tenant_id
128
+
129
+ # Priority 2: Check location in context
130
+ location = context.get("location")
131
+ if location:
132
+ # Try to extract proper tenant_id from location string
133
+ location_result = extract_location_detailed(location)
134
+ if location_result.status == LocationStatus.FOUND and location_result.tenant_id:
135
+ city_name = location_result.city_info.full_name.split(",")[0] if location_result.city_info else location.title()
136
+ proper_tenant_id = location_result.tenant_id
137
+ logger.info(f"βœ… City from context location: {city_name} ({proper_tenant_id})")
138
+ return city_name, proper_tenant_id
139
+ else:
140
+ # Fallback to old method if extraction fails
141
+ city_name = location.title()
142
+ city_name, proper_tenant_id = _normalize_tenant_id(location.lower().replace(" ", "_"))
143
+ logger.info(f"βœ… City from context location (fallback): {city_name} ({proper_tenant_id})")
144
+ return city_name, proper_tenant_id
145
+
146
+ # Priority 3: Fall back to extracting from message
147
+ location_result = extract_location_detailed(user_input)
148
+ if location_result.status == LocationStatus.FOUND and location_result.tenant_id:
149
+ city_name = location_result.city_info.full_name.split(",")[0] if location_result.city_info else extract_city_name(user_input)
150
+ proper_tenant_id = location_result.tenant_id
151
+ logger.info(f"⚠️ City extracted from message: {city_name} ({proper_tenant_id})")
152
+ return city_name, proper_tenant_id
153
+ else:
154
+ # Fallback to old method
155
+ city_name = extract_city_name(user_input)
156
+ city_name, proper_tenant_id = _normalize_tenant_id(city_name.lower().replace(" ", "_"))
157
+ logger.info(f"⚠️ City extracted from message (fallback): {city_name} ({proper_tenant_id})")
158
+ return city_name, proper_tenant_id
159
+
160
+
161
+ # ============================================================
162
+ # MAIN TOOL REQUEST HANDLER (ENHANCED)
163
+ # ============================================================
164
+
165
+ async def handle_tool_request(
166
+ user_input: str,
167
+ role: str = "unknown",
168
+ lat: Optional[float] = None,
169
+ lon: Optional[float] = None,
170
+ context: Optional[Dict[str, Any]] = None
171
+ ) -> Dict[str, Any]:
172
+ """
173
+ πŸ› οΈ Handles tool-based actions for civic services.
174
+
175
+ Routes user requests to appropriate civic data sources and real-time
176
+ services, including weather, events, transit, trash, and emergency info.
177
+
178
+ Args:
179
+ user_input: User's request text
180
+ role: User's role (resident, official, etc.)
181
+ lat: Latitude coordinate (optional)
182
+ lon: Longitude coordinate (optional)
183
+ context: Request context with tenant_id, location, etc. (optional)
184
+
185
+ Returns:
186
+ Dictionary containing:
187
+ - tool: str (which tool was used)
188
+ - city: str (detected city name)
189
+ - response: str or dict (user-facing response)
190
+ - data: dict (optional, raw data)
191
+ - tenant_id: str (optional, standardized city identifier)
192
+
193
+ Example:
194
+ result = await handle_tool_request(
195
+ user_input="What's the weather in Atlanta?",
196
+ role="resident",
197
+ lat=33.7490,
198
+ lon=-84.3880,
199
+ context={"tenant_id": "atlanta"}
200
+ )
201
+ """
202
+ global _tool_request_count
203
+ _tool_request_count += 1
204
+
205
+ start_time = time.time()
206
+
207
+ # Initialize context if not provided
208
+ if context is None:
209
+ context = {}
210
+
211
+ # Sanitize input for logging (PII protection)
212
+ safe_input = sanitize_for_logging(user_input)
213
+ logger.info(f"πŸ› οΈ Tool request #{_tool_request_count}: '{safe_input[:50]}...'")
214
+ logger.info(f"πŸ“ Context: {context}")
215
+
216
+ try:
217
+ # Check if location utilities are available
218
+ if not LOCATION_UTILS_AVAILABLE:
219
+ logger.error("Location utilities not available")
220
+ return {
221
+ "tool": "error",
222
+ "response": (
223
+ "I'm having trouble accessing city data right now. "
224
+ "Try again in a moment! πŸ’›"
225
+ ),
226
+ "error": "Location utilities not loaded"
227
+ }
228
+
229
+ lowered = user_input.lower()
230
+
231
+ # πŸ”₯ NEW: Get city from context first, then fall back to message
232
+ city_name, tenant_id = get_city_from_context(context, user_input)
233
+
234
+ logger.info(f"πŸ™οΈ Detected city: {city_name} (tenant_id: {tenant_id})")
235
+
236
+ # Route to appropriate handler
237
+ result = None
238
+
239
+ # Weather queries
240
+ if any(keyword in lowered for keyword in ["weather", "forecast", "temperature", "rain", "sunny"]):
241
+ result = await _handle_weather_query(
242
+ user_input=user_input,
243
+ city_name=city_name,
244
+ tenant_id=tenant_id,
245
+ lat=lat,
246
+ lon=lon
247
+ )
248
+
249
+ # Event queries
250
+ elif any(keyword in lowered for keyword in ["events", "meetings", "city hall", "happening", "activities"]):
251
+ result = await _handle_events_query(
252
+ user_input=user_input,
253
+ city_name=city_name,
254
+ tenant_id=tenant_id,
255
+ lat=lat,
256
+ lon=lon
257
+ )
258
+
259
+ # Resource queries (trash, transit, emergency, food banks, libraries, shelters, etc.)
260
+ elif any(keyword in lowered for keyword in [
261
+ "trash", "recycling", "garbage", "waste",
262
+ "bus", "train", "transit", "transportation", "schedule",
263
+ "alert", "warning", "non emergency", "emergency",
264
+ "food bank", "foodbank", "food pantry", "pantry",
265
+ "library", "libraries", "shelter", "shelters",
266
+ "warming center", "cooling center", "warming", "cooling",
267
+ "help center", "help center", "assistance", "resource",
268
+ "clinic", "hospital", "pharmacy", "health",
269
+ "housing", "utility", "water", "electric", "gas"
270
+ ]):
271
+ result = await _handle_resource_query(
272
+ user_input=user_input,
273
+ city_name=city_name,
274
+ tenant_id=tenant_id,
275
+ lowered=lowered
276
+ )
277
+
278
+ # Unknown/fallback
279
+ else:
280
+ result = _handle_unknown_query(city_name)
281
+
282
+ # Add metadata and log interaction
283
+ response_time = (time.time() - start_time) * 1000
284
+ result["response_time_ms"] = round(response_time, 2)
285
+ result["role"] = role
286
+
287
+ log_interaction(
288
+ tenant_id=tenant_id,
289
+ interaction_type="tool_request",
290
+ intent=result.get("tool", "unknown"),
291
+ response_time_ms=response_time,
292
+ success=result.get("error") is None,
293
+ metadata={
294
+ "city": city_name,
295
+ "tool": result.get("tool"),
296
+ "role": role,
297
+ "has_location": lat is not None and lon is not None
298
+ }
299
+ )
300
+
301
+ logger.info(
302
+ f"βœ… Tool request complete: {result.get('tool')} "
303
+ f"({response_time:.0f}ms)"
304
+ )
305
+
306
+ return result
307
+
308
+ except Exception as e:
309
+ response_time = (time.time() - start_time) * 1000
310
+ logger.error(f"❌ Tool agent error: {e}", exc_info=True)
311
+
312
+ log_interaction(
313
+ tenant_id="unknown",
314
+ interaction_type="tool_error",
315
+ intent="error",
316
+ response_time_ms=response_time,
317
+ success=False,
318
+ metadata={
319
+ "error": str(e),
320
+ "error_type": type(e).__name__
321
+ }
322
+ )
323
+
324
+ return {
325
+ "tool": "error",
326
+ "response": (
327
+ "I ran into trouble processing that request. "
328
+ "Could you try rephrasing? πŸ’›"
329
+ ),
330
+ "error": str(e),
331
+ "response_time_ms": round(response_time, 2)
332
+ }
333
+
334
+
335
+ # ============================================================
336
+ # WEATHER QUERY HANDLER (ENHANCED)
337
+ # ============================================================
338
+
339
+ async def _handle_weather_query(
340
+ user_input: str,
341
+ city_name: str,
342
+ tenant_id: str,
343
+ lat: Optional[float],
344
+ lon: Optional[float]
345
+ ) -> Dict[str, Any]:
346
+ """
347
+ 🌀️ Handles weather-related queries with outfit recommendations.
348
+ """
349
+ global _weather_request_count
350
+ _weather_request_count += 1
351
+
352
+ logger.info(f"🌀️ Weather query #{_weather_request_count} for {city_name}")
353
+
354
+ # Check weather agent availability
355
+ if not WEATHER_AGENT_AVAILABLE:
356
+ logger.warning("Weather agent not available")
357
+ return {
358
+ "tool": "weather",
359
+ "city": city_name,
360
+ "response": "Weather service isn't available right now. Try again soon! 🌀️"
361
+ }
362
+
363
+ # Get coordinates if not provided
364
+ if lat is None or lon is None:
365
+ coords = get_city_coordinates(tenant_id)
366
+ if coords:
367
+ lat, lon = coords["lat"], coords["lon"]
368
+ logger.info(f"Using city coordinates: {lat}, {lon}")
369
+
370
+ if lat is None or lon is None:
371
+ return {
372
+ "tool": "weather",
373
+ "city": city_name,
374
+ "response": (
375
+ f"To get weather for {city_name}, I need location coordinates. "
376
+ f"Can you share your location? πŸ“"
377
+ )
378
+ }
379
+
380
+ try:
381
+ # Fetch weather data
382
+ weather = await get_weather_for_location(lat, lon)
383
+
384
+ # Get weather-based event recommendations
385
+ recommendations = weather_to_event_recommendations(weather)
386
+
387
+ # Get outfit recommendation
388
+ temp = weather.get("temperature", {}).get("value", 70)
389
+ phrase = weather.get("phrase", "Clear")
390
+ outfit = recommend_outfit(temp, phrase)
391
+
392
+ # Format weather summary
393
+ weather_summary = format_weather_summary(weather)
394
+
395
+ # Build user-friendly response
396
+ response_text = (
397
+ f"🌀️ **Weather for {city_name}:**\n"
398
+ f"{weather_summary}\n\n"
399
+ f"πŸ‘• **What to wear:** {outfit}"
400
+ )
401
+
402
+ # Add event recommendations if available
403
+ if recommendations:
404
+ rec = recommendations[0] # Get top recommendation
405
+ response_text += f"\n\nπŸ“… **Activity suggestion:** {rec['reason']}"
406
+
407
+ return {
408
+ "tool": "weather",
409
+ "city": city_name,
410
+ "tenant_id": tenant_id,
411
+ "response": response_text,
412
+ "data": {
413
+ "weather": weather,
414
+ "recommendations": recommendations,
415
+ "outfit": outfit
416
+ }
417
+ }
418
+
419
+ except Exception as e:
420
+ logger.error(f"Weather query error: {e}", exc_info=True)
421
+ return {
422
+ "tool": "weather",
423
+ "city": city_name,
424
+ "response": (
425
+ f"I couldn't get the weather for {city_name} right now. "
426
+ f"Try again in a moment! 🌀️"
427
+ ),
428
+ "error": str(e)
429
+ }
430
+
431
+
432
+ # ============================================================
433
+ # EVENTS QUERY HANDLER (ENHANCED)
434
+ # ============================================================
435
+
436
+ async def _handle_events_query(
437
+ user_input: str,
438
+ city_name: str,
439
+ tenant_id: str,
440
+ lat: Optional[float],
441
+ lon: Optional[float]
442
+ ) -> Dict[str, Any]:
443
+ """
444
+ πŸ“… Handles event discovery queries.
445
+ """
446
+ global _event_request_count
447
+ _event_request_count += 1
448
+
449
+ logger.info(f"πŸ“… Event query #{_event_request_count} for {city_name}")
450
+
451
+ try:
452
+ # Load structured event data
453
+ event_data = load_city_events(tenant_id)
454
+ events = event_data.get("events", [])
455
+ num_events = len(events)
456
+
457
+ if num_events == 0:
458
+ return {
459
+ "tool": "civic_events",
460
+ "city": city_name,
461
+ "tenant_id": tenant_id,
462
+ "response": (
463
+ f"I don't have any upcoming events for {city_name} right now. "
464
+ f"Check back soon! πŸ“…"
465
+ )
466
+ }
467
+
468
+ # Get top event
469
+ top_event = events[0]
470
+ top_event_name = top_event.get("name", "Upcoming event")
471
+
472
+ # Build response
473
+ if num_events == 1:
474
+ response_text = (
475
+ f"πŸ“… **Upcoming event in {city_name}:**\n"
476
+ f"β€’ {top_event_name}\n\n"
477
+ f"Check the full details in the attached data!"
478
+ )
479
+ else:
480
+ response_text = (
481
+ f"πŸ“… **Found {num_events} upcoming events in {city_name}!**\n"
482
+ f"Top event: {top_event_name}\n\n"
483
+ f"Check the full list in the attached data!"
484
+ )
485
+
486
+ return {
487
+ "tool": "civic_events",
488
+ "city": city_name,
489
+ "tenant_id": tenant_id,
490
+ "response": response_text,
491
+ "data": event_data
492
+ }
493
+
494
+ except FileNotFoundError:
495
+ logger.warning(f"Event data file not found for {tenant_id}")
496
+ return {
497
+ "tool": "civic_events",
498
+ "city": city_name,
499
+ "response": (
500
+ f"Event data for {city_name} isn't available yet. "
501
+ f"I'm still learning about events in your area! πŸ“…"
502
+ ),
503
+ "error": "Event data file not found"
504
+ }
505
+
506
+ except Exception as e:
507
+ logger.error(f"Events query error: {e}", exc_info=True)
508
+ return {
509
+ "tool": "civic_events",
510
+ "city": city_name,
511
+ "response": (
512
+ f"I had trouble loading events for {city_name}. "
513
+ f"Try again soon! πŸ“…"
514
+ ),
515
+ "error": str(e)
516
+ }
517
+
518
+
519
+ # ============================================================
520
+ # RESOURCE QUERY HANDLER (ENHANCED)
521
+ # ============================================================
522
+
523
+ async def _handle_resource_query(
524
+ user_input: str,
525
+ city_name: str,
526
+ tenant_id: str,
527
+ lowered: str
528
+ ) -> Dict[str, Any]:
529
+ """
530
+ ♻️ Handles resource queries (trash, transit, emergency).
531
+ """
532
+ global _resource_request_count
533
+ _resource_request_count += 1
534
+
535
+ logger.info(f"♻️ Resource query #{_resource_request_count} for {city_name}")
536
+
537
+ # Map keywords to resource types
538
+ resource_query_map = {
539
+ # Trash & Recycling
540
+ "trash": "trash_and_recycling",
541
+ "recycling": "trash_and_recycling",
542
+ "garbage": "trash_and_recycling",
543
+ "waste": "trash_and_recycling",
544
+ # Transit
545
+ "bus": "transit",
546
+ "train": "transit",
547
+ "transit": "transit",
548
+ "transportation": "transit",
549
+ "schedule": "transit",
550
+ # Emergency
551
+ "alert": "emergency",
552
+ "warning": "emergency",
553
+ "non emergency": "emergency",
554
+ "emergency": "emergency",
555
+ # Food Assistance
556
+ "food bank": "food_assistance",
557
+ "foodbank": "food_assistance",
558
+ "food pantry": "food_assistance",
559
+ "pantry": "food_assistance",
560
+ # Libraries
561
+ "library": "libraries",
562
+ "libraries": "libraries",
563
+ # Shelters
564
+ "shelter": "shelters",
565
+ "shelters": "shelters",
566
+ "warming center": "shelters",
567
+ "cooling center": "shelters",
568
+ "warming": "shelters",
569
+ "cooling": "shelters",
570
+ # General Resources
571
+ "help center": "community_resources",
572
+ "help center": "community_resources",
573
+ "assistance": "community_resources",
574
+ "resource": "community_resources",
575
+ "resources": "community_resources",
576
+ # Health Services
577
+ "clinic": "health_services",
578
+ "hospital": "health_services",
579
+ "pharmacy": "health_services",
580
+ "health": "health_services",
581
+ # Behavioral Health & CSB
582
+ "behavioral health": "behavioral_health",
583
+ "mental health": "behavioral_health",
584
+ "csb": "behavioral_health",
585
+ "community services board": "behavioral_health",
586
+ "crisis": "behavioral_health",
587
+ "counseling": "behavioral_health",
588
+ "therapy": "behavioral_health",
589
+ "substance abuse": "behavioral_health",
590
+ "addiction": "behavioral_health",
591
+ # Housing & Utilities
592
+ "housing": "housing_utilities",
593
+ "utility": "housing_utilities",
594
+ "utilities": "housing_utilities",
595
+ "water": "housing_utilities",
596
+ "electric": "housing_utilities",
597
+ "gas": "housing_utilities"
598
+ }
599
+
600
+ # Find matching resource type (check longer phrases first for better specificity)
601
+ # Sort keys by length (longest first) to match "food bank" before "food" (if it existed)
602
+ sorted_keys = sorted(resource_query_map.keys(), key=len, reverse=True)
603
+ resource_key = next(
604
+ (resource_query_map[key] for key in sorted_keys if key in lowered),
605
+ None
606
+ )
607
+
608
+ if not resource_key:
609
+ return {
610
+ "tool": "unknown",
611
+ "city": city_name,
612
+ "response": (
613
+ "I'm not sure which resource you're asking about. "
614
+ "Try asking about trash, transit, food banks, libraries, shelters, "
615
+ "health services, or emergency services! πŸ’¬"
616
+ )
617
+ }
618
+
619
+ try:
620
+ # Load structured resource data
621
+ resource_data = load_city_resources(tenant_id)
622
+ service_info = resource_data["services"].get(resource_key, {})
623
+
624
+ if not service_info:
625
+ return {
626
+ "tool": resource_key,
627
+ "city": city_name,
628
+ "response": (
629
+ f"I don't have {resource_key.replace('_', ' ')} information "
630
+ f"for {city_name} yet. Check the city's official website! πŸ›οΈ"
631
+ )
632
+ }
633
+
634
+ # Build resource-specific response
635
+ if resource_key == "trash_and_recycling":
636
+ pickup_days = service_info.get('pickup_days', 'Varies by address')
637
+ response_text = (
638
+ f"♻️ **Trash & Recycling for {city_name}:**\n"
639
+ f"Pickup days: {pickup_days}\n\n"
640
+ f"Check the official link for your specific schedule!"
641
+ )
642
+
643
+ elif resource_key == "transit":
644
+ provider = service_info.get('provider', 'The local transit authority')
645
+ response_text = (
646
+ f"🚌 **Transit for {city_name}:**\n"
647
+ f"Provider: {provider}\n\n"
648
+ f"Use the provided links to find routes and schedules!"
649
+ )
650
+
651
+ elif resource_key == "emergency":
652
+ non_emergency = service_info.get('non_emergency_phone', 'N/A')
653
+ response_text = (
654
+ f"🚨 **Emergency Info for {city_name}:**\n"
655
+ f"Non-emergency: {non_emergency}\n\n"
656
+ f"**For life-threatening emergencies, always call 911.**"
657
+ )
658
+
659
+ elif resource_key == "food_assistance":
660
+ locations = service_info.get('locations', [])
661
+ if locations:
662
+ response_text = (
663
+ f"🍽️ **Food Assistance in {city_name}:**\n"
664
+ f"Found {len(locations)} food bank(s) or pantry(ies).\n\n"
665
+ f"Check the attached data for locations, hours, and contact information!"
666
+ )
667
+ else:
668
+ response_text = (
669
+ f"🍽️ **Food Assistance in {city_name}:**\n"
670
+ f"Food assistance information is available. Check the attached data for details!"
671
+ )
672
+
673
+ elif resource_key == "libraries":
674
+ locations = service_info.get('locations', [])
675
+ if locations:
676
+ response_text = (
677
+ f"πŸ“š **Libraries in {city_name}:**\n"
678
+ f"Found {len(locations)} library(ies).\n\n"
679
+ f"Check the attached data for locations, hours, and services!"
680
+ )
681
+ else:
682
+ response_text = (
683
+ f"πŸ“š **Libraries in {city_name}:**\n"
684
+ f"Library information is available. Check the attached data for details!"
685
+ )
686
+
687
+ elif resource_key == "shelters":
688
+ locations = service_info.get('locations', [])
689
+ if locations:
690
+ response_text = (
691
+ f"🏠 **Shelters in {city_name}:**\n"
692
+ f"Found {len(locations)} shelter(s).\n\n"
693
+ f"Check the attached data for locations, availability, and contact information!"
694
+ )
695
+ else:
696
+ response_text = (
697
+ f"🏠 **Shelters in {city_name}:**\n"
698
+ f"Shelter information is available. Check the attached data for details!"
699
+ )
700
+
701
+ elif resource_key == "community_resources":
702
+ resources = service_info.get('resources', [])
703
+ if resources:
704
+ response_text = (
705
+ f"πŸ›οΈ **Community Resources in {city_name}:**\n"
706
+ f"Found {len(resources)} resource(s).\n\n"
707
+ f"Check the attached data for available services and contact information!"
708
+ )
709
+ else:
710
+ response_text = (
711
+ f"πŸ›οΈ **Community Resources in {city_name}:**\n"
712
+ f"Community resource information is available. Check the attached data for details!"
713
+ )
714
+
715
+ elif resource_key == "health_services":
716
+ locations = service_info.get('locations', [])
717
+ if locations:
718
+ response_text = (
719
+ f"πŸ₯ **Health Services in {city_name}:**\n"
720
+ f"Found {len(locations)} health service location(s).\n\n"
721
+ f"Check the attached data for clinics, hospitals, and pharmacies!"
722
+ )
723
+ else:
724
+ response_text = (
725
+ f"πŸ₯ **Health Services in {city_name}:**\n"
726
+ f"Health service information is available. Check the attached data for details!"
727
+ )
728
+
729
+ elif resource_key == "housing_utilities":
730
+ response_text = (
731
+ f"🏘️ **Housing & Utilities in {city_name}:**\n"
732
+ f"Housing and utility information is available.\n\n"
733
+ f"Check the attached data for housing assistance and utility services!"
734
+ )
735
+
736
+ elif resource_key == "behavioral_health":
737
+ resources = service_info.get('resources', [])
738
+ if resources:
739
+ # Format behavioral health resources with emphasis on crisis support
740
+ response_text = f"πŸ’š **Behavioral Health Resources in {city_name}:**\n\n"
741
+
742
+ # Prioritize crisis resources
743
+ crisis_resources = [r for r in resources if "crisis" in r.get("name", "").lower() or "988" in r.get("phone", "")]
744
+ other_resources = [r for r in resources if r not in crisis_resources]
745
+
746
+ if crisis_resources:
747
+ response_text += "🚨 **Crisis Support (24/7):**\n"
748
+ for resource in crisis_resources:
749
+ name = resource.get("name", "")
750
+ phone = resource.get("phone", "")
751
+ link = resource.get("link", "")
752
+ response_text += f"β€’ **{name}**"
753
+ if phone:
754
+ response_text += f" - {phone}"
755
+ response_text += "\n"
756
+ if link:
757
+ response_text += f" {link}\n"
758
+ response_text += "\n"
759
+
760
+ if other_resources:
761
+ response_text += "πŸ’š **Local Services:**\n"
762
+ for resource in other_resources[:3]: # Limit to 3
763
+ name = resource.get("name", "")
764
+ phone = resource.get("phone", "")
765
+ link = resource.get("link", "")
766
+ response_text += f"β€’ **{name}**"
767
+ if phone:
768
+ response_text += f" - {phone}"
769
+ response_text += "\n"
770
+ if link:
771
+ response_text += f" {link}\n"
772
+
773
+ response_text += "\nπŸ’‘ **Remember:** For immediate crisis, call 988 or 911."
774
+ else:
775
+ response_text = (
776
+ f"πŸ’š **Behavioral Health Resources in {city_name}:**\n"
777
+ f"Behavioral health information is available. Check the attached data for details!\n\n"
778
+ f"πŸ’‘ **For immediate crisis support:**\n"
779
+ f"β€’ National Suicide Prevention Lifeline: 988\n"
780
+ f"β€’ Crisis Text Line: Text HOME to 741741\n"
781
+ f"β€’ For life-threatening emergencies: 911"
782
+ )
783
+
784
+ else:
785
+ response_text = f"Information found for {resource_key.replace('_', ' ')}, but details aren't available yet."
786
+
787
+ return {
788
+ "tool": resource_key,
789
+ "city": city_name,
790
+ "tenant_id": tenant_id,
791
+ "response": response_text,
792
+ "data": service_info
793
+ }
794
+
795
+ except FileNotFoundError:
796
+ logger.warning(f"Resource data file not found for {tenant_id}")
797
+ return {
798
+ "tool": "resource_loader",
799
+ "city": city_name,
800
+ "response": (
801
+ f"Resource data for {city_name} isn't available yet. "
802
+ f"Check back soon! πŸ›οΈ"
803
+ ),
804
+ "error": "Resource data file not found"
805
+ }
806
+
807
+ except Exception as e:
808
+ logger.error(f"Resource query error: {e}", exc_info=True)
809
+ return {
810
+ "tool": "resource_loader",
811
+ "city": city_name,
812
+ "response": (
813
+ f"I had trouble loading resource data for {city_name}. "
814
+ f"Try again soon! πŸ›οΈ"
815
+ ),
816
+ "error": str(e)
817
+ }
818
+
819
+
820
+ # ============================================================
821
+ # UNKNOWN QUERY HANDLER
822
+ # ============================================================
823
+
824
+ def _handle_unknown_query(city_name: str) -> Dict[str, Any]:
825
+ """
826
+ ❓ Fallback for queries that don't match any tool.
827
+ """
828
+ logger.info(f"❓ Unknown query for {city_name}")
829
+
830
+ return {
831
+ "tool": "unknown",
832
+ "city": city_name,
833
+ "response": (
834
+ "I'm not sure which civic service you're asking about. "
835
+ "Try asking about weather, events, trash, or transit! πŸ’¬"
836
+ )
837
+ }
838
+
839
+
840
+ # ============================================================
841
+ # HEALTH CHECK & DIAGNOSTICS
842
+ # ============================================================
843
+
844
+ def get_tool_agent_health() -> Dict[str, Any]:
845
+ """
846
+ πŸ“Š Returns tool agent health status.
847
+
848
+ Used by the main application health check endpoint.
849
+ """
850
+ return {
851
+ "status": "operational",
852
+ "service_availability": {
853
+ "weather_agent": WEATHER_AGENT_AVAILABLE,
854
+ "location_utils": LOCATION_UTILS_AVAILABLE
855
+ },
856
+ "statistics": {
857
+ "total_requests": _tool_request_count,
858
+ "weather_requests": _weather_request_count,
859
+ "event_requests": _event_request_count,
860
+ "resource_requests": _resource_request_count
861
+ },
862
+ "supported_queries": [
863
+ "weather",
864
+ "events",
865
+ "trash_and_recycling",
866
+ "transit",
867
+ "emergency"
868
+ ]
869
+ }
870
+
871
+
872
+ # ============================================================
873
+ # TESTING
874
+ # ============================================================
875
+
876
+ if __name__ == "__main__":
877
+ """πŸ§ͺ Test tool agent functionality"""
878
+ import asyncio
879
+
880
+ print("=" * 60)
881
+ print("πŸ§ͺ Testing Tool Agent")
882
+ print("=" * 60)
883
+
884
+ # Display service availability
885
+ print("\nπŸ“Š Service Availability:")
886
+ print(f" Weather Agent: {'βœ…' if WEATHER_AGENT_AVAILABLE else '❌'}")
887
+ print(f" Location Utils: {'βœ…' if LOCATION_UTILS_AVAILABLE else '❌'}")
888
+
889
+ print("\n" + "=" * 60)
890
+
891
+ test_queries = [
892
+ {
893
+ "name": "Weather query with context",
894
+ "input": "What's the weather?",
895
+ "lat": 33.7490,
896
+ "lon": -84.3880,
897
+ "context": {"tenant_id": "atlanta"}
898
+ },
899
+ {
900
+ "name": "Events query with context",
901
+ "input": "show me local events",
902
+ "lat": None,
903
+ "lon": None,
904
+ "context": {"tenant_id": "atlanta"}
905
+ },
906
+ {
907
+ "name": "Trash query with context",
908
+ "input": "When is trash pickup?",
909
+ "lat": None,
910
+ "lon": None,
911
+ "context": {"tenant_id": "atlanta"}
912
+ }
913
+ ]
914
+
915
+ async def run_tests():
916
+ for i, query in enumerate(test_queries, 1):
917
+ print(f"\n--- Test {i}: {query['name']} ---")
918
+ print(f"Query: {query['input']}")
919
+ print(f"Context: {query.get('context', {})}")
920
+
921
+ try:
922
+ result = await handle_tool_request(
923
+ user_input=query["input"],
924
+ role="test_user",
925
+ lat=query.get("lat"),
926
+ lon=query.get("lon"),
927
+ context=query.get("context", {})
928
+ )
929
+
930
+ print(f"Tool: {result.get('tool')}")
931
+ print(f"City: {result.get('city')}")
932
+ print(f"Tenant ID: {result.get('tenant_id')}")
933
+
934
+ response = result.get('response')
935
+ if isinstance(response, str):
936
+ print(f"Response: {response[:150]}...")
937
+ else:
938
+ print(f"Response: [Dict with {len(response)} keys]")
939
+
940
+ if result.get('response_time_ms'):
941
+ print(f"Response time: {result['response_time_ms']:.0f}ms")
942
+
943
+ except Exception as e:
944
+ print(f"❌ Error: {e}")
945
+
946
+ asyncio.run(run_tests())
947
+
948
+ print("\n" + "=" * 60)
949
+ print("πŸ“Š Final Statistics:")
950
+ health = get_tool_agent_health()
951
+ for key, value in health["statistics"].items():
952
+ print(f" {key}: {value}")
953
+
954
+ print("\n" + "=" * 60)
955
+ print("βœ… Tests complete")
956
+ print("=" * 60)