""" FastAPI application entry point. Run with: uvicorn app.main:app --reload --port 8000 (see backend/README.md / the project documentation for full setup steps) """ from __future__ import annotations import logging import threading from contextlib import asynccontextmanager from pathlib import Path from fastapi import FastAPI, HTTPException from fastapi.middleware.cors import CORSMiddleware from fastapi.staticfiles import StaticFiles from fastapi.responses import FileResponse from app.infrastructure.settings import API_CORS_ORIGINS from app.api.routers import health, brands, search, chat, catalog, system from app.api.routers import stores, discounts, analytics as store_analytics, trending, recommendations, store_admin from app.api.routers import nutrition, nutrition_admin, upload from app.api.routers import auth, user_products, admin_train from app.services.store_db import ensure_store_intelligence_schema from app.services.nutrition_db import ensure_nutrition_schema logging.basicConfig( level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", ) logger = logging.getLogger(__name__) @asynccontextmanager async def lifespan(_app: FastAPI): """ Schema checks and auto-seeding, kicked off without blocking startup. The work runs on a daemon thread rather than being awaited: it touches Postgres, which may be slow or briefly unreachable on a cold boot, and the server answering /api/health in under a second is what lets the container healthcheck pass while that settles. """ def _async_init(): try: ensure_store_intelligence_schema() ensure_nutrition_schema() from app.api.routers.system import _run_background_auto_seed, _run_background_brand_sync _run_background_auto_seed() # Runs after the auto-seed so a cold boot has already loaded the # bundled catalogs and this finds nothing to do. On a warm boot the # auto-seed no-ops and this is what picks up a brand table or seed # file that appeared since last time. _run_background_brand_sync() except Exception as e: logger.warning("Startup background init warning: %s", e) threading.Thread(target=_async_init, daemon=True).start() yield app = FastAPI( title="Brand Product Search Engine - RAG API", description=( "Local, CPU-only RAG API over an Indian FMCG product catalog stored in pgvector. " "Includes automated multi-store intelligence, ML discount engines, and nutrition intelligence." ), version="3.2.0", lifespan=lifespan, ) # When the app is served same-origin (the frontend's nginx proxies /api/* to # this service), API_CORS_ORIGINS is empty and no cross-origin request is ever # made. It is populated only when the API is also published on its own host - # see api.{$DOMAIN} in the Caddyfile. # # A wildcard origin and credentialed requests are mutually exclusive under the # CORS spec: browsers reject `Access-Control-Allow-Origin: *` on any request # carrying credentials. Sending both is a silent misconfiguration - the server # looks configured while every browser call fails - so a wildcard turns # credentials off explicitly and says so in the log. _allow_credentials = "*" not in API_CORS_ORIGINS if not _allow_credentials: logger.warning( "API_CORS_ORIGINS contains '*': disabling allow_credentials, because " "browsers reject credentialed cross-origin requests to a wildcard origin. " "List the exact origins instead if you need cookies or Authorization headers." ) app.add_middleware( CORSMiddleware, allow_origins=API_CORS_ORIGINS, allow_credentials=_allow_credentials, allow_methods=["*"], allow_headers=["*"], ) app.include_router(health.router, prefix="/api") app.include_router(auth.router, prefix="/api") app.include_router(user_products.router, prefix="/api") app.include_router(admin_train.router, prefix="/api") app.include_router(system.router, prefix="/api") app.include_router(brands.router, prefix="/api") app.include_router(search.router, prefix="/api") app.include_router(chat.router, prefix="/api") app.include_router(catalog.router, prefix="/api") app.include_router(stores.router, prefix="/api") app.include_router(discounts.router, prefix="/api") app.include_router(store_analytics.router, prefix="/api") app.include_router(trending.router, prefix="/api") app.include_router(recommendations.router, prefix="/api") app.include_router(store_admin.router, prefix="/api") app.include_router(nutrition.router, prefix="/api") app.include_router(nutrition_admin.router, prefix="/api") app.include_router(upload.router, prefix="/api") # Serve built frontend static files if dist exists (single-port unified deployment) FRONTEND_DIST = Path(__file__).resolve().parents[2] / "frontend" / "dist" if FRONTEND_DIST.exists() and (FRONTEND_DIST / "assets").exists(): app.mount("/assets", StaticFiles(directory=str(FRONTEND_DIST / "assets")), name="assets") @app.get("/{full_path:path}") def serve_frontend(full_path: str): # API and docs paths are served by the routers registered above, so # this catch-all only sees them when the path genuinely doesn't exist. # Returning None there would answer 200 with a `null` body - an unknown # endpoint would look like a successful call to any client. 404 is the # honest answer, and it matters now that the API is also reachable # directly at api.{$DOMAIN} rather than only behind the frontend. if full_path.startswith(("api", "docs", "redoc", "openapi.json")): raise HTTPException(status_code=404, detail="Not found") file_path = FRONTEND_DIST / full_path if file_path.exists() and file_path.is_file(): return FileResponse(file_path) return FileResponse(FRONTEND_DIST / "index.html") else: @app.get("/") def root() -> dict: return { "service": "Brand Product Search Engine - RAG API", "docs": "/docs", "health": "/api/health", "system_status": "/api/system/status", }