Add forecasting service

This commit is contained in:
Urtzi Alfaro
2025-07-21 19:48:56 +02:00
parent 2d85dd3e9e
commit 0e7ca10a29
24 changed files with 2937 additions and 179 deletions

View File

@@ -1,72 +0,0 @@
from fastapi import APIRouter, Depends, HTTPException, Query, BackgroundTasks
from typing import List, Optional, Dict, Any
from datetime import datetime, date
import structlog
from app.schemas.forecast import (
ForecastRequest,
ForecastResponse,
BatchForecastRequest,
ForecastPerformanceResponse
)
from app.services.forecast_service import ForecastService
from app.services.messaging import publish_forecast_generated
# Import unified authentication
from shared.auth.decorators import (
get_current_user_dep,
get_current_tenant_id_dep
)
router = APIRouter(prefix="/forecasts", tags=["forecasting"])
logger = structlog.get_logger()
@router.post("/generate", response_model=ForecastResponse)
async def generate_forecast(
request: ForecastRequest,
background_tasks: BackgroundTasks,
tenant_id: str = Depends(get_current_tenant_id_dep),
current_user: Dict[str, Any] = Depends(get_current_user_dep),
):
"""Generate forecast for products"""
try:
logger.info("Generating forecast",
tenant_id=tenant_id,
user_id=current_user["user_id"],
products=len(request.products) if request.products else "all")
forecast_service = ForecastService()
# Ensure products belong to tenant
if request.products:
valid_products = await forecast_service.validate_products(
tenant_id, request.products
)
if len(valid_products) != len(request.products):
raise HTTPException(
status_code=400,
detail="Some products not found or not accessible"
)
# Generate forecast
forecast = await forecast_service.generate_forecast(
tenant_id=tenant_id,
request=request,
user_id=current_user["user_id"]
)
# Publish event
background_tasks.add_task(
publish_forecast_generated,
forecast_id=forecast.id,
tenant_id=tenant_id,
user_id=current_user["user_id"]
)
return forecast
except HTTPException:
raise
except Exception as e:
logger.error("Failed to generate forecast", error=str(e))
raise HTTPException(status_code=500, detail=str(e))

View File

@@ -0,0 +1,326 @@
# ================================================================
# services/forecasting/app/api/forecasts.py
# ================================================================
"""
Forecast API endpoints
"""
import structlog
from fastapi import APIRouter, Depends, HTTPException, status, Query
from sqlalchemy.ext.asyncio import AsyncSession
from typing import List, Optional
from datetime import date
from app.core.database import get_db
from app.core.auth import get_current_user_from_headers
from app.services.forecasting_service import ForecastingService
from app.schemas.forecasts import (
ForecastRequest, ForecastResponse, BatchForecastRequest,
BatchForecastResponse, AlertResponse
)
from app.models.forecasts import Forecast, PredictionBatch, ForecastAlert
logger = structlog.get_logger()
router = APIRouter()
# Initialize service
forecasting_service = ForecastingService()
@router.post("/single", response_model=ForecastResponse)
async def create_single_forecast(
request: ForecastRequest,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user_from_headers)
):
"""Generate a single product forecast"""
try:
# Verify tenant access
if str(request.tenant_id) != str(current_user.get("tenant_id")):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Access denied to this tenant"
)
# Generate forecast
forecast = await forecasting_service.generate_forecast(request, db)
# Convert to response model
return ForecastResponse(
id=str(forecast.id),
tenant_id=str(forecast.tenant_id),
product_name=forecast.product_name,
location=forecast.location,
forecast_date=forecast.forecast_date,
predicted_demand=forecast.predicted_demand,
confidence_lower=forecast.confidence_lower,
confidence_upper=forecast.confidence_upper,
confidence_level=forecast.confidence_level,
model_id=str(forecast.model_id),
model_version=forecast.model_version,
algorithm=forecast.algorithm,
business_type=forecast.business_type,
is_holiday=forecast.is_holiday,
is_weekend=forecast.is_weekend,
day_of_week=forecast.day_of_week,
weather_temperature=forecast.weather_temperature,
weather_precipitation=forecast.weather_precipitation,
weather_description=forecast.weather_description,
traffic_volume=forecast.traffic_volume,
created_at=forecast.created_at,
processing_time_ms=forecast.processing_time_ms,
features_used=forecast.features_used
)
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e)
)
except Exception as e:
logger.error("Error creating single forecast", error=str(e))
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Internal server error"
)
@router.post("/batch", response_model=BatchForecastResponse)
async def create_batch_forecast(
request: BatchForecastRequest,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user_from_headers)
):
"""Generate batch forecasts for multiple products"""
try:
# Verify tenant access
if str(request.tenant_id) != str(current_user.get("tenant_id")):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Access denied to this tenant"
)
# Generate batch forecast
batch = await forecasting_service.generate_batch_forecast(request, db)
# Get associated forecasts
forecasts = await forecasting_service.get_forecasts(
tenant_id=request.tenant_id,
location=request.location,
db=db
)
# Convert forecasts to response models
forecast_responses = []
for forecast in forecasts[:batch.total_products]: # Limit to batch size
forecast_responses.append(ForecastResponse(
id=str(forecast.id),
tenant_id=str(forecast.tenant_id),
product_name=forecast.product_name,
location=forecast.location,
forecast_date=forecast.forecast_date,
predicted_demand=forecast.predicted_demand,
confidence_lower=forecast.confidence_lower,
confidence_upper=forecast.confidence_upper,
confidence_level=forecast.confidence_level,
model_id=str(forecast.model_id),
model_version=forecast.model_version,
algorithm=forecast.algorithm,
business_type=forecast.business_type,
is_holiday=forecast.is_holiday,
is_weekend=forecast.is_weekend,
day_of_week=forecast.day_of_week,
weather_temperature=forecast.weather_temperature,
weather_precipitation=forecast.weather_precipitation,
weather_description=forecast.weather_description,
traffic_volume=forecast.traffic_volume,
created_at=forecast.created_at,
processing_time_ms=forecast.processing_time_ms,
features_used=forecast.features_used
))
return BatchForecastResponse(
id=str(batch.id),
tenant_id=str(batch.tenant_id),
batch_name=batch.batch_name,
status=batch.status,
total_products=batch.total_products,
completed_products=batch.completed_products,
failed_products=batch.failed_products,
requested_at=batch.requested_at,
completed_at=batch.completed_at,
processing_time_ms=batch.processing_time_ms,
forecasts=forecast_responses
)
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e)
)
except Exception as e:
logger.error("Error creating batch forecast", error=str(e))
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Internal server error"
)
@router.get("/list", response_model=List[ForecastResponse])
async def list_forecasts(
location: str,
start_date: Optional[date] = Query(None),
end_date: Optional[date] = Query(None),
product_name: Optional[str] = Query(None),
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user_from_headers)
):
"""List forecasts with filtering"""
try:
tenant_id = str(current_user.get("tenant_id"))
# Get forecasts
forecasts = await forecasting_service.get_forecasts(
tenant_id=tenant_id,
location=location,
start_date=start_date,
end_date=end_date,
product_name=product_name,
db=db
)
# Convert to response models
return [
ForecastResponse(
id=str(forecast.id),
tenant_id=str(forecast.tenant_id),
product_name=forecast.product_name,
location=forecast.location,
forecast_date=forecast.forecast_date,
predicted_demand=forecast.predicted_demand,
confidence_lower=forecast.confidence_lower,
confidence_upper=forecast.confidence_upper,
confidence_level=forecast.confidence_level,
model_id=str(forecast.model_id),
model_version=forecast.model_version,
algorithm=forecast.algorithm,
business_type=forecast.business_type,
is_holiday=forecast.is_holiday,
is_weekend=forecast.is_weekend,
day_of_week=forecast.day_of_week,
weather_temperature=forecast.weather_temperature,
weather_precipitation=forecast.weather_precipitation,
weather_description=forecast.weather_description,
traffic_volume=forecast.traffic_volume,
created_at=forecast.created_at,
processing_time_ms=forecast.processing_time_ms,
features_used=forecast.features_used
)
for forecast in forecasts
]
except Exception as e:
logger.error("Error listing forecasts", error=str(e))
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Internal server error"
)
@router.get("/alerts", response_model=List[AlertResponse])
async def get_forecast_alerts(
active_only: bool = Query(True),
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user_from_headers)
):
"""Get forecast alerts for tenant"""
try:
from sqlalchemy import select, and_
tenant_id = current_user.get("tenant_id")
# Build query
query = select(ForecastAlert).where(
ForecastAlert.tenant_id == tenant_id
)
if active_only:
query = query.where(ForecastAlert.is_active == True)
query = query.order_by(ForecastAlert.created_at.desc())
# Execute query
result = await db.execute(query)
alerts = result.scalars().all()
# Convert to response models
return [
AlertResponse(
id=str(alert.id),
tenant_id=str(alert.tenant_id),
forecast_id=str(alert.forecast_id),
alert_type=alert.alert_type,
severity=alert.severity,
message=alert.message,
is_active=alert.is_active,
created_at=alert.created_at,
acknowledged_at=alert.acknowledged_at,
notification_sent=alert.notification_sent
)
for alert in alerts
]
except Exception as e:
logger.error("Error getting forecast alerts", error=str(e))
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Internal server error"
)
@router.put("/alerts/{alert_id}/acknowledge")
async def acknowledge_alert(
alert_id: str,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user_from_headers)
):
"""Acknowledge a forecast alert"""
try:
from sqlalchemy import select, update
from datetime import datetime
tenant_id = current_user.get("tenant_id")
# Get alert
result = await db.execute(
select(ForecastAlert).where(
and_(
ForecastAlert.id == alert_id,
ForecastAlert.tenant_id == tenant_id
)
)
)
alert = result.scalar_one_or_none()
if not alert:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Alert not found"
)
# Update alert
alert.acknowledged_at = datetime.now()
alert.is_active = False
await db.commit()
return {"message": "Alert acknowledged successfully"}
except HTTPException:
raise
except Exception as e:
logger.error("Error acknowledging alert", error=str(e))
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Internal server error"
)

View File

@@ -0,0 +1,141 @@
# ================================================================
# services/forecasting/app/api/predictions.py
# ================================================================
"""
Prediction API endpoints - Real-time prediction capabilities
"""
import structlog
from fastapi import APIRouter, Depends, HTTPException, status, Query
from sqlalchemy.ext.asyncio import AsyncSession
from typing import List, Dict, Any
from datetime import date, datetime, timedelta
from app.core.database import get_db
from app.core.auth import get_current_user_from_headers
from app.services.prediction_service import PredictionService
from app.schemas.forecasts import ForecastRequest
logger = structlog.get_logger()
router = APIRouter()
# Initialize service
prediction_service = PredictionService()
@router.post("/realtime")
async def get_realtime_prediction(
product_name: str,
location: str,
forecast_date: date,
features: Dict[str, Any],
current_user: dict = Depends(get_current_user_from_headers)
):
"""Get real-time prediction without storing in database"""
try:
tenant_id = str(current_user.get("tenant_id"))
# Get latest model
from app.services.forecasting_service import ForecastingService
forecasting_service = ForecastingService()
model_info = await forecasting_service._get_latest_model(
tenant_id, product_name, location
)
if not model_info:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"No trained model found for {product_name}"
)
# Generate prediction
prediction = await prediction_service.predict(
model_id=model_info["model_id"],
features=features,
confidence_level=0.8
)
return {
"product_name": product_name,
"location": location,
"forecast_date": forecast_date,
"predicted_demand": prediction["demand"],
"confidence_lower": prediction["lower_bound"],
"confidence_upper": prediction["upper_bound"],
"model_id": model_info["model_id"],
"model_version": model_info["version"],
"generated_at": datetime.now(),
"features_used": features
}
except HTTPException:
raise
except Exception as e:
logger.error("Error getting realtime prediction", error=str(e))
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Internal server error"
)
@router.get("/quick/{product_name}")
async def get_quick_prediction(
product_name: str,
location: str = Query(...),
days_ahead: int = Query(1, ge=1, le=7),
current_user: dict = Depends(get_current_user_from_headers)
):
"""Get quick prediction for next few days"""
try:
tenant_id = str(current_user.get("tenant_id"))
# Generate predictions for the next N days
predictions = []
for day in range(1, days_ahead + 1):
forecast_date = date.today() + timedelta(days=day)
# Prepare basic features
features = {
"date": forecast_date.isoformat(),
"day_of_week": forecast_date.weekday(),
"is_weekend": forecast_date.weekday() >= 5,
"business_type": "individual"
}
# Get model and predict
from app.services.forecasting_service import ForecastingService
forecasting_service = ForecastingService()
model_info = await forecasting_service._get_latest_model(
tenant_id, product_name, location
)
if model_info:
prediction = await prediction_service.predict(
model_id=model_info["model_id"],
features=features
)
predictions.append({
"date": forecast_date,
"predicted_demand": prediction["demand"],
"confidence_lower": prediction["lower_bound"],
"confidence_upper": prediction["upper_bound"]
})
return {
"product_name": product_name,
"location": location,
"predictions": predictions,
"generated_at": datetime.now()
}
except Exception as e:
logger.error("Error getting quick prediction", error=str(e))
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Internal server error"
)