Files
UmbraAudio/umbraaudio/web/language_routes.py
Umbra Tools ff73c99f8d Show the reviewer why a translation lane has a gap, not just that it has one
A delivered gap is not a missing translation. The delivery arrives and says
this target language does not voice this utterance, with a reason - the
translation renders a different paragraph, say. That is a different problem
with a different fix, and the reviewer needs to be able to tell them apart.

The reason was being thrown away. The row is stored with an empty text and
a review_reason; the resolver skipped every row with empty text, so the gap
was dropped and the review lane said "Chinese has not been translated yet".
That is a false statement about a translation that was delivered and
rejected.

So the resolver now keeps a not-voiced row as a gap with its reason, the
chapter page renders it as a gap rather than as missing text, and the
language page lists the delivered gaps under "Needs attention" with their
reasons. A gap row is also no longer counted as translated text, so the
coverage the reviewer sees is the coverage the book has.

4 new tests. Suite: 913 passed, 8 failed (the same 8 pre-existing failures).

Also adds scripts/probe_cross_language_tts.py: the open prerequisite for any
target-language audio lane is whether Qwen3-TTS can speak Chinese with an
English voice reference. The probe answers it by transcription rather than
by listening - Whisper transcribes each clip and reports what language it
heard, and the English-reference clip reading Chinese is compared against
the same clip reading Chinese with English phonology, so the only variable
is the language parameter.
2026-11-06 21:56:28 -08:00

370 lines
13 KiB
Python

"""Language selection UI (spec 30/31).
Reads translation rows from the active project's ``utterance_translations`` table
so the UI always shows the real project state, not a stale sample snapshot.
Writes per-language audio under ``<project>/Lang_test/<CODE>``. Audio generation
is driven by the same pipeline that renders English — the ``code`` is passed
through to the TTS engine so each language gets its own rendered chapter files.
Do not delete this module: the language-mockup route is how the operator reviews
language state before spec 31 commits to a full pipeline rewrite.
"""
from __future__ import annotations
import logging
import re
import sqlite3
from collections import defaultdict
from typing import TYPE_CHECKING
from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse
if TYPE_CHECKING:
from pathlib import Path
logger = logging.getLogger(__name__)
router = APIRouter()
# The 10 languages Qwen3-TTS speaks. Lowercase codes as the model expects
# (spec 30 section 8). The 5 gap languages are NOT offered: showing them as
# selectable would imply we can voice them.
SUPPORTED: list[tuple[str, str]] = [
("en", "English"),
("de", "German"),
("fr", "French"),
("es", "Spanish"),
("it", "Italian"),
("pt", "Portuguese"),
("ru", "Russian"),
("zh", "Chinese"),
("ja", "Japanese"),
("ko", "Korean"),
]
SOURCE_LANGUAGE = "en"
# Above this, the issue list stops being scannable and groups by chapter instead.
ISSUE_INLINE_THRESHOLD = 25
def _project_db(request: Request) -> Path:
"""Path to the active project's SQLite DB.
``data_dir`` (``<project>/.umbra``), the same file routes.py opens. A
0-byte ``project.db`` also sits in the project root; opening that one
finds no tables at all, so every query fails with "no such table".
"""
from umbraaudio.web.routes import _get_active_config
return _get_active_config(request).data_dir / "project.db"
def _language_code_variants(code: str) -> list[str]:
"""Return a language code and its common variants for DB lookup.
The project DB stores ``'zh-Hans'`` while SUPPORTED uses ``'zh'``.
This function returns both so the query can match either.
"""
variants: list[str] = [code]
if code == "zh":
variants.append("zh-Hans")
if code == "en":
variants.append("en-US")
return variants
def _translation_rows(request: Request, code: str) -> list[dict]:
"""The translated rows for *code*, from the DB and the sample files.
Each row has: index, speaker, type, text — matching the shape the mockup
template expects so the HTML table renders without changes.
The project's ``utterance_translations`` table is the pipeline's output,
but today it holds one placeholder row; the real translations live in the
on-disk sample files under ``samples/translations``. Both are read, and
the DB wins where it holds a real translation.
"""
from umbraaudio.translations import resolved_translations
from umbraaudio.web.routes import _get_active_config
cfg = _get_active_config(request)
db = _project_db(request)
con = sqlite3.connect(f"file:{db}?mode=ro", uri=True)
con.row_factory = sqlite3.Row
try:
resolved = resolved_translations(con, cfg.project_dir, code)
finally:
con.close()
rows: list[dict] = []
for (chapter_id, index), entry in sorted(resolved.items()):
rows.append(
{
"chapter_id": chapter_id,
"index": index,
"speaker": entry.speaker or "",
"type": entry.type or "",
"text": entry.text,
"status": entry.status,
"origin": entry.origin,
"voiced": entry.voiced,
"reason": entry.reason,
}
)
return rows
def _total_utterances(request: Request) -> int:
"""Total utterances in the project - the denominator for coverage."""
db = _project_db(request)
with sqlite3.connect(f"file:{db}?mode=ro", uri=True) as con:
return con.execute("SELECT COUNT(*) FROM utterances").fetchone()[0]
def _lang_audio_dir(request: Request, code: str) -> Path:
"""Where per-language audio is written: <project>/Lang_test/<CODE>."""
from umbraaudio.web.routes import _get_active_config
cfg = _get_active_config(request)
return cfg.project_dir / "Lang_test" / code.upper()
def _count_utterances(request: Request) -> int:
"""Total utterances in the project - the denominator for both coverages."""
db = _project_db(request)
with sqlite3.connect(f"file:{db}?mode=ro", uri=True) as con:
return con.execute("SELECT COUNT(*) FROM utterances").fetchone()[0]
def _delivered_gaps(request: Request, code: str) -> list[dict]:
"""Utterances the delivery explicitly does not voice, and why.
A delivered gap is not a missing translation: the delivery arrived and
said this target language does not voice this utterance, with a reason.
That is a different problem with a different fix, so it has to be
listed separately rather than folded into "not translated yet".
"""
from umbraaudio.translations import canonical_code
db = _project_db(request)
con = sqlite3.connect(f"file:{db}?mode=ro", uri=True)
con.row_factory = sqlite3.Row
try:
rows = con.execute(
"""
SELECT u.chapter_id, u.utterance_index, u.text AS source_text,
t.review_reason, t.language_code
FROM utterance_translations t
JOIN utterances u ON u.id = t.utterance_id
WHERE t.voiced = 0
ORDER BY u.chapter_id, u.utterance_index
"""
).fetchall()
except sqlite3.OperationalError:
logger.exception("language page: utterance_translations could not be read")
return []
finally:
con.close()
target = canonical_code(code) or str(code).strip().lower()
out: list[dict] = []
for row in rows:
stored = canonical_code(row["language_code"]) or str(row["language_code"] or "").lower()
if stored != target:
continue
out.append(
{
"chapter": row["chapter_id"],
"index": row["utterance_index"],
"reason": str(row["review_reason"] or ""),
"source_text": str(row["source_text"] or ""),
}
)
return out
def _collect_issues(request: Request, code: str, text_count: int) -> list[dict]:
"""Issues that need fixing or confirming for *code*.
Only genuine content problems are listed here. Deliberately NOT listed:
missing staleness markers on the sample render. Those exist because
render_lang_test.py does not write markers, not because anything is wrong
with the translation - reporting them would send someone hunting a defect
that is not there.
"""
issues: list[dict] = []
if text_count == 0:
# Nothing translated at all - one representative row rather than 4,319.
return [
{
"chapter": 1,
"index": 0,
"kind": "no translation",
"badge": "badge-red",
"text": f"No {code.upper()} text delivered yet - whole book untranslated.",
}
]
if text_count < 25:
# Too little translated to call the book ready. This is the honest
# headline for any language that has not had a real translation run.
issues.append(
{
"chapter": 1,
"index": 0,
"kind": "unconfirmed",
"badge": "badge-yellow",
"text": (
f"Only {text_count} of 4,319 utterances translated, and none "
f"confirmed. Sample only - not a publishable translation."
),
}
)
# Delivered gaps: the translation arrived and declined to voice these
# utterances, with a reason. Listed with the reason so the reviewer knows
# what to do about them.
for gap in _delivered_gaps(request, code):
issues.append(
{
"chapter": gap["chapter"],
"index": gap["index"],
"kind": "delivered gap",
"badge": "badge-yellow",
"text": f"{gap['reason'] or 'the delivery did not voice this utterance'}",
}
)
return issues
def _group_by_chapter(issues: list[dict]) -> list[dict]:
"""Compact >25 issues into one row per chapter, worst first."""
buckets: dict[int, dict] = defaultdict(lambda: {"count": 0, "kinds": set()})
for i in issues:
b = buckets[i["chapter"]]
b["count"] += 1
b["kinds"].add(i["kind"])
rows = [
{
"chapter": ch,
"count": b["count"],
"kinds": ", ".join(sorted(b["kinds"])),
}
for ch, b in sorted(buckets.items(), key=lambda kv: -kv[1]["count"])
]
return rows
def _source_rows(request: Request, chapter_id: int = 1) -> dict[int, dict]:
"""The project's own English text for *chapter_id*, keyed by index.
The source column of the mockup shows what the book actually says. The
sample translation files paraphrase the English slightly, so reading the
source from the DB keeps the two columns honest.
"""
db = _project_db(request)
con = sqlite3.connect(f"file:{db}?mode=ro", uri=True)
con.row_factory = sqlite3.Row
try:
rows = con.execute(
"""
SELECT utterance_index, speaker_name, type, text
FROM utterances
WHERE chapter_id = ?
ORDER BY utterance_index
""",
(chapter_id,),
).fetchall()
except sqlite3.OperationalError:
logger.exception("mockup: utterances could not be read")
return {}
finally:
con.close()
return {
row["utterance_index"]: {
"index": row["utterance_index"],
"speaker": row["speaker_name"] or "",
"type": row["type"] or "",
"text": row["text"] or "",
}
for row in rows
}
@router.get("/language-mockup", response_class=HTMLResponse)
async def language_mockup_page(request: Request, lang: str = "de"):
from umbraaudio.web.routes import templates
code = lang.lower() if lang.lower() in dict(SUPPORTED) else "de"
names = dict(SUPPORTED)
# No fallback path here on purpose: a silent fallback would render English
# under a German heading and look like a working page.
target = _translation_rows(request, code)
source = _source_rows(request)
total = _total_utterances(request)
rows: list[dict] = []
adir = _lang_audio_dir(request, code)
# Rendered clips are named utt<NN>_<Speaker>.wav, not utt<NNNN>.wav, so
# match by index prefix rather than assuming the padded form.
audio_by_index: dict[int, bool] = {}
if adir.exists():
for f in adir.glob("utt*_*.wav"):
m = re.match(r"utt(\d+)_", f.name)
if m:
audio_by_index[int(m.group(1))] = True
for u in target:
src = source.get(u["index"])
rows.append(
{
"index": u["index"],
"speaker": u.get("speaker", "") or (src["speaker"] if src else ""),
"type": u.get("type", "") or (src["type"] if src else ""),
"target_text": u["text"],
"source_text": src["text"] if src else "",
"has_audio": audio_by_index.get(u["index"], False),
"voiced": u.get("voiced", True),
"reason": u.get("reason", ""),
}
)
# A delivered gap is not translated text. Counting it as translated would
# report coverage the book does not have.
text_have = sum(1 for r in rows if r["target_text"].strip())
audio_have = sum(1 for r in rows if r["has_audio"])
text_pct = (text_have / total * 100) if total else 0.0
audio_pct = (audio_have / total * 100) if total else 0.0
issues = _collect_issues(request, code, text_have)
return templates.TemplateResponse(
request,
"language_mockup.html",
{
"languages": SUPPORTED,
"code": code,
"language_name": names.get(code, code),
"rows": rows,
"total": total,
"text_have": text_have,
"text_pct": text_pct,
"audio_have": audio_have,
"audio_pct": audio_pct,
"audio_blocked": audio_have < text_have,
"audio_blocked_count": text_have - audio_have,
"issues": issues,
"issue_count": len(issues),
"issue_threshold": ISSUE_INLINE_THRESHOLD,
"issue_by_chapter": _group_by_chapter(issues),
"is_source": code == SOURCE_LANGUAGE,
"show_source_ref": False,
},
)