The pipeline was hard-coded to Simplified Chinese in three places: the drafter was told to translate into Chinese, the critic was told to compare English with Chinese, and the glossary's translated forms were read from a "zh" key. A production that declared target_language: es-ES was therefore translated into Chinese and filed under a Spanish name - measured on oldbox, deal-hunter-es held 3,038 paragraphs of Chinese and was reported complete. A new registry, tools/translation/languages.py, is the single source of truth for what a target language is: its names, its script, its punctuation, its glossary key, its typography and its chapter-numbering convention. Ten languages are registered. The prompt scaffolding stays in Chinese - that is what the models read best - and the target is named with its Chinese, English and native names so there is no room to misread it. The registry now reaches every stage: - prompts.py: the analyst, drafter, critic and glossary blocks take the target. The hard rules about this book - Deal Hunter is the ship's name, the AI's name and the book's title, so it stays in English - are the language's own table for Chinese and the global rules for everything else, so a language with no table does not lose them. The Chinese rules are byte-for-byte what they were. - pipeline.py: --target-language, a run-directory guard that refuses to reuse a directory belonging to another language (from the manifest, or by measuring the text it already holds), a draft-keyed-by-target reader, and a refusal of a draft that came back in the wrong script. - book.py: per-language chapter-title and subtitle tables. A language with no table falls back to the English title, never to another language's; a language with a table and a missing chapter gets a visible marker in its own language. - qa.py: the target's quote marks, punctuation, script count and forbidden title forms. The Latin-residue check is skipped for a Latin target, where it would flag the whole book. - align.py: the aligner and verifier prompts name the target, and the sentence and clause splitting uses the target's own terminators. - render.py: the fonts, the language and country codes, the writing direction, the chapter headings and the fixed editorial strings around the translation are the target's. The Latin-run span is skipped for a Latin target, where marking the text as English would tell the renderer it is English. - project_status.py: the dashboard measures a sample of a production's text and reports a production whose text is not in the language it declares as "mismatched", not "complete". A chapter count is a claim about the files; the script check is a measurement of the text. Also fixes: the dashboard gated every language's production on the source production existing, so a Spanish production could never be reported even when it was there. The Chinese render is byte-identical to before on the real book, and the Chinese rules are unchanged. Suite: 620 passed, the same 68 pre-existing environment failures, +49 new tests.
1102 lines
43 KiB
Python
1102 lines
43 KiB
Python
# Copyright 2026 Umbra Tools (umbratools.com)
|
||
# SPDX-License-Identifier: Apache-2.0
|
||
|
||
"""Map the translated paragraphs back onto the verified source utterances.
|
||
|
||
The gap this closes
|
||
-------------------
|
||
The translation pipeline works on *paragraphs*. Pass 0 groups consecutive
|
||
narration sentences into a readable paragraph and Passes 1-4 translate that
|
||
paragraph. The reading edition needs paragraphs: epub and pdf are paragraph
|
||
media, and a paragraph is what a reader sees.
|
||
|
||
The audiobook needs *utterances*. UmbraAudio has already verified who speaks
|
||
every one of the book's utterances, and every clip is voiced separately by
|
||
that speaker's own voice. A paragraph-level translation therefore cannot be
|
||
spoken: it has to be cut back up, or it gets spoken by the wrong voice.
|
||
|
||
``tools/translation/README.md`` said so plainly: *"It does not produce a 1:1
|
||
utterance alignment for TTS. Source ranges are preserved on every paragraph;
|
||
splitting or aligning back to the 4,319 utterances is a separate pass."*
|
||
|
||
This is that pass.
|
||
|
||
What it does
|
||
------------
|
||
For every source paragraph it takes the translated paragraph and assigns the
|
||
translated text back to the individual source utterances the paragraph was
|
||
built from:
|
||
|
||
* one source utterance -> the translation is copied across unchanged;
|
||
* several source utterances -> the translation is divided between them.
|
||
|
||
The division is by meaning, not by position. A sentence-level translator
|
||
would have produced one target sentence per source sentence and the counts
|
||
would line up on their own. An intent-based translator does not: it merges
|
||
two English sentences into one Chinese sentence, or splits one into two,
|
||
because that is what the target language wants. So the division is done by a
|
||
model that is given the source utterances and the translation and asked which
|
||
part of the translation renders which source utterance.
|
||
|
||
Every source utterance gets exactly one segment, so the alignment can never
|
||
silently lose a line. When the target absorbed a source utterance, the
|
||
segment is recorded as *not voiced* with the reason, which is what
|
||
UmbraAudio's ``utterance_translations.voiced`` flag is for: the row is kept
|
||
so the gap is visible and reconstructable, and no clip is generated for it.
|
||
|
||
Two rules the model cannot break:
|
||
|
||
* a source utterance may only be absorbed into another utterance by the same
|
||
speaker - a line must not be handed to a different character;
|
||
* a scene break is silence, and silence has no translation.
|
||
|
||
Nothing here rewrites the translation. The partition is checked against the
|
||
translated text: the segments must reproduce it exactly once whitespace is
|
||
removed. A model answer that drops, duplicates or invents text is rejected
|
||
and the paragraph falls back to a mechanical split, which is reported as
|
||
needing review.
|
||
|
||
The output is a delivery payload in the shape UmbraAudio's
|
||
``translations.import_translations`` accepts, so neither side has to invent a
|
||
second contract.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import hashlib
|
||
import json
|
||
import re
|
||
import sys
|
||
from dataclasses import dataclass, field
|
||
from pathlib import Path
|
||
from typing import Any
|
||
|
||
from languages import TargetLanguage, by_code, resolve
|
||
from llm import LLM, LLMError
|
||
|
||
# The aligner compares English source against translated text, which is the
|
||
# critic's job, but it has to *rewrite* the partition rather than report on
|
||
# it. The drafter model is the one that produced the target text and knows
|
||
# its sentence boundaries best, so it is the default.
|
||
DEFAULT_ALIGN_MODEL = "qwen/qwen3.5-9b"
|
||
|
||
CHINESE = resolve("zh-Hans")
|
||
|
||
|
||
def system_aligner(target: TargetLanguage) -> str:
|
||
"""The aligner's instructions, in the target language's terms.
|
||
|
||
The task is the same in every language - map a translated paragraph back
|
||
onto the source utterances it was built from - but the sentence merging
|
||
the translation does is the *target's* merging, so the prompt has to name
|
||
the target.
|
||
"""
|
||
return f"""你是一位双语对齐编辑,负责把已经译好的{target.chinese_name}段落还原到英文原文的每一条话语上。
|
||
你的任务不是翻译,也不是改写,而是判断:{target.chinese_name}译文里的哪一部分对应英文原文里的哪一条话语。
|
||
译文为了{target.chinese_name}的自然可能合并或拆分了句子,所以不能按句子序号机械对应,要按意思对应。
|
||
每一条英文话语都必须恰好出现一次;被合并进别条话语的,用 merged_into 指明并把 target_text 留空。
|
||
只输出 JSON,不要解释,不要 Markdown 代码围栏。"""
|
||
|
||
|
||
def normalise_target_language(value: str) -> str:
|
||
"""``zh-CN`` -> ``zh``.
|
||
|
||
UmbraAudio stores the dropdown code, not the locale, so the payload has
|
||
to carry the base code or the delivery lands in a language the review UI
|
||
does not offer.
|
||
|
||
A registered language answers from the registry, which is the single
|
||
source of truth for what a language's base code is; an unregistered one
|
||
falls back to the string surgery.
|
||
"""
|
||
language = by_code(value)
|
||
if language is not None:
|
||
return language.base
|
||
return value.strip().split("-")[0].split("_")[0].lower()
|
||
|
||
|
||
def parse_row_id(source_id: str) -> int | None:
|
||
"""``utt-83578`` -> ``83578``.
|
||
|
||
The bundle names an utterance by its UmbraAudio database row, so the
|
||
numeric suffix is the id UmbraAudio stores the translation against.
|
||
"""
|
||
match = re.search(r"(\d+)\s*$", source_id or "")
|
||
return int(match.group(1)) if match else None
|
||
|
||
|
||
def _compact(text: str) -> tuple[str, list[int]]:
|
||
"""Strip whitespace, remembering where each kept character came from."""
|
||
chars: list[str] = []
|
||
positions: list[int] = []
|
||
for index, ch in enumerate(text):
|
||
if not ch.isspace():
|
||
chars.append(ch)
|
||
positions.append(index)
|
||
return "".join(chars), positions
|
||
|
||
|
||
def split_target_sentences(
|
||
text: str,
|
||
target: TargetLanguage | None = None,
|
||
) -> list[str]:
|
||
"""Split translated text into sentences, keeping the terminator.
|
||
|
||
The terminators and the closing marks are the target's own: Chinese
|
||
terminates with a full stop and a closing quote may follow it, while a
|
||
Spanish sentence ends with a question mark or a full stop.
|
||
"""
|
||
target = target or CHINESE
|
||
terminators = target.sentence_terminators
|
||
closing = target.closing_marks
|
||
sentences: list[str] = []
|
||
buffer: list[str] = []
|
||
index = 0
|
||
while index < len(text):
|
||
ch = text[index]
|
||
buffer.append(ch)
|
||
index += 1
|
||
if ch in terminators:
|
||
while index < len(text) and text[index] in closing:
|
||
buffer.append(text[index])
|
||
index += 1
|
||
piece = "".join(buffer).strip()
|
||
if piece:
|
||
sentences.append(piece)
|
||
buffer = []
|
||
tail = "".join(buffer).strip()
|
||
if tail:
|
||
sentences.append(tail)
|
||
return sentences
|
||
|
||
|
||
def _split_at_comma(
|
||
text: str,
|
||
target: TargetLanguage | None = None,
|
||
) -> list[str] | None:
|
||
"""Split one sentence in two at its last clause boundary.
|
||
|
||
An intent-based translation merges English sentences with a comma, a
|
||
colon or a dash rather than with a full stop, because that is what reads
|
||
naturally in the target language. So when the target has fewer sentences
|
||
than the source has utterances, the boundary is a clause boundary - and
|
||
which characters those are depends on the language.
|
||
"""
|
||
target = target or CHINESE
|
||
for mark in target.clause_marks:
|
||
positions = [m.start() for m in re.finditer(re.escape(mark), text)]
|
||
for position in reversed(positions):
|
||
if position <= 0:
|
||
continue
|
||
end = position + len(mark)
|
||
if end >= len(text):
|
||
# A mark at the very end has nothing after it to split off.
|
||
continue
|
||
head = text[:end].strip()
|
||
tail = text[end:].strip()
|
||
if head and tail:
|
||
return [head, tail]
|
||
return None
|
||
|
||
|
||
def mechanical_split(
|
||
text: str,
|
||
count: int,
|
||
target: TargetLanguage | None = None,
|
||
) -> list[str] | None:
|
||
"""Divide *text* into exactly *count* pieces, or return None.
|
||
|
||
This is the fallback when the model cannot be trusted. It is not good
|
||
enough to publish - a mechanical split cannot tell which Chinese clause
|
||
renders which English sentence - but it never loses or invents text, and
|
||
every paragraph it touches is reported as needing review.
|
||
"""
|
||
if count <= 0:
|
||
return None
|
||
if count == 1:
|
||
return [text.strip()] if text.strip() else None
|
||
|
||
pieces = split_target_sentences(text, target)
|
||
if not pieces:
|
||
return None
|
||
|
||
while len(pieces) > count:
|
||
# Merge the adjacent pair with the smallest combined length, so the
|
||
# result stays as close to the original sentence boundaries as it can.
|
||
best = 0
|
||
best_length = None
|
||
for i in range(len(pieces) - 1):
|
||
length = len(pieces[i]) + len(pieces[i + 1])
|
||
if best_length is None or length < best_length:
|
||
best = i
|
||
best_length = length
|
||
pieces[best : best + 2] = [(pieces[best] + pieces[best + 1]).strip()]
|
||
pieces = [p for p in pieces if p]
|
||
|
||
while len(pieces) < count:
|
||
# Split the longest piece that can actually be split. The longest
|
||
# piece is not always the splittable one: a Chinese sentence with no
|
||
# clause boundary can be longer than one with three commas in it.
|
||
ordered = sorted(range(len(pieces)), key=lambda i: len(pieces[i]), reverse=True)
|
||
target_index = None
|
||
replacement = None
|
||
for index in ordered:
|
||
candidate = _split_at_comma(pieces[index], target)
|
||
if candidate is not None:
|
||
target_index = index
|
||
replacement = candidate
|
||
break
|
||
if target_index is None:
|
||
return None
|
||
pieces[target_index : target_index + 1] = replacement
|
||
|
||
if len(pieces) != count or not all(p.strip() for p in pieces):
|
||
return None
|
||
return pieces
|
||
|
||
|
||
def repartition(translation: str, anchors: list[str]) -> list[str] | None:
|
||
"""Cut *translation* into exactly ``len(anchors)`` parts using *anchors*.
|
||
|
||
The anchors are the model's proposed segment texts. They are located in
|
||
the translation and the gaps between them are given to the preceding
|
||
part, so the parts concatenate back to the translation exactly. That is
|
||
what makes the alignment checkable: the translation is partitioned, never
|
||
rewritten.
|
||
|
||
Returns None when an anchor cannot be found, which means the model
|
||
invented or dropped text.
|
||
"""
|
||
if not anchors or len(anchors) == 0:
|
||
return None
|
||
compact_text, positions = _compact(translation)
|
||
if not compact_text:
|
||
return None
|
||
|
||
compact_anchors: list[str] = []
|
||
for anchor in anchors:
|
||
compact_anchor, _ = _compact(anchor)
|
||
if not compact_anchor:
|
||
return None
|
||
compact_anchors.append(compact_anchor)
|
||
|
||
cursor = 0
|
||
ends: list[int] = []
|
||
for anchor in compact_anchors:
|
||
found = compact_text.find(anchor, cursor)
|
||
if found < 0:
|
||
return None
|
||
cursor = found + len(anchor)
|
||
ends.append(cursor)
|
||
if cursor != len(compact_text):
|
||
# The model left content behind. The tail belongs to the last part.
|
||
ends[-1] = len(compact_text)
|
||
|
||
# Turn the compact-space boundaries back into offsets in the original.
|
||
boundaries: list[int] = []
|
||
for end in ends:
|
||
if end <= 0:
|
||
boundaries.append(0)
|
||
elif end >= len(positions):
|
||
boundaries.append(len(translation))
|
||
else:
|
||
boundaries.append(positions[end - 1] + 1)
|
||
|
||
parts: list[str] = []
|
||
start = 0
|
||
for boundary in boundaries:
|
||
parts.append(translation[start:boundary])
|
||
start = boundary
|
||
parts = [p.strip() for p in parts]
|
||
if len(parts) != len(anchors) or not all(parts):
|
||
return None
|
||
return parts
|
||
|
||
|
||
@dataclass
|
||
class SourceUtterance:
|
||
"""One verified source utterance from the UmbraAudio bundle."""
|
||
|
||
source_id: str
|
||
row_id: int | None
|
||
index: int
|
||
speaker: str
|
||
type: str
|
||
text: str
|
||
|
||
@property
|
||
def voice(self) -> str:
|
||
return (self.speaker or "narrator").strip().lower()
|
||
|
||
|
||
@dataclass
|
||
class Segment:
|
||
"""The part of the translation that renders one source utterance."""
|
||
|
||
source_id: str
|
||
text: str = ""
|
||
voiced: bool = True
|
||
merged_into: str = ""
|
||
reason: str = ""
|
||
|
||
|
||
@dataclass
|
||
class ParagraphAlignment:
|
||
"""How one translated paragraph was divided between its source utterances."""
|
||
|
||
paragraph_id: str
|
||
method: str
|
||
segments: list[Segment] = field(default_factory=list)
|
||
problems: list[str] = field(default_factory=list)
|
||
|
||
@property
|
||
def needs_review(self) -> bool:
|
||
return bool(self.problems) or self.method in {"mechanical", "failed", "mismatch"}
|
||
|
||
|
||
def source_utterances(paragraph: dict, bundle: dict[str, dict]) -> list[SourceUtterance]:
|
||
"""The verified utterances a paragraph was built from.
|
||
|
||
The bundle is the authority: it carries the text, the speaker UmbraAudio
|
||
resolved, and the database row the translation will be stored against.
|
||
"""
|
||
out: list[SourceUtterance] = []
|
||
source_ids = paragraph.get("source_ids") or []
|
||
source_indices = paragraph.get("source_indices") or []
|
||
for position, source_id in enumerate(source_ids):
|
||
record = bundle.get(source_id) or {}
|
||
index = source_indices[position] if position < len(source_indices) else position
|
||
row_id = record.get("database_row_id")
|
||
if row_id is None:
|
||
row_id = parse_row_id(source_id)
|
||
out.append(
|
||
SourceUtterance(
|
||
source_id=source_id,
|
||
row_id=int(row_id) if row_id is not None else None,
|
||
index=int(index),
|
||
speaker=record.get("speaker_name_resolved") or paragraph.get("speaker") or "narrator",
|
||
type=record.get("utterance_type") or paragraph.get("type") or "narration",
|
||
text=(record.get("text") or "").strip(),
|
||
)
|
||
)
|
||
return out
|
||
|
||
|
||
def alignment_prompt(
|
||
paragraph_id: str,
|
||
sources: list[SourceUtterance],
|
||
translation: str,
|
||
target: TargetLanguage | None = None,
|
||
) -> str:
|
||
"""The prompt that asks for the partition of a translated paragraph."""
|
||
target = target or CHINESE
|
||
lines = []
|
||
for source in sources:
|
||
lines.append(f"[{source.source_id}] (说话者:{source.speaker}) {source.text}")
|
||
return f"""段落 id:{paragraph_id}
|
||
|
||
英文原文(每一条都是一条已经确认说话者的话语):
|
||
{chr(10).join(lines)}
|
||
|
||
{target.chinese_name}译文:
|
||
{translation}
|
||
|
||
请把{target.chinese_name}译文分配回上面每一条英文话语。输出 JSON:
|
||
{{
|
||
"segments": [
|
||
{{"source_id": "utt-xxxxx", "target_text": "对应的{target.chinese_name}片段"}},
|
||
{{"source_id": "utt-yyyyy", "target_text": "", "merged_into": "utt-xxxxx"}}
|
||
]
|
||
}}
|
||
|
||
要求:
|
||
- segments 必须为原文的每一条话语各给一项,source_id 一一对应,不多不少。
|
||
- target_text 必须是{target.chinese_name}译文里**原样出现**的连续片段,按原文顺序拼接起来必须等于整段译文,不得删改一个字。
|
||
- 如果译文把两条原文合并成了一句,被合并的那条 target_text 留空,用 merged_into 指明它并入了哪一条。
|
||
- merged_into 只能指向同一条段落里、同一说话者、且有 target_text 的话语。
|
||
- 不要翻译,不要润色,不要补充,只做对应。"""
|
||
|
||
|
||
def parse_segments(
|
||
data: Any,
|
||
sources: list[SourceUtterance],
|
||
translation: str,
|
||
) -> tuple[list[Segment], list[str]]:
|
||
"""Validate a model answer and turn it into segments.
|
||
|
||
Returns ``(segments, problems)``. A non-empty problem list means the
|
||
answer was rejected; the caller falls back to a mechanical split.
|
||
"""
|
||
problems: list[str] = []
|
||
raw = data.get("segments") if isinstance(data, dict) else None
|
||
if not isinstance(raw, list):
|
||
return [], ["model answer has no segments list"]
|
||
|
||
by_id = {source.source_id: source for source in sources}
|
||
seen: dict[str, dict] = {}
|
||
for item in raw:
|
||
if not isinstance(item, dict):
|
||
problems.append("segment is not an object")
|
||
continue
|
||
source_id = str(item.get("source_id") or "")
|
||
if source_id not in by_id:
|
||
problems.append(f"unknown source_id {source_id!r}")
|
||
continue
|
||
if source_id in seen:
|
||
problems.append(f"source_id {source_id!r} appears twice")
|
||
continue
|
||
seen[source_id] = item
|
||
|
||
missing = [source.source_id for source in sources if source.source_id not in seen]
|
||
if missing:
|
||
problems.append(f"missing segments for {', '.join(missing)}")
|
||
if problems:
|
||
return [], problems
|
||
|
||
merged: dict[str, str] = {}
|
||
for source_id, item in seen.items():
|
||
target = str(item.get("merged_into") or "").strip()
|
||
if target:
|
||
if target not in by_id:
|
||
problems.append(f"{source_id} merged into unknown {target!r}")
|
||
continue
|
||
if target == source_id:
|
||
problems.append(f"{source_id} merged into itself")
|
||
continue
|
||
if by_id[target].voice != by_id[source_id].voice:
|
||
problems.append(
|
||
f"{source_id} ({by_id[source_id].speaker}) may not be merged into "
|
||
f"{target} ({by_id[target].speaker}): a different speaker's line"
|
||
)
|
||
continue
|
||
merged[source_id] = target
|
||
|
||
for source_id, target in merged.items():
|
||
if target in merged:
|
||
problems.append(f"{source_id} merged into {target}, which is itself merged")
|
||
if problems:
|
||
return [], problems
|
||
|
||
segments: list[Segment] = []
|
||
anchors: list[str] = []
|
||
for source in sources:
|
||
item = seen[source.source_id]
|
||
if source.source_id in merged:
|
||
target = merged[source.source_id]
|
||
segments.append(
|
||
Segment(
|
||
source_id=source.source_id,
|
||
text="",
|
||
voiced=False,
|
||
merged_into=target,
|
||
reason=f"absorbed into {target} in the translated paragraph",
|
||
)
|
||
)
|
||
continue
|
||
text = str(item.get("target_text") or "").strip()
|
||
if not text:
|
||
problems.append(f"{source.source_id} has no target text and no merge target")
|
||
continue
|
||
anchors.append(text)
|
||
segments.append(Segment(source_id=source.source_id, text=text))
|
||
|
||
if problems:
|
||
return [], problems
|
||
|
||
parts = repartition(translation, anchors)
|
||
if parts is None:
|
||
return [], ["model answer does not reproduce the translated text"]
|
||
|
||
# Hand the exact slices back to the segments, in source order.
|
||
position = 0
|
||
for segment in segments:
|
||
if not segment.voiced:
|
||
continue
|
||
segment.text = parts[position]
|
||
position += 1
|
||
return segments, []
|
||
|
||
|
||
def align_paragraph(
|
||
paragraph: dict,
|
||
translation: str,
|
||
bundle: dict[str, dict],
|
||
llm: LLM | None = None,
|
||
model: str = DEFAULT_ALIGN_MODEL,
|
||
target: TargetLanguage | None = None,
|
||
) -> ParagraphAlignment:
|
||
"""Divide one translated paragraph between its source utterances."""
|
||
target = target or CHINESE
|
||
paragraph_id = str(paragraph.get("id") or "")
|
||
sources = source_utterances(paragraph, bundle)
|
||
|
||
if paragraph.get("type") == "scene_break":
|
||
return ParagraphAlignment(paragraph_id=paragraph_id, method="scene_break")
|
||
|
||
if not sources:
|
||
return ParagraphAlignment(
|
||
paragraph_id=paragraph_id,
|
||
method="failed",
|
||
problems=["paragraph has no source utterances"],
|
||
)
|
||
|
||
if len(sources) == 1:
|
||
return ParagraphAlignment(
|
||
paragraph_id=paragraph_id,
|
||
method="one_to_one",
|
||
segments=[Segment(source_id=sources[0].source_id, text=translation.strip())],
|
||
)
|
||
|
||
if llm is not None:
|
||
try:
|
||
data, _usage = llm.chat_json(
|
||
system_aligner(target),
|
||
alignment_prompt(paragraph_id, sources, translation, target),
|
||
model=model,
|
||
temperature=0.0,
|
||
cache_key=f"align-{paragraph_id}",
|
||
)
|
||
segments, problems = parse_segments(data, sources, translation)
|
||
if not problems:
|
||
return ParagraphAlignment(paragraph_id=paragraph_id, method="llm", segments=segments)
|
||
except LLMError as exc:
|
||
problem = f"alignment model failed: {exc}"
|
||
else:
|
||
problem = "; ".join(problems)
|
||
mechanical = mechanical_split(translation, len(sources), target)
|
||
if mechanical is None:
|
||
return ParagraphAlignment(paragraph_id=paragraph_id, method="failed", problems=[problem])
|
||
return ParagraphAlignment(
|
||
paragraph_id=paragraph_id,
|
||
method="mechanical",
|
||
segments=[
|
||
Segment(source_id=source.source_id, text=text) for source, text in zip(sources, mechanical, strict=True)
|
||
],
|
||
problems=[problem],
|
||
)
|
||
|
||
mechanical = mechanical_split(translation, len(sources), target)
|
||
if mechanical is None:
|
||
return ParagraphAlignment(
|
||
paragraph_id=paragraph_id,
|
||
method="failed",
|
||
problems=["cannot divide the translated paragraph between its source utterances"],
|
||
)
|
||
return ParagraphAlignment(
|
||
paragraph_id=paragraph_id,
|
||
method="mechanical",
|
||
segments=[
|
||
Segment(source_id=source.source_id, text=text) for source, text in zip(sources, mechanical, strict=True)
|
||
],
|
||
)
|
||
|
||
|
||
# --- drift verification ----------------------------------------------------
|
||
|
||
SYSTEM_VERIFIER = """你是一位双语校对,负责检查一段译文是否真的对应它的英文原文。
|
||
译文是按段落 id 存储的;如果写译文时把内容写到了相邻段落,id 和内容就会错位。
|
||
只输出 JSON,不要解释,不要 Markdown 代码围栏。"""
|
||
|
||
|
||
# The three verdicts. The distinction matters: only a paragraph whose
|
||
# translation belongs to a *different* paragraph may be dropped from the
|
||
# delivery. A paragraph that merely leaves something out is still the right
|
||
# translation in the right place, and dropping it would delete a good line and
|
||
# leave a hole where the audio should have spoken.
|
||
VERDICT_ALIGNED = "aligned"
|
||
VERDICT_INCOMPLETE = "incomplete"
|
||
VERDICT_DRIFT = "drift"
|
||
VERDICTS = (VERDICT_ALIGNED, VERDICT_INCOMPLETE, VERDICT_DRIFT)
|
||
|
||
# A paragraph whose English does not end on a sentence boundary is a fragment:
|
||
# Pass 0 split the original passage mid-sentence. A fragment is where the
|
||
# drafter is most likely to re-flow the passage into natural target-language
|
||
# prose and lose the id-to-text correspondence, so that is where the check
|
||
# starts.
|
||
_SENTENCE_END = ".!?…"
|
||
|
||
|
||
def is_fragment(text: str) -> bool:
|
||
"""True when *text* does not end on a sentence boundary.
|
||
|
||
Trailing closing quotes and brackets are ignored: a quoted label such as
|
||
``"operations"`` is a complete quotation but not a complete sentence.
|
||
"""
|
||
stripped = text.strip().rstrip("”’\"')]】》")
|
||
if not stripped:
|
||
return False
|
||
return stripped[-1] not in _SENTENCE_END
|
||
|
||
|
||
def verification_prompt(
|
||
source_text: str,
|
||
translation: str,
|
||
previous_text: str = "",
|
||
next_text: str = "",
|
||
target: TargetLanguage | None = None,
|
||
) -> str:
|
||
"""Ask which paragraph *translation* actually renders.
|
||
|
||
The neighbouring paragraphs are given because the question "does this
|
||
translation belong to this paragraph?" cannot be answered without them:
|
||
a drifted paragraph's translation is a perfectly good translation - of
|
||
the paragraph next door.
|
||
"""
|
||
target = target or CHINESE
|
||
previous_block = f"\n【前一段原文】\n{previous_text}\n" if previous_text else ""
|
||
next_block = f"\n【后一段原文】\n{next_text}\n" if next_text else ""
|
||
return f"""【本段原文】
|
||
{source_text}
|
||
{previous_block}{next_block}
|
||
【本段{target.chinese_name}译文】
|
||
{translation}
|
||
|
||
请判断这段{target.chinese_name}译文到底对应哪一段原文,三选一:
|
||
|
||
- "aligned":译文对应本段原文,哪怕不完美。措辞不同、近义词、漏掉一个副词、
|
||
数字或单复数的小差异,都算 aligned。
|
||
- "incomplete":译文对应本段原文,但明显少了一部分内容,而且没有混入别段内容。
|
||
- "drift":译文对应的是前一段或后一段原文的内容(内容错位)。
|
||
|
||
举例(这些都算 aligned,措辞或数量上的小差异不算错位):
|
||
* 本段 "The hunter robot holds out a hand and says to her"
|
||
译文 "机器人伸出双手对她说" —— 单复数不一致,still aligned。
|
||
* 本段 "Her brow furrowed, then says" 译文 "她眉头紧锁,说道" —— 省略 then,still aligned。
|
||
* 本段 "...You gave me the helmet..." 译文 "...你给我这顶帽子..." —— 头盔写成帽子,still aligned。
|
||
|
||
举例(这些算 drift,因为译文是相邻那一段的内容):
|
||
* 本段 "“Yes, Captain.”" 译文 "“上一次有人登上这艘舰桥还是三十四年前,那时是西顿船长在任?”"
|
||
—— 这是上一段的译文。
|
||
* 本段 "“The last time someone was on this bridge...?" 译文 "“Captain”字样。她环顾四周……"
|
||
—— 这是上一段的译文。
|
||
|
||
举例(这种算 incomplete:译文是自己这段的内容,但少了后面几句):
|
||
* 本段有三句话,译文只译了前两句的意思,译文内容没有混入别段。
|
||
|
||
只输出 JSON:{{"kind": "aligned|incomplete|drift", "reason": "一句话理由"}}"""
|
||
|
||
|
||
def verify_translation(
|
||
source_text: str,
|
||
translation: str,
|
||
llm: LLM,
|
||
model: str = DEFAULT_ALIGN_MODEL,
|
||
previous_text: str = "",
|
||
next_text: str = "",
|
||
target: TargetLanguage | None = None,
|
||
) -> tuple[str, str]:
|
||
"""Which paragraph does *translation* actually render?
|
||
|
||
Returns ``(verdict, reason)`` where *verdict* is one of :data:`VERDICTS`.
|
||
A model failure is reported as ``drift``: the delivery must not carry a
|
||
line nobody checked.
|
||
"""
|
||
prompt = verification_prompt(source_text, translation, previous_text, next_text, target)
|
||
try:
|
||
data, _usage = llm.chat_json(
|
||
SYSTEM_VERIFIER,
|
||
prompt,
|
||
model=model,
|
||
temperature=0.0,
|
||
cache_key=f"verify-{hashlib.sha256(prompt.encode('utf-8')).hexdigest()[:16]}",
|
||
)
|
||
except LLMError as exc:
|
||
return VERDICT_DRIFT, f"verification model failed: {exc}"
|
||
|
||
if not isinstance(data, dict):
|
||
return VERDICT_DRIFT, "verification answer was not an object"
|
||
kind = str(data.get("kind") or "").strip().lower()
|
||
reason = str(data.get("reason") or "").strip()
|
||
if kind not in VERDICTS:
|
||
# An unrecognised verdict is not a pass. A model that cannot answer
|
||
# the question has not verified anything.
|
||
return VERDICT_DRIFT, f"verification answer was not understood: {kind or '<empty>'}"
|
||
return kind, reason
|
||
|
||
|
||
def _apply_drift_verification(
|
||
alignments: list[ParagraphAlignment],
|
||
chapter: dict,
|
||
translated: dict[str, str],
|
||
bundle: dict[str, dict],
|
||
llm: LLM,
|
||
model: str,
|
||
target: TargetLanguage | None = None,
|
||
) -> None:
|
||
"""Check the paragraphs whose translation may have drifted across ids.
|
||
|
||
The check starts at a sentence fragment - Pass 0 split the original
|
||
passage mid-sentence there, and a fragment is exactly where an
|
||
intent-based drafter is told to write natural target-language prose and
|
||
lose the id-to-text correspondence.
|
||
|
||
The paragraphs are then judged as a *window* rather than one at a time,
|
||
because a re-flowed passage cannot be judged one paragraph at a time:
|
||
the same paragraph can be called "aligned" and be wrong (the model does
|
||
exactly that on the two paragraphs before a real drift), and the start of
|
||
a drift is not knowable from any single paragraph.
|
||
|
||
A paragraph the model says belongs to another paragraph is marked
|
||
``mismatch`` and its segments are dropped: the delivery must not carry
|
||
text that renders the wrong source utterance, and a visible gap is better
|
||
than a confident wrong line in the audiobook. A paragraph that is merely
|
||
incomplete keeps its segments and is flagged for review, unless the
|
||
window it sits in is already broken.
|
||
|
||
The cost of this choice is explicit: a fragment inside a broken window is
|
||
dropped even when the model called it aligned, so the audiobook has a
|
||
gap there. The alternative - keeping it - puts a line in the audiobook
|
||
that belongs to a different sentence, which is worse.
|
||
"""
|
||
target = target or CHINESE
|
||
by_id = {alignment.paragraph_id: alignment for alignment in alignments}
|
||
paragraphs = list(chapter.get("paragraphs") or [])
|
||
index_of = {str(p.get("id") or ""): i for i, p in enumerate(paragraphs)}
|
||
|
||
# A re-flowed passage is judged as a *window*, not as a series of
|
||
# independent paragraphs. Two measurements forced this shape:
|
||
#
|
||
# * The same paragraph can be judged "aligned" and be wrong. In the real
|
||
# ch-15 window, the model called the two paragraphs before the drift
|
||
# "aligned" when their Chinese was demonstrably the neighbouring
|
||
# paragraph's.
|
||
# * The start of a drift is not knowable from any single paragraph. The
|
||
# drafter re-flowed the passage, so the content of paragraph N may sit
|
||
# under paragraph N+1, and the first symptom can be a long way from the
|
||
# cause.
|
||
#
|
||
# So: judge every candidate, then resolve each window as a whole. A
|
||
# window that contains a confirmed drift is broken, and inside a broken
|
||
# window a per-paragraph "aligned" verdict is not trustworthy.
|
||
window: list[tuple[str, ParagraphAlignment, str, bool, str]] = []
|
||
window_has_drift = False
|
||
|
||
def resolve_window() -> None:
|
||
"""Apply the window's verdict to the paragraphs inside it."""
|
||
nonlocal window_has_drift
|
||
for _paragraph_id, alignment, reason, was_fragment, verdict in window:
|
||
if alignment.method == "mismatch":
|
||
continue
|
||
if window_has_drift and was_fragment and verdict == VERDICT_ALIGNED:
|
||
# A fragment inside a confirmed drift is exactly where the
|
||
# re-flow happened, and the model's "aligned" verdict there is
|
||
# the one that is known to be unreliable. A paragraph the
|
||
# model called "incomplete" is not dropped on this evidence:
|
||
# it demonstrably renders its own leading content.
|
||
alignment.method = "mismatch"
|
||
alignment.problems = [f"translation is part of a drifted passage: {reason}"]
|
||
alignment.segments = []
|
||
window.clear()
|
||
window_has_drift = False
|
||
|
||
for paragraph in paragraphs:
|
||
paragraph_id = str(paragraph.get("id") or "")
|
||
alignment = by_id.get(paragraph_id)
|
||
if alignment is None:
|
||
continue
|
||
source_text = str(paragraph.get("text") or "").strip()
|
||
translation = str(translated.get(paragraph_id) or "").strip()
|
||
if not source_text or not translation:
|
||
continue
|
||
|
||
fragment = is_fragment(source_text)
|
||
if not window and not fragment:
|
||
continue
|
||
|
||
position = index_of.get(paragraph_id, -1)
|
||
previous_text = ""
|
||
next_text = ""
|
||
if 0 <= position - 1 < len(paragraphs):
|
||
previous_text = str(paragraphs[position - 1].get("text") or "").strip()
|
||
if 0 <= position + 1 < len(paragraphs):
|
||
next_text = str(paragraphs[position + 1].get("text") or "").strip()
|
||
|
||
verdict, reason = verify_translation(
|
||
source_text,
|
||
translation,
|
||
llm,
|
||
model,
|
||
previous_text=previous_text,
|
||
next_text=next_text,
|
||
target=target,
|
||
)
|
||
|
||
if verdict == VERDICT_ALIGNED:
|
||
if window and not fragment:
|
||
# The passage has re-synchronised: this paragraph is a
|
||
# complete sentence and the model says it is where it
|
||
# belongs. The window ends here.
|
||
resolve_window()
|
||
else:
|
||
window.append((paragraph_id, alignment, reason, fragment, verdict))
|
||
continue
|
||
|
||
if verdict == VERDICT_DRIFT:
|
||
window_has_drift = True
|
||
alignment.method = "mismatch"
|
||
alignment.problems = [f"translation belongs to another paragraph: {reason}"]
|
||
alignment.segments = []
|
||
elif window_has_drift:
|
||
# Inside a confirmed drift, "my own text, truncated" is part of
|
||
# the same defect: the drafter re-flowed the passage and this
|
||
# paragraph is one of the pieces it was cut into. The English
|
||
# around a drift is often itself a sentence split across
|
||
# paragraph ids, so a truncated lead-in here is the tail of the
|
||
# previous paragraph rather than a translation of this one.
|
||
alignment.method = "mismatch"
|
||
alignment.problems = [f"translation is part of a drifted passage: {reason}"]
|
||
alignment.segments = []
|
||
else:
|
||
# Nothing has been proven wrong yet, so the text is kept and
|
||
# flagged: it is still this paragraph's own translation, just
|
||
# short of the whole of it.
|
||
alignment.problems = [f"translation is incomplete: {reason}"]
|
||
|
||
window.append((paragraph_id, alignment, reason, fragment, verdict))
|
||
|
||
resolve_window()
|
||
|
||
|
||
def align_chapter(
|
||
chapter: dict,
|
||
translated: dict[str, str],
|
||
bundle: dict[str, dict],
|
||
llm: LLM | None = None,
|
||
model: str = DEFAULT_ALIGN_MODEL,
|
||
verify: bool = False,
|
||
target: TargetLanguage | None = None,
|
||
) -> tuple[list[ParagraphAlignment], dict[str, Any]]:
|
||
"""Align every translated paragraph of one chapter.
|
||
|
||
With *verify*, the paragraphs where the drafter could have re-flowed the
|
||
passage across paragraph ids are checked as well - see
|
||
:func:`verify_drift_run`.
|
||
"""
|
||
target = target or CHINESE
|
||
alignments: list[ParagraphAlignment] = []
|
||
for paragraph in chapter.get("paragraphs") or []:
|
||
paragraph_id = str(paragraph.get("id") or "")
|
||
if paragraph.get("type") == "scene_break":
|
||
continue
|
||
translation = translated.get(paragraph_id)
|
||
if translation is None or not translation.strip():
|
||
alignments.append(
|
||
ParagraphAlignment(
|
||
paragraph_id=paragraph_id,
|
||
method="failed",
|
||
problems=["paragraph has no translation"],
|
||
)
|
||
)
|
||
continue
|
||
alignments.append(align_paragraph(paragraph, translation, bundle, llm=llm, model=model, target=target))
|
||
|
||
if verify and llm is not None:
|
||
_apply_drift_verification(alignments, chapter, translated, bundle, llm, model, target)
|
||
|
||
methods: dict[str, int] = {}
|
||
problems: list[str] = []
|
||
for alignment in alignments:
|
||
methods[alignment.method] = methods.get(alignment.method, 0) + 1
|
||
for problem in alignment.problems:
|
||
problems.append(f"{alignment.paragraph_id}: {problem}")
|
||
|
||
report = {
|
||
"chapter_id": chapter.get("id"),
|
||
"paragraphs": len(alignments),
|
||
"methods": methods,
|
||
"needs_review": sum(1 for a in alignments if a.needs_review),
|
||
"problems": problems,
|
||
}
|
||
return alignments, report
|
||
|
||
|
||
def build_delivery(
|
||
book: dict,
|
||
chapters: list[tuple[dict, list[ParagraphAlignment]]],
|
||
bundle: dict[str, dict],
|
||
*,
|
||
source_ref: str = "",
|
||
target: TargetLanguage | None = None,
|
||
) -> dict[str, Any]:
|
||
"""The payload UmbraAudio's ``import_translations`` accepts."""
|
||
if target is None:
|
||
target = resolve(book.get("target_language") or "zh-Hans")
|
||
target_code = target.base
|
||
utterances: list[dict[str, Any]] = []
|
||
unmatched: list[str] = []
|
||
|
||
for chapter, alignments in chapters:
|
||
bundle_ids = {str(paragraph.get("id") or ""): paragraph for paragraph in chapter.get("paragraphs") or []}
|
||
for alignment in alignments:
|
||
paragraph = bundle_ids.get(alignment.paragraph_id) or {}
|
||
sources = {source.source_id: source for source in source_utterances(paragraph, bundle)}
|
||
if alignment.method == "mismatch":
|
||
# The paragraph's translation could not be trusted - it
|
||
# renders another paragraph. The utterances still get a row,
|
||
# marked as not voiced and carrying the reason, so the gap is
|
||
# visible in the review lane and says why. Silence would be
|
||
# indistinguishable from "never translated".
|
||
reason = "; ".join(alignment.problems) or "the translation could not be verified"
|
||
for source in sources.values():
|
||
if source.row_id is None:
|
||
continue
|
||
utterances.append(
|
||
{
|
||
"utterance_id": source.row_id,
|
||
"voiced": False,
|
||
"reason": reason,
|
||
"source_ref": source_ref or alignment.paragraph_id,
|
||
}
|
||
)
|
||
continue
|
||
for segment in alignment.segments:
|
||
source = sources.get(segment.source_id)
|
||
if source is None or source.row_id is None:
|
||
unmatched.append(f"{alignment.paragraph_id}:{segment.source_id}")
|
||
continue
|
||
if segment.voiced:
|
||
utterances.append(
|
||
{
|
||
"utterance_id": source.row_id,
|
||
"text": segment.text,
|
||
"voiced": True,
|
||
"source_ref": source_ref or alignment.paragraph_id,
|
||
}
|
||
)
|
||
else:
|
||
utterances.append(
|
||
{
|
||
"utterance_id": source.row_id,
|
||
"voiced": False,
|
||
"reason": segment.reason,
|
||
"source_ref": source_ref or alignment.paragraph_id,
|
||
}
|
||
)
|
||
|
||
return {
|
||
"target_language": target_code,
|
||
"source_system": "umbratrans",
|
||
"utterances": utterances,
|
||
"unmatched": unmatched,
|
||
}
|
||
|
||
|
||
def load_bundle(path: Path) -> dict[str, dict]:
|
||
"""Read the UmbraAudio controller bundle, keyed by utterance id."""
|
||
bundle: dict[str, dict] = {}
|
||
with path.open("r", encoding="utf-8") as handle:
|
||
for line in handle:
|
||
line = line.strip()
|
||
if not line:
|
||
continue
|
||
record = json.loads(line)
|
||
if record.get("kind") == "utterance":
|
||
bundle[str(record.get("id"))] = record
|
||
return bundle
|
||
|
||
|
||
def load_translations(run_dir: Path, chapter_id: str) -> dict[str, str]:
|
||
"""The translated paragraphs of one chapter."""
|
||
path = run_dir / chapter_id / "translation.json"
|
||
if not path.exists():
|
||
return {}
|
||
data = json.loads(path.read_text(encoding="utf-8"))
|
||
paragraphs = data.get("paragraphs") or {}
|
||
return {str(key): str(value) for key, value in paragraphs.items()}
|
||
|
||
|
||
def main(argv: list[str] | None = None) -> int:
|
||
parser = argparse.ArgumentParser(
|
||
description="Map the translated paragraphs back onto the verified source utterances."
|
||
)
|
||
parser.add_argument("--book", type=Path, required=True)
|
||
parser.add_argument("--run-dir", type=Path, required=True)
|
||
parser.add_argument("--out", type=Path, required=True)
|
||
parser.add_argument("--bundle", type=Path, default=None)
|
||
parser.add_argument("--chapters", default="all")
|
||
parser.add_argument("--model", default=DEFAULT_ALIGN_MODEL)
|
||
parser.add_argument("--base-url", default=None)
|
||
parser.add_argument("--no-llm", action="store_true", help="use the mechanical split only")
|
||
parser.add_argument(
|
||
"--verify",
|
||
action="store_true",
|
||
help=(
|
||
"check the paragraphs where the drafter could have re-flowed the "
|
||
"passage across paragraph ids, and drop the ones that do not "
|
||
"render their own source"
|
||
),
|
||
)
|
||
parser.add_argument("--source-ref", default="")
|
||
parser.add_argument(
|
||
"--target-language",
|
||
default=None,
|
||
help="the language the translations are in. Defaults to the book's own.",
|
||
)
|
||
args = parser.parse_args(argv)
|
||
|
||
from pipeline import select_chapters # noqa: PLC0415 - keeps the tools importable standalone
|
||
|
||
book = json.loads(args.book.read_text(encoding="utf-8"))
|
||
target = resolve(args.target_language or book.get("target_language") or "zh-Hans")
|
||
bundle_path = args.bundle or Path(str(book.get("bundle") or ""))
|
||
if not bundle_path.exists():
|
||
print(f"bundle not found: {bundle_path}", file=sys.stderr)
|
||
return 2
|
||
bundle = load_bundle(bundle_path)
|
||
|
||
llm = None
|
||
if not args.no_llm:
|
||
# The cache directory has to exist before the first call: the client
|
||
# writes the answer as it arrives, and a missing directory would turn
|
||
# every model answer into an error - which the fallback would then
|
||
# quietly turn into a mechanical split for the whole book.
|
||
cache_dir = args.run_dir / "cache"
|
||
cache_dir.mkdir(parents=True, exist_ok=True)
|
||
llm = LLM(base_url=args.base_url, cache_dir=cache_dir) if args.base_url else LLM(cache_dir=cache_dir)
|
||
|
||
selected = select_chapters(book, args.chapters)
|
||
chapters: list[tuple[dict, list[ParagraphAlignment]]] = []
|
||
reports: list[dict[str, Any]] = []
|
||
|
||
for chapter in selected:
|
||
chapter_id = str(chapter.get("id") or "")
|
||
translated = load_translations(args.run_dir, chapter_id)
|
||
alignments, report = align_chapter(
|
||
chapter,
|
||
translated,
|
||
bundle,
|
||
llm=llm,
|
||
model=args.model,
|
||
verify=args.verify,
|
||
target=target,
|
||
)
|
||
chapters.append((chapter, alignments))
|
||
reports.append(report)
|
||
print(
|
||
f"{chapter_id}: {report['paragraphs']} paragraphs, "
|
||
f"methods={report['methods']}, needs review={report['needs_review']}",
|
||
flush=True,
|
||
)
|
||
|
||
delivery = build_delivery(book, chapters, bundle, source_ref=args.source_ref, target=target)
|
||
args.out.parent.mkdir(parents=True, exist_ok=True)
|
||
args.out.write_text(
|
||
json.dumps(delivery, ensure_ascii=False, indent=2) + "\n",
|
||
encoding="utf-8",
|
||
)
|
||
|
||
voiced = sum(1 for u in delivery["utterances"] if u.get("voiced"))
|
||
collapsed = len(delivery["utterances"]) - voiced
|
||
print(
|
||
f"delivery: {len(delivery['utterances'])} utterances "
|
||
f"({voiced} voiced, {collapsed} collapsed), {len(delivery['unmatched'])} unmatched"
|
||
)
|
||
print(f"wrote {args.out}")
|
||
print(json.dumps({"chapters": reports}, ensure_ascii=False, indent=2))
|
||
return 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
raise SystemExit(main())
|