NutriLoop / app /main.py
AB1N05's picture
Nutriloop V2 Backend - Global Model Architected
1fa55f4
Raw
History Blame Contribute Delete
8.78 kB
"""
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 env vars on startup
load_dotenv()
# Ensure models directory exists
MODELS_DIR = Path(__file__).parent.parent / "models"
MODELS_DIR.mkdir(exist_ok=True)
# Version
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,
)
# Allow CORS for Next.js dashboard
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}")
# Try Global Model first
model = load_model()
source = "global_model"
mae = 0.0
metadata = load_restaurant_metadata(_supabase_client, request.restaurant_id)
if model is None:
# Fall back to cold-start clustering if no global model exists
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:
# We have the global model
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",
)
# Allow running directly
if __name__ == "__main__":
import uvicorn
uvicorn.run("app.main:app", host="0.0.0.0", port=7860, reload=True)