Mockup v2: header dropdown, split coverage, issue list
Rework per operator feedback that v1 was cluttered.
Language selection moves into the header beside Active project, as a
dropdown, replacing the large dedicated card. It belongs there rather than
on one page because it changes what every page shows. base.html now
carries the selector, guarded by {% if languages is defined %} so no other
page is affected until spec 30 is real. Verified the other pages still
render (dashboard, speakers, chapters, library, lang-samples all 200).
Coverage is now two independent numbers rather than one:
text 12 / 4319 utterances translated 0.28%
audio 12 / 4319 utterances voiced 0.28%
They fail independently: text can arrive before any audio exists, and
audio can be missing because a render failed rather than because the
translation does. One combined percentage hides which of the two is stuck,
which is the question you actually need answered before a release.
Needs attention replaces the old static coverage table. Up to 25 issues
list inline; above that it groups by chapter and links to /chapters-audio
rather than scrolling to unusable length. Verified the grouping branch
directly with 2,592 synthetic issues: 32 chapter rows, worst first, and
the per-chapter counts sum back to the total. Nothing is hidden - the
grouped view still accounts for every issue.
Also fixed a real measurement bug carried in from v1: rendered clips are
named utt<NN>_<Speaker>.wav, not utt<NNNN>.wav, so my original
exists() check matched nothing and reported 0 of 12 voiced. Now matches
by index prefix; the 12/12 figure is read from disk, not assumed.
Dropped the English reference column and the long explanatory prose;
the design notes collapsed into a <details> block so the page reads as a
page rather than a specification.
Suite: 721 passed, 8 pre-existing. ruff clean.
This commit is contained in:
@@ -1,59 +1,55 @@
|
||||
"""Language-selection UI mockup (spec 30) - PROPOSAL, NOT PRODUCTION.
|
||||
|
||||
This module exists so the operator can review the proposed German UI before
|
||||
any of it is wired into the pipeline. It reads the real sample translations
|
||||
already on disk and renders them through a functional language selector, so
|
||||
what is being reviewed is the actual proposed behaviour rather than a drawing.
|
||||
Exists so the operator can review the proposed language UI before any of it is
|
||||
wired into the pipeline. It reads the real sample translations and the real
|
||||
project DB, so what is reviewed is the proposed behaviour rather than a drawing.
|
||||
|
||||
Nothing here writes to the project DB, generates audio, or alters the English
|
||||
pipeline. It is a separate router for the same reason name_token_routes.py
|
||||
exists: to keep the proposed work out of the hot routes.py until it is agreed.
|
||||
Nothing here writes to the DB, generates audio, or touches the English pipeline.
|
||||
Separate module for the reason name_token_routes.py exists: to keep proposed
|
||||
work out of the hot routes.py until it is agreed.
|
||||
|
||||
Delete this module once spec 30 is implemented for real.
|
||||
Delete once spec 30 is implemented for real.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
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:
|
||||
# Annotation-only; `from __future__ import annotations` makes this safe.
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
# The 10 languages Qwen3-TTS speaks. Lowercase codes as the model expects them
|
||||
# (spec 30 section 8). The 5 gap languages are listed but not offered, because
|
||||
# offering them would imply we can voice them.
|
||||
# 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"),
|
||||
("de", "German"),
|
||||
("fr", "French"),
|
||||
("it", "Italian"),
|
||||
("es", "Spanish"),
|
||||
("pt", "Portuguese"),
|
||||
("ru", "Russian"),
|
||||
]
|
||||
|
||||
UNSUPPORTED: list[tuple[str, str]] = [
|
||||
("nl", "Dutch"),
|
||||
("no", "Norwegian"),
|
||||
("pl", "Polish"),
|
||||
("ar", "Arabic"),
|
||||
("hi", "Hindi"),
|
||||
]
|
||||
|
||||
SOURCE_LANGUAGE = "en"
|
||||
|
||||
# Above this, the issue list stops being scannable and groups by chapter instead.
|
||||
ISSUE_INLINE_THRESHOLD = 25
|
||||
|
||||
|
||||
def _translations_dir(request: Request) -> Path:
|
||||
"""Sample translations live beside the project, not in .umbra.
|
||||
@@ -72,6 +68,18 @@ def _project_db(request: Request) -> Path:
|
||||
return _get_active_config(request).data_dir / "project.db"
|
||||
|
||||
|
||||
def _lang_audio_dir(request: Request, code: str) -> Path:
|
||||
"""Where render_lang_test.py writes per-language audio: <project>/Lang_test.
|
||||
|
||||
Stands in for the proposed chapter_audio_<code>/ namespace until spec 30
|
||||
lands. English lives in .umbra/chapter_audio instead.
|
||||
"""
|
||||
from umbraaudio.web.routes import _get_active_config
|
||||
|
||||
cfg = _get_active_config(request)
|
||||
return cfg.project_dir / "Lang_test" / code.upper()
|
||||
|
||||
|
||||
def _read_sample(root: Path, code: str) -> dict | None:
|
||||
path = root / f"deal_hunter_sample_ch1_{code.upper()}.json"
|
||||
if not path.exists():
|
||||
@@ -83,53 +91,104 @@ def _read_sample(root: Path, code: str) -> dict | None:
|
||||
return None
|
||||
|
||||
|
||||
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 _collect_issues(request: Request, code: str, text_count: int) -> list[dict]:
|
||||
"""Issues that need fixing or confirming for *code*.
|
||||
|
||||
Three kinds, all real signals rather than placeholders:
|
||||
no_translation - a translated row exists but the text is empty
|
||||
no_audio - translated but never rendered
|
||||
empty_text - a marker exists whose text is blank
|
||||
|
||||
German issues are largely *absence* problems, which is exactly what the
|
||||
operator needs surfaced: not "0 errors" but "4,307 untranslated".
|
||||
"""
|
||||
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.",
|
||||
}
|
||||
]
|
||||
|
||||
adir = _lang_audio_dir(request, code)
|
||||
missing_audio = 0
|
||||
if adir.exists():
|
||||
for f in sorted(adir.glob("utt*.wav")):
|
||||
if f.with_suffix(".wav.txt").exists():
|
||||
continue
|
||||
missing_audio += 1
|
||||
if missing_audio:
|
||||
issues.append(
|
||||
{
|
||||
"chapter": 1,
|
||||
"index": 0,
|
||||
"kind": "audio missing",
|
||||
"badge": "badge-yellow",
|
||||
"text": f"{missing_audio} rendered clip(s) have no staleness marker.",
|
||||
}
|
||||
)
|
||||
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
|
||||
|
||||
|
||||
@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.
|
||||
root = _translations_dir(request)
|
||||
|
||||
source = _read_sample(root, SOURCE_LANGUAGE)
|
||||
target = _read_sample(root, code)
|
||||
|
||||
# Coverage is measured against the real project, not the 12-row sample, so
|
||||
# the number the operator sees is the honest one.
|
||||
coverage: list[dict] = []
|
||||
try:
|
||||
import sqlite3
|
||||
|
||||
db = _project_db(request)
|
||||
with sqlite3.connect(f"file:{db}?mode=ro", uri=True) as con:
|
||||
total = con.execute("SELECT COUNT(*) FROM utterances").fetchone()[0]
|
||||
for c, name in SUPPORTED:
|
||||
doc = _read_sample(root, c)
|
||||
have = len(doc["utterances"]) if doc else 0
|
||||
coverage.append(
|
||||
{
|
||||
"code": c,
|
||||
"name": name,
|
||||
"have": have,
|
||||
"total": total,
|
||||
"pct": (have / total * 100) if total else 0.0,
|
||||
"selected": c == code,
|
||||
}
|
||||
)
|
||||
except Exception:
|
||||
# Surfaced in the template rather than silently rendering 0%, which
|
||||
# would read as "nothing translated" instead of "could not measure".
|
||||
logger.exception("mockup: coverage unavailable")
|
||||
total = 0
|
||||
coverage_error = True
|
||||
else:
|
||||
coverage_error = False
|
||||
total = _count_utterances(request)
|
||||
|
||||
rows: list[dict] = []
|
||||
if source and target:
|
||||
src_by_idx = {u["index"]: u for u in source["utterances"]}
|
||||
if target:
|
||||
src_by_idx = {u["index"]: u for u in (source or {"utterances": []})["utterances"]}
|
||||
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["utterances"]:
|
||||
src = src_by_idx.get(u["index"])
|
||||
rows.append(
|
||||
@@ -139,24 +198,37 @@ async def language_mockup_page(request: Request, lang: str = "de"):
|
||||
"type": u.get("type", ""),
|
||||
"target_text": u["text"],
|
||||
"source_text": src["text"] if src else "",
|
||||
"translated": bool(src and src["text"] != u["text"]),
|
||||
"has_audio": audio_by_index.get(u["index"], False),
|
||||
}
|
||||
)
|
||||
|
||||
text_have = len(rows)
|
||||
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,
|
||||
"unsupported": UNSUPPORTED,
|
||||
"code": code,
|
||||
"language_name": dict(SUPPORTED).get(code, code),
|
||||
"target": target,
|
||||
"source": source,
|
||||
"language_name": names.get(code, code),
|
||||
"rows": rows,
|
||||
"coverage": coverage,
|
||||
"coverage_error": coverage_error,
|
||||
"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,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -80,9 +80,23 @@
|
||||
<h1 style="display:flex; align-items:center; gap:0.75rem;">
|
||||
<img src="/static/logo.png" alt="UmbraAudio logo" style="height:56px; width:56px; border-radius:12px;">
|
||||
UmbraAudio Review
|
||||
<span class="badge badge-blue" style="margin-left:auto; font-size:0.9rem; padding:0.4rem 0.9rem; display:inline-flex; align-items:center; gap:0.5rem;" title="Project: {{ ap.slug }}">
|
||||
<span class="badge badge-blue" style="font-size:0.9rem; padding:0.4rem 0.9rem; display:inline-flex; align-items:center; gap:0.5rem;" title="Project: {{ ap.slug }}">
|
||||
Active project: <strong style="color:#e0e7ff">{{ ap.name }}</strong>
|
||||
</span>
|
||||
{# Spec 30 MOCKUP: language dropdown beside Active project. It belongs here rather
|
||||
than on one page because it changes what every page shows. This partial is a
|
||||
placeholder for spec 30's active_language selector; it posts nowhere yet. #}
|
||||
<span style="margin-left:auto; font-size:0.9rem; display:inline-flex; align-items:center; gap:0.5rem;">
|
||||
<label for="lang-select" style="color:#94a3b8;">Content language:</label>
|
||||
<select id="lang-select"
|
||||
onchange="window.location.href = '/language-mockup?lang=' + this.value;"
|
||||
style="background:#0f3460; color:#e0e7ff; border:1px solid #7c3aed;
|
||||
border-radius:4px; padding:0.35rem 0.5rem; font-size:0.9rem; cursor:pointer;">
|
||||
{% for c, name in (languages or []) %}
|
||||
<option value="{{ c }}" {% if c == code %}selected{% endif %}>{{ name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</span>
|
||||
</h1>
|
||||
<nav class="nav">
|
||||
<a href="/">Dashboard</a>
|
||||
|
||||
@@ -1,123 +1,135 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
|
||||
<div style="border:2px dashed #7c3aed; border-radius:8px; padding:0.75rem 1rem; margin-bottom:1.5rem; background:#1e1b4b;">
|
||||
<strong style="color:#c4b5fd">MOCKUP — spec 30, not built.</strong>
|
||||
<span style="color:#94a3b8; font-size:0.85rem">
|
||||
Read-only preview of the proposed language UI. Nothing here writes to the database,
|
||||
generates audio, or changes the English pipeline.
|
||||
</span>
|
||||
<div style="border:2px dashed #7c3aed; border-radius:8px; padding:0.6rem 1rem; margin-bottom:1.25rem; background:#1e1b4b;">
|
||||
<strong style="color:#c4b5fd">MOCKUP — spec 30.</strong>
|
||||
<span style="color:#94a3b8; font-size:0.85rem">Read-only. The language dropdown in the header is the proposed permanent placement.</span>
|
||||
</div>
|
||||
|
||||
<!-- ============================ language selector ============================ -->
|
||||
{# ------------------------------------------------------------------ coverage #}
|
||||
<div class="card">
|
||||
<h2>Language selection</h2>
|
||||
<p style="color:#94a3b8; font-size:0.9rem; margin-bottom:1rem">
|
||||
Sits in the page header beside <em>Active project</em> in the real build, because it
|
||||
changes what every page shows. Controls below stay in English always — only the
|
||||
content column changes.
|
||||
<h2>{{ language_name }} — progress</h2>
|
||||
|
||||
<div class="stat-grid" style="margin-bottom:0.5rem;">
|
||||
<div class="stat">
|
||||
<div class="num">{{ text_have }}</div>
|
||||
<div class="label">of {{ total }} utterances translated</div>
|
||||
<div class="progress-bar" style="margin-top:0.6rem;">
|
||||
<div class="progress-fill" style="width: {{ '%.4f' % text_pct }}%;"></div>
|
||||
</div>
|
||||
<div class="label" style="margin-top:0.35rem;">{{ '%.2f' % text_pct }}%</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="num">{{ audio_have }}</div>
|
||||
<div class="label">of {{ total }} utterances voiced</div>
|
||||
<div class="progress-bar" style="margin-top:0.6rem;">
|
||||
<div class="progress-fill" style="width: {{ '%.4f' % audio_pct }}%; background:#10b981;"></div>
|
||||
</div>
|
||||
<div class="label" style="margin-top:0.35rem;">{{ '%.2f' % audio_pct }}%</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p style="color:#64748b; font-size:0.8rem; margin-top:0.75rem">
|
||||
Two separate numbers because they fail independently. Text can arrive before any audio is
|
||||
produced, and audio can be missing because a clip failed rather than because the
|
||||
translation does not exist. One combined percentage hides which of the two is stuck.
|
||||
</p>
|
||||
|
||||
<div style="display:flex; flex-wrap:wrap; gap:0.4rem; align-items:center; margin-bottom:1rem;">
|
||||
<span style="color:#a78bfa; font-weight:600; margin-right:0.5rem;">Content language:</span>
|
||||
{% for c, name in languages %}
|
||||
<a href="/language-mockup?lang={{ c }}"
|
||||
class="btn"
|
||||
style="{% if c == code %}background:#7c3aed; color:white; font-weight:700;{% else %}background:#0f3460; color:#c4b5fd;{% endif %}">
|
||||
{{ name }}
|
||||
</a>
|
||||
{% endfor %}
|
||||
{% if audio_blocked %}
|
||||
<div style="margin-top:0.75rem; padding:0.6rem 0.8rem; background:#78350f; border-radius:6px; border-left:3px solid #f59e0b;">
|
||||
<strong style="color:#fde68a">{{ audio_blocked_count }} utterance{{ 's' if audio_blocked_count != 1 else '' }} translated but not voiced.</strong>
|
||||
<span style="color:#94a3b8; font-size:0.85rem"> Audio is behind translation, which is the expected state before a render pass.</span>
|
||||
</div>
|
||||
|
||||
<div style="padding:0.6rem 0.8rem; background:#16213e; border-radius:6px; border-left:3px solid #475569;">
|
||||
<span style="color:#94a3b8; font-size:0.85rem">
|
||||
Not offered yet (Qwen3-TTS cannot voice them — spec 26 §gap):
|
||||
{% for c, name in unsupported %}<span class="badge" style="background:#334155; color:#94a3b8; margin-right:0.3rem;">{{ name }}</span>{% endfor %}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ============================== coverage ================================= -->
|
||||
<div class="card">
|
||||
<h2>Translation coverage</h2>
|
||||
{% if coverage_error %}
|
||||
<div style="padding:0.6rem 0.8rem; background:#7f1d1d; border-radius:6px; margin-bottom:1rem; border-left:3px solid #ef4444;">
|
||||
<strong>Coverage could not be measured.</strong>
|
||||
<span style="font-size:0.85rem;">The project database was not readable, so these figures
|
||||
are absent rather than reported as zero.</span>
|
||||
</div>
|
||||
{% else %}
|
||||
<p style="color:#94a3b8; font-size:0.9rem; margin-bottom:1rem">
|
||||
Measured against the real project ({{ total }} utterances), not the 12-row sample.
|
||||
In the built version this is the number that tells you whether a book is safe to
|
||||
export — a book that is accidentally half-German must be impossible to miss.
|
||||
</p>
|
||||
{% endif %}
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Language</th><th>Delivered</th><th>Coverage</th><th>Status</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for c in coverage if not coverage_error %}
|
||||
<tr {% if c.selected %}style="background:#1e1b4b;"{% endif %}>
|
||||
<td>
|
||||
{% if c.selected %}<strong style="color:#c4b5fd;">{{ c.name }}</strong>
|
||||
{% else %}{{ c.name }}{% endif %}
|
||||
</td>
|
||||
<td>{{ c.have }} / {{ c.total }}</td>
|
||||
<td style="min-width:180px;">
|
||||
<div class="progress-bar" style="width:160px;">
|
||||
<div class="progress-fill" style="width: {{ '%.4f' % c.pct }}%;"></div>
|
||||
</div>
|
||||
<span style="font-size:0.75rem; color:#94a3b8;">{{ '%.3f' % c.pct }}%</span>
|
||||
</td>
|
||||
<td>
|
||||
{% if c.code == 'en' %}
|
||||
<span class="badge badge-green">source · editable</span>
|
||||
{% elif c.pct == 0 %}
|
||||
<span class="badge" style="background:#334155; color:#94a3b8;">not started</span>
|
||||
{% elif c.pct >= 99.9 %}
|
||||
<span class="badge badge-yellow">complete · unconfirmed</span>
|
||||
{% else %}
|
||||
<span class="badge badge-red">partial</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
<p style="color:#94a3b8; font-size:0.8rem; margin-top:0.75rem">
|
||||
English is <strong>editable</strong>. Translations are <strong>confirm-only</strong> —
|
||||
they arrive from UmbraTrans, so they are reviewed and marked confirmed, never typed over.
|
||||
</div>
|
||||
|
||||
{# -------------------------------------------------------------------- issues #}
|
||||
<div class="card">
|
||||
<h2>Needs attention</h2>
|
||||
|
||||
{% if issue_count == 0 %}
|
||||
<p style="color:#10b981; font-weight:600;">Nothing outstanding for {{ language_name }}.</p>
|
||||
{% elif issue_count <= issue_threshold %}
|
||||
{# Small enough to read in one place - list it. #}
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:5rem;">Chapter</th>
|
||||
<th style="width:4rem;">#</th>
|
||||
<th style="width:12rem;">Kind</th>
|
||||
<th>Text</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for i in issues %}
|
||||
<tr>
|
||||
<td><a href="/chapters-audio/{{ i.chapter }}" style="color:#7c3aed;">Ch {{ i.chapter }}</a></td>
|
||||
<td style="color:#64748b;">{{ i.index }}</td>
|
||||
<td><span class="badge {{ i.badge }}">{{ i.kind }}</span></td>
|
||||
<td>{{ i.text }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
{# Too many to be useful inline - summarise and link to the chapters. #}
|
||||
<p style="color:#94a3b8; margin-bottom:1rem;">
|
||||
<strong style="color:#e0e0e0;">{{ issue_count }} issues</strong> across
|
||||
{{ issue_chapters }} chapters — too many to list usefully here.
|
||||
</p>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:6rem;">Chapter</th>
|
||||
<th style="width:6rem;">Issues</th>
|
||||
<th>Kinds</th>
|
||||
<th style="width:8rem;"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for ch in issue_by_chapter %}
|
||||
<tr>
|
||||
<td><a href="/chapters-audio/{{ ch.chapter }}" style="color:#7c3aed;">Chapter {{ ch.chapter }}</a></td>
|
||||
<td>{{ ch.count }}</td>
|
||||
<td style="color:#94a3b8; font-size:0.85rem;">{{ ch.kinds }}</td>
|
||||
<td><a href="/chapters-audio/{{ ch.chapter }}" class="btn btn-primary" style="padding:0.25rem 0.6rem; font-size:0.75rem;">Review</a></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endif %}
|
||||
|
||||
<p style="color:#64748b; font-size:0.8rem; margin-top:0.75rem">
|
||||
Up to {{ issue_threshold }} issues are listed inline. Beyond that the list stops being
|
||||
scannable, so it groups by chapter and links out instead of hiding anything.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- =========================== chapter content ============================= -->
|
||||
{# ------------------------------------------------------------------- content #}
|
||||
<div class="card">
|
||||
<h2>Chapter content — rendered in {{ language_name }}</h2>
|
||||
<h2>Content — {{ language_name }}</h2>
|
||||
|
||||
{% if is_source %}
|
||||
<div style="padding:0.6rem 0.8rem; background:#0f3460; border-radius:6px; margin-bottom:1rem; border-left:3px solid #3b82f6;">
|
||||
Source language selected. Content is the editable original — no translation layer involved.
|
||||
<div style="padding:0.5rem 0.75rem; background:#0f3460; border-radius:6px; margin-bottom:1rem; border-left:3px solid #3b82f6; font-size:0.85rem;">
|
||||
Source language. Content is the editable original — no translation layer involved.
|
||||
</div>
|
||||
{% else %}
|
||||
<div style="padding:0.6rem 0.8rem; background:#0f3460; border-radius:6px; margin-bottom:1rem; border-left:3px solid #3b82f6;">
|
||||
Selected language is <strong style="color:#c4b5fd;">{{ language_name }}</strong>.
|
||||
Chapter title, utterance text and review statements render in it. Navigation, buttons and
|
||||
status labels stay English.
|
||||
<div style="padding:0.5rem 0.75rem; background:#0f3460; border-radius:6px; margin-bottom:1rem; border-left:3px solid #3b82f6; font-size:0.85rem;">
|
||||
Utterances render in {{ language_name }}. Navigation, buttons and statuses stay English.
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if not rows %}
|
||||
<p style="color:#94a3b8;">No translation loaded for this language.</p>
|
||||
<p style="color:#94a3b8;">No {{ language_name }} content yet.</p>
|
||||
{% else %}
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:3rem;">#</th>
|
||||
<th style="width:11rem;">Speaker</th>
|
||||
<th>{{ language_name }} — what will be voiced</th>
|
||||
{% if not is_source %}<th style="width:26rem;">English source (reference only)</th>{% endif %}
|
||||
<th style="width:10rem;">Speaker</th>
|
||||
<th>What will be voiced</th>
|
||||
{% if not is_source and show_source_ref %}<th style="width:22rem;">English source</th>{% endif %}
|
||||
<th style="width:7rem;">Audio</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -127,17 +139,22 @@
|
||||
<td>{{ r.speaker }}</td>
|
||||
<td>
|
||||
{% if r.type == 'narration' %}
|
||||
<span class="badge badge-blue" style="margin-bottom:0.3rem;">narration</span>
|
||||
<span class="badge badge-blue">narration</span>
|
||||
{% else %}
|
||||
<span class="badge badge-green" style="margin-bottom:0.3rem;">dialogue</span>
|
||||
<span class="badge badge-green">dialogue</span>
|
||||
{% endif %}
|
||||
<div style="margin-top:0.35rem;">{{ r.target_text }}</div>
|
||||
</td>
|
||||
{% if not is_source %}
|
||||
<td style="color:#64748b; font-size:0.85rem; border-left:2px solid #1e293b;">
|
||||
{{ r.source_text }}
|
||||
</td>
|
||||
{% if not is_source and show_source_ref %}
|
||||
<td style="color:#64748b; font-size:0.85rem; border-left:2px solid #1e293b;">{{ r.source_text }}</td>
|
||||
{% endif %}
|
||||
<td>
|
||||
{% if r.has_audio %}
|
||||
<span class="badge badge-green">voiced</span>
|
||||
{% else %}
|
||||
<span class="badge" style="background:#334155; color:#94a3b8;">none</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
@@ -145,71 +162,40 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- ====================== what is proposed, in short ====================== -->
|
||||
<div class="card">
|
||||
<h2>What the real build changes</h2>
|
||||
<table>
|
||||
<thead><tr><th style="width:15rem;">Area</th><th>Change</th></tr></thead>
|
||||
{# -------------------------------------------------------------------- design #}
|
||||
<details style="margin-top:1rem;">
|
||||
<summary style="cursor:pointer; color:#a78bfa; font-weight:600;">What the real build changes</summary>
|
||||
<table style="margin-top:1rem;">
|
||||
<thead><tr><th style="width:13rem;">Area</th><th>Change</th></tr></thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><strong>Database</strong></td>
|
||||
<td>
|
||||
New <code>utterance_translations</code> table
|
||||
(<code>utterance_id</code>, <code>language_code</code>, <code>text</code>,
|
||||
<code>status</code>, <code>origin</code>). English stays editable in
|
||||
<tr><td><strong>Selection</strong></td>
|
||||
<td>Dropdown in the header beside <em>Active project</em>. Backed by a separate
|
||||
<code>active_language</code> cookie, defaulting to the project source language,
|
||||
so switching language cannot disturb project selection. Fully reversible.</td></tr>
|
||||
<tr><td><strong>Database</strong></td>
|
||||
<td>New <code>utterance_translations</code> table. English stays editable in
|
||||
<code>utterances.text</code>; translations are confirm-only.
|
||||
<div style="color:#94a3b8; font-size:0.8rem; margin-top:0.3rem;">
|
||||
Rejected: a <code>language</code> column on <code>utterances</code> — one row
|
||||
can hold only one language, so either 10× duplication or overwritten source.
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Audio on disk</strong></td>
|
||||
<td>
|
||||
<code>chapter_audio/ch6/utt_0001.wav</code> (English, unchanged) →
|
||||
<div style="color:#94a3b8; font-size:0.8rem; margin-top:0.3rem;">Rejected: a
|
||||
<code>language</code> column on <code>utterances</code> — one row holds one language,
|
||||
so it forces 10× duplication or overwrites the source.</div></td></tr>
|
||||
<tr><td><strong>Audio on disk</strong></td>
|
||||
<td><code>chapter_audio/ch6/utt_0001.wav</code> (English, unchanged) →
|
||||
<code>chapter_audio_de/ch6/utt_0001.wav</code>.
|
||||
<div style="color:#fca5a5; font-size:0.8rem; margin-top:0.3rem;">
|
||||
Required, not optional: the staleness marker records the spoken text, so two
|
||||
languages sharing one filename would each mark the other stale and overwrite
|
||||
it in a loop.
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Staleness marker</strong></td>
|
||||
<td>
|
||||
Marker JSON gains <code>"language": "de"</code>, checked by
|
||||
<code>is_current()</code>, so an export can prove what it shipped.
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Selection state</strong></td>
|
||||
<td>
|
||||
Separate <code>active_language</code> cookie, defaulting to the project source
|
||||
language. Separate from <code>active_project</code> so switching language cannot
|
||||
disturb project selection. Fully reversible.
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>UmbraTrans handoff</strong></td>
|
||||
<td>
|
||||
Export <code>(utterance_id, chapter, index, speaker, source_text, context_before,
|
||||
context_after)</code>; import returns the same shape plus
|
||||
<code>language_code</code> + <code>text</code>. Idempotent on re-delivery.
|
||||
Context matters — “Yes.” is untranslatable alone.
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Server</strong></td>
|
||||
<td>
|
||||
<code>/v1/tts</code> takes any string as <code>language</code> today. Validate
|
||||
against the 10 supported codes and fail fast rather than producing wrong audio.
|
||||
</td>
|
||||
</tr>
|
||||
<div style="color:#fca5a5; font-size:0.8rem; margin-top:0.3rem;">Required: the
|
||||
staleness marker records spoken text, so two languages sharing a filename would
|
||||
each mark the other stale and overwrite it in a loop.</div></td></tr>
|
||||
<tr><td><strong>Marker</strong></td>
|
||||
<td>Marker JSON gains <code>"language": "de"</code>, checked by
|
||||
<code>is_current()</code>, so an export can prove what it shipped.</td></tr>
|
||||
<tr><td><strong>Handoff</strong></td>
|
||||
<td>Export <code>(utterance_id, chapter, index, speaker, source_text, context_before,
|
||||
context_after)</code>; import returns the same plus <code>language_code</code> and
|
||||
<code>text</code>. Idempotent. Context matters — “Yes.” is untranslatable alone.</td></tr>
|
||||
<tr><td><strong>Server</strong></td>
|
||||
<td><code>/v1/tts</code> accepts any string as <code>language</code> today. Validate
|
||||
against the 10 supported codes and fail fast rather than producing wrong audio.</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div style="margin-top:1rem; padding:0.75rem 1rem; background:#16213e; border-radius:6px; border-left:3px solid #f59e0b;">
|
||||
<strong style="color:#f59e0b">Open questions</strong>
|
||||
<ol style="color:#94a3b8; font-size:0.85rem; margin-top:0.4rem; padding-left:1.2rem;">
|
||||
@@ -218,6 +204,6 @@
|
||||
<li>Should a missing translation <em>block</em> generation, or generate English and mark it fallback? Proposal: block it.</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user