Improve the UI and training

This commit is contained in:
Urtzi Alfaro
2025-11-15 15:20:10 +01:00
parent c349b845a6
commit 843cd2bf5c
19 changed files with 2073 additions and 233 deletions

View File

@@ -4,7 +4,8 @@ Forecasting Operations API - Business operations for forecast generation and pre
"""
import structlog
from fastapi import APIRouter, Depends, HTTPException, status, Query, Path, Request
from fastapi import APIRouter, Depends, HTTPException, status, Query, Path, Request, BackgroundTasks
from fastapi.responses import JSONResponse
from typing import List, Dict, Any, Optional
from datetime import date, datetime, timezone
import uuid
@@ -202,6 +203,97 @@ async def generate_multi_day_forecast(
)
async def execute_batch_forecast_background(
tenant_id: str,
batch_id: str,
inventory_product_ids: List[str],
forecast_days: int,
batch_name: str
):
"""
Background task for batch forecast generation.
Prevents blocking the API thread for long-running batch operations.
"""
logger.info("Starting background batch forecast",
batch_id=batch_id,
tenant_id=tenant_id,
product_count=len(inventory_product_ids))
database_manager = create_database_manager(settings.DATABASE_URL, "forecasting-service")
forecasting_service = EnhancedForecastingService(database_manager)
try:
# Update batch status to running
async with database_manager.get_session() as session:
from app.repositories import PredictionBatchRepository
batch_repo = PredictionBatchRepository(session)
await batch_repo.update(
batch_id,
{"status": "processing", "completed_products": 0}
)
await session.commit()
# Generate forecasts for all products
from app.schemas.forecasts import BatchForecastRequest
batch_request = BatchForecastRequest(
tenant_id=tenant_id,
batch_name=batch_name,
inventory_product_ids=inventory_product_ids,
forecast_days=forecast_days
)
result = await forecasting_service.generate_batch_forecasts(
tenant_id=tenant_id,
request=batch_request
)
# Update batch status to completed
async with database_manager.get_session() as session:
from app.repositories import PredictionBatchRepository
batch_repo = PredictionBatchRepository(session)
await batch_repo.update(
batch_id,
{
"status": "completed",
"completed_at": datetime.now(timezone.utc),
"completed_products": result.get("successful_forecasts", 0),
"failed_products": result.get("failed_forecasts", 0)
}
)
await session.commit()
logger.info("Background batch forecast completed",
batch_id=batch_id,
successful=result.get("successful_forecasts", 0),
failed=result.get("failed_forecasts", 0))
except Exception as e:
logger.error("Background batch forecast failed",
batch_id=batch_id,
error=str(e))
try:
async with database_manager.get_session() as session:
from app.repositories import PredictionBatchRepository
batch_repo = PredictionBatchRepository(session)
await batch_repo.update(
batch_id,
{
"status": "failed",
"completed_at": datetime.now(timezone.utc),
"error_message": str(e)
}
)
await session.commit()
except Exception as update_error:
logger.error("Failed to update batch status after error",
batch_id=batch_id,
error=str(update_error))
@router.post(
route_builder.build_operations_route("batch"),
response_model=BatchForecastResponse
@@ -211,11 +303,17 @@ async def generate_multi_day_forecast(
async def generate_batch_forecast(
request: BatchForecastRequest,
tenant_id: str = Path(..., description="Tenant ID"),
background_tasks: BackgroundTasks = BackgroundTasks(),
request_obj: Request = None,
current_user: dict = Depends(get_current_user_dep),
enhanced_forecasting_service: EnhancedForecastingService = Depends(get_enhanced_forecasting_service)
):
"""Generate forecasts for multiple products in batch (Admin+ only, quota enforced)"""
"""
Generate forecasts for multiple products in batch (Admin+ only, quota enforced).
IMPROVEMENT: Now uses background tasks for large batches to prevent API timeouts.
Returns immediately with batch_id for status tracking.
"""
metrics = get_metrics_collector(request_obj)
try:
@@ -258,48 +356,104 @@ async def generate_batch_forecast(
error_message=None
)
# Skip rate limiting for service-to-service calls (orchestrator)
# Rate limiting is handled at the gateway level for user requests
# IMPROVEMENT: For large batches (>5 products), use background task
# For small batches, execute synchronously for immediate results
batch_name = getattr(request, 'batch_name', f"batch-{datetime.now().strftime('%Y%m%d_%H%M%S')}")
forecast_days = getattr(request, 'forecast_days', 7)
# Create a copy of the request with the actual list of product IDs to forecast
# (whether originally provided or fetched from inventory service)
from app.schemas.forecasts import BatchForecastRequest
updated_request = BatchForecastRequest(
tenant_id=tenant_id, # Use the tenant_id from the path parameter
batch_name=getattr(request, 'batch_name', f"orchestrator-batch-{datetime.now().strftime('%Y%m%d')}"),
inventory_product_ids=inventory_product_ids,
forecast_days=getattr(request, 'forecast_days', 7)
)
batch_result = await enhanced_forecasting_service.generate_batch_forecasts(
tenant_id=tenant_id,
request=updated_request
)
if metrics:
metrics.increment_counter("batch_forecasts_success_total")
logger.info("Batch forecast generated successfully",
tenant_id=tenant_id,
total_forecasts=batch_result.get('total_forecasts', 0))
# Convert the service result to BatchForecastResponse format
from app.schemas.forecasts import BatchForecastResponse
# Create batch record first
batch_id = str(uuid.uuid4())
now = datetime.now(timezone.utc)
return BatchForecastResponse(
id=batch_result.get('id', str(uuid.uuid4())), # Use 'id' field (UUID) instead of 'batch_id' (string)
tenant_id=tenant_id,
batch_name=updated_request.batch_name,
status="completed",
total_products=batch_result.get('total_forecasts', 0),
completed_products=batch_result.get('successful_forecasts', 0),
failed_products=batch_result.get('failed_forecasts', 0),
requested_at=now,
completed_at=now,
processing_time_ms=0,
forecasts=[],
error_message=None
)
async with enhanced_forecasting_service.database_manager.get_session() as session:
from app.repositories import PredictionBatchRepository
batch_repo = PredictionBatchRepository(session)
batch_data = {
"tenant_id": tenant_id,
"batch_name": batch_name,
"total_products": len(inventory_product_ids),
"forecast_days": forecast_days,
"status": "pending"
}
batch = await batch_repo.create_batch(batch_data)
batch_id = str(batch.id)
await session.commit()
# Use background task for large batches to prevent API timeout
use_background = len(inventory_product_ids) > 5
if use_background:
# Queue background task
background_tasks.add_task(
execute_batch_forecast_background,
tenant_id=tenant_id,
batch_id=batch_id,
inventory_product_ids=inventory_product_ids,
forecast_days=forecast_days,
batch_name=batch_name
)
logger.info("Batch forecast queued for background processing",
tenant_id=tenant_id,
batch_id=batch_id,
product_count=len(inventory_product_ids))
# Return immediately with pending status
from app.schemas.forecasts import BatchForecastResponse
return BatchForecastResponse(
id=batch_id,
tenant_id=tenant_id,
batch_name=batch_name,
status="pending",
total_products=len(inventory_product_ids),
completed_products=0,
failed_products=0,
requested_at=now,
completed_at=None,
processing_time_ms=0,
forecasts=None,
error_message=None
)
else:
# Small batch - execute synchronously
from app.schemas.forecasts import BatchForecastRequest
updated_request = BatchForecastRequest(
tenant_id=tenant_id,
batch_name=batch_name,
inventory_product_ids=inventory_product_ids,
forecast_days=forecast_days
)
batch_result = await enhanced_forecasting_service.generate_batch_forecasts(
tenant_id=tenant_id,
request=updated_request
)
if metrics:
metrics.increment_counter("batch_forecasts_success_total")
logger.info("Batch forecast completed synchronously",
tenant_id=tenant_id,
total_forecasts=batch_result.get('total_forecasts', 0))
# Convert the service result to BatchForecastResponse format
from app.schemas.forecasts import BatchForecastResponse
return BatchForecastResponse(
id=batch_id,
tenant_id=tenant_id,
batch_name=batch_name,
status="completed",
total_products=batch_result.get('total_forecasts', 0),
completed_products=batch_result.get('successful_forecasts', 0),
failed_products=batch_result.get('failed_forecasts', 0),
requested_at=now,
completed_at=datetime.now(timezone.utc),
processing_time_ms=0,
forecasts=[],
error_message=None
)
except ValueError as e:
if metrics:
@@ -806,3 +960,50 @@ async def preview_tenant_data_deletion(
status_code=500,
detail=f"Failed to preview tenant data deletion: {str(e)}"
)
@router.get("/health/database")
async def database_health():
"""
Database health check endpoint with connection pool monitoring.
Returns detailed connection pool statistics for monitoring and alerting.
Useful for detecting connection pool exhaustion before it causes issues.
"""
from app.core.database import get_db_health, get_connection_pool_stats
from datetime import datetime
try:
# Check database connectivity
db_healthy = await get_db_health()
# Get connection pool statistics
pool_stats = await get_connection_pool_stats()
response = {
"service": "forecasting",
"timestamp": datetime.now(timezone.utc).isoformat(),
"database_connected": db_healthy,
"connection_pool": pool_stats,
"overall_status": "healthy" if db_healthy and pool_stats.get("status") == "healthy" else "degraded"
}
# Return appropriate status code based on health
if not db_healthy or pool_stats.get("status") == "critical":
return JSONResponse(status_code=503, content=response)
elif pool_stats.get("status") == "warning":
return JSONResponse(status_code=200, content=response)
else:
return response
except Exception as e:
logger.error("Health check failed", error=str(e))
return JSONResponse(
status_code=503,
content={
"service": "forecasting",
"timestamp": datetime.now(timezone.utc).isoformat(),
"overall_status": "unhealthy",
"error": str(e)
}
)