SignalMod / src /api /main.py
Ruperth's picture
fix: serve static assets from the SPA dist before falling back to index.html
db51717
"""
youtube_hate_detector API
Run: uv run uvicorn src.api.main:app --reload --port 8000
Docs: http://localhost:8000/docs
"""
from __future__ import annotations
import os
import time
from contextlib import asynccontextmanager
from pathlib import Path
from dotenv import load_dotenv
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
load_dotenv()
from src.api.routes import health, models, predict, videos
from src.api.state import PROJECT_ROOT, get_state
from src.service.model_service import (
AVAILABLE_MODELS,
ModelService,
_DEFAULT_MODEL_NAME,
check_model_availability,
)
from src.utils.logger import get_logger
logger = get_logger(__name__)
FRONTEND_DIST = PROJECT_ROOT / "frontend" / "dist"
@asynccontextmanager
async def lifespan(app: FastAPI):
state = get_state()
model_name = os.getenv("MODEL_NAME", _DEFAULT_MODEL_NAME)
available, reason = check_model_availability(model_name, PROJECT_ROOT)
if not available:
fallback = _DEFAULT_MODEL_NAME
if not check_model_availability(fallback, PROJECT_ROOT)[0]:
fallback = next(iter(AVAILABLE_MODELS.keys()))
logger.warning(
"MODEL_NAME '%s' unavailable (%s) — using '%s'",
model_name,
reason,
fallback,
)
model_name = fallback
logger.info("Starting youtube_hate_detector API — model: %s", model_name)
state["service"] = ModelService(model_name, PROJECT_ROOT)
state["model_name"] = model_name
state["startup_time"] = time.time()
state["predictions_served"] = 0
try:
state["service"].predict("warmup")
logger.info("Model warm-up complete")
except Exception as exc:
logger.warning("Warm-up failed (non-critical): %s", exc)
yield
state["service"] = None
logger.info("API shutdown")
app = FastAPI(
title="youtube_hate_detector API",
description="Toxic comment detection for YouTube-style moderation demos",
version="1.0.0",
lifespan=lifespan,
)
app.add_middleware(
CORSMiddleware,
allow_origins=[
"http://localhost:5173",
"http://127.0.0.1:5173",
"http://localhost:8000",
],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(health.router)
app.include_router(models.router)
app.include_router(predict.router)
app.include_router(videos.router)
_API_PATH_ROOTS = frozenset(
{"models", "model", "videos", "predict", "health", "docs", "redoc", "openapi"}
)
def _is_api_spa_path(full_path: str) -> bool:
root = full_path.split("/")[0] if full_path else ""
return root in _API_PATH_ROOTS
def _mount_frontend() -> None:
if not FRONTEND_DIST.is_dir():
return
assets = FRONTEND_DIST / "assets"
if assets.is_dir():
app.mount("/assets", StaticFiles(directory=assets), name="assets")
@app.get("/{full_path:path}", include_in_schema=False)
async def spa_fallback(full_path: str):
if _is_api_spa_path(full_path):
from fastapi import HTTPException
raise HTTPException(status_code=404, detail="Not found")
static_file = FRONTEND_DIST / full_path
if full_path and static_file.is_file():
return FileResponse(static_file)
index = FRONTEND_DIST / "index.html"
if index.exists():
return FileResponse(index)
from fastapi import HTTPException
raise HTTPException(status_code=404, detail="Not found")
_mount_frontend()