imporve features

This commit is contained in:
Urtzi Alfaro
2025-11-14 07:23:56 +01:00
parent 9bc048d360
commit a8d8828935
32 changed files with 5436 additions and 271 deletions

View File

@@ -213,17 +213,17 @@ async def check_is_school_holiday(
response_model=TenantLocationContextResponse
)
async def get_tenant_location_context(
tenant_id: UUID = Depends(get_current_user_dep),
tenant_id: str = Path(..., description="Tenant ID"),
current_user: dict = Depends(get_current_user_dep),
db: AsyncSession = Depends(get_db)
):
"""Get location context for a tenant including school calendar assignment (cached)"""
try:
tenant_id_str = str(tenant_id)
# Check cache first
cached = await cache.get_cached_tenant_context(tenant_id_str)
cached = await cache.get_cached_tenant_context(tenant_id)
if cached:
logger.debug("Returning cached tenant context", tenant_id=tenant_id_str)
logger.debug("Returning cached tenant context", tenant_id=tenant_id)
return TenantLocationContextResponse(**cached)
# Cache miss - fetch from database
@@ -261,11 +261,16 @@ async def get_tenant_location_context(
)
async def create_or_update_tenant_location_context(
request: TenantLocationContextCreateRequest,
tenant_id: UUID = Depends(get_current_user_dep),
tenant_id: str = Path(..., description="Tenant ID"),
current_user: dict = Depends(get_current_user_dep),
db: AsyncSession = Depends(get_db)
):
"""Create or update tenant location context"""
try:
# Convert to UUID for use with repository
tenant_uuid = UUID(tenant_id)
repo = CalendarRepository(db)
# Validate calendar_id if provided
@@ -279,7 +284,7 @@ async def create_or_update_tenant_location_context(
# Create or update context
context_obj = await repo.create_or_update_tenant_location_context(
tenant_id=tenant_id,
tenant_id=tenant_uuid,
city_id=request.city_id,
school_calendar_id=request.school_calendar_id,
neighborhood=request.neighborhood,
@@ -288,13 +293,13 @@ async def create_or_update_tenant_location_context(
)
# Invalidate cache since context was updated
await cache.invalidate_tenant_context(str(tenant_id))
await cache.invalidate_tenant_context(tenant_id)
# Get full context with calendar details
context = await repo.get_tenant_with_calendar(tenant_id)
context = await repo.get_tenant_with_calendar(tenant_uuid)
# Cache the new context
await cache.set_cached_tenant_context(str(tenant_id), context)
await cache.set_cached_tenant_context(tenant_id, context)
return TenantLocationContextResponse(**context)
@@ -317,13 +322,18 @@ async def create_or_update_tenant_location_context(
status_code=204
)
async def delete_tenant_location_context(
tenant_id: UUID = Depends(get_current_user_dep),
tenant_id: str = Path(..., description="Tenant ID"),
current_user: dict = Depends(get_current_user_dep),
db: AsyncSession = Depends(get_db)
):
"""Delete tenant location context"""
try:
# Convert to UUID for use with repository
tenant_uuid = UUID(tenant_id)
repo = CalendarRepository(db)
deleted = await repo.delete_tenant_location_context(tenant_id)
deleted = await repo.delete_tenant_location_context(tenant_uuid)
if not deleted:
raise HTTPException(
@@ -347,6 +357,97 @@ async def delete_tenant_location_context(
)
# ===== Calendar Suggestion Endpoint =====
@router.post(
route_builder.build_base_route("location-context/suggest-calendar")
)
async def suggest_calendar_for_tenant(
tenant_id: str = Path(..., description="Tenant ID"),
current_user: dict = Depends(get_current_user_dep),
db: AsyncSession = Depends(get_db)
):
"""
Suggest an appropriate school calendar for a tenant based on location and POI data.
This endpoint analyzes:
- Tenant's city location
- Detected schools nearby (from POI detection)
- Available calendars for the city
- Bakery-specific heuristics (primary schools = stronger morning rush)
Returns a suggestion with confidence score and reasoning.
Does NOT automatically assign - requires admin approval.
"""
try:
from app.utils.calendar_suggester import CalendarSuggester
from app.repositories.poi_context_repository import POIContextRepository
tenant_uuid = UUID(tenant_id)
# Get tenant's location context
calendar_repo = CalendarRepository(db)
location_context = await calendar_repo.get_tenant_location_context(tenant_uuid)
if not location_context:
raise HTTPException(
status_code=404,
detail="Location context not found. Create location context first."
)
city_id = location_context.city_id
# Get available calendars for city
calendars_result = await calendar_repo.get_calendars_by_city(city_id, enabled_only=True)
calendars = calendars_result.get("calendars", []) if calendars_result else []
# Get POI context if available
poi_repo = POIContextRepository(db)
poi_context = await poi_repo.get_by_tenant_id(tenant_uuid)
poi_data = poi_context.to_dict() if poi_context else None
# Generate suggestion
suggester = CalendarSuggester()
suggestion = suggester.suggest_calendar_for_tenant(
city_id=city_id,
available_calendars=calendars,
poi_context=poi_data,
tenant_data=None # Could include tenant info if needed
)
# Format for admin display
admin_message = suggester.format_suggestion_for_admin(suggestion)
logger.info(
"Calendar suggestion generated",
tenant_id=tenant_id,
city_id=city_id,
suggested_calendar=suggestion.get("suggested_calendar_id"),
confidence=suggestion.get("confidence")
)
return {
**suggestion,
"admin_message": admin_message,
"tenant_id": tenant_id,
"current_calendar_id": str(location_context.school_calendar_id) if location_context.school_calendar_id else None
}
except HTTPException:
raise
except Exception as e:
logger.error(
"Error generating calendar suggestion",
tenant_id=tenant_id,
error=str(e),
exc_info=True
)
raise HTTPException(
status_code=500,
detail=f"Error generating calendar suggestion: {str(e)}"
)
# ===== Helper Endpoints =====
@router.get(

View File

@@ -21,10 +21,10 @@ from app.core.redis_client import get_redis_client
logger = structlog.get_logger()
router = APIRouter(prefix="/poi-context", tags=["POI Context"])
router = APIRouter(prefix="/tenants", tags=["POI Context"])
@router.post("/{tenant_id}/detect")
@router.post("/{tenant_id}/poi-context/detect")
async def detect_pois_for_tenant(
tenant_id: str,
latitude: float = Query(..., description="Bakery latitude"),
@@ -209,13 +209,79 @@ async def detect_pois_for_tenant(
relevant_categories=len(feature_selection.get("relevant_categories", []))
)
# Phase 3: Auto-trigger calendar suggestion after POI detection
# This helps admins by providing intelligent calendar recommendations
calendar_suggestion = None
try:
from app.utils.calendar_suggester import CalendarSuggester
from app.repositories.calendar_repository import CalendarRepository
# Get tenant's location context
calendar_repo = CalendarRepository(db)
location_context = await calendar_repo.get_tenant_location_context(tenant_uuid)
if location_context and location_context.school_calendar_id is None:
# Only suggest if no calendar assigned yet
city_id = location_context.city_id
# Get available calendars for city
calendars_result = await calendar_repo.get_calendars_by_city(city_id, enabled_only=True)
calendars = calendars_result.get("calendars", []) if calendars_result else []
if calendars:
# Generate suggestion using POI data
suggester = CalendarSuggester()
calendar_suggestion = suggester.suggest_calendar_for_tenant(
city_id=city_id,
available_calendars=calendars,
poi_context=poi_context.to_dict(),
tenant_data=None
)
logger.info(
"Calendar suggestion auto-generated after POI detection",
tenant_id=tenant_id,
suggested_calendar=calendar_suggestion.get("calendar_name"),
confidence=calendar_suggestion.get("confidence_percentage"),
should_auto_assign=calendar_suggestion.get("should_auto_assign")
)
# TODO: Send notification to admin about available suggestion
# This will be implemented when notification service is integrated
else:
logger.info(
"No calendars available for city, skipping suggestion",
tenant_id=tenant_id,
city_id=city_id
)
elif location_context and location_context.school_calendar_id:
logger.info(
"Calendar already assigned, skipping suggestion",
tenant_id=tenant_id,
calendar_id=str(location_context.school_calendar_id)
)
else:
logger.warning(
"No location context found, skipping calendar suggestion",
tenant_id=tenant_id
)
except Exception as e:
# Non-blocking: POI detection should succeed even if suggestion fails
logger.warning(
"Failed to auto-generate calendar suggestion (non-blocking)",
tenant_id=tenant_id,
error=str(e)
)
return {
"status": "success",
"source": "detection",
"poi_context": poi_context.to_dict(),
"feature_selection": feature_selection,
"competitor_analysis": competitor_analysis,
"competitive_insights": competitive_insights
"competitive_insights": competitive_insights,
"calendar_suggestion": calendar_suggestion # Include suggestion in response
}
except Exception as e:
@@ -231,7 +297,7 @@ async def detect_pois_for_tenant(
)
@router.get("/{tenant_id}")
@router.get("/{tenant_id}/poi-context")
async def get_poi_context(
tenant_id: str,
db: AsyncSession = Depends(get_db)
@@ -265,7 +331,7 @@ async def get_poi_context(
}
@router.post("/{tenant_id}/refresh")
@router.post("/{tenant_id}/poi-context/refresh")
async def refresh_poi_context(
tenant_id: str,
db: AsyncSession = Depends(get_db)
@@ -299,7 +365,7 @@ async def refresh_poi_context(
)
@router.delete("/{tenant_id}")
@router.delete("/{tenant_id}/poi-context")
async def delete_poi_context(
tenant_id: str,
db: AsyncSession = Depends(get_db)
@@ -327,7 +393,7 @@ async def delete_poi_context(
}
@router.get("/{tenant_id}/feature-importance")
@router.get("/{tenant_id}/poi-context/feature-importance")
async def get_feature_importance(
tenant_id: str,
db: AsyncSession = Depends(get_db)
@@ -364,7 +430,7 @@ async def get_feature_importance(
}
@router.get("/{tenant_id}/competitor-analysis")
@router.get("/{tenant_id}/poi-context/competitor-analysis")
async def get_competitor_analysis(
tenant_id: str,
db: AsyncSession = Depends(get_db)

View File

@@ -0,0 +1,342 @@
"""
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