Proposal plus a working read-only mockup at /language-mockup for operator
review. German selected by default, as asked.
Measured first, so the proposal reflects reality:
utterances 4,319 rows, no language column - one text per utterance
language absent from the DB entirely; exists only as a filename
convention in the experimental lang-samples page
/v1/tts language: str = Form("english") - free-form, unvalidated
marker utt_XXXX.wav.txt JSON has speaker/text/voice_fp, no language
The load-bearing finding is that audio MUST be namespaced per language
before any second language is generated. staleness.is_current() compares
the marker's recorded text against the utterance text, so German and
English sharing ch6/utt_0001.wav would each mark the other stale and
overwrite it in a loop. That is a correctness requirement, not a tidy-up.
Schema choice: a new utterance_translations table rather than a language
column on utterances. One row can hold only one language, so a column
either forces 10x duplication of speaker/emotion/pace/attribution or
overwrites the source text. UNIQUE(utterance_id, language_code) makes
UmbraTrans re-delivery an update instead of a second competing German.
English stays in utterances.text and stays editable; translations are
confirm-only, matching the agreed asymmetry.
Audio: chapter_audio/ (English, path untouched, zero regression risk)
gains siblings chapter_audio_de/ etc. Marker JSON gains "language" so an
export can prove what it shipped.
The mockup is a separate router for the reason name_token_routes.py exists
- it keeps proposed work out of the hot routes.py until it is agreed. It
writes nothing, generates nothing, and touches the English pipeline not at
all. Verified: 200 on all 10 languages, real German text rendered,
coverage read from the real 4,319-row DB, unknown codes fall back to
German without a traceback, and the auth gate still 303s to /login.
I initially invented a routes.py helper (resolve_project_dir) that does
not exist; the real one is _get_active_config(request).project_dir. I also
removed a silent cwd fallback that would have rendered English under a
German heading and looked like a working page, and made an unreadable DB
show 'could not measure' rather than a misleading 0%.
Suite: 721 passed, 8 pre-existing. ruff clean on new files.
141 lines
5.6 KiB
Python
141 lines
5.6 KiB
Python
from __future__ import annotations
|
|
|
|
import logging
|
|
from pathlib import Path
|
|
from typing import TYPE_CHECKING
|
|
|
|
from fastapi import FastAPI, Request
|
|
from fastapi.responses import JSONResponse, RedirectResponse
|
|
from fastapi.staticfiles import StaticFiles
|
|
|
|
if TYPE_CHECKING:
|
|
from umbraaudio.config import AppConfig
|
|
|
|
_app: FastAPI | None = None
|
|
|
|
|
|
def create_app(config: AppConfig) -> FastAPI:
|
|
global _app
|
|
if _app is not None:
|
|
return _app
|
|
|
|
logger = logging.getLogger(__name__)
|
|
app = FastAPI(title="UmbraAudio Review", version="0.2.0")
|
|
|
|
static_dir = Path(__file__).parent / "static"
|
|
|
|
if static_dir.exists():
|
|
app.mount("/static", StaticFiles(directory=str(static_dir)), name="static")
|
|
|
|
from umbraaudio.config import list_projects
|
|
from umbraaudio.pipeline_manager import PipelineManager
|
|
from umbraaudio.voices.generation_manager import GenerationManager
|
|
from umbraaudio.web.language_routes import router as language_router
|
|
from umbraaudio.web.name_token_routes import router as name_token_router
|
|
from umbraaudio.web.routes import NoActiveProjectError, router
|
|
from umbraaudio.web.voice_queue_routes import router as voice_queue_router
|
|
|
|
app.include_router(router)
|
|
# Spec 30 MOCKUP: language-selection UI preview for operator review. Read-only,
|
|
# and removed once spec 30 is implemented for real.
|
|
app.include_router(language_router)
|
|
# Spec 17 Phase 3: orphan name-token review queue lives in its own module so
|
|
# the UI never has to be merged into the hot routes.py.
|
|
app.include_router(name_token_router)
|
|
# Voice-regeneration queue. Separate module for the same reason, and so the
|
|
# eventual controller can drive the same endpoints remotely.
|
|
app.include_router(voice_queue_router)
|
|
|
|
# ---------------------------------------------------------------- auth gate
|
|
# Installed as middleware rather than per-route dependencies so that a newly
|
|
# added route is protected by default: forgetting to decorate a handler is
|
|
# not a way to accidentally expose the UI.
|
|
#
|
|
# Fail-CLOSED: any error while checking the session denies the request. An
|
|
# exception here must never fall through to "no session required", because
|
|
# that would turn a bug into an open door.
|
|
from umbraaudio import auth
|
|
from umbraaudio.web.auth_routes import router as auth_router
|
|
|
|
app.include_router(auth_router)
|
|
|
|
@app.middleware("http")
|
|
async def _auth_gate(request: Request, call_next):
|
|
path = request.url.path
|
|
|
|
if (
|
|
path in auth.PUBLIC_PATHS
|
|
or path.startswith(auth.PUBLIC_PREFIXES)
|
|
or request.method == "OPTIONS"
|
|
):
|
|
return await call_next(request)
|
|
|
|
try:
|
|
user = auth.verify_session(request.cookies.get(auth.COOKIE_NAME))
|
|
except Exception:
|
|
logger.exception("auth: session check failed; denying request")
|
|
user = None
|
|
|
|
if not user:
|
|
# API and HTMX callers get a status they can act on; browsers get
|
|
# sent to the login page.
|
|
wants_json = request.headers.get("hx-request") or (
|
|
"application/json" in (request.headers.get("accept") or "")
|
|
and "text/html" not in (request.headers.get("accept") or "")
|
|
)
|
|
if wants_json:
|
|
return JSONResponse(
|
|
{"detail": "Not authenticated.", "redirect": "/login"},
|
|
status_code=401,
|
|
)
|
|
return RedirectResponse(url="/login", status_code=303)
|
|
|
|
# First login is forced through a password change before any other page.
|
|
state = auth.password_state()
|
|
if state["must_change"] and path not in ("/change-password", "/api/change-password", "/api/logout"):
|
|
if path.startswith("/static/"):
|
|
return await call_next(request)
|
|
return RedirectResponse(url="/change-password", status_code=303)
|
|
|
|
return await call_next(request)
|
|
|
|
# Spec 17 §3.1/§3.2: speaker identification confidence. Exposed as a template
|
|
# global (like active_project) so the speakers page can show a
|
|
# clear / needs-review designation for every speaker without touching the
|
|
# hot routes.py rendering code.
|
|
from umbraaudio.analysis.speaker_confidence import load_speaker_assessments
|
|
from umbraaudio.web.routes import templates as _templates
|
|
|
|
def _speaker_designations_for_template(request) -> dict:
|
|
try:
|
|
slug = request.cookies.get("active_project")
|
|
if not slug:
|
|
return {}
|
|
return load_speaker_assessments(Path(slug) / ".umbra")
|
|
except Exception:
|
|
return {}
|
|
|
|
_templates.env.globals["speaker_designations"] = _speaker_designations_for_template
|
|
|
|
@app.exception_handler(NoActiveProjectError)
|
|
async def _no_active_project_handler(request: Request, exc: NoActiveProjectError):
|
|
"""HTML pages go to the project picker; API callers get a 400 error."""
|
|
accept = (request.headers.get("accept") or "").lower()
|
|
if "text/html" in accept:
|
|
return RedirectResponse(url="/projects", status_code=303)
|
|
return JSONResponse(
|
|
{"detail": str(exc), "redirect": "/projects"}, status_code=400
|
|
)
|
|
|
|
app.state.config = config
|
|
app.state.projects = list_projects()
|
|
if config.data_dir and config.data_dir.exists() and config.data_dir.name != ".umbra":
|
|
app.state.pipeline_manager = PipelineManager(config.data_dir)
|
|
app.state.generation_manager = GenerationManager(config.data_dir)
|
|
else:
|
|
app.state.pipeline_manager = None
|
|
app.state.generation_manager = None
|
|
|
|
_app = app
|
|
return app
|