"""FastAPI entry-point for the Outlet Intelligence web app. Run locally: cd webapp/backend pip install -r requirements.txt cp .env.example .env # set GEMINI_API_KEY python -m uvicorn main:app --reload --port 8000 The app loads gold parquets / audit CSVs into memory once at startup and exposes the data through JSON endpoints. CORS is open in local dev so the Next.js frontend on :3000 can call it directly. """ from __future__ import annotations import sys from contextlib import asynccontextmanager from pathlib import Path # Ensure routers/ and services/ are importable as top-level packages # whether the app is launched as `main:app` from webapp/backend/ or as # `backend.main:app` from webapp/. _BACKEND_DIR = Path(__file__).resolve().parent if str(_BACKEND_DIR) not in sys.path: sys.path.insert(0, str(_BACKEND_DIR)) from dotenv import load_dotenv # noqa: E402 from fastapi import FastAPI # noqa: E402 from fastapi.middleware.cors import CORSMiddleware # noqa: E402 # Always load the .env that lives next to this file, regardless of CWD. load_dotenv(_BACKEND_DIR / ".env") from routers import outlets as outlets_router # noqa: E402 from routers import dashboards as dash_router # noqa: E402 from routers import xai as xai_router # noqa: E402 from services.data_loader import get_cache # noqa: E402 @asynccontextmanager async def lifespan(app: FastAPI): import os cache = get_cache() print(f"[startup] Repo root: {cache.repo_root}") print(f"[startup] outlets table: {cache.outlets_table.shape}") print(f"[startup] predictions: {cache.predictions.shape}") print(f"[startup] dormancy: {cache.dormancy.shape}") key = os.environ.get("GEMINI_API_KEY") if key: print(f"[startup] GEMINI_API_KEY loaded ({len(key)} chars) " f"using model {os.environ.get('GEMINI_MODEL', 'gemini-2.5-flash')}") else: print("[startup] WARN: GEMINI_API_KEY not set — /api/xai endpoints will 503") yield app = FastAPI( title="Outlet Intelligence API", version="0.1.0", description="Beverage outlet potential, budget allocation, and XAI narratives.", lifespan=lifespan, ) # CORS — local dev (localhost / 127.0.0.1 any port) plus deployed origins: # Vercel previews/production (*.vercel.app), the HF Space's own origin so # the FastAPI Swagger UI works in-place, and any extra origins listed in # the CORS_EXTRA_ORIGINS env var (comma-separated). The regex is intentionally # strict so we don't accept arbitrary HTTP origins from the internet. import os as _os # noqa: E402 _extra = [o.strip() for o in _os.environ.get("CORS_EXTRA_ORIGINS", "").split(",") if o.strip()] app.add_middleware( CORSMiddleware, allow_origin_regex=( r"https?://(localhost|127\.0\.0\.1)(:\d+)?|" r"https://[a-zA-Z0-9-]+\.vercel\.app|" r"https://[a-zA-Z0-9-]+-[a-zA-Z0-9-]+\.vercel\.app|" r"https://huggingface\.co|" r"https://[a-zA-Z0-9-]+\.hf\.space" ), allow_origins=_extra, allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) app.include_router(outlets_router.router) app.include_router(dash_router.router) app.include_router(xai_router.router) @app.get("/") def root(): return { "service": "Outlet Intelligence API", "endpoints": [ "GET /health", "GET /api/summary", "GET /api/outlets", "GET /api/outlets/filters", "GET /api/outlets/{outlet_id}", "POST /api/xai/explain/{outlet_id}", "GET /api/budget/distributors", "GET /api/budget/channels", "GET /api/budget/outlets", "GET /api/cooler-roi/top100", "GET /api/cooler-roi/summary", "GET /api/dormancy/top", "GET /api/dormancy/bands", "GET /api/scorecard", "GET /api/territories", "GET /api/forensics", "GET /api/shap/global", ], } @app.get("/health") def health(): """Liveness probe used by the frontend on first paint.""" import os cache = get_cache() return { "status": "ok", "outlets_loaded": int(len(cache.outlets_table)), "predictions_loaded": int(len(cache.predictions)), "gemini_configured": bool(os.environ.get("GEMINI_API_KEY")), }