Files
bakery-ia/shared/clients/forecast_client.py

511 lines
19 KiB
Python
Raw Normal View History

2025-07-29 15:08:55 +02:00
# shared/clients/forecast_client.py
"""
2025-11-30 09:12:40 +01:00
Forecast Service Client for Inter-Service Communication
This client provides a high-level API for interacting with the Forecasting Service,
which generates demand predictions using Prophet ML algorithm, validates forecast accuracy,
and provides enterprise network demand aggregation for multi-location bakeries.
Key Capabilities:
- Forecast Generation: Single product, multi-day, batch forecasting
- Real-Time Predictions: On-demand predictions with custom features
- Forecast Validation: Compare predictions vs actual sales, track accuracy
- Analytics: Prediction performance metrics, historical accuracy trends
- Enterprise Aggregation: Network-wide demand forecasting for parent-child hierarchies
- Caching: Redis-backed caching for high-performance prediction serving
Backend Architecture:
- ATOMIC: /forecasting/forecasts (CRUD operations on forecast records)
- BUSINESS: /forecasting/operations/* (forecast generation, validation)
- ANALYTICS: /forecasting/analytics/* (performance metrics, accuracy trends)
- ENTERPRISE: /forecasting/enterprise/* (network demand aggregation)
Enterprise Features (NEW):
- Network demand aggregation across all child outlets for centralized production planning
- Child contribution tracking (each outlet's % of total network demand)
- Redis caching with 1-hour TTL for enterprise forecasts
- Subscription gating (requires Enterprise tier)
Usage Example:
```python
from shared.clients import get_forecast_client
from shared.config.base import get_settings
from datetime import date, timedelta
config = get_settings()
client = get_forecast_client(config, calling_service_name="production")
# Generate 7-day forecast for a product
forecast = await client.generate_multi_day_forecast(
tenant_id=tenant_id,
inventory_product_id=product_id,
forecast_date=date.today(),
forecast_days=7,
include_recommendations=True
)
# Batch forecast for multiple products
batch_forecast = await client.generate_batch_forecast(
tenant_id=tenant_id,
inventory_product_ids=[product_id_1, product_id_2],
forecast_date=date.today(),
forecast_days=7
)
# Validate forecasts against actual sales
validation = await client.validate_forecasts(
tenant_id=tenant_id,
date=date.today() - timedelta(days=1)
)
# Get predictions for a specific date (from cache or DB)
predictions = await client.get_predictions_for_date(
tenant_id=tenant_id,
target_date=date.today()
)
```
Service Architecture:
- Base URL: Configured via FORECASTING_SERVICE_URL environment variable
- Authentication: Uses BaseServiceClient with tenant_id header validation
- Error Handling: Returns None on errors, logs detailed error context
- Async: All methods are async and use httpx for HTTP communication
- Caching: 24-hour TTL for standard forecasts, 1-hour TTL for enterprise aggregations
ML Model Details:
- Algorithm: Facebook Prophet (time series forecasting)
- Features: 20+ temporal, weather, traffic, holiday, POI features
- Accuracy: 15-25% MAPE (Mean Absolute Percentage Error)
- Training: Weekly retraining via orchestrator automation
- Confidence Intervals: 95% confidence bounds (yhat_lower, yhat_upper)
Related Services:
- Production Service: Uses forecasts for production planning
- Procurement Service: Uses forecasts for ingredient ordering
- Orchestrator Service: Triggers daily forecast generation, displays network forecasts on enterprise dashboard
- Tenant Service: Validates hierarchy for enterprise aggregation
- Distribution Service: Network forecasts inform capacity planning
For more details, see services/forecasting/README.md
2025-07-29 15:08:55 +02:00
"""
from typing import Dict, Any, Optional, List
from datetime import date
2026-01-02 11:12:50 +01:00
import structlog
2025-07-29 15:08:55 +02:00
from .base_service_client import BaseServiceClient
from shared.config.base import BaseServiceSettings
2026-01-02 11:12:50 +01:00
logger = structlog.get_logger()
2025-07-29 15:08:55 +02:00
class ForecastServiceClient(BaseServiceClient):
"""Client for communicating with the forecasting service"""
2025-10-06 15:27:01 +02:00
2025-07-29 15:08:55 +02:00
def __init__(self, config: BaseServiceSettings, calling_service_name: str = "unknown"):
super().__init__(calling_service_name, config)
2025-10-06 15:27:01 +02:00
2025-07-29 15:08:55 +02:00
def get_service_base_path(self) -> str:
return "/api/v1"
2025-10-06 15:27:01 +02:00
2025-07-29 15:08:55 +02:00
# ================================================================
2025-10-06 15:27:01 +02:00
# ATOMIC: Forecast CRUD Operations
2025-07-29 15:08:55 +02:00
# ================================================================
2025-10-06 15:27:01 +02:00
2025-07-29 15:08:55 +02:00
async def get_forecast(self, tenant_id: str, forecast_id: str) -> Optional[Dict[str, Any]]:
2025-10-06 15:27:01 +02:00
"""Get forecast details by ID"""
return await self.get(f"forecasting/forecasts/{forecast_id}", tenant_id=tenant_id)
2025-07-29 15:08:55 +02:00
async def list_forecasts(
self,
tenant_id: str,
2025-10-06 15:27:01 +02:00
inventory_product_id: Optional[str] = None,
start_date: Optional[date] = None,
end_date: Optional[date] = None,
limit: int = 50,
offset: int = 0
2025-07-29 15:08:55 +02:00
) -> Optional[List[Dict[str, Any]]]:
2025-10-06 15:27:01 +02:00
"""List forecasts for a tenant with optional filters"""
params = {"limit": limit, "offset": offset}
if inventory_product_id:
params["inventory_product_id"] = inventory_product_id
if start_date:
params["start_date"] = start_date.isoformat()
if end_date:
params["end_date"] = end_date.isoformat()
return await self.get("forecasting/forecasts", tenant_id=tenant_id, params=params)
2025-07-29 15:08:55 +02:00
async def delete_forecast(self, tenant_id: str, forecast_id: str) -> Optional[Dict[str, Any]]:
"""Delete a forecast"""
2025-10-06 15:27:01 +02:00
return await self.delete(f"forecasting/forecasts/{forecast_id}", tenant_id=tenant_id)
2025-07-29 15:08:55 +02:00
# ================================================================
2025-10-06 15:27:01 +02:00
# BUSINESS: Forecasting Operations
2025-07-29 15:08:55 +02:00
# ================================================================
2025-10-06 15:27:01 +02:00
async def generate_single_forecast(
2025-07-29 15:08:55 +02:00
self,
tenant_id: str,
2025-10-06 15:27:01 +02:00
inventory_product_id: str,
forecast_date: date,
include_recommendations: bool = False
) -> Optional[Dict[str, Any]]:
"""Generate a single product forecast"""
data = {
"inventory_product_id": inventory_product_id,
"forecast_date": forecast_date.isoformat(),
"include_recommendations": include_recommendations
}
return await self.post("forecasting/operations/single", data=data, tenant_id=tenant_id)
async def generate_multi_day_forecast(
2025-07-29 15:08:55 +02:00
self,
tenant_id: str,
2025-10-06 15:27:01 +02:00
inventory_product_id: str,
forecast_date: date,
forecast_days: int = 7,
include_recommendations: bool = False
) -> Optional[Dict[str, Any]]:
"""Generate multiple daily forecasts for the specified period"""
data = {
"inventory_product_id": inventory_product_id,
"forecast_date": forecast_date.isoformat(),
"forecast_days": forecast_days,
"include_recommendations": include_recommendations
}
return await self.post("forecasting/operations/multi-day", data=data, tenant_id=tenant_id)
async def generate_batch_forecast(
self,
tenant_id: str,
inventory_product_ids: List[str],
forecast_date: date,
forecast_days: int = 1
) -> Optional[Dict[str, Any]]:
"""Generate forecasts for multiple products in batch"""
data = {
"inventory_product_ids": inventory_product_ids,
"forecast_date": forecast_date.isoformat(),
"forecast_days": forecast_days
}
return await self.post("forecasting/operations/batch", data=data, tenant_id=tenant_id)
async def generate_realtime_prediction(
self,
tenant_id: str,
inventory_product_id: str,
2025-07-29 15:08:55 +02:00
model_id: str,
features: Dict[str, Any],
2025-10-06 15:27:01 +02:00
model_path: Optional[str] = None,
confidence_level: float = 0.8
2025-07-29 15:08:55 +02:00
) -> Optional[Dict[str, Any]]:
2025-10-06 15:27:01 +02:00
"""Generate real-time prediction"""
2025-07-29 15:08:55 +02:00
data = {
2025-10-06 15:27:01 +02:00
"inventory_product_id": inventory_product_id,
2025-07-29 15:08:55 +02:00
"model_id": model_id,
"features": features,
2025-10-06 15:27:01 +02:00
"confidence_level": confidence_level
2025-07-29 15:08:55 +02:00
}
2025-10-06 15:27:01 +02:00
if model_path:
data["model_path"] = model_path
return await self.post("forecasting/operations/realtime", data=data, tenant_id=tenant_id)
async def validate_predictions(
self,
tenant_id: str,
2025-10-06 15:27:01 +02:00
start_date: date,
end_date: date
) -> Optional[Dict[str, Any]]:
2025-10-06 15:27:01 +02:00
"""Validate predictions against actual sales data"""
params = {
"start_date": start_date.isoformat(),
"end_date": end_date.isoformat()
}
2025-10-06 15:27:01 +02:00
return await self.post("forecasting/operations/validate-predictions", params=params, tenant_id=tenant_id)
2025-11-05 13:34:56 +01:00
async def validate_forecasts(
self,
tenant_id: str,
date: date
) -> Optional[Dict[str, Any]]:
"""
Validate forecasts for a specific date against actual sales.
Calculates MAPE, RMSE, MAE and identifies products with poor accuracy.
Args:
tenant_id: Tenant UUID
2025-11-18 07:17:17 +01:00
date: Date to validate (validates this single day)
2025-11-05 13:34:56 +01:00
Returns:
Dict with overall metrics and poor accuracy products list
"""
2025-11-18 07:17:17 +01:00
from datetime import datetime, timezone
# Convert date to datetime with timezone for start/end of day
start_datetime = datetime.combine(date, datetime.min.time()).replace(tzinfo=timezone.utc)
end_datetime = datetime.combine(date, datetime.max.time()).replace(tzinfo=timezone.utc)
# Call the new validation endpoint
result = await self.post(
"forecasting/validation/validate-yesterday",
params={"orchestration_run_id": None},
tenant_id=tenant_id
)
if not result:
return None
# Transform the new response format to match the expected format
overall_metrics = result.get("overall_metrics", {})
# Get poor accuracy products from the result
poor_accuracy_products = result.get("poor_accuracy_products", [])
return {
"overall_mape": overall_metrics.get("mape", 0),
"overall_rmse": overall_metrics.get("rmse", 0),
"overall_mae": overall_metrics.get("mae", 0),
"overall_r2_score": overall_metrics.get("r2_score", 0),
"overall_accuracy_percentage": overall_metrics.get("accuracy_percentage", 0),
"products_validated": result.get("forecasts_with_actuals", 0),
"poor_accuracy_products": poor_accuracy_products,
"validation_run_id": result.get("validation_run_id"),
"forecasts_evaluated": result.get("forecasts_evaluated", 0),
"forecasts_with_actuals": result.get("forecasts_with_actuals", 0),
"forecasts_without_actuals": result.get("forecasts_without_actuals", 0)
2025-11-05 13:34:56 +01:00
}
2025-10-06 15:27:01 +02:00
async def get_forecast_statistics(
2025-07-29 15:08:55 +02:00
self,
tenant_id: str,
2025-10-06 15:27:01 +02:00
start_date: Optional[date] = None,
end_date: Optional[date] = None
2025-07-29 15:08:55 +02:00
) -> Optional[Dict[str, Any]]:
2025-10-06 15:27:01 +02:00
"""Get forecast statistics"""
2025-07-29 15:08:55 +02:00
params = {}
if start_date:
2025-10-06 15:27:01 +02:00
params["start_date"] = start_date.isoformat()
2025-07-29 15:08:55 +02:00
if end_date:
2025-10-06 15:27:01 +02:00
params["end_date"] = end_date.isoformat()
return await self.get("forecasting/operations/statistics", tenant_id=tenant_id, params=params)
async def clear_prediction_cache(self, tenant_id: str) -> Optional[Dict[str, Any]]:
"""Clear prediction cache"""
return await self.delete("forecasting/operations/cache", tenant_id=tenant_id)
# ================================================================
# ANALYTICS: Forecasting Analytics
# ================================================================
async def get_predictions_performance(
2025-07-29 15:08:55 +02:00
self,
tenant_id: str,
2025-10-06 15:27:01 +02:00
start_date: Optional[date] = None,
end_date: Optional[date] = None
2025-07-29 15:08:55 +02:00
) -> Optional[Dict[str, Any]]:
2025-10-06 15:27:01 +02:00
"""Get predictions performance analytics"""
params = {}
if start_date:
params["start_date"] = start_date.isoformat()
if end_date:
params["end_date"] = end_date.isoformat()
return await self.get("forecasting/analytics/predictions-performance", tenant_id=tenant_id, params=params)
2025-11-05 13:34:56 +01:00
# ================================================================
# ML INSIGHTS: Dynamic Rules Generation
# ================================================================
async def trigger_rules_generation(
self,
tenant_id: str,
product_ids: Optional[List[str]] = None,
lookback_days: int = 90,
min_samples: int = 10
) -> Optional[Dict[str, Any]]:
"""
Trigger dynamic business rules learning for demand forecasting.
Args:
tenant_id: Tenant UUID
product_ids: Specific product IDs to analyze. If None, analyzes all products
lookback_days: Days of historical data to analyze (30-365)
min_samples: Minimum samples required for rule learning (5-100)
Returns:
Dict with rules generation results including insights posted
"""
data = {
"product_ids": product_ids,
"lookback_days": lookback_days,
"min_samples": min_samples
}
return await self.post("forecasting/ml/insights/generate-rules", data=data, tenant_id=tenant_id)
async def trigger_demand_insights_internal(
self,
tenant_id: str
) -> Optional[Dict[str, Any]]:
"""
Trigger demand forecasting insights for a tenant (internal service use only).
2026-01-12 22:15:11 +01:00
This method calls the internal endpoint which is protected by x-internal-service header.
Used by demo-session service after cloning to generate AI insights from seeded data.
Args:
tenant_id: Tenant ID to trigger insights for
Returns:
Dict with trigger results or None if failed
"""
try:
result = await self._make_request(
method="POST",
endpoint=f"forecasting/internal/ml/generate-demand-insights",
tenant_id=tenant_id,
data={"tenant_id": tenant_id},
2026-01-12 22:15:11 +01:00
headers={"x-internal-service": "demo-session"}
)
if result:
2026-01-02 11:12:50 +01:00
logger.info(
"Demand insights triggered successfully via internal endpoint",
tenant_id=tenant_id,
insights_posted=result.get("insights_posted", 0)
)
else:
2026-01-02 11:12:50 +01:00
logger.warning(
"Demand insights internal endpoint returned no result",
tenant_id=tenant_id
)
return result
except Exception as e:
2026-01-02 11:12:50 +01:00
logger.error(
"Failed to trigger demand insights",
tenant_id=tenant_id,
error=str(e)
)
return None
2025-07-29 15:08:55 +02:00
# ================================================================
2025-10-06 15:27:01 +02:00
# Legacy/Compatibility Methods (deprecated)
2025-07-29 15:08:55 +02:00
# ================================================================
2025-10-06 15:27:01 +02:00
2025-11-05 13:34:56 +01:00
async def generate_forecasts(
self,
tenant_id: str,
forecast_days: int = 7,
inventory_product_ids: Optional[List[str]] = None
) -> Optional[Dict[str, Any]]:
"""
COMPATIBILITY: Orchestrator-friendly method to generate forecasts
This method is called by the orchestrator service and generates batch forecasts
for either specified products or all products.
Args:
tenant_id: Tenant UUID
forecast_days: Number of days to forecast (default 7)
inventory_product_ids: Optional list of product IDs. If None, forecasts all products.
Returns:
Dict with forecast results
"""
from datetime import datetime
# If no product IDs specified, let the backend handle it
if not inventory_product_ids:
# Call the batch operation endpoint to forecast all products
# The forecasting service will handle fetching all products internally
data = {
"batch_name": f"orchestrator-batch-{datetime.now().strftime('%Y%m%d')}",
"inventory_product_ids": [], # Empty list will trigger fetching all products
"forecast_days": forecast_days
}
return await self.post("forecasting/operations/batch", data=data, tenant_id=tenant_id)
# Otherwise use the standard batch forecast
return await self.generate_batch_forecast(
tenant_id=tenant_id,
inventory_product_ids=inventory_product_ids,
forecast_date=datetime.now().date(),
forecast_days=forecast_days
)
2025-11-30 16:29:38 +01:00
async def get_aggregated_forecast(
self,
parent_tenant_id: str,
start_date: date,
end_date: date,
product_id: Optional[str] = None
) -> Dict[str, Any]:
"""
Get aggregated forecast for enterprise tenant and all children.
This method calls the enterprise forecasting aggregation endpoint which
combines demand forecasts across the parent tenant and all child tenants
in the network. Used for centralized production planning.
Args:
parent_tenant_id: The parent tenant (central bakery) UUID
start_date: Start date for forecast range
end_date: End date for forecast range
product_id: Optional product ID to filter forecasts
Returns:
Aggregated forecast data including:
- total_demand: Sum of all child demands
- child_contributions: Per-child demand breakdown
- forecast_date_range: Date range for the forecast
- cached: Whether data was served from Redis cache
"""
params = {
"start_date": start_date.isoformat(),
"end_date": end_date.isoformat()
}
if product_id:
params["product_id"] = product_id
2025-12-05 20:07:01 +01:00
# Use _make_request directly because the base_service_client adds /tenants/{tenant_id}/ prefix
# Gateway route is: /api/v1/tenants/{tenant_id}/forecasting/enterprise/{path}
# So we need the full path without tenant_id parameter to avoid double prefixing
return await self._make_request(
"GET",
f"tenants/{parent_tenant_id}/forecasting/enterprise/aggregated",
2025-11-30 16:29:38 +01:00
params=params
)
2025-10-06 15:27:01 +02:00
async def create_forecast(
2025-07-29 15:08:55 +02:00
self,
tenant_id: str,
model_id: str,
start_date: str,
end_date: str,
2025-10-06 15:27:01 +02:00
product_ids: Optional[List[str]] = None,
include_confidence_intervals: bool = True,
2025-07-29 15:08:55 +02:00
**kwargs
) -> Optional[Dict[str, Any]]:
2025-10-06 15:27:01 +02:00
"""
DEPRECATED: Use generate_single_forecast or generate_batch_forecast instead
Legacy method for backward compatibility
"""
# Map to new batch forecast operation
if product_ids:
return await self.generate_batch_forecast(
tenant_id=tenant_id,
inventory_product_ids=product_ids,
forecast_date=date.fromisoformat(start_date),
forecast_days=1
)
return None
2025-11-30 09:12:40 +01:00
# Backward compatibility alias
def create_forecast_client(config: BaseServiceSettings, service_name: str = "unknown") -> ForecastServiceClient:
"""Create a forecast service client (backward compatibility)"""
return ForecastServiceClient(config, service_name)