Start fixing forecast service API 3

This commit is contained in:
Urtzi Alfaro
2025-07-29 15:08:55 +02:00
parent dfb619a7b5
commit 84ed4a7a2e
14 changed files with 1607 additions and 447 deletions

View File

@@ -47,7 +47,7 @@ async def create_single_forecast(
)
# Generate forecast
forecast = await forecasting_service.generate_forecast(request, db)
forecast = await forecasting_service.generate_forecast(tenant_id, request, db)
# Convert to response model
return ForecastResponse(

View File

@@ -24,14 +24,7 @@ class ForecastRequest(BaseModel):
"""Request schema for generating forecasts"""
tenant_id: str = Field(..., description="Tenant ID")
product_name: str = Field(..., description="Product name")
location: str = Field(..., description="Location identifier")
forecast_date: date = Field(..., description="Date for which to generate forecast")
business_type: BusinessType = Field(BusinessType.INDIVIDUAL, description="Business model type")
# Optional context
include_weather: bool = Field(True, description="Include weather data in forecast")
include_traffic: bool = Field(True, description="Include traffic data in forecast")
confidence_level: float = Field(0.8, ge=0.5, le=0.95, description="Confidence level for intervals")
@validator('forecast_date')
def validate_forecast_date(cls, v):

View File

@@ -0,0 +1,64 @@
# services/training/app/services/data_client.py
"""
Training Service Data Client
Migrated to use shared service clients - much simpler now!
"""
import structlog
from typing import Dict, Any, List, Optional
from datetime import datetime
# Import the shared clients
from shared.clients import get_data_client, get_service_clients
from app.core.config import settings
logger = structlog.get_logger()
class DataClient:
"""
Data client for training service
Now uses the shared data service client under the hood
"""
def __init__(self):
# Get the shared data client configured for this service
self.data_client = get_data_client(settings, "forecasting")
# Or alternatively, get all clients at once:
# self.clients = get_service_clients(settings, "training")
# Then use: self.clients.data.get_sales_data(...)
async def fetch_weather_forecast(
self,
tenant_id: str,
days: str,
latitude: Optional[float] = None,
longitude: Optional[float] = None
) -> List[Dict[str, Any]]:
"""
Fetch weather data for forecats
All the error handling and retry logic is now in the base client!
"""
try:
weather_data = await self.data_client.get_weather_forecast(
tenant_id=tenant_id,
days=days,
latitude=latitude,
longitude=longitude
)
if weather_data:
logger.info(f"Fetched {len(weather_data)} weather records",
tenant_id=tenant_id)
return weather_data
else:
logger.warning("No weather data returned", tenant_id=tenant_id)
return []
except Exception as e:
logger.error(f"Error fetching weather data: {e}", tenant_id=tenant_id)
return []
# Global instance - same as before, but much simpler implementation
data_client = DataClient()

View File

@@ -21,6 +21,8 @@ from app.services.prediction_service import PredictionService
from app.services.messaging import publish_forecast_completed, publish_alert_created
from app.core.config import settings
from shared.monitoring.metrics import MetricsCollector
from app.services.model_client import ModelClient
from app.services.data_client import DataClient
logger = structlog.get_logger()
metrics = MetricsCollector("forecasting-service")
@@ -33,6 +35,8 @@ class ForecastingService:
def __init__(self):
self.prediction_service = PredictionService()
self.model_client = ModelClient()
self.data_client = DataClient()
async def generate_forecast(self, request: ForecastRequest, db: AsyncSession) -> Forecast:
"""Generate a single forecast for a product"""
@@ -47,8 +51,7 @@ class ForecastingService:
# Get the latest trained model for this tenant/product
model_info = await self._get_latest_model(
request.tenant_id,
request.product_name,
request.location
request.product_name,
)
if not model_info:
@@ -66,10 +69,9 @@ class ForecastingService:
# Create forecast record
forecast = Forecast(
tenant_id=uuid.UUID(request.tenant_id),
product_name=request.product_name,
location=request.location,
forecast_date=datetime.combine(request.forecast_date, datetime.min.time()),
tenant_id=uuid.UUID(tenant_id),
product_name=product_name,
forecast_date=datetime.combine(forecast_date, datetime.min.time()),
# Prediction results
predicted_demand=prediction_result["demand"],
@@ -243,27 +245,12 @@ class ForecastingService:
logger.error("Error retrieving forecasts", error=str(e))
raise
async def _get_latest_model(self, tenant_id: str, product_name: str, location: str) -> Optional[Dict[str, Any]]:
async def _get_latest_model(self, tenant_id: str, product_name: str) -> Optional[Dict[str, Any]]:
"""Get the latest trained model for a tenant/product combination"""
try:
# Call training service to get model information
async with httpx.AsyncClient() as client:
response = await client.get(
f"{settings.TRAINING_SERVICE_URL}/tenants/{tenant_id}/models/{product_name}/active",
params={},
headers={"X-Service-Auth": settings.SERVICE_AUTH_TOKEN}
)
if response.status_code == 200:
return response.json()
elif response.status_code == 404:
logger.warning("No model found",
tenant_id=tenant_id,
product=product_name)
return None
else:
response.raise_for_status()
model_data = await self.data_client.get_best_model_for_forecasting(tenant_id, product_name)
return model_data
except Exception as e:
logger.error("Error getting latest model", error=str(e))
@@ -275,22 +262,15 @@ class ForecastingService:
features = {
"date": request.forecast_date.isoformat(),
"day_of_week": request.forecast_date.weekday(),
"is_weekend": request.forecast_date.weekday() >= 5,
"business_type": request.business_type.value
"is_weekend": request.forecast_date.weekday() >= 5
}
# Add Spanish holidays
features["is_holiday"] = await self._is_spanish_holiday(request.forecast_date)
# Add weather data if requested
if request.include_weather:
weather_data = await self._get_weather_forecast(request.forecast_date)
features.update(weather_data)
# Add traffic data if requested
if request.include_traffic:
traffic_data = await self._get_traffic_forecast(request.forecast_date, request.location)
features.update(traffic_data)
weather_data = await self._get_weather_forecast(request.tenant_id, 1)
features.update(weather_data)
return features
@@ -315,61 +295,16 @@ class ForecastingService:
logger.warning("Error checking holiday status", error=str(e))
return False
async def _get_weather_forecast(self, forecast_date: date) -> Dict[str, Any]:
async def _get_weather_forecast(self, tenant_id: str, days: str) -> Dict[str, Any]:
"""Get weather forecast for the date"""
try:
# Call data service for weather forecast
async with httpx.AsyncClient() as client:
response = await client.get(
f"{settings.DATA_SERVICE_URL}/api/v1/weather/forecast",
params={"date": forecast_date.isoformat()},
headers={"X-Service-Auth": settings.SERVICE_AUTH_TOKEN}
)
if response.status_code == 200:
weather = response.json()
return {
"temperature": weather.get("temperature"),
"precipitation": weather.get("precipitation"),
"humidity": weather.get("humidity"),
"weather_description": weather.get("description")
}
else:
return {}
weather_data = await self.data_client.fetch_weather_forecast(tenant_id, days)
return weather_data
except Exception as e:
logger.warning("Error getting weather forecast", error=str(e))
return {}
async def _get_traffic_forecast(self, forecast_date: date, location: str) -> Dict[str, Any]:
"""Get traffic forecast for the date and location"""
try:
# Call data service for traffic forecast
async with httpx.AsyncClient() as client:
response = await client.get(
f"{settings.DATA_SERVICE_URL}/api/v1/traffic/forecast",
params={
"date": forecast_date.isoformat(),
"location": location
},
headers={"X-Service-Auth": settings.SERVICE_AUTH_TOKEN}
)
if response.status_code == 200:
traffic = response.json()
return {
"traffic_volume": traffic.get("volume"),
"pedestrian_count": traffic.get("pedestrian_count")
}
else:
return {}
except Exception as e:
logger.warning("Error getting traffic forecast", error=str(e))
return {}
async def _check_and_create_alerts(self, forecast: Forecast, db: AsyncSession):
"""Check forecast and create alerts if needed"""

View File

@@ -0,0 +1,183 @@
# services/forecasting/app/services/model_client.py
"""
Forecast Service Model Client
Demonstrates calling training service to get models
"""
import structlog
from typing import Dict, Any, List, Optional
# Import shared clients - no more code duplication!
from shared.clients import get_service_clients, get_training_client, get_data_client
from app.core.config import settings
logger = structlog.get_logger()
class ModelClient:
"""
Client for managing models in forecasting service
Shows how to call multiple services cleanly
"""
def __init__(self):
# Option 1: Get all clients at once
self.clients = get_service_clients(settings, "forecasting")
# Option 2: Get specific clients
# self.training_client = get_training_client(settings, "forecasting")
# self.data_client = get_data_client(settings, "forecasting")
async def get_available_models(
self,
tenant_id: str,
model_type: Optional[str] = None
) -> List[Dict[str, Any]]:
"""
Get available trained models from training service
"""
try:
models = await self.clients.training.list_models(
tenant_id=tenant_id,
status="deployed", # Only get deployed models
model_type=model_type
)
if models:
logger.info(f"Found {len(models)} available models",
tenant_id=tenant_id, model_type=model_type)
return models
else:
logger.warning("No available models found", tenant_id=tenant_id)
return []
except Exception as e:
logger.error(f"Error fetching available models: {e}", tenant_id=tenant_id)
return []
async def get_best_model_for_forecasting(
self,
tenant_id: str,
product_id: Optional[str] = None
) -> Optional[Dict[str, Any]]:
"""
Get the best model for forecasting based on performance metrics
"""
try:
# Get latest model
latest_model = await self.clients.training.get_latest_model(
tenant_id=tenant_id,
model_type="forecasting"
)
if not latest_model:
logger.warning("No trained models found", tenant_id=tenant_id)
return None
# Get model metrics to validate quality
metrics = await self.clients.training.get_model_metrics(
tenant_id=tenant_id,
model_id=latest_model["id"]
)
if metrics and metrics.get("accuracy", 0) > 0.7: # 70% accuracy threshold
logger.info(f"Selected model {latest_model['id']} with accuracy {metrics.get('accuracy')}",
tenant_id=tenant_id)
return latest_model
else:
logger.warning(f"Model accuracy too low: {metrics.get('accuracy', 'unknown')}",
tenant_id=tenant_id)
return None
except Exception as e:
logger.error(f"Error selecting best model: {e}", tenant_id=tenant_id)
return None
async def validate_model_data_compatibility(
self,
tenant_id: str,
model_id: str,
forecast_start_date: str,
forecast_end_date: str
) -> Dict[str, Any]:
"""
Validate that we have sufficient data for the model to make forecasts
Demonstrates calling both training and data services
"""
try:
# Get model details from training service
model = await self.clients.training.get_model(
tenant_id=tenant_id,
model_id=model_id
)
if not model:
return {"is_valid": False, "error": "Model not found"}
# Get data statistics from data service
data_stats = await self.clients.data.get_data_statistics(
tenant_id=tenant_id,
start_date=forecast_start_date,
end_date=forecast_end_date
)
if not data_stats:
return {"is_valid": False, "error": "Could not retrieve data statistics"}
# Check if we have minimum required data points
min_required = model.get("metadata", {}).get("min_data_points", 30)
available_points = data_stats.get("total_records", 0)
is_valid = available_points >= min_required
result = {
"is_valid": is_valid,
"model_id": model_id,
"required_points": min_required,
"available_points": available_points,
"data_coverage": data_stats.get("coverage_percentage", 0)
}
if not is_valid:
result["error"] = f"Insufficient data: need {min_required}, have {available_points}"
logger.info("Model data compatibility check completed",
tenant_id=tenant_id, model_id=model_id, is_valid=is_valid)
return result
except Exception as e:
logger.error(f"Error validating model compatibility: {e}",
tenant_id=tenant_id, model_id=model_id)
return {"is_valid": False, "error": str(e)}
async def trigger_model_retraining(
self,
tenant_id: str,
include_weather: bool = True,
include_traffic: bool = False
) -> Optional[Dict[str, Any]]:
"""
Trigger a new training job if current model is outdated
"""
try:
# Create training job through training service
job = await self.clients.training.create_training_job(
tenant_id=tenant_id,
include_weather=include_weather,
include_traffic=include_traffic,
min_data_points=50 # Higher threshold for forecasting
)
if job:
logger.info(f"Training job created: {job['job_id']}", tenant_id=tenant_id)
return job
else:
logger.error("Failed to create training job", tenant_id=tenant_id)
return None
except Exception as e:
logger.error(f"Error triggering model retraining: {e}", tenant_id=tenant_id)
return None
# Global instance
model_client = ModelClient()