Files
language-llm/backend/app/main.py
T
dennisthiessen d974aaedc8
Deploy FluentGerman.ai / deploy (push) Successful in 1m25s
Improve deployment safety and chat reliability
2026-08-29 17:28:50 +02:00

171 lines
6.1 KiB
Python

"""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")