""" POI Context API Endpoints REST API for POI detection, retrieval, and management. """ from fastapi import APIRouter, Depends, HTTPException, Query from sqlalchemy.ext.asyncio import AsyncSession from typing import Optional import structlog import uuid from app.core.database import get_db from app.services.poi_detection_service import POIDetectionService from app.services.poi_feature_selector import POIFeatureSelector from app.services.competitor_analyzer import CompetitorAnalyzer from app.services.poi_refresh_service import POIRefreshService from app.repositories.poi_context_repository import POIContextRepository from app.cache.poi_cache_service import POICacheService from app.core.redis_client import get_redis_client from shared.routing.route_builder import RouteBuilder logger = structlog.get_logger() route_builder = RouteBuilder('external') router = APIRouter(tags=["POI Context"]) @router.post( route_builder.build_base_route("poi-context/detect") ) async def detect_pois_for_tenant( tenant_id: str, latitude: float = Query(..., description="Bakery latitude"), longitude: float = Query(..., description="Bakery longitude"), force_refresh: bool = Query(False, description="Force refresh, skip cache"), db: AsyncSession = Depends(get_db) ): """ Detect POIs for a tenant's bakery location. Performs automated POI detection using Overpass API, calculates ML features, and stores results for demand forecasting. """ try: tenant_uuid = uuid.UUID(tenant_id) except ValueError: raise HTTPException(status_code=400, detail="Invalid tenant_id format") logger.info( "POI detection requested", tenant_id=tenant_id, location=(latitude, longitude), force_refresh=force_refresh ) try: # Initialize services poi_service = POIDetectionService() feature_selector = POIFeatureSelector() competitor_analyzer = CompetitorAnalyzer() poi_repo = POIContextRepository(db) redis_client = await get_redis_client() cache_service = POICacheService(redis_client) # Check cache first (unless force refresh) if not force_refresh: cached_result = await cache_service.get_cached_pois(latitude, longitude) if cached_result: logger.info("Using cached POI results", tenant_id=tenant_id) # Still save to database for this tenant poi_context = await poi_repo.create_or_update(tenant_uuid, cached_result) return { "status": "success", "source": "cache", "poi_context": poi_context.to_dict() } # Detect POIs poi_results = await poi_service.detect_pois_for_bakery( latitude, longitude, tenant_id ) # Select relevant features try: feature_selection = feature_selector.select_relevant_features( poi_results["poi_categories"], tenant_id ) except Exception as e: logger.error( "Feature selection failed", tenant_id=tenant_id, error=str(e), exc_info=True ) # Provide default feature selection to continue feature_selection = { "features": {}, "relevant_categories": [], "relevance_report": [], "total_features": 0, "total_relevant_categories": 0 } # Analyze competitors specifically try: competitors_data = poi_results["poi_categories"].get("competitors", {}) competitor_pois = competitors_data.get("pois", []) competitor_analysis = competitor_analyzer.analyze_competitive_landscape( competitor_pois, (latitude, longitude), tenant_id ) except Exception as e: logger.error( "Competitor analysis failed", tenant_id=tenant_id, error=str(e), exc_info=True ) # Provide default competitor analysis to continue competitor_analysis = { "competitive_pressure_score": 0.0, "direct_competitors_count": 0, "nearby_competitors_count": 0, "market_competitors_count": 0, "total_competitors_count": 0, "competitive_zone": "low_competition", "market_type": "underserved", "competitive_advantage": "first_mover", "ml_feature_competitive_pressure": 0.0, "ml_feature_has_direct_competitor": 0, "ml_feature_competitor_density_500m": 0, "competitor_details": [], "nearest_competitor": None } # Generate competitive insights try: competitive_insights = competitor_analyzer.get_competitive_insights( competitor_analysis ) except Exception as e: logger.warning( "Failed to generate competitive insights", tenant_id=tenant_id, error=str(e) ) competitive_insights = [] # Combine results enhanced_results = { **poi_results, "ml_features": feature_selection.get("features", {}), "relevant_categories": feature_selection.get("relevant_categories", []), "relevance_report": feature_selection.get("relevance_report", []), "competitor_analysis": competitor_analysis, "competitive_insights": competitive_insights } # Cache results try: await cache_service.cache_poi_results(latitude, longitude, enhanced_results) except Exception as e: logger.warning( "Failed to cache POI results", tenant_id=tenant_id, error=str(e) ) # Save to database try: poi_context = await poi_repo.create_or_update(tenant_uuid, enhanced_results) except Exception as e: logger.error( "Failed to save POI context to database", tenant_id=tenant_id, error=str(e), exc_info=True ) raise HTTPException( status_code=500, detail=f"Failed to save POI context: {str(e)}" ) # Schedule automatic refresh job (180 days from now) try: poi_refresh_service = POIRefreshService() refresh_job = await poi_refresh_service.schedule_refresh_job( tenant_id=tenant_id, latitude=latitude, longitude=longitude, session=db ) logger.info( "POI refresh job scheduled", tenant_id=tenant_id, job_id=str(refresh_job.id), scheduled_at=refresh_job.scheduled_at ) except Exception as e: logger.warning( "Failed to schedule POI refresh job", tenant_id=tenant_id, error=str(e) ) logger.info( "POI detection completed", tenant_id=tenant_id, total_pois=poi_context.total_pois_detected, 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, "calendar_suggestion": calendar_suggestion # Include suggestion in response } except Exception as e: logger.error( "POI detection failed", tenant_id=tenant_id, error=str(e), exc_info=True ) raise HTTPException( status_code=500, detail=f"POI detection failed: {str(e)}" ) @router.get( route_builder.build_base_route("poi-context") ) async def get_poi_context( tenant_id: str, db: AsyncSession = Depends(get_db) ): """ Get POI context for a tenant. Returns stored POI detection results and ML features. """ try: tenant_uuid = uuid.UUID(tenant_id) except ValueError: raise HTTPException(status_code=400, detail="Invalid tenant_id format") poi_repo = POIContextRepository(db) poi_context = await poi_repo.get_by_tenant_id(tenant_uuid) if not poi_context: raise HTTPException( status_code=404, detail=f"POI context not found for tenant {tenant_id}" ) # Check if stale is_stale = poi_context.is_stale() return { "poi_context": poi_context.to_dict(), "is_stale": is_stale, "needs_refresh": is_stale } @router.post( route_builder.build_base_route("poi-context/refresh") ) async def refresh_poi_context( tenant_id: str, db: AsyncSession = Depends(get_db) ): """ Refresh POI context for a tenant. Re-detects POIs and updates stored data. """ try: tenant_uuid = uuid.UUID(tenant_id) except ValueError: raise HTTPException(status_code=400, detail="Invalid tenant_id format") poi_repo = POIContextRepository(db) existing_context = await poi_repo.get_by_tenant_id(tenant_uuid) if not existing_context: raise HTTPException( status_code=404, detail=f"POI context not found for tenant {tenant_id}. Use detect endpoint first." ) # Perform detection with force_refresh=True return await detect_pois_for_tenant( tenant_id=tenant_id, latitude=existing_context.latitude, longitude=existing_context.longitude, force_refresh=True, db=db ) @router.delete( route_builder.build_base_route("poi-context") ) async def delete_poi_context( tenant_id: str, db: AsyncSession = Depends(get_db) ): """ Delete POI context for a tenant. """ try: tenant_uuid = uuid.UUID(tenant_id) except ValueError: raise HTTPException(status_code=400, detail="Invalid tenant_id format") poi_repo = POIContextRepository(db) deleted = await poi_repo.delete_by_tenant_id(tenant_uuid) if not deleted: raise HTTPException( status_code=404, detail=f"POI context not found for tenant {tenant_id}" ) return { "status": "success", "message": f"POI context deleted for tenant {tenant_id}" } @router.get( route_builder.build_base_route("poi-context/feature-importance") ) async def get_feature_importance( tenant_id: str, db: AsyncSession = Depends(get_db) ): """ Get feature importance summary for tenant's POI context. Shows which POI categories are relevant and their impact scores. """ try: tenant_uuid = uuid.UUID(tenant_id) except ValueError: raise HTTPException(status_code=400, detail="Invalid tenant_id format") poi_repo = POIContextRepository(db) poi_context = await poi_repo.get_by_tenant_id(tenant_uuid) if not poi_context: raise HTTPException( status_code=404, detail=f"POI context not found for tenant {tenant_id}" ) feature_selector = POIFeatureSelector() importance_summary = feature_selector.get_feature_importance_summary( poi_context.poi_detection_results ) return { "tenant_id": tenant_id, "feature_importance": importance_summary, "total_categories": len(importance_summary), "relevant_categories": sum(1 for cat in importance_summary if cat["is_relevant"]) } @router.get( route_builder.build_base_route("poi-context/competitor-analysis") ) async def get_competitor_analysis( tenant_id: str, db: AsyncSession = Depends(get_db) ): """ Get detailed competitor analysis for tenant location. """ try: tenant_uuid = uuid.UUID(tenant_id) except ValueError: raise HTTPException(status_code=400, detail="Invalid tenant_id format") poi_repo = POIContextRepository(db) poi_context = await poi_repo.get_by_tenant_id(tenant_uuid) if not poi_context: raise HTTPException( status_code=404, detail=f"POI context not found for tenant {tenant_id}" ) competitor_analyzer = CompetitorAnalyzer() competitors = poi_context.poi_detection_results.get("competitors", {}).get("pois", []) analysis = competitor_analyzer.analyze_competitive_landscape( competitors, (poi_context.latitude, poi_context.longitude), tenant_id ) insights = competitor_analyzer.get_competitive_insights(analysis) return { "tenant_id": tenant_id, "location": { "latitude": poi_context.latitude, "longitude": poi_context.longitude }, "competitor_analysis": analysis, "insights": insights } @router.get("/health") async def poi_health_check(): """ Check POI detection service health. Verifies Overpass API accessibility. """ poi_service = POIDetectionService() health = await poi_service.health_check() if not health["healthy"]: raise HTTPException( status_code=503, detail=f"POI detection service unhealthy: {health.get('error', 'Unknown error')}" ) return { "status": "healthy", "overpass_api": health } @router.get("/cache/stats") async def get_cache_stats(): """ Get POI cache statistics. """ try: redis_client = await get_redis_client() cache_service = POICacheService(redis_client) stats = await cache_service.get_cache_stats() return { "status": "success", "cache_stats": stats } except Exception as e: logger.error("Failed to get cache stats", error=str(e)) raise HTTPException( status_code=500, detail=f"Failed to get cache stats: {str(e)}" )