Improve deployment safety and chat reliability
Deploy FluentGerman.ai / deploy (push) Successful in 1m25s
Deploy FluentGerman.ai / deploy (push) Successful in 1m25s
This commit is contained in:
+78
-6
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user