Files
UmbraAudio/umbraaudio/translations.py
B.D. Murphy 2b294d7fa4 Per-utterance language flag + a working language selector (spec 31)
The review UI's language dropdown did nothing: every selection kept
rendering the English text. Two independent causes, both fixed.

1. The translations were never being read. The pipeline's
   `utterance_translations` table holds one row in Deal Hunter, and that
   row stores the *English* text under a `zh-Hans` code. The real
   translations live in the on-disk sample files under
   `samples/translations/`. `umbraaudio/translations.py` now resolves both
   sources and merges them by (chapter_id, utterance_index): the table wins
   where it holds a real translation, the sample files fill the gaps. A
   candidate whose text is identical to the source is a placeholder, not a
   translation, so it is ignored - otherwise the page would show English
   under a Chinese heading and claim it was translated.

2. The query that read them was broken. `language_routes.py` selected
   `u.index` and `u.speaker`; the columns are `utterance_index` and
   `speaker_name`, so every read raised `sqlite3.OperationalError: near
   "index": syntax error` and `/language-mockup` returned 500. It also
   opened `<project>/project.db` - a 0-byte decoy - instead of
   `<project>/.umbra/project.db`, the file that actually holds the data.

Also in this commit:

* The chapter review page honours `?lang=`, remembers the choice in a
  cookie (set by a middleware, so it survives on every page), and shows the
  selected language's *name* in the "has not been translated yet" message.
* The page now shows the translation, not the source, and the edit box is
  bound to the source text so saving cannot overwrite the English with the
  Chinese.
* The stray language selector that sat after `</html>` in base.html is
  gone; the chapters-list page's dropdown had zero options because the
  route never passed the language list.
* The language tag is wired into generation: `utterance.language_code`
  (NULL = project source language) reaches `engine.generate()` and the
  staleness marker, so a retagged line is re-rendered instead of keeping
  English phonology behind an unchanged text. The marker records the
  language; a marker written before the tag existed means English, so the
  ~4,300 existing clips are not invalidated.
* `project.source_language` (spec 31 section 13.1) records the book's own
  language, with a migration for existing project DBs.
* A scene-break marker deliberately records no language: it is silence,
  identical in every language, and tagging it would claim a phonology the
  audio does not have.

Tests: 883 passed / 8 failed - the same 8 pre-existing failures
(2 test_pronunciation, 6 voice_profile_manager accent). 26 new tests
cover the resolver and the generation wiring. ruff introduces no new
findings.
2026-11-06 16:55:49 -08:00

766 lines
27 KiB
Python

"""Ingest and query the source-language tag and per-language translations.
Spec 31. Two axes, deliberately separate:
* **Source** - ``utterances.language_code`` says what language the original text
is *written in*. A Spanish line in an English book is tagged ``es`` and is
**never translated**: it is story content. "Bob overhears them speaking in
German" - the German is payload the reader is meant to read.
* **Target** - ``utterance_translations`` holds one row per (utterance, target
language). The ``voiced`` flag lives here, because a line can be voiced in
English and not in German (three source sentences collapsing into two).
The functions here take a plain ``sqlite3.Connection``. Both the web layer
(raw sqlite3) and the CLI/scripts can call them, and the import logic stays
testable without standing up the async database.
"""
from __future__ import annotations
import json
import logging
import re
import sqlite3
from dataclasses import dataclass, field
from datetime import UTC, datetime
from typing import TYPE_CHECKING, Any
from umbraaudio import languages as lang
if TYPE_CHECKING:
from pathlib import Path
logger = logging.getLogger(__name__)
# Import rows are stored as 'draft' unless the caller says otherwise. A draft
# is visible and audible but explicitly not approved (spec 30 section 3.2).
STATUS_DRAFT = "draft"
STATUS_CONFIRMED = "confirmed"
def _now() -> str:
return datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
def _col(row: Any, name: str, index: int) -> Any:
"""Read a column from a sqlite3.Row or a plain tuple.
Callers may not have set ``row_factory``, so both shapes are supported
rather than assuming one.
"""
if row is None:
return None
try:
return row[name]
except (TypeError, IndexError, KeyError):
return row[index]
@dataclass
class ImportReport:
"""What an import actually did. Counts only - never the text."""
accepted: int = 0
source_tagged: int = 0
skipped_confirmed: int = 0
not_voiced: int = 0
refused_voice: int = 0
foreign_untouched: int = 0
unknown_language: list[str] = field(default_factory=list)
problems: list[str] = field(default_factory=list)
def as_dict(self) -> dict[str, Any]:
return {
"accepted": self.accepted,
"source_tagged": self.source_tagged,
"skipped_confirmed": self.skipped_confirmed,
"not_voiced": self.not_voiced,
"refused_voice": self.refused_voice,
"foreign_untouched": self.foreign_untouched,
"unknown_language": sorted(set(self.unknown_language)),
"problems": self.problems,
}
def _translations_table_exists(conn: sqlite3.Connection) -> bool:
"""True when this connection can see the translations table.
Read-only callers exist (routes opens ``mode=ro`` in places), so the read
helpers must not create the table - they check instead, and report "nothing
translated yet" rather than raising.
"""
try:
row = conn.execute(
"SELECT 1 FROM sqlite_master WHERE type='table' AND name='utterance_translations'"
).fetchone()
except sqlite3.OperationalError:
return False
return row is not None
def ensure_tables(conn: sqlite3.Connection) -> None:
"""Create the translations table if an old project DB lacks it.
The column and index additions live in ``database._migrate`` and
``web.routes._ensure_utterance_columns``; this is the same statement exposed
for callers that hold a bare connection (scripts, tests).
"""
conn.execute(
"""
CREATE TABLE IF NOT EXISTS utterance_translations (
id INTEGER PRIMARY KEY,
project_id INTEGER NOT NULL REFERENCES project(id),
utterance_id INTEGER NOT NULL REFERENCES utterances(id),
language_code TEXT NOT NULL,
language_name TEXT NOT NULL,
text TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'draft',
origin TEXT NOT NULL DEFAULT 'umbratrans',
source_ref TEXT,
delivered_at TEXT,
confirmed_at TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
voiced INTEGER NOT NULL DEFAULT 1,
review_reason TEXT,
UNIQUE(utterance_id, language_code)
)
"""
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_utterance_translations_utt "
"ON utterance_translations(utterance_id)"
)
def source_language(conn: sqlite3.Connection) -> str:
"""The project's primary language, defaulting to English.
Spec 31 section 13.1 flagged this as an open question; until a column
exists, the project's source language is 'en', which is what every existing
row means anyway (NULL on the utterance = this value).
"""
try:
row = conn.execute("SELECT source_language FROM project LIMIT 1").fetchone()
except sqlite3.OperationalError:
return "en"
return lang.normalize(_col(row, "source_language", 0)) or "en"
def set_source_language(
conn: sqlite3.Connection,
utterance_id: int,
code: str | None,
origin: str = lang.ORIGIN_UMBRATRANS,
*,
force: bool = False,
) -> bool:
"""Record the language an utterance's text is written in.
Returns True when a change was written. ``code`` is normalised; an
unrecognised language is still stored (spec 31 section 4.3 - the tag is
allowed, the render is refused) but reported by the caller.
An existing ``operator`` tag is not overwritten by a machine identification
unless ``force`` is set: a human correction outranks a re-delivery of the
same machine guess.
"""
if origin not in lang.ORIGINS:
raise ValueError(f"unknown origin {origin!r}")
row = conn.execute(
"SELECT language_code, language_origin FROM utterances WHERE id=?",
(utterance_id,),
).fetchone()
if row is None:
return False
current_code = _col(row, "language_code", 0)
current_origin = _col(row, "language_origin", 1)
# An unrecognised language is STORED, not discarded (spec 31 section 4.3:
# "allow the tag, refuse the render"). Collapsing it to NULL would make the
# line look untagged, so it would fall back to the primary language and be
# spoken with the wrong phonology - the silent failure the decision exists
# to prevent. The raw value is kept so the refusal can name it.
if code:
stored: str | None = lang.normalize(code) or str(code).strip().lower()
else:
stored = None
operator_owns = current_origin == lang.ORIGIN_OPERATOR and origin != lang.ORIGIN_OPERATOR
if operator_owns and not force:
return False
if stored == current_code and origin == current_origin:
return False
conn.execute(
"UPDATE utterances SET language_code=?, language_origin=? WHERE id=?",
(stored, origin, utterance_id),
)
return True
def foreign_utterance_ids(conn: sqlite3.Connection, primary: str | None = None) -> set[int]:
"""Utterances whose text is NOT in the project's primary language.
These are excluded from translation: they are story content, not something
to render into another language.
"""
primary = lang.normalize(primary) or source_language(conn)
rows = conn.execute(
"SELECT id FROM utterances WHERE language_code IS NOT NULL AND language_code <> ?",
(primary,),
).fetchall()
return {_col(r, "id", 0) for r in rows}
def utterances_to_translate(conn: sqlite3.Connection, primary: str | None = None) -> list[int]:
"""Utterances eligible for translation: source-language ones only.
The complement of :func:`foreign_utterance_ids`. A NULL tag means "the
project's primary language" (spec 31 section 6.1), so untagged rows are
included - which is every existing utterance until UmbraTrans speaks.
"""
primary = lang.normalize(primary) or source_language(conn)
rows = conn.execute(
"SELECT id FROM utterances "
"WHERE language_code IS NULL OR language_code = ? "
"ORDER BY chapter_id, utterance_index",
(primary,),
).fetchall()
return [_col(r, "id", 0) for r in rows]
def import_translations(
conn: sqlite3.Connection,
project_id: int,
payload: dict[str, Any],
*,
origin: str = lang.ORIGIN_UMBRATRANS,
confirm: bool = False,
) -> ImportReport:
"""Store one delivery from UmbraTrans.
``payload`` shape (spec 31 section 8)::
{"target_language": "de",
"utterances": [{"utterance_id": 1, "source_language": "en",
"text": "Hallo", "voiced": true},
{"utterance_id": 2, "source_language": "es"},
{"utterance_id": 3, "voiced": false,
"reason": "absorbed into utterance 2"}]}
Three row shapes, all expected:
* ``source_language`` only - a language *identification*. This is the shape
for a foreign line, which is deliberately left untranslated. Counted, not
complained about.
* ``text`` (+ optional ``voiced``) - a translation into the target language.
* ``voiced: false`` with no text - a collapse orphan: the source sentence was
absorbed into another translated sentence. A row is still written, so the
gap is visible and reconstructable; deleting it would lose the alignment.
"""
ensure_tables(conn)
report = ImportReport()
target = lang.normalize(payload.get("target_language"))
target_name = lang.language_name(target) if target else None
accepted_status = STATUS_CONFIRMED if confirm else STATUS_DRAFT
now = _now()
rows = payload.get("utterances") or []
if not isinstance(rows, list):
raise ValueError("'utterances' must be a list")
for row in rows:
if not isinstance(row, dict) or "utterance_id" not in row:
report.problems.append(f"row without utterance_id: {json.dumps(row)[:80]}")
continue
uid = int(row["utterance_id"])
# --- source axis: what language the ORIGINAL text is in ------------
src = row.get("source_language")
if src:
if not lang.is_known(src):
report.unknown_language.append(str(src))
if set_source_language(conn, uid, src, origin):
report.source_tagged += 1
if not lang.is_voiceable(src):
# Tag honoured, render refused (spec 31 section 4.3).
report.refused_voice += 1
# --- target axis: the translated text -----------------------------
text = row.get("text")
voiced = bool(row.get("voiced", True))
if not target:
# No target language on the delivery: identification only.
if text is not None:
report.problems.append(
f"utterance {uid} carries text but the payload has no target_language"
)
continue
if text is None and voiced:
# A foreign line, or one with nothing to translate. Untouched.
report.foreign_untouched += 1
continue
reason = row.get("reason") or row.get("review_reason")
existing = conn.execute(
"SELECT status FROM utterance_translations WHERE utterance_id=? AND language_code=?",
(uid, target),
).fetchone()
if _col(existing, "status", 0) == STATUS_CONFIRMED:
report.skipped_confirmed += 1
continue
conn.execute(
"""
INSERT INTO utterance_translations
(project_id, utterance_id, language_code, language_name, text,
status, origin, source_ref, delivered_at, created_at, updated_at,
voiced, review_reason)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(utterance_id, language_code) DO UPDATE SET
text=excluded.text,
status=excluded.status,
origin=excluded.origin,
source_ref=excluded.source_ref,
delivered_at=excluded.delivered_at,
updated_at=excluded.updated_at,
voiced=excluded.voiced,
review_reason=excluded.review_reason
""",
(
project_id,
uid,
target,
target_name or target,
str(text) if text is not None else "",
accepted_status,
origin,
row.get("source_ref"),
now,
now,
now,
1 if voiced else 0,
reason,
),
)
report.accepted += 1
if not voiced:
report.not_voiced += 1
if not lang.is_voiceable(target):
# Target language cannot be spoken: stored, but nothing renders.
report.refused_voice += 1
conn.commit()
return report
def coverage(conn: sqlite3.Connection, language_code: str) -> dict[str, int]:
"""How much of the book exists in a target language.
``voiced`` counts rows that will actually produce audio, so a collapse
orphan shows as a gap rather than quietly inflating the total.
A project with no translations table yet reports "nothing delivered" rather
than raising: that is a real state (a book that has never been sent), not an
error.
"""
code = lang.normalize(language_code) or language_code
def scalar(sql: str, args: tuple = ()) -> int:
return conn.execute(sql, args).fetchone()[0]
total = scalar("SELECT COUNT(*) FROM utterances")
if not _translations_table_exists(conn):
return {
"total": total,
"delivered": 0,
"voiced": 0,
"not_voiced": 0,
"confirmed": 0,
"missing": total,
}
delivered = scalar("SELECT COUNT(*) FROM utterance_translations WHERE language_code=?", (code,))
voiced = scalar(
"SELECT COUNT(*) FROM utterance_translations WHERE language_code=? AND voiced=1",
(code,),
)
confirmed = scalar(
"SELECT COUNT(*) FROM utterance_translations WHERE language_code=? AND status=?",
(code, STATUS_CONFIRMED),
)
return {
"total": total,
"delivered": delivered,
"voiced": voiced,
"not_voiced": delivered - voiced,
"confirmed": confirmed,
"missing": max(total - delivered, 0),
}
def not_voiced_rows(conn: sqlite3.Connection, language_code: str) -> list[dict[str, Any]]:
"""The collapse orphans for a language - what a reviewer must look at.
These are utterances where the target used fewer sentences than the source,
so a source line has no counterpart. They are *kept*, with a reason, rather
than deleted: deleting loses the alignment (spec 31 section 6.2).
"""
code = lang.normalize(language_code) or language_code
if not _translations_table_exists(conn):
return []
rows = conn.execute(
"""
SELECT t.utterance_id, t.review_reason, u.chapter_id, u.utterance_index,
u.speaker_name, u.text
FROM utterance_translations t
JOIN utterances u ON u.id = t.utterance_id
WHERE t.language_code = ? AND t.voiced = 0
ORDER BY u.chapter_id, u.utterance_index
""",
(code,),
).fetchall()
return [
{
"utterance_id": _col(r, "utterance_id", 0),
"review_reason": _col(r, "review_reason", 1),
"chapter_id": _col(r, "chapter_id", 2),
"utterance_index": _col(r, "utterance_index", 3),
"speaker_name": _col(r, "speaker_name", 4),
"text": _col(r, "text", 5),
}
for r in rows
]
def refused_render_rows(conn: sqlite3.Connection) -> list[dict[str, Any]]:
"""Utterances tagged in a language Qwen3-TTS cannot speak.
Spec 31 section 11 / decision 4.3: the tag is honoured and the render
refused. This is the list that makes the resulting hole *visible* instead of
silent. Sorted by chapter so it reads like a worklist.
"""
rows = conn.execute(
"""
SELECT id, chapter_id, utterance_index, speaker_name, text, language_code
FROM utterances
WHERE language_code IS NOT NULL
ORDER BY chapter_id, utterance_index
"""
).fetchall()
out = []
for r in rows:
code = _col(r, "language_code", 5)
if lang.is_voiceable(code):
continue
out.append(
{
"utterance_id": _col(r, "id", 0),
"chapter_id": _col(r, "chapter_id", 1),
"utterance_index": _col(r, "utterance_index", 2),
"speaker_name": _col(r, "speaker_name", 3),
"text": _col(r, "text", 4),
"language_code": code,
"reason": lang.refusal_reason(code),
}
)
return out
def effective_language(
conn: sqlite3.Connection, utterance_id: int, primary: str | None = None
) -> str:
"""The language an utterance should be spoken in.
NULL means "the project's primary language" (spec 31 section 6.1), so the
caller gets a concrete code rather than a None to branch on.
A tag that is present but unrecognised is returned **as-is**, never replaced
by the primary language. Falling back would voice a Klingon line as English,
which is exactly the silent wrongness the refuse-render decision prevents.
"""
row = conn.execute(
"SELECT language_code FROM utterances WHERE id=?", (utterance_id,)
).fetchone()
code = _col(row, "language_code", 0)
if code:
return lang.normalize(code) or str(code)
return lang.normalize(primary) or source_language(conn)
def build_export_payload(
conn: sqlite3.Connection,
primary: str | None = None,
*,
include_foreign: bool = True,
) -> dict[str, Any]:
"""The per-utterance payload for UmbraTrans (spec 31 section 7).
``language_code`` is sent when already known so a re-delivery does not ask
UmbraTrans to re-identify a line the operator corrected. It is null on the
first pass, which is normal.
``include_foreign`` defaults to True because UmbraTrans is the *authority*
on identification: it must see every line to identify it. Set it False to
send only the lines that need translating.
"""
primary = lang.normalize(primary) or source_language(conn)
foreign = foreign_utterance_ids(conn, primary)
rows = conn.execute(
"""
SELECT u.id, u.chapter_id, c.chapter_index, u.utterance_index,
u.speaker_name, u.text, u.context_before, u.context_after,
u.language_code
FROM utterances u
LEFT JOIN chapters c ON c.id = u.chapter_id
ORDER BY u.chapter_id, u.utterance_index
"""
).fetchall()
utterances = []
for r in rows:
uid = _col(r, "id", 0)
if not include_foreign and uid in foreign:
continue
utterances.append(
{
"utterance_id": uid,
"chapter_id": _col(r, "chapter_id", 1),
"chapter_index": _col(r, "chapter_index", 2),
"utterance_index": _col(r, "utterance_index", 3),
"speaker_name": _col(r, "speaker_name", 4),
"source_text": _col(r, "text", 5),
"context_before": _col(r, "context_before", 6),
"context_after": _col(r, "context_after", 7),
"language_code": _col(r, "language_code", 8),
}
)
return {
"source_system": "umbraaudio",
"book_language": primary,
"utterance_count": len(utterances),
"foreign_count": len(foreign),
"utterances": utterances,
}
# ---------------------------------------------------------------------------
# Resolution: where a translation actually comes from
# ---------------------------------------------------------------------------
#
# Two stores hold translations and they answer different questions:
#
# * ``utterance_translations`` - the pipeline's output. What UmbraTrans
# delivered and what the operator confirmed. Authoritative when it holds a
# real translation.
# * ``<project>/samples/translations/*.json`` - the on-disk sample files. These
# are what ``render_lang_test.py`` rendered the per-language sample audio
# from, so the text and the audio on disk agree.
#
# Today the sample files are the only place the sample chapter's translations
# exist; the table holds one placeholder row. A resolver that reads only one
# of the two shows English for a chapter that has a valid Chinese translation,
# which is the bug this layer exists to prevent.
@dataclass(frozen=True)
class TranslationEntry:
"""One resolved translation for one utterance in one target language."""
text: str
status: str = STATUS_DRAFT
origin: str = "sample"
speaker: str = ""
type: str = ""
@property
def confirmed(self) -> bool:
return self.status == STATUS_CONFIRMED
def canonical_code(value: str | None) -> str | None:
"""Normalise a language code, tolerating script subtags.
``lang.normalize`` only knows the canonical codes, so ``'zh-Hans'`` (what
UmbraTrans writes) and ``'zh_CN'`` both collapse to ``'zh'`` here.
"""
if not value:
return None
raw = str(value).strip()
if not raw:
return None
direct = lang.normalize(raw)
if direct:
return direct
base = re.split(r"[-_]", raw)[0]
return lang.normalize(base)
def sample_translations_dir(project_dir: Path) -> Path:
"""Where the on-disk sample translations live for *project_dir*.
``project_dir`` rather than ``data_dir``: ``.umbra`` is gitignored runtime
state, while the sample translations are reviewable project content that
travels with the book.
"""
return project_dir / "samples" / "translations"
def load_sample_translations(project_dir: Path) -> dict[str, dict[tuple[int, int], str]]:
"""Read every sample translation file under the project.
Returns ``{language_code: {(chapter_id, utterance_index): text}}``. A file
that cannot be read or parsed is skipped with a warning rather than taking
the page down - one bad file must not hide the other nine.
"""
out: dict[str, dict[tuple[int, int], str]] = {}
directory = sample_translations_dir(project_dir)
if not directory.is_dir():
return out
for path in sorted(directory.glob("*.json")):
try:
doc = json.loads(path.read_text(encoding="utf-8"))
except (OSError, ValueError):
logger.warning("translation sample unreadable: %s", path)
continue
if not isinstance(doc, dict):
logger.warning("translation sample is not an object: %s", path)
continue
code = canonical_code(doc.get("language_code"))
if code is None:
logger.warning("translation sample has no usable language_code: %s", path)
continue
chapter_id = doc.get("chapter_id")
try:
chapter_id = int(chapter_id)
except (TypeError, ValueError):
logger.warning("translation sample has no usable chapter_id: %s", path)
continue
bucket = out.setdefault(code, {})
for entry in doc.get("utterances") or []:
if not isinstance(entry, dict):
continue
index = _as_index(entry.get("index"))
text = entry.get("text")
if index is None or not isinstance(text, str) or not text.strip():
continue
bucket[(chapter_id, index)] = text
return out
def _as_index(value: Any) -> int | None:
"""The sample files store the index as a string ("0"), the DB as an int."""
try:
return int(str(value).strip())
except (TypeError, ValueError):
return None
def _db_translation_rows(
conn: sqlite3.Connection,
) -> list[dict[str, Any]]:
"""Rows from ``utterance_translations`` joined to their utterance.
The join uses ``utterances.utterance_index`` and ``utterances.speaker_name``
- the column names that actually exist. (An earlier draft of the language
UI selected ``u.index`` and ``u.speaker``, which do not, so every read
raised ``sqlite3.OperationalError: near "index": syntax error``.)
"""
if not _translations_table_exists(conn):
return []
try:
rows = conn.execute(
"""
SELECT u.chapter_id, u.utterance_index, u.text AS source_text,
t.text AS target_text, t.status, t.origin, u.speaker_name,
t.language_code
FROM utterance_translations t
JOIN utterances u ON u.id = t.utterance_id
"""
).fetchall()
except sqlite3.OperationalError:
logger.exception("utterance_translations could not be read")
return []
return [
{
"chapter_id": _col(r, "chapter_id", 0),
"utterance_index": _col(r, "utterance_index", 1),
"source_text": _col(r, "source_text", 2),
"target_text": _col(r, "target_text", 3),
"status": _col(r, "status", 4),
"origin": _col(r, "origin", 5),
"speaker_name": _col(r, "speaker_name", 6),
"language_code": _col(r, "language_code", 7),
}
for r in rows
]
def resolved_translations(
conn: sqlite3.Connection,
project_dir: Path,
code: str,
) -> dict[tuple[int, int], TranslationEntry]:
"""The translation text to show for *code*, per ``(chapter_id, index)``.
The DB wins where it holds a real translation; the on-disk sample files
fill the gaps. A candidate whose text is identical to the source text is a
placeholder rather than a translation, so it is ignored - otherwise the
page would show English under a Chinese heading and claim it was
translated.
"""
target = canonical_code(code) or str(code).strip().lower()
if not target:
return {}
resolved: dict[tuple[int, int], TranslationEntry] = {}
# --- the pipeline's output -------------------------------------------
for row in _db_translation_rows(conn):
if canonical_code(row["language_code"]) != target:
continue
source_text = row["source_text"] or ""
text = row["target_text"] or ""
if not text.strip():
continue
if source_text and text.strip() == source_text.strip():
# The English text stored under a target-language code: a
# placeholder, not a translation.
continue
key = (int(row["chapter_id"]), int(row["utterance_index"]))
resolved[key] = TranslationEntry(
text=text,
status=row["status"] or STATUS_DRAFT,
origin=row["origin"] or "umbratrans",
speaker=row["speaker_name"] or "",
)
# --- the on-disk sample files ----------------------------------------
files = load_sample_translations(project_dir)
reference = files.get("en", {})
for key, text in files.get(target, {}).items():
if key in resolved:
continue
source_text = reference.get(key)
if source_text is not None and source_text.strip() == text.strip():
# Identical to the source-language file: not translated.
continue
resolved[key] = TranslationEntry(
text=text,
status=STATUS_DRAFT,
origin="sample",
)
return resolved