""" Calendar Suggester Utility Provides intelligent school calendar suggestions based on POI detection data, tenant location, and heuristics optimized for bakery demand forecasting. """ from typing import Optional, Dict, List, Any, Tuple from datetime import datetime, date, timezone import structlog logger = structlog.get_logger() class CalendarSuggester: """ Suggests appropriate school calendars for tenants based on location context. Uses POI detection data, proximity analysis, and bakery-specific heuristics to provide intelligent calendar recommendations with confidence scores. """ def __init__(self): self.logger = logger def suggest_calendar_for_tenant( self, city_id: str, available_calendars: List[Dict[str, Any]], poi_context: Optional[Dict[str, Any]] = None, tenant_data: Optional[Dict[str, Any]] = None ) -> Dict[str, Any]: """ Suggest the most appropriate calendar for a tenant. Args: city_id: Normalized city ID (e.g., "madrid") available_calendars: List of available school calendars for the city poi_context: Optional POI detection results including school data tenant_data: Optional tenant information (location, etc.) Returns: Dict with: - suggested_calendar_id: UUID of suggested calendar or None - calendar_name: Name of suggested calendar - confidence: Float 0.0-1.0 confidence score - reasoning: List of reasoning steps - fallback_calendars: Alternative suggestions - should_assign: Boolean recommendation to auto-assign """ if not available_calendars: return self._no_calendars_available(city_id) # Get current academic year academic_year = self._get_current_academic_year() # Filter calendars for current academic year current_year_calendars = [ cal for cal in available_calendars if cal.get("academic_year") == academic_year ] if not current_year_calendars: # Fallback to any calendar if current year not available current_year_calendars = available_calendars self.logger.warning( "No calendars for current academic year, using all available", city_id=city_id, academic_year=academic_year ) # Analyze POI context if available school_analysis = self._analyze_schools_from_poi(poi_context) if poi_context else None # Apply bakery-specific heuristics suggestion = self._apply_suggestion_heuristics( current_year_calendars, school_analysis, city_id ) return suggestion def _get_current_academic_year(self) -> str: """ Determine current academic year based on date. Academic year runs September to June (Spain): - Jan-Aug: Previous year (e.g., 2024-2025) - Sep-Dec: Current year (e.g., 2025-2026) Returns: Academic year string (e.g., "2024-2025") """ today = date.today() year = today.year # Academic year starts in September if today.month >= 9: # September onwards return f"{year}-{year + 1}" else: # January-August return f"{year - 1}-{year}" def _analyze_schools_from_poi( self, poi_context: Dict[str, Any] ) -> Optional[Dict[str, Any]]: """ Analyze school POIs to infer school type preferences. Args: poi_context: POI detection results Returns: Dict with: - has_schools_nearby: Boolean - school_count: Int count of schools - nearest_distance: Float distance to nearest school (meters) - proximity_score: Float proximity score - school_names: List of detected school names """ try: poi_results = poi_context.get("poi_detection_results", {}) schools_data = poi_results.get("schools", {}) if not schools_data: return None school_pois = schools_data.get("pois", []) school_count = len(school_pois) if school_count == 0: return None # Extract school details school_names = [ poi.get("name", "Unknown School") for poi in school_pois if poi.get("name") ] # Get proximity metrics features = schools_data.get("features", {}) proximity_score = features.get("proximity_score", 0.0) # Calculate nearest distance (approximate from POI data) nearest_distance = None if school_pois: # If we have POIs, estimate nearest distance # This is approximate - exact calculation would require tenant coords nearest_distance = 100.0 # Default assumption if schools detected return { "has_schools_nearby": True, "school_count": school_count, "nearest_distance": nearest_distance, "proximity_score": proximity_score, "school_names": school_names } except Exception as e: self.logger.warning( "Failed to analyze schools from POI", error=str(e) ) return None def _apply_suggestion_heuristics( self, calendars: List[Dict[str, Any]], school_analysis: Optional[Dict[str, Any]], city_id: str ) -> Dict[str, Any]: """ Apply heuristics to suggest best calendar. Bakery-specific heuristics: 1. If schools detected nearby -> Prefer primary (stronger morning rush) 2. If no schools detected -> Still suggest primary (more common, safer default) 3. Primary schools have stronger impact on bakery traffic Args: calendars: List of available calendars school_analysis: Analysis of nearby schools city_id: City identifier Returns: Suggestion dict with confidence and reasoning """ reasoning = [] confidence = 0.0 # Separate calendars by type primary_calendars = [c for c in calendars if c.get("school_type") == "primary"] secondary_calendars = [c for c in calendars if c.get("school_type") == "secondary"] other_calendars = [c for c in calendars if c.get("school_type") not in ["primary", "secondary"]] # Heuristic 1: Schools detected nearby if school_analysis and school_analysis.get("has_schools_nearby"): school_count = school_analysis.get("school_count", 0) proximity_score = school_analysis.get("proximity_score", 0.0) reasoning.append(f"Detected {school_count} schools nearby (proximity score: {proximity_score:.2f})") if primary_calendars: suggested = primary_calendars[0] confidence = min(0.85, 0.65 + (proximity_score * 0.1)) # 65-85% confidence reasoning.append("Primary schools create strong morning rush (7:30-9am drop-off)") reasoning.append("Primary calendars recommended for bakeries near schools") elif secondary_calendars: suggested = secondary_calendars[0] confidence = 0.70 reasoning.append("Secondary school calendars available (later morning start)") else: suggested = calendars[0] confidence = 0.50 reasoning.append("Using available calendar (school type not specified)") # Heuristic 2: No schools detected else: reasoning.append("No schools detected within 500m radius") if primary_calendars: suggested = primary_calendars[0] confidence = 0.60 # Lower confidence without detected schools reasoning.append("Defaulting to primary calendar (more common, safer choice)") reasoning.append("Primary school holidays still affect general foot traffic") elif secondary_calendars: suggested = secondary_calendars[0] confidence = 0.55 reasoning.append("Secondary calendar available as default") elif other_calendars: suggested = other_calendars[0] confidence = 0.50 reasoning.append("Using available calendar") else: suggested = calendars[0] confidence = 0.45 reasoning.append("No preferred calendar type available") # Confidence adjustment based on school analysis quality if school_analysis: if school_analysis.get("school_count", 0) >= 3: confidence = min(1.0, confidence + 0.05) # Boost for multiple schools reasoning.append("High confidence: Multiple schools detected") proximity = school_analysis.get("proximity_score", 0.0) if proximity > 2.0: confidence = min(1.0, confidence + 0.05) # Boost for close proximity reasoning.append("High confidence: Schools very close to bakery") # Determine if we should auto-assign # Only auto-assign if confidence >= 75% AND schools detected should_auto_assign = ( confidence >= 0.75 and school_analysis is not None and school_analysis.get("has_schools_nearby", False) ) # Build fallback suggestions fallback_calendars = [] for cal in calendars: if cal.get("id") != suggested.get("id"): fallback_calendars.append({ "calendar_id": str(cal.get("id")), "calendar_name": cal.get("name"), "school_type": cal.get("school_type"), "academic_year": cal.get("academic_year") }) return { "suggested_calendar_id": str(suggested.get("id")), "calendar_name": suggested.get("name"), "school_type": suggested.get("school_type"), "academic_year": suggested.get("academic_year"), "confidence": round(confidence, 2), "confidence_percentage": round(confidence * 100, 1), "reasoning": reasoning, "fallback_calendars": fallback_calendars[:2], # Top 2 alternatives "should_auto_assign": should_auto_assign, "school_analysis": school_analysis, "city_id": city_id } def _no_calendars_available(self, city_id: str) -> Dict[str, Any]: """Return response when no calendars available for city.""" return { "suggested_calendar_id": None, "calendar_name": None, "school_type": None, "academic_year": None, "confidence": 0.0, "confidence_percentage": 0.0, "reasoning": [ f"No school calendars configured for city: {city_id}", "Calendar assignment not possible at this time", "Location context created without calendar (can be added later)" ], "fallback_calendars": [], "should_auto_assign": False, "school_analysis": None, "city_id": city_id } def format_suggestion_for_admin(self, suggestion: Dict[str, Any]) -> str: """ Format suggestion as human-readable text for admin UI. Args: suggestion: Suggestion dict from suggest_calendar_for_tenant Returns: Formatted string for display """ if not suggestion.get("suggested_calendar_id"): return f"⚠️ No calendars available for {suggestion.get('city_id', 'this city')}" confidence_pct = suggestion.get("confidence_percentage", 0) calendar_name = suggestion.get("calendar_name", "Unknown") school_type = suggestion.get("school_type", "").capitalize() # Confidence emoji if confidence_pct >= 80: emoji = "✅" elif confidence_pct >= 60: emoji = "📊" else: emoji = "💡" text = f"{emoji} **Suggested**: {calendar_name}\n" text += f"**Type**: {school_type} | **Confidence**: {confidence_pct}%\n\n" text += "**Reasoning**:\n" for reason in suggestion.get("reasoning", []): text += f"• {reason}\n" if suggestion.get("fallback_calendars"): text += "\n**Alternatives**:\n" for alt in suggestion.get("fallback_calendars", [])[:2]: text += f"• {alt.get('calendar_name')} ({alt.get('school_type')})\n" return text