"""FluentGerman.ai — FastAPI application entry point.""" import asyncio import logging import time from contextlib import asynccontextmanager from fastapi import FastAPI, Request from fastapi.middleware.cors import CORSMiddleware from fastapi.staticfiles import StaticFiles from sqlalchemy import inspect, select from sqlalchemy.exc import IntegrityError, OperationalError, ProgrammingError from app.auth import hash_password from app.config import get_settings from app.database import Base, engine, async_session from app.models import User from app.routers import auth, chat, instructions, users, voice # ── Logging ────────────────────────────────────────────────────────── import os os.makedirs("logs", exist_ok=True) logging.basicConfig( level=logging.INFO, format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", handlers=[ logging.StreamHandler(), logging.FileHandler("logs/app.log", mode="a"), ], ) logger = logging.getLogger("fluentgerman") # A worker that loses the create_all race has to wait for the winner to finish # creating the remaining tables before it can judge the schema complete. SCHEMA_RETRIES = 10 SCHEMA_RETRY_DELAY = 0.5 def _is_table_already_exists(exc: Exception) -> bool: """True only for the specific DDL error that means another worker won. Anything else — bad credentials, missing privileges, an unreachable server — must not be mistaken for a race. """ orig = getattr(exc, "orig", None) args = getattr(orig, "args", ()) if args and args[0] == 1050: # MySQL ER_TABLE_EXISTS_ERROR return True return "already exists" in str(orig or exc).lower() async def _ensure_schema() -> None: """Create the tables and refuse to continue on a half-built schema. An 'already exists' error aborts create_all part-way, so the losing worker may still be missing tables the winner has not created yet. Retry rather than treating that moment as a broken database. """ missing: list[str] = [] for attempt in range(SCHEMA_RETRIES): try: async with engine.begin() as conn: await conn.run_sync(Base.metadata.create_all) except (IntegrityError, OperationalError, ProgrammingError) as exc: if not _is_table_already_exists(exc): raise logger.warning("Schema creation raced with another worker: %s", exc) async with engine.connect() as conn: present = await conn.run_sync(lambda c: set(inspect(c).get_table_names())) missing = sorted(set(Base.metadata.tables) - present) if not missing: return logger.info( "Waiting for another worker to finish creating %s (attempt %d/%d)", missing, attempt + 1, SCHEMA_RETRIES, ) await asyncio.sleep(SCHEMA_RETRY_DELAY) raise RuntimeError(f"Database schema is incomplete — missing tables: {missing}") @asynccontextmanager async def lifespan(app: FastAPI): """Create tables and bootstrap admin user on startup. uvicorn runs this once per worker and the unit file starts two, so on an empty database both can reach the same check at the same moment. Both steps below are written to tolerate losing that race. """ await _ensure_schema() # Bootstrap admin if not exists settings = get_settings() async with async_session() as db: result = await db.execute(select(User).where(User.is_admin == True)) # noqa: E712 if not result.scalar_one_or_none(): admin = User( username=settings.admin_username, email=settings.admin_email, hashed_password=hash_password(settings.admin_password), is_admin=True, ) db.add(admin) try: await db.commit() logger.info("Admin user created: %s", settings.admin_username) except IntegrityError: # Either another worker inserted the same admin, or the # configured username/email is taken by a non-admin account. await db.rollback() result = await db.execute(select(User).where(User.is_admin == True)) # noqa: E712 if result.scalar_one_or_none() is None: logger.error( "Cannot create admin '%s': the username or email already " "belongs to a non-admin account.", settings.admin_username, ) raise logger.info("Admin user already created by another worker") logger.info("FluentGerman.ai started — LLM: %s/%s, Voice: %s", settings.llm_provider, settings.llm_model, settings.voice_mode) yield logger.info("FluentGerman.ai shutting down") app = FastAPI( title="FluentGerman.ai", description="Personalized LLM-powered German language learning platform", version="1.0.0", lifespan=lifespan, ) # ── Request logging middleware ──────────────────────────────────────── @app.middleware("http") async def log_requests(request: Request, call_next): start = time.time() response = await call_next(request) duration = round((time.time() - start) * 1000) # Skip logging static file requests to keep logs clean path = request.url.path if path.startswith("/api/"): logger.info("%s %s → %s (%dms)", request.method, path, response.status_code, duration) return response # CORS — restrict in production app.add_middleware( CORSMiddleware, allow_origins=["*"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) # API routers app.include_router(auth.router) app.include_router(users.router) app.include_router(instructions.router) app.include_router(chat.router) app.include_router(voice.router) # Serve frontend static files app.mount("/", StaticFiles(directory="../frontend", html=True), name="frontend")