pythonprincess commited on
Commit
3b5d1d6
·
verified ·
1 Parent(s): 27e707c

Upload event_weather.py

Browse files
Files changed (1) hide show
  1. app/event_weather.py +761 -0
app/event_weather.py ADDED
@@ -0,0 +1,761 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # app/event_weather.py
2
+ """
3
+ 🌤️ Penny's Event + Weather Matchmaker
4
+ Helps residents find the perfect community activity based on real-time weather.
5
+ Penny always suggests what's actually enjoyable — not just what exists.
6
+
7
+ Production-ready version with structured logging, performance tracking, and robust error handling.
8
+ """
9
+
10
+ import json
11
+ import time
12
+ from pathlib import Path
13
+ from typing import Dict, Any, List, Optional, Tuple
14
+ from datetime import datetime
15
+ from enum import Enum
16
+
17
+ from app.weather_agent import get_weather_for_location
18
+ from app.location_utils import load_city_events
19
+ from app.logging_utils import log_interaction, sanitize_for_logging
20
+
21
+ # --- LOGGING SETUP (Structured, Azure-compatible) ---
22
+ import logging
23
+ logger = logging.getLogger(__name__)
24
+
25
+
26
+ # --- CONFIGURATION CONSTANTS ---
27
+ class EventWeatherConfig:
28
+ """Configuration constants for event recommendation system."""
29
+ MAX_FALLBACK_EVENTS = 10
30
+ MAX_RECOMMENDATIONS = 20
31
+ WEATHER_TIMEOUT_SECONDS = 5.0
32
+ SLOW_OPERATION_THRESHOLD_MS = 2000
33
+
34
+
35
+ # --- PENNY'S WEATHER WISDOM (Personality-Driven Thresholds) ---
36
+ class WeatherThresholds:
37
+ """
38
+ Penny's practical weather rules for event recommendations.
39
+ These are based on real resident comfort, not just data.
40
+ """
41
+ WARM_THRESHOLD = 70 # F° - Great for outdoor events
42
+ HOT_THRESHOLD = 85 # F° - Maybe too hot for some activities
43
+ COOL_THRESHOLD = 60 # F° - Bring a jacket
44
+ COLD_THRESHOLD = 40 # F° - Indoor events preferred
45
+
46
+ RAINY_KEYWORDS = ["rain", "shower", "storm", "drizzle", "thunderstorm"]
47
+ SNOWY_KEYWORDS = ["snow", "flurries", "blizzard", "ice"]
48
+ NICE_KEYWORDS = ["clear", "sunny", "fair", "partly cloudy"]
49
+
50
+
51
+ class ErrorType(str, Enum):
52
+ """Structured error types for event weather system."""
53
+ NOT_FOUND = "event_data_not_found"
54
+ PARSE_ERROR = "json_parse_error"
55
+ WEATHER_ERROR = "weather_service_error"
56
+ UNKNOWN = "unknown_error"
57
+
58
+
59
+ class EventWeatherException(Exception):
60
+ """Base exception for event weather system."""
61
+ def __init__(self, error_type: ErrorType, message: str, original_error: Optional[Exception] = None):
62
+ self.error_type = error_type
63
+ self.message = message
64
+ self.original_error = original_error
65
+ super().__init__(message)
66
+
67
+
68
+ # --- MAIN RECOMMENDATION FUNCTION ---
69
+ async def get_event_recommendations_with_weather(
70
+ tenant_id: str,
71
+ lat: float,
72
+ lon: float,
73
+ include_all_events: bool = False,
74
+ session_id: Optional[str] = None,
75
+ user_id: Optional[str] = None
76
+ ) -> Dict[str, Any]:
77
+ """
78
+ 🌤️ Penny's Event + Weather Intelligence System
79
+
80
+ Combines real-time weather with community events to give residents
81
+ smart, helpful suggestions about what to do today.
82
+
83
+ Args:
84
+ tenant_id: City identifier (e.g., 'atlanta_ga', 'seattle_wa')
85
+ lat: Latitude for weather lookup
86
+ lon: Longitude for weather lookup
87
+ include_all_events: If True, returns all events regardless of weather fit
88
+ session_id: Optional session identifier for logging
89
+ user_id: Optional user identifier for logging
90
+
91
+ Returns:
92
+ Dict containing:
93
+ - weather: Current conditions
94
+ - suggestions: Penny's prioritized recommendations
95
+ - all_events: Optional full event list
96
+ - metadata: Useful context (timestamp, event count, etc.)
97
+
98
+ Raises:
99
+ EventWeatherException: When critical errors occur
100
+
101
+ Example:
102
+ >>> recommendations = await get_event_recommendations_with_weather(
103
+ ... tenant_id="norfolk_va",
104
+ ... lat=36.8508,
105
+ ... lon=-76.2859
106
+ ... )
107
+ >>> print(recommendations["suggestions"][0])
108
+ 🌟 **Outdoor Concert**at Town Point Park — Perfect outdoor weather! This is the one.
109
+ """
110
+ start_time = time.time()
111
+
112
+ # Sanitize inputs for logging
113
+ safe_tenant_id = sanitize_for_logging(tenant_id)
114
+ safe_coords = f"({lat:.4f}, {lon:.4f})"
115
+
116
+ logger.info(
117
+ f"🌤️ Event weather recommendation request: tenant={safe_tenant_id}, coords={safe_coords}"
118
+ )
119
+
120
+ try:
121
+ # --- STEP 1: Load City Events (Standardized) ---
122
+ events, event_load_time = await _load_events_with_timing(tenant_id)
123
+
124
+ if not events:
125
+ response = _create_no_events_response(tenant_id)
126
+ _log_operation(
127
+ operation="event_weather_recommendations",
128
+ tenant_id=tenant_id,
129
+ session_id=session_id,
130
+ user_id=user_id,
131
+ success=True,
132
+ event_count=0,
133
+ response_time_ms=_calculate_response_time(start_time),
134
+ fallback_used=False,
135
+ weather_available=False
136
+ )
137
+ return response
138
+
139
+ logger.info(f"✅ Loaded {len(events)} events for {safe_tenant_id} in {event_load_time:.2f}s")
140
+
141
+ # --- STEP 2: Get Live Weather Data ---
142
+ weather, weather_available = await _get_weather_with_fallback(lat, lon)
143
+
144
+ # --- STEP 3: Generate Recommendations ---
145
+ if weather_available:
146
+ response = await _generate_weather_optimized_recommendations(
147
+ tenant_id=tenant_id,
148
+ events=events,
149
+ weather=weather,
150
+ include_all_events=include_all_events
151
+ )
152
+ else:
153
+ # Graceful degradation: Still show events without weather optimization
154
+ response = _create_fallback_response(tenant_id, events)
155
+
156
+ # --- STEP 4: Calculate Performance Metrics ---
157
+ response_time_ms = _calculate_response_time(start_time)
158
+
159
+ # Add performance metadata
160
+ response["performance"] = {
161
+ "response_time_ms": response_time_ms,
162
+ "event_load_time_ms": int(event_load_time * 1000),
163
+ "weather_available": weather_available
164
+ }
165
+
166
+ # Warn if operation was slow
167
+ if response_time_ms > EventWeatherConfig.SLOW_OPERATION_THRESHOLD_MS:
168
+ logger.warning(
169
+ f"⚠️ Slow event weather operation: {response_time_ms}ms for {safe_tenant_id}"
170
+ )
171
+
172
+ # --- STEP 5: Log Structured Interaction ---
173
+ _log_operation(
174
+ operation="event_weather_recommendations",
175
+ tenant_id=tenant_id,
176
+ session_id=session_id,
177
+ user_id=user_id,
178
+ success=True,
179
+ event_count=len(events),
180
+ response_time_ms=response_time_ms,
181
+ fallback_used=not weather_available,
182
+ weather_available=weather_available
183
+ )
184
+
185
+ logger.info(
186
+ f"✅ Returning {len(response.get('suggestions', []))} recommendations "
187
+ f"for {safe_tenant_id} in {response_time_ms}ms"
188
+ )
189
+
190
+ return response
191
+
192
+ except EventWeatherException as e:
193
+ # Known error with structured handling
194
+ response_time_ms = _calculate_response_time(start_time)
195
+
196
+ _log_operation(
197
+ operation="event_weather_recommendations",
198
+ tenant_id=tenant_id,
199
+ session_id=session_id,
200
+ user_id=user_id,
201
+ success=False,
202
+ event_count=0,
203
+ response_time_ms=response_time_ms,
204
+ fallback_used=False,
205
+ weather_available=False,
206
+ error_type=e.error_type.value,
207
+ error_message=str(e)
208
+ )
209
+
210
+ return _create_error_response(
211
+ tenant_id=tenant_id,
212
+ error_type=e.error_type.value,
213
+ message=e.message
214
+ )
215
+
216
+ except Exception as e:
217
+ # Unexpected error
218
+ response_time_ms = _calculate_response_time(start_time)
219
+
220
+ logger.error(
221
+ f"❌ Unexpected error in event weather recommendations: {str(e)}",
222
+ exc_info=True
223
+ )
224
+
225
+ _log_operation(
226
+ operation="event_weather_recommendations",
227
+ tenant_id=tenant_id,
228
+ session_id=session_id,
229
+ user_id=user_id,
230
+ success=False,
231
+ event_count=0,
232
+ response_time_ms=response_time_ms,
233
+ fallback_used=False,
234
+ weather_available=False,
235
+ error_type=ErrorType.UNKNOWN.value,
236
+ error_message="Unexpected system error"
237
+ )
238
+
239
+ return _create_error_response(
240
+ tenant_id=tenant_id,
241
+ error_type=ErrorType.UNKNOWN.value,
242
+ message="Something unexpected happened. Please try again in a moment."
243
+ )
244
+
245
+
246
+ # --- EVENT LOADING WITH TIMING ---
247
+ async def _load_events_with_timing(tenant_id: str) -> Tuple[List[Dict[str, Any]], float]:
248
+ """
249
+ Load city events with performance timing.
250
+
251
+ Args:
252
+ tenant_id: City identifier
253
+
254
+ Returns:
255
+ Tuple of (events list, load time in seconds)
256
+
257
+ Raises:
258
+ EventWeatherException: When event loading fails
259
+ """
260
+ load_start = time.time()
261
+
262
+ try:
263
+ loaded_data = load_city_events(tenant_id)
264
+ events = loaded_data.get("events", [])
265
+ load_time = time.time() - load_start
266
+
267
+ return events, load_time
268
+
269
+ except FileNotFoundError as e:
270
+ logger.error(f"❌ Event data file not found for tenant: {tenant_id}")
271
+ raise EventWeatherException(
272
+ error_type=ErrorType.NOT_FOUND,
273
+ message=f"I don't have event data for {tenant_id} yet. Let me know if you'd like me to add it!",
274
+ original_error=e
275
+ )
276
+
277
+ except json.JSONDecodeError as e:
278
+ logger.error(f"❌ Invalid JSON in event data for {tenant_id}: {e}")
279
+ raise EventWeatherException(
280
+ error_type=ErrorType.PARSE_ERROR,
281
+ message="There's an issue with the event data format. Our team has been notified!",
282
+ original_error=e
283
+ )
284
+
285
+ except Exception as e:
286
+ logger.error(f"❌ Unexpected error loading events: {e}", exc_info=True)
287
+ raise EventWeatherException(
288
+ error_type=ErrorType.UNKNOWN,
289
+ message="Something went wrong loading events. Please try again in a moment.",
290
+ original_error=e
291
+ )
292
+
293
+
294
+ # --- WEATHER RETRIEVAL WITH FALLBACK ---
295
+ async def _get_weather_with_fallback(
296
+ lat: float,
297
+ lon: float
298
+ ) -> Tuple[Dict[str, Any], bool]:
299
+ """
300
+ Get weather data with graceful fallback if service is unavailable.
301
+
302
+ Args:
303
+ lat: Latitude
304
+ lon: Longitude
305
+
306
+ Returns:
307
+ Tuple of (weather data dict, availability boolean)
308
+ """
309
+ try:
310
+ weather = await get_weather_for_location(lat, lon)
311
+
312
+ temp = weather.get("temperature", {}).get("value")
313
+ phrase = weather.get("phrase", "N/A")
314
+
315
+ logger.info(f"✅ Weather retrieved: {phrase} at {temp}°F")
316
+
317
+ return weather, True
318
+
319
+ except Exception as e:
320
+ logger.warning(f"⚠️ Weather service unavailable: {str(e)}")
321
+ return {"error": "Weather service unavailable"}, False
322
+
323
+
324
+ # --- WEATHER-OPTIMIZED RECOMMENDATIONS ---
325
+ async def _generate_weather_optimized_recommendations(
326
+ tenant_id: str,
327
+ events: List[Dict[str, Any]],
328
+ weather: Dict[str, Any],
329
+ include_all_events: bool
330
+ ) -> Dict[str, Any]:
331
+ """
332
+ Generate event recommendations optimized for current weather conditions.
333
+
334
+ Args:
335
+ tenant_id: City identifier
336
+ events: List of available events
337
+ weather: Weather data dictionary
338
+ include_all_events: Whether to include full event list in response
339
+
340
+ Returns:
341
+ Structured response with weather-optimized suggestions
342
+ """
343
+ temp = weather.get("temperature", {}).get("value")
344
+ phrase = weather.get("phrase", "").lower()
345
+
346
+ # Analyze weather conditions
347
+ weather_analysis = _analyze_weather_conditions(temp, phrase)
348
+
349
+ # Generate Penny's smart suggestions
350
+ suggestions = _generate_recommendations(
351
+ events=events,
352
+ weather_analysis=weather_analysis,
353
+ temp=temp,
354
+ phrase=phrase
355
+ )
356
+
357
+ # Build response
358
+ response = {
359
+ "weather": weather,
360
+ "weather_summary": _create_weather_summary(temp, phrase),
361
+ "suggestions": suggestions[:EventWeatherConfig.MAX_RECOMMENDATIONS],
362
+ "tenant_id": tenant_id,
363
+ "event_count": len(events),
364
+ "timestamp": datetime.utcnow().isoformat(),
365
+ "weather_analysis": weather_analysis
366
+ }
367
+
368
+ # Optionally include full event list
369
+ if include_all_events:
370
+ response["all_events"] = events
371
+
372
+ return response
373
+
374
+
375
+ # --- HELPER FUNCTIONS (Penny's Intelligence Layer) ---
376
+
377
+ def _analyze_weather_conditions(temp: Optional[float], phrase: str) -> Dict[str, Any]:
378
+ """
379
+ 🧠 Penny's weather interpretation logic.
380
+ Returns structured analysis of current conditions.
381
+
382
+ Args:
383
+ temp: Temperature in Fahrenheit
384
+ phrase: Weather description phrase
385
+
386
+ Returns:
387
+ Dictionary with weather analysis including outdoor suitability
388
+ """
389
+ analysis = {
390
+ "is_rainy": any(keyword in phrase for keyword in WeatherThresholds.RAINY_KEYWORDS),
391
+ "is_snowy": any(keyword in phrase for keyword in WeatherThresholds.SNOWY_KEYWORDS),
392
+ "is_nice": any(keyword in phrase for keyword in WeatherThresholds.NICE_KEYWORDS),
393
+ "temp_category": None,
394
+ "outdoor_friendly": False,
395
+ "indoor_preferred": False
396
+ }
397
+
398
+ if temp:
399
+ if temp >= WeatherThresholds.HOT_THRESHOLD:
400
+ analysis["temp_category"] = "hot"
401
+ elif temp >= WeatherThresholds.WARM_THRESHOLD:
402
+ analysis["temp_category"] = "warm"
403
+ elif temp >= WeatherThresholds.COOL_THRESHOLD:
404
+ analysis["temp_category"] = "mild"
405
+ elif temp >= WeatherThresholds.COLD_THRESHOLD:
406
+ analysis["temp_category"] = "cool"
407
+ else:
408
+ analysis["temp_category"] = "cold"
409
+
410
+ # Outdoor-friendly = warm/mild + not rainy/snowy
411
+ analysis["outdoor_friendly"] = (
412
+ temp >= WeatherThresholds.COOL_THRESHOLD and
413
+ not analysis["is_rainy"] and
414
+ not analysis["is_snowy"]
415
+ )
416
+
417
+ # Indoor preferred = cold or rainy or snowy
418
+ analysis["indoor_preferred"] = (
419
+ temp < WeatherThresholds.COOL_THRESHOLD or
420
+ analysis["is_rainy"] or
421
+ analysis["is_snowy"]
422
+ )
423
+
424
+ return analysis
425
+
426
+
427
+ def _generate_recommendations(
428
+ events: List[Dict[str, Any]],
429
+ weather_analysis: Dict[str, Any],
430
+ temp: Optional[float],
431
+ phrase: str
432
+ ) -> List[str]:
433
+ """
434
+ 🎯 Penny's event recommendation engine.
435
+ Prioritizes events based on weather + category fit.
436
+ Keeps Penny's warm, helpful voice throughout.
437
+
438
+ Args:
439
+ events: List of available events
440
+ weather_analysis: Weather condition analysis
441
+ temp: Current temperature
442
+ phrase: Weather description
443
+
444
+ Returns:
445
+ List of formatted event suggestions
446
+ """
447
+ suggestions = []
448
+
449
+ # Sort events: Best weather fit first
450
+ scored_events = []
451
+ for event in events:
452
+ score = _calculate_event_weather_score(event, weather_analysis)
453
+ scored_events.append((score, event))
454
+
455
+ scored_events.sort(reverse=True, key=lambda x: x[0])
456
+
457
+ # Generate suggestions with Penny's personality
458
+ for score, event in scored_events:
459
+ event_name = event.get("name", "Unnamed Event")
460
+ event_category = event.get("category", "").lower()
461
+ event_location = event.get("location", "")
462
+
463
+ # Build suggestion with appropriate emoji + messaging
464
+ suggestion = _create_suggestion_message(
465
+ event_name=event_name,
466
+ event_category=event_category,
467
+ event_location=event_location,
468
+ score=score,
469
+ weather_analysis=weather_analysis,
470
+ temp=temp,
471
+ phrase=phrase
472
+ )
473
+
474
+ suggestions.append(suggestion)
475
+
476
+ return suggestions
477
+
478
+
479
+ def _calculate_event_weather_score(
480
+ event: Dict[str, Any],
481
+ weather_analysis: Dict[str, Any]
482
+ ) -> int:
483
+ """
484
+ 📊 Scores event suitability based on weather (0-100).
485
+ Higher = better match for current conditions.
486
+
487
+ Args:
488
+ event: Event dictionary with category information
489
+ weather_analysis: Weather condition analysis
490
+
491
+ Returns:
492
+ Integer score from 0-100
493
+ """
494
+ category = event.get("category", "").lower()
495
+ score = 50 # Neutral baseline
496
+
497
+ # Perfect matches
498
+ if "outdoor" in category and weather_analysis["outdoor_friendly"]:
499
+ score = 95
500
+ elif "indoor" in category and weather_analysis["indoor_preferred"]:
501
+ score = 90
502
+
503
+ # Good matches
504
+ elif "indoor" in category and not weather_analysis["outdoor_friendly"]:
505
+ score = 75
506
+ elif "outdoor" in category and weather_analysis["temp_category"] in ["warm", "mild"]:
507
+ score = 70
508
+
509
+ # Acceptable matches
510
+ elif "civic" in category or "community" in category:
511
+ score = 60 # Usually indoor, weather-neutral
512
+
513
+ # Poor matches (but still list them)
514
+ elif "outdoor" in category and weather_analysis["indoor_preferred"]:
515
+ score = 30
516
+
517
+ return score
518
+
519
+
520
+ def _create_suggestion_message(
521
+ event_name: str,
522
+ event_category: str,
523
+ event_location: str,
524
+ score: int,
525
+ weather_analysis: Dict[str, Any],
526
+ temp: Optional[float],
527
+ phrase: str
528
+ ) -> str:
529
+ """
530
+ 💬 Penny's voice: Generates natural, helpful event suggestions.
531
+ Adapts tone based on weather fit score.
532
+
533
+ Args:
534
+ event_name: Name of the event
535
+ event_category: Event category (outdoor, indoor, etc.)
536
+ event_location: Event location/venue
537
+ score: Weather suitability score (0-100)
538
+ weather_analysis: Weather condition analysis
539
+ temp: Current temperature
540
+ phrase: Weather description
541
+
542
+ Returns:
543
+ Formatted suggestion string with emoji and helpful context
544
+ """
545
+ location_text = f" at {event_location}" if event_location else ""
546
+
547
+ # PERFECT MATCHES (90-100)
548
+ if score >= 90:
549
+ if "outdoor" in event_category:
550
+ return f"🌟 **{event_name}**{location_text} — Perfect outdoor weather! This is the one."
551
+ else:
552
+ return f"🏛️ **{event_name}**{location_text} — Ideal indoor activity for today's weather!"
553
+
554
+ # GOOD MATCHES (70-89)
555
+ elif score >= 70:
556
+ if "outdoor" in event_category:
557
+ return f"☀️ **{event_name}**{location_text} — Great day for outdoor activities!"
558
+ else:
559
+ return f"🔵 **{event_name}**{location_text} — Solid indoor option!"
560
+
561
+ # DECENT MATCHES (50-69)
562
+ elif score >= 50:
563
+ if "outdoor" in event_category:
564
+ temp_text = f" (It's {int(temp)}°F)" if temp else ""
565
+ return f"🌤️ **{event_name}**{location_text} — Weather's okay for outdoor events{temp_text}."
566
+ else:
567
+ return f"⚪ **{event_name}**{location_text} — Weather-neutral activity."
568
+
569
+ # POOR MATCHES (Below 50)
570
+ else:
571
+ if "outdoor" in event_category and weather_analysis["is_rainy"]:
572
+ return f"🌧️ **{event_name}**{location_text} — Outdoor event, but it's rainy. Bring an umbrella or check if it's postponed!"
573
+ elif "outdoor" in event_category and weather_analysis.get("temp_category") == "cold":
574
+ return f"❄️ **{event_name}**{location_text} — Outdoor event, but bundle up — it's chilly!"
575
+ else:
576
+ return f"⚪ **{event_name}**{location_text} — Check weather before heading out."
577
+
578
+
579
+ def _create_weather_summary(temp: Optional[float], phrase: str) -> str:
580
+ """
581
+ 🌤️ Penny's plain-English weather summary.
582
+
583
+ Args:
584
+ temp: Temperature in Fahrenheit
585
+ phrase: Weather description phrase
586
+
587
+ Returns:
588
+ Human-readable weather summary
589
+ """
590
+ if not temp:
591
+ return f"Current conditions: {phrase.title()}"
592
+
593
+ temp_desc = ""
594
+ if temp >= 85:
595
+ temp_desc = "hot"
596
+ elif temp >= 70:
597
+ temp_desc = "warm"
598
+ elif temp >= 60:
599
+ temp_desc = "mild"
600
+ elif temp >= 40:
601
+ temp_desc = "cool"
602
+ else:
603
+ temp_desc = "cold"
604
+
605
+ return f"It's {temp_desc} at {int(temp)}°F — {phrase.lower()}."
606
+
607
+
608
+ # --- ERROR RESPONSE HELPERS (Penny stays helpful even in failures) ---
609
+
610
+ def _create_no_events_response(tenant_id: str) -> Dict[str, Any]:
611
+ """
612
+ Returns friendly response when no events are found.
613
+
614
+ Args:
615
+ tenant_id: City identifier
616
+
617
+ Returns:
618
+ Structured response with helpful message
619
+ """
620
+ return {
621
+ "weather": {},
622
+ "suggestions": [
623
+ f"🤔 I don't have any events loaded for {tenant_id} right now. "
624
+ "Let me know if you'd like me to check again or add some!"
625
+ ],
626
+ "tenant_id": tenant_id,
627
+ "event_count": 0,
628
+ "timestamp": datetime.utcnow().isoformat()
629
+ }
630
+
631
+
632
+ def _create_error_response(
633
+ tenant_id: str,
634
+ error_type: str,
635
+ message: str
636
+ ) -> Dict[str, Any]:
637
+ """
638
+ Returns structured error with Penny's helpful tone.
639
+
640
+ Args:
641
+ tenant_id: City identifier
642
+ error_type: Structured error type code
643
+ message: User-friendly error message
644
+
645
+ Returns:
646
+ Error response dictionary
647
+ """
648
+ logger.error(f"Error in event_weather: {error_type} - {message}")
649
+ return {
650
+ "weather": {},
651
+ "suggestions": [f"⚠️ {message}"],
652
+ "tenant_id": tenant_id,
653
+ "event_count": 0,
654
+ "error_type": error_type,
655
+ "timestamp": datetime.utcnow().isoformat()
656
+ }
657
+
658
+
659
+ def _create_fallback_response(
660
+ tenant_id: str,
661
+ events: List[Dict[str, Any]]
662
+ ) -> Dict[str, Any]:
663
+ """
664
+ Graceful degradation: Shows events even if weather service is down.
665
+ Penny stays helpful!
666
+
667
+ Args:
668
+ tenant_id: City identifier
669
+ events: List of available events
670
+
671
+ Returns:
672
+ Fallback response with events but no weather optimization
673
+ """
674
+ # Limit to configured maximum
675
+ display_events = events[:EventWeatherConfig.MAX_FALLBACK_EVENTS]
676
+
677
+ suggestions = [
678
+ f"📅 **{event.get('name', 'Event')}** — {event.get('category', 'Community event')}"
679
+ for event in display_events
680
+ ]
681
+
682
+ suggestions.insert(0, "⚠️ Weather service is temporarily unavailable, but here are today's events:")
683
+
684
+ return {
685
+ "weather": {"error": "Weather service unavailable"},
686
+ "suggestions": suggestions,
687
+ "tenant_id": tenant_id,
688
+ "event_count": len(events),
689
+ "timestamp": datetime.utcnow().isoformat(),
690
+ "fallback_mode": True
691
+ }
692
+
693
+
694
+ # --- STRUCTURED LOGGING HELPER ---
695
+
696
+ def _log_operation(
697
+ operation: str,
698
+ tenant_id: str,
699
+ success: bool,
700
+ event_count: int,
701
+ response_time_ms: int,
702
+ fallback_used: bool,
703
+ weather_available: bool,
704
+ session_id: Optional[str] = None,
705
+ user_id: Optional[str] = None,
706
+ error_type: Optional[str] = None,
707
+ error_message: Optional[str] = None
708
+ ) -> None:
709
+ """
710
+ Log event weather operation with structured data.
711
+
712
+ Args:
713
+ operation: Operation name
714
+ tenant_id: City identifier
715
+ success: Whether operation succeeded
716
+ event_count: Number of events processed
717
+ response_time_ms: Total response time in milliseconds
718
+ fallback_used: Whether fallback mode was used
719
+ weather_available: Whether weather data was available
720
+ session_id: Optional session identifier
721
+ user_id: Optional user identifier
722
+ error_type: Optional error type if failed
723
+ error_message: Optional error message if failed
724
+ """
725
+ log_data = {
726
+ "operation": operation,
727
+ "tenant_id": sanitize_for_logging(tenant_id),
728
+ "success": success,
729
+ "event_count": event_count,
730
+ "response_time_ms": response_time_ms,
731
+ "fallback_used": fallback_used,
732
+ "weather_available": weather_available,
733
+ "timestamp": datetime.utcnow().isoformat()
734
+ }
735
+
736
+ if session_id:
737
+ log_data["session_id"] = sanitize_for_logging(session_id)
738
+
739
+ if user_id:
740
+ log_data["user_id"] = sanitize_for_logging(user_id)
741
+
742
+ if error_type:
743
+ log_data["error_type"] = error_type
744
+
745
+ if error_message:
746
+ log_data["error_message"] = sanitize_for_logging(error_message)
747
+
748
+ log_interaction(log_data)
749
+
750
+
751
+ def _calculate_response_time(start_time: float) -> int:
752
+ """
753
+ Calculate response time in milliseconds.
754
+
755
+ Args:
756
+ start_time: Operation start time from time.time()
757
+
758
+ Returns:
759
+ Response time in milliseconds
760
+ """
761
+ return int((time.time() - start_time) * 1000)