Initial commit - production deployment
This commit is contained in:
532
services/external/app/api/poi_context.py
vendored
Normal file
532
services/external/app/api/poi_context.py
vendored
Normal file
@@ -0,0 +1,532 @@
|
||||
"""
|
||||
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)}"
|
||||
)
|
||||
Reference in New Issue
Block a user