Improve deployment safety and chat reliability
Deploy FluentGerman.ai / deploy (push) Successful in 1m25s

This commit is contained in:
2026-08-29 17:28:50 +02:00
parent e9f12bc2ba
commit d974aaedc8
15 changed files with 746 additions and 153 deletions
+78 -6
View File
@@ -1,5 +1,6 @@
"""FluentGerman.ai — FastAPI application entry point."""
import asyncio
import logging
import time
from contextlib import asynccontextmanager
@@ -7,7 +8,8 @@ from contextlib import asynccontextmanager
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from sqlalchemy import select
from sqlalchemy import inspect, select
from sqlalchemy.exc import IntegrityError, OperationalError, ProgrammingError
from app.auth import hash_password
from app.config import get_settings
@@ -30,11 +32,68 @@ logging.basicConfig(
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."""
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
"""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()
@@ -48,8 +107,21 @@ async def lifespan(app: FastAPI):
is_admin=True,
)
db.add(admin)
await db.commit()
logger.info("Admin user created: %s", settings.admin_username)
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)
+3 -3
View File
@@ -7,7 +7,7 @@ from fastapi.responses import Response
from app.auth import require_admin, get_current_user
from app.config import get_settings
from app.models import User
from app.schemas import VoiceConfigOut, VoiceInstructionRequest
from app.schemas import SynthesizeRequest, VoiceConfigOut, VoiceInstructionRequest
from app.services.llm_service import summarize_instruction
from app.services.voice_service import synthesize, transcribe
@@ -48,12 +48,12 @@ async def transcribe_audio(
@router.post("/synthesize")
async def synthesize_text(
text: str,
body: SynthesizeRequest,
user: User = Depends(get_current_user),
):
"""Convert text to speech audio (API mode only)."""
try:
audio_bytes = await synthesize(text)
audio_bytes = await synthesize(body.text)
return Response(content=audio_bytes, media_type="audio/mpeg")
except Exception as e:
logger.error(f"Synthesis failed: {str(e)}", exc_info=True)
+4
View File
@@ -88,6 +88,10 @@ class VoiceInstructionRequest(BaseModel):
raw_text: str
class SynthesizeRequest(BaseModel):
text: str
class VoiceConfigOut(BaseModel):
voice_mode: str # "api" | "browser"
voice_api_available: bool = False # True if API STT (Whisper) is configured