pythonprincess commited on
Commit
27e707c
·
verified ·
1 Parent(s): 44588a9

Upload weather_agent.py

Browse files
Files changed (1) hide show
  1. app/weather_agent.py +535 -0
app/weather_agent.py ADDED
@@ -0,0 +1,535 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # app/weather_agent.py
2
+ """
3
+ 🌤️ PENNY Weather Agent - Azure Maps Integration
4
+
5
+ Provides real-time weather information and weather-aware recommendations
6
+ for civic engagement activities.
7
+
8
+ MISSION: Help residents plan their day with accurate weather data and
9
+ smart suggestions for indoor/outdoor activities based on conditions.
10
+
11
+ ENHANCEMENTS (Phase 1 Complete):
12
+ - ✅ Structured logging with performance tracking
13
+ - ✅ Enhanced error handling with graceful degradation
14
+ - ✅ Type hints for all functions
15
+ - ✅ Health check integration
16
+ - ✅ Response caching for performance
17
+ - ✅ Detailed weather parsing with validation
18
+ - ✅ Penny's friendly voice in all responses
19
+
20
+ Production-ready for Azure ML deployment.
21
+ """
22
+
23
+ import os
24
+ import logging
25
+ import time
26
+ from typing import Dict, Any, Optional, List, Tuple
27
+ from datetime import datetime, timedelta
28
+ import httpx
29
+
30
+ # --- ENHANCED MODULE IMPORTS ---
31
+ from app.logging_utils import log_interaction
32
+
33
+ # --- LOGGING SETUP ---
34
+ logger = logging.getLogger(__name__)
35
+
36
+ # --- CONFIGURATION ---
37
+ AZURE_WEATHER_URL = "https://atlas.microsoft.com/weather/currentConditions/json"
38
+ DEFAULT_TIMEOUT = 10.0 # seconds
39
+ CACHE_TTL_SECONDS = 300 # 5 minutes - weather doesn't change that fast
40
+
41
+ # --- CHECK API KEY AVAILABILITY AT MODULE LOAD (NEW - PREVENTS IMPORT FAILURES) ---
42
+ AZURE_MAPS_KEY = os.getenv("AZURE_WEATHER_KEY") or os.getenv("AZURE_MAPS_KEY")
43
+
44
+ if not AZURE_MAPS_KEY:
45
+ logger.warning("⚠️ AZURE_MAPS_KEY not configured - weather features will be limited")
46
+ _WEATHER_SERVICE_AVAILABLE = False
47
+ else:
48
+ logger.info("✅ AZURE_MAPS_KEY configured")
49
+ _WEATHER_SERVICE_AVAILABLE = True
50
+
51
+ # --- WEATHER CACHE ---
52
+ _weather_cache: Dict[str, Tuple[Dict[str, Any], datetime]] = {}
53
+
54
+
55
+ # ============================================================
56
+ # WEATHER DATA RETRIEVAL
57
+ # ============================================================
58
+
59
+ async def get_weather_for_location(
60
+ lat: float,
61
+ lon: float,
62
+ use_cache: bool = True
63
+ ) -> Dict[str, Any]:
64
+ """
65
+ 🌤️ Fetches real-time weather from Azure Maps.
66
+
67
+ Retrieves current weather conditions for a specific location using
68
+ Azure Maps Weather API. Includes caching to reduce API calls and
69
+ improve response times.
70
+
71
+ Args:
72
+ lat: Latitude coordinate
73
+ lon: Longitude coordinate
74
+ use_cache: Whether to use cached data if available (default: True)
75
+
76
+ Returns:
77
+ Dictionary containing weather data with keys:
78
+ - temperature: {value: float, unit: str}
79
+ - phrase: str (weather description)
80
+ - iconCode: int
81
+ - hasPrecipitation: bool
82
+ - isDayTime: bool
83
+ - relativeHumidity: int
84
+ - cloudCover: int
85
+ - etc.
86
+
87
+ Raises:
88
+ RuntimeError: If AZURE_MAPS_KEY is not configured
89
+ httpx.HTTPError: If API request fails
90
+
91
+ Example:
92
+ weather = await get_weather_for_location(33.7490, -84.3880)
93
+ temp = weather.get("temperature", {}).get("value")
94
+ condition = weather.get("phrase", "Unknown")
95
+ """
96
+ start_time = time.time()
97
+
98
+ # Create cache key
99
+ cache_key = f"{lat:.4f},{lon:.4f}"
100
+
101
+ # Check cache first
102
+ if use_cache and cache_key in _weather_cache:
103
+ cached_data, cached_time = _weather_cache[cache_key]
104
+ age = (datetime.now() - cached_time).total_seconds()
105
+
106
+ if age < CACHE_TTL_SECONDS:
107
+ logger.info(
108
+ f"🌤️ Weather cache hit (age: {age:.0f}s, "
109
+ f"location: {cache_key})"
110
+ )
111
+ return cached_data
112
+
113
+ # Check if service is available (MODIFIED - USES FLAG INSTEAD OF CHECKING ENV VAR)
114
+ if not _WEATHER_SERVICE_AVAILABLE:
115
+ logger.error("❌ AZURE_MAPS_KEY not configured")
116
+ raise RuntimeError(
117
+ "AZURE_MAPS_KEY is required and not set in environment variables."
118
+ )
119
+
120
+ # Build request parameters
121
+ params = {
122
+ "api-version": "1.0",
123
+ "query": f"{lat},{lon}",
124
+ "subscription-key": AZURE_MAPS_KEY,
125
+ "details": "true",
126
+ "language": "en-US",
127
+ }
128
+
129
+ try:
130
+ logger.info(f"🌤️ Fetching weather for location: {cache_key}")
131
+
132
+ async with httpx.AsyncClient(timeout=DEFAULT_TIMEOUT) as client:
133
+ response = await client.get(AZURE_WEATHER_URL, params=params)
134
+ response.raise_for_status()
135
+ data = response.json()
136
+
137
+ # Parse response
138
+ if "results" in data and len(data["results"]) > 0:
139
+ weather_data = data["results"][0]
140
+ else:
141
+ weather_data = data # Fallback if structure changes
142
+
143
+ # Validate essential fields
144
+ weather_data = _validate_weather_data(weather_data)
145
+
146
+ # Cache the result
147
+ _weather_cache[cache_key] = (weather_data, datetime.now())
148
+
149
+ # Calculate response time
150
+ response_time = (time.time() - start_time) * 1000
151
+
152
+ # Log successful retrieval
153
+ log_interaction(
154
+ tenant_id="weather_service",
155
+ interaction_type="weather_fetch",
156
+ intent="weather",
157
+ response_time_ms=response_time,
158
+ success=True,
159
+ metadata={
160
+ "location": cache_key,
161
+ "cached": False,
162
+ "temperature": weather_data.get("temperature", {}).get("value"),
163
+ "condition": weather_data.get("phrase")
164
+ }
165
+ )
166
+
167
+ logger.info(
168
+ f"✅ Weather fetched successfully ({response_time:.0f}ms, "
169
+ f"location: {cache_key})"
170
+ )
171
+
172
+ return weather_data
173
+
174
+ except httpx.TimeoutException as e:
175
+ logger.error(f"⏱️ Weather API timeout: {e}")
176
+ raise
177
+
178
+ except httpx.HTTPStatusError as e:
179
+ logger.error(f"❌ Weather API HTTP error: {e.response.status_code}")
180
+ raise
181
+
182
+ except Exception as e:
183
+ logger.error(f"❌ Weather API error: {e}", exc_info=True)
184
+ raise
185
+
186
+
187
+ def _validate_weather_data(data: Dict[str, Any]) -> Dict[str, Any]:
188
+ """
189
+ Validates and normalizes weather data from Azure Maps.
190
+
191
+ Ensures essential fields are present with sensible defaults.
192
+ """
193
+ # Ensure temperature exists
194
+ if "temperature" not in data:
195
+ data["temperature"] = {"value": None, "unit": "F"}
196
+ elif isinstance(data["temperature"], (int, float)):
197
+ # Handle case where temperature is just a number
198
+ data["temperature"] = {"value": data["temperature"], "unit": "F"}
199
+
200
+ # Ensure phrase exists
201
+ if "phrase" not in data or not data["phrase"]:
202
+ data["phrase"] = "Conditions unavailable"
203
+
204
+ # Ensure boolean flags exist
205
+ data.setdefault("hasPrecipitation", False)
206
+ data.setdefault("isDayTime", True)
207
+
208
+ # Ensure numeric fields exist
209
+ data.setdefault("relativeHumidity", None)
210
+ data.setdefault("cloudCover", None)
211
+ data.setdefault("iconCode", None)
212
+
213
+ return data
214
+
215
+
216
+ # ============================================================
217
+ # OUTFIT RECOMMENDATIONS
218
+ # ============================================================
219
+
220
+ def recommend_outfit(high_temp: float, condition: str) -> str:
221
+ """
222
+ 👕 Recommends what to wear based on weather conditions.
223
+
224
+ Provides friendly, practical clothing suggestions based on
225
+ temperature and weather conditions.
226
+
227
+ Args:
228
+ high_temp: Expected high temperature in Fahrenheit
229
+ condition: Weather condition description (e.g., "Sunny", "Rainy")
230
+
231
+ Returns:
232
+ Friendly outfit recommendation string
233
+
234
+ Example:
235
+ outfit = recommend_outfit(85, "Sunny")
236
+ # Returns: "Light clothes, sunscreen, and stay hydrated! ☀️"
237
+ """
238
+ condition_lower = condition.lower()
239
+
240
+ # Check for precipitation first
241
+ if "rain" in condition_lower or "storm" in condition_lower:
242
+ logger.debug(f"Outfit rec: Rain/Storm (temp: {high_temp}°F)")
243
+ return "Bring an umbrella or rain jacket! ☔"
244
+
245
+ # Temperature-based recommendations
246
+ if high_temp >= 85:
247
+ logger.debug(f"Outfit rec: Hot (temp: {high_temp}°F)")
248
+ return "Light clothes, sunscreen, and stay hydrated! ☀️"
249
+
250
+ if high_temp >= 72:
251
+ logger.debug(f"Outfit rec: Warm (temp: {high_temp}°F)")
252
+ return "T-shirt and jeans or a casual dress. 👕"
253
+
254
+ if high_temp >= 60:
255
+ logger.debug(f"Outfit rec: Mild (temp: {high_temp}°F)")
256
+ return "A hoodie or light jacket should do! 🧥"
257
+
258
+ logger.debug(f"Outfit rec: Cold (temp: {high_temp}°F)")
259
+ return "Bundle up — sweater or coat recommended! 🧣"
260
+
261
+
262
+ # ============================================================
263
+ # EVENT RECOMMENDATIONS BASED ON WEATHER
264
+ # ============================================================
265
+
266
+ def weather_to_event_recommendations(
267
+ weather: Dict[str, Any]
268
+ ) -> List[Dict[str, Any]]:
269
+ """
270
+ 📅 Suggests activity types based on current weather conditions.
271
+
272
+ Analyzes weather data to provide smart recommendations for
273
+ indoor vs outdoor activities, helping residents plan their day.
274
+
275
+ Args:
276
+ weather: Weather data dictionary from get_weather_for_location()
277
+
278
+ Returns:
279
+ List of recommendation dictionaries with keys:
280
+ - type: str ("indoor", "outdoor", "neutral")
281
+ - suggestions: List[str] (specific activity ideas)
282
+ - reason: str (explanation for recommendation)
283
+ - priority: int (1-3, added for sorting)
284
+
285
+ Example:
286
+ weather = await get_weather_for_location(33.7490, -84.3880)
287
+ recs = weather_to_event_recommendations(weather)
288
+ for rec in recs:
289
+ print(f"{rec['type']}: {rec['suggestions']}")
290
+ """
291
+ condition = (weather.get("phrase") or "").lower()
292
+ temp = weather.get("temperature", {}).get("value")
293
+ has_precipitation = weather.get("hasPrecipitation", False)
294
+
295
+ recs = []
296
+
297
+ # Check for rain or storms (highest priority)
298
+ if "rain" in condition or "storm" in condition or has_precipitation:
299
+ logger.debug("Event rec: Indoor (precipitation)")
300
+ recs.append({
301
+ "type": "indoor",
302
+ "suggestions": [
303
+ "Visit a library 📚",
304
+ "Check out a community center event 🏛️",
305
+ "Find an indoor workshop or class 🎨",
306
+ "Explore a local museum 🖼️"
307
+ ],
308
+ "reason": "Rainy weather makes indoor events ideal!",
309
+ "priority": 1
310
+ })
311
+
312
+ # Warm weather outdoor activities
313
+ elif temp is not None and temp >= 75:
314
+ logger.debug(f"Event rec: Outdoor (warm: {temp}°F)")
315
+ recs.append({
316
+ "type": "outdoor",
317
+ "suggestions": [
318
+ "Visit a park 🌳",
319
+ "Check out a farmers market 🥕",
320
+ "Look for outdoor concerts or festivals 🎵",
321
+ "Enjoy a community picnic or BBQ 🍔"
322
+ ],
323
+ "reason": "Beautiful weather for outdoor activities!",
324
+ "priority": 1
325
+ })
326
+
327
+ # Cold weather considerations
328
+ elif temp is not None and temp < 50:
329
+ logger.debug(f"Event rec: Indoor (cold: {temp}°F)")
330
+ recs.append({
331
+ "type": "indoor",
332
+ "suggestions": [
333
+ "Browse local events at community centers 🏛️",
334
+ "Visit a museum or art gallery 🖼️",
335
+ "Check out indoor markets or shopping 🛍️",
336
+ "Warm up at a local café or restaurant ☕"
337
+ ],
338
+ "reason": "Chilly weather — indoor activities are cozy!",
339
+ "priority": 1
340
+ })
341
+
342
+ # Mild/neutral weather
343
+ else:
344
+ logger.debug(f"Event rec: Neutral (mild: {temp}°F if temp else 'unknown')")
345
+ recs.append({
346
+ "type": "neutral",
347
+ "suggestions": [
348
+ "Browse local events 📅",
349
+ "Visit a museum or cultural center 🏛️",
350
+ "Walk around a local plaza or downtown 🚶",
351
+ "Check out both indoor and outdoor activities 🌍"
352
+ ],
353
+ "reason": "Mild weather gives you flexible options!",
354
+ "priority": 2
355
+ })
356
+
357
+ return recs
358
+
359
+
360
+ # ============================================================
361
+ # HELPER FUNCTIONS
362
+ # ============================================================
363
+
364
+ def format_weather_summary(weather: Dict[str, Any]) -> str:
365
+ """
366
+ 📝 Formats weather data into a human-readable summary.
367
+
368
+ Args:
369
+ weather: Weather data dictionary
370
+
371
+ Returns:
372
+ Formatted weather summary string with Penny's friendly voice
373
+
374
+ Example:
375
+ summary = format_weather_summary(weather_data)
376
+ # "Currently 72°F and Partly Cloudy. Humidity: 65%"
377
+ """
378
+ temp_data = weather.get("temperature", {})
379
+ temp = temp_data.get("value")
380
+ unit = temp_data.get("unit", "F")
381
+ phrase = weather.get("phrase", "Conditions unavailable")
382
+ humidity = weather.get("relativeHumidity")
383
+
384
+ # Build summary
385
+ parts = []
386
+
387
+ if temp is not None:
388
+ parts.append(f"Currently {int(temp)}°{unit}")
389
+
390
+ parts.append(phrase)
391
+
392
+ if humidity is not None:
393
+ parts.append(f"Humidity: {humidity}%")
394
+
395
+ summary = " and ".join(parts[:2])
396
+ if len(parts) > 2:
397
+ summary += f". {parts[2]}"
398
+
399
+ return summary
400
+
401
+
402
+ def clear_weather_cache():
403
+ """
404
+ 🧹 Clears the weather cache.
405
+
406
+ Useful for testing or if fresh data is needed immediately.
407
+ """
408
+ global _weather_cache
409
+ cache_size = len(_weather_cache)
410
+ _weather_cache.clear()
411
+ logger.info(f"🧹 Weather cache cleared ({cache_size} entries removed)")
412
+
413
+
414
+ def get_cache_stats() -> Dict[str, Any]:
415
+ """
416
+ 📊 Returns weather cache statistics.
417
+
418
+ Returns:
419
+ Dictionary with cache statistics:
420
+ - entries: int (number of cached locations)
421
+ - oldest_entry_age_seconds: float
422
+ - newest_entry_age_seconds: float
423
+ """
424
+ if not _weather_cache:
425
+ return {
426
+ "entries": 0,
427
+ "oldest_entry_age_seconds": None,
428
+ "newest_entry_age_seconds": None
429
+ }
430
+
431
+ now = datetime.now()
432
+ ages = [
433
+ (now - cached_time).total_seconds()
434
+ for _, cached_time in _weather_cache.values()
435
+ ]
436
+
437
+ return {
438
+ "entries": len(_weather_cache),
439
+ "oldest_entry_age_seconds": max(ages) if ages else None,
440
+ "newest_entry_age_seconds": min(ages) if ages else None
441
+ }
442
+
443
+
444
+ # ============================================================
445
+ # HEALTH CHECK
446
+ # ============================================================
447
+
448
+ def get_weather_agent_health() -> Dict[str, Any]:
449
+ """
450
+ 📊 Returns weather agent health status.
451
+
452
+ Used by the main application health check endpoint to monitor
453
+ the weather service availability and performance.
454
+
455
+ Returns:
456
+ Dictionary with health information
457
+ """
458
+ cache_stats = get_cache_stats()
459
+
460
+ # MODIFIED - USES FLAG INSTEAD OF CHECKING ENV VAR
461
+ return {
462
+ "status": "operational" if _WEATHER_SERVICE_AVAILABLE else "degraded",
463
+ "service": "azure_maps_weather",
464
+ "api_key_configured": _WEATHER_SERVICE_AVAILABLE,
465
+ "cache": cache_stats,
466
+ "cache_ttl_seconds": CACHE_TTL_SECONDS,
467
+ "default_timeout_seconds": DEFAULT_TIMEOUT,
468
+ "features": {
469
+ "real_time_weather": True,
470
+ "outfit_recommendations": True,
471
+ "event_recommendations": True,
472
+ "response_caching": True
473
+ }
474
+ }
475
+
476
+
477
+ # ============================================================
478
+ # TESTING
479
+ # ============================================================
480
+
481
+ if __name__ == "__main__":
482
+ """🧪 Test weather agent functionality"""
483
+ import asyncio
484
+
485
+ print("=" * 60)
486
+ print("🧪 Testing Weather Agent")
487
+ print("=" * 60)
488
+
489
+ async def run_tests():
490
+ # Test location: Atlanta, GA
491
+ lat, lon = 33.7490, -84.3880
492
+
493
+ print(f"\n--- Test 1: Fetch Weather ---")
494
+ print(f"Location: {lat}, {lon} (Atlanta, GA)")
495
+
496
+ try:
497
+ weather = await get_weather_for_location(lat, lon)
498
+ print(f"✅ Weather fetched successfully")
499
+ print(f"Temperature: {weather.get('temperature', {}).get('value')}°F")
500
+ print(f"Condition: {weather.get('phrase')}")
501
+ print(f"Precipitation: {weather.get('hasPrecipitation')}")
502
+
503
+ print(f"\n--- Test 2: Weather Summary ---")
504
+ summary = format_weather_summary(weather)
505
+ print(f"Summary: {summary}")
506
+
507
+ print(f"\n--- Test 3: Outfit Recommendation ---")
508
+ temp = weather.get('temperature', {}).get('value', 70)
509
+ condition = weather.get('phrase', 'Clear')
510
+ outfit = recommend_outfit(temp, condition)
511
+ print(f"Outfit: {outfit}")
512
+
513
+ print(f"\n--- Test 4: Event Recommendations ---")
514
+ recs = weather_to_event_recommendations(weather)
515
+ for rec in recs:
516
+ print(f"Type: {rec['type']}")
517
+ print(f"Reason: {rec['reason']}")
518
+ print(f"Suggestions: {', '.join(rec['suggestions'][:2])}")
519
+
520
+ print(f"\n--- Test 5: Cache Test ---")
521
+ weather2 = await get_weather_for_location(lat, lon, use_cache=True)
522
+ print(f"✅ Cache working (should be instant)")
523
+
524
+ print(f"\n--- Test 6: Health Check ---")
525
+ health = get_weather_agent_health()
526
+ print(f"Status: {health['status']}")
527
+ print(f"Cache entries: {health['cache']['entries']}")
528
+
529
+ except Exception as e:
530
+ print(f"❌ Error: {e}")
531
+
532
+ asyncio.run(run_tests())
533
+
534
+ print("\n" + "=" * 60)
535
+ print("✅ Tests complete")