171 lines
6.1 KiB
Python
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")
|