453 lines
14 KiB
Python
453 lines
14 KiB
Python
"""
|
|
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
|
|
|
|
logger = structlog.get_logger()
|
|
|
|
router = APIRouter(prefix="/poi-context", tags=["POI Context"])
|
|
|
|
|
|
@router.post("/{tenant_id}/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", []))
|
|
)
|
|
|
|
return {
|
|
"status": "success",
|
|
"source": "detection",
|
|
"poi_context": poi_context.to_dict(),
|
|
"feature_selection": feature_selection,
|
|
"competitor_analysis": competitor_analysis,
|
|
"competitive_insights": competitive_insights
|
|
}
|
|
|
|
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("/{tenant_id}")
|
|
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("/{tenant_id}/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("/{tenant_id}")
|
|
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("/{tenant_id}/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("/{tenant_id}/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)}"
|
|
)
|