| """ |
| FastAPI application entry point for NutriLoop AI. |
| Provides /health, /predict, and /cold-start endpoints. |
| """ |
| from contextlib import asynccontextmanager |
| import os |
| import pandas as pd |
| from pathlib import Path |
| from typing import Optional |
|
|
| from dotenv import load_dotenv |
| from fastapi import FastAPI, HTTPException |
| from fastapi.middleware.cors import CORSMiddleware |
| from fastapi.responses import RedirectResponse |
|
|
| from app.schemas import ( |
| ColdStartRequest, |
| HealthResponse, |
| PredictRequest, |
| PredictResponse, |
| PredictionPoint, |
| ) |
| from app.predict import get_model_mae, load_model, run_forecast |
| from app.cold_start import cold_start_forecast |
| from app.news_adjuster import get_news_multiplier |
| from app.restaurant_metadata import create_supabase_client_from_env, load_restaurant_metadata |
|
|
| |
| load_dotenv() |
|
|
| |
| MODELS_DIR = Path(__file__).parent.parent / "models" |
| MODELS_DIR.mkdir(exist_ok=True) |
|
|
| |
| VERSION = "0.1.0" |
|
|
| _supabase_client = create_supabase_client_from_env() |
|
|
|
|
| @asynccontextmanager |
| async def lifespan(app: FastAPI): |
| """Lifespan event handler - runs on startup and shutdown.""" |
| print("[NutriLoop] Starting NutriLoop AI server") |
| print(f"[NutriLoop] Models directory: {MODELS_DIR}") |
| yield |
| print("[NutriLoop] Shutting down NutriLoop AI server") |
|
|
|
|
| app = FastAPI( |
| title="NutriLoop AI", |
| description="Food demand forecasting, cold-start clustering, and news-adjusted predictions", |
| version=VERSION, |
| lifespan=lifespan, |
| ) |
|
|
| |
| app.add_middleware( |
| CORSMiddleware, |
| allow_origins=["*"], |
| allow_credentials=True, |
| allow_methods=["*"], |
| allow_headers=["*"], |
| ) |
|
|
|
|
| @app.get("/", include_in_schema=False) |
| async def root(): |
| """Redirect root to API documentation.""" |
| return RedirectResponse(url="/docs") |
|
|
|
|
| @app.get("/health", response_model=HealthResponse) |
| async def health(): |
| """ |
| Health check endpoint. |
| Returns server status, model count, last retrain time, and version. |
| """ |
| prophet_model_count = 0 |
| cluster_model_present = False |
| if MODELS_DIR.exists(): |
| prophet_model_count = len(list(MODELS_DIR.glob("*__*.pkl"))) |
| cluster_model_present = (MODELS_DIR / "cluster_model.pkl").exists() |
|
|
| config_valid = bool( |
| os.environ.get("SUPABASE_URL") and |
| os.environ.get("SUPABASE_KEY") and |
| os.environ.get("HF_TOKEN") and |
| os.environ.get("HF_REPO_ID") |
| ) |
|
|
| last_retrain_val = None |
| last_retrain_file = MODELS_DIR / "last_retrain.txt" |
| if last_retrain_file.exists(): |
| try: |
| last_retrain_val = last_retrain_file.read_text().strip() |
| except Exception: |
| pass |
|
|
| global_model_path = Path("models/global_model.pkl") |
| global_model_present = global_model_path.exists() |
| |
| cluster_model_path = Path("models/cluster_model.pkl") |
| cluster_model_present = cluster_model_path.exists() |
|
|
| return HealthResponse( |
| status="ok", |
| global_model_present=global_model_present, |
| cluster_model_present=cluster_model_present, |
| config_valid=config_valid, |
| last_retrain=last_retrain_val, |
| version=VERSION, |
| ) |
|
|
|
|
| @app.post("/predict", response_model=PredictResponse) |
| async def predict(request: PredictRequest): |
| """ |
| Generate a demand forecast for a restaurant item. |
| |
| Logic: |
| 1. Try to load a trained Prophet model for restaurant_id + item_name |
| 2. If not found, fall back to cold_start logic |
| 3. Run Prophet prediction for `days` horizon |
| 4. Apply news-based adjustment multiplier |
| """ |
| print(f"[NutriLoop] /predict for restaurant={request.restaurant_id}, item={request.item_name}") |
|
|
| |
| model = load_model() |
| source = "global_model" |
| mae = 0.0 |
|
|
| metadata = load_restaurant_metadata(_supabase_client, request.restaurant_id) |
|
|
| if model is None: |
| |
| print(f"[NutriLoop] No Global model available, using cold-start for {request.restaurant_id}/{request.item_name}") |
| source = "cold_start" |
| try: |
| cold_preds = cold_start_forecast( |
| latitude=metadata.latitude, |
| longitude=metadata.longitude, |
| cuisine_type=metadata.cuisine_type, |
| avg_daily_quantity=metadata.avg_daily_quantity, |
| item_name=request.item_name, |
| days=request.days, |
| ) |
| except Exception as e: |
| print(f"[NutriLoop] Error running fallback cold start: {e}") |
| raise HTTPException(status_code=500, detail=f"Failed to generate cold-start forecast: {e}") |
|
|
| if not cold_preds: |
| raise HTTPException(status_code=500, detail="Cold-start forecast returned empty results.") |
|
|
| news_mult = get_news_multiplier(request.city) |
|
|
| predictions = [] |
| for p in cold_preds: |
| adj_qty = max(1, round(p["quantity"] * news_mult)) |
| predictions.append(PredictionPoint( |
| date=p["date"], |
| quantity=p["quantity"], |
| adjusted_quantity=adj_qty, |
| )) |
|
|
| return PredictResponse( |
| restaurant_id=request.restaurant_id, |
| item_name=request.item_name, |
| predictions=predictions, |
| news_multiplier=news_mult, |
| model_mae=mae, |
| source=source, |
| ) |
| else: |
| |
| try: |
| mae = get_model_mae() |
| preds_df = run_forecast( |
| model=model, |
| days=request.days, |
| restaurant_id=request.restaurant_id, |
| item_name=request.item_name, |
| latitude=metadata.latitude, |
| longitude=metadata.longitude, |
| cuisine_type=metadata.cuisine_type, |
| avg_daily_quantity=metadata.avg_daily_quantity |
| ) |
| except Exception as e: |
| print(f"[NutriLoop] Error running global forecast: {e}") |
| raise HTTPException(status_code=500, detail=f"Failed to generate multivariate forecast: {e}") |
|
|
| if preds_df is None or preds_df.empty: |
| raise HTTPException(status_code=500, detail="Multivariate forecasting logic failed completely.") |
| |
| news_mult = get_news_multiplier(request.city) |
| predictions = [] |
| for _, row in preds_df.iterrows(): |
| d = row["date"].strftime("%Y-%m-%d") if pd.notnull(row["date"]) else "1970-01-01" |
| qty = max(1, int(round(float(row["quantity"]))) if pd.notnull(row["quantity"]) else 1) |
| adj_qty = max(1, round(qty * news_mult)) |
| predictions.append(PredictionPoint( |
| date=d, |
| quantity=qty, |
| adjusted_quantity=adj_qty, |
| )) |
|
|
| return PredictResponse( |
| restaurant_id=request.restaurant_id, |
| item_name=request.item_name, |
| predictions=predictions, |
| news_multiplier=news_mult, |
| model_mae=mae, |
| source=source, |
| ) |
|
|
|
|
| @app.post("/cold-start", response_model=PredictResponse) |
| async def cold_start(request: ColdStartRequest): |
| """ |
| Generate a forecast for a new restaurant using KMeans clustering. |
| The restaurant is assigned to a cluster and gets the cluster's average forecast. |
| """ |
| print(f"[NutriLoop] /cold-start for lat={request.latitude}, lng={request.longitude}") |
|
|
| news_mult = get_news_multiplier(request.city) |
|
|
| try: |
| cold_preds = cold_start_forecast( |
| latitude=request.latitude, |
| longitude=request.longitude, |
| cuisine_type=request.cuisine_type, |
| avg_daily_quantity=request.avg_daily_quantity, |
| item_name=request.item_name, |
| days=request.days, |
| ) |
| except Exception as e: |
| print(f"[NutriLoop] Error running cold start: {e}") |
| raise HTTPException(status_code=500, detail=f"Failed to generate cold-start forecast: {e}") |
|
|
| if not cold_preds: |
| raise HTTPException(status_code=500, detail="Cold-start forecast returned empty results.") |
|
|
| predictions = [] |
| for p in cold_preds: |
| adj_qty = max(1, round(p["quantity"] * news_mult)) |
| predictions.append(PredictionPoint( |
| date=p["date"], |
| quantity=p["quantity"], |
| adjusted_quantity=adj_qty, |
| )) |
|
|
| return PredictResponse( |
| restaurant_id="cold_start", |
| item_name=request.item_name, |
| predictions=predictions, |
| news_multiplier=news_mult, |
| model_mae=0.0, |
| source="cold_start", |
| ) |
|
|
|
|
| |
| if __name__ == "__main__": |
| import uvicorn |
| uvicorn.run("app.main:app", host="0.0.0.0", port=7860, reload=True) |