Files
UmbraTrans/tools/translation/tests/test_align.py
Umbra Tools 996fe37f4f Align the translated paragraphs back onto the verified source utterances
The translation pipeline works on paragraphs, because epub and pdf are
paragraph media. The audiobook needs utterances: UmbraAudio has verified who
speaks every one of them, and each clip is voiced separately by that
speaker's voice. A paragraph-level translation cannot be spoken.

This is the pass that was missing. tools/translation/README.md said so
plainly: "It does not produce a 1:1 utterance alignment for TTS ... splitting
or aligning back to the 4,319 utterances is a separate pass."

The division is by meaning, not by position. An intent-based translation
merges and splits sentences for the target language's sake, so the sentence
counts do not line up on their own - that is the whole point of Passes 1-2.
A model is given the source utterances and the translation and asked which
part of the translation renders which source utterance.

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;
* the partition must reproduce the translated text exactly, modulo
  whitespace, so the alignment can never rewrite the translation.

An answer that drops, duplicates or invents text is rejected and the
paragraph falls back to a mechanical split, which is reported as needing
review. A source utterance the target does not voice is delivered as
voiced=false with the reason, which is what UmbraAudio's
utterance_translations.voiced flag is for: the row is kept so the gap stays
visible, and no clip is generated for it.

The output is a delivery payload in the shape UmbraAudio's
translations.import_translations already accepts, so neither side has to
invent a second contract.

Measured on the real book, 42 sampled multi-utterance paragraphs across seven
chapters: 41 aligned by the model, 1 fell back to the mechanical split, 0
partition mismatches. The one fallback was a model answer that did not
reproduce the translated text; the content check caught it.

44 tests, ruff clean.
2026-11-06 19:13:30 -08:00

336 lines
13 KiB
Python

# Copyright 2026 Umbra Tools (umbratools.com)
# SPDX-License-Identifier: Apache-2.0
"""Tests for the utterance alignment pass.
The alignment model is an LLM, so the tests cover the deterministic parts:
the mechanical split, the repartition that guarantees the translation is
never rewritten, the validation rules that stop a line being handed to a
different speaker, and the delivery payload UmbraAudio ingests.
"""
from __future__ import annotations
import sys
import unittest
from pathlib import Path
HERE = Path(__file__).resolve().parent
sys.path.insert(0, str(HERE.parent))
from align import ( # noqa: E402
Segment,
align_paragraph,
build_delivery,
mechanical_split,
normalise_target_language,
parse_row_id,
parse_segments,
repartition,
source_utterances,
split_target_sentences,
)
def bundle_fixture() -> dict[str, dict]:
"""Three narration utterances and one dialogue, as UmbraAudio has them."""
return {
"utt-100": {
"id": "utt-100",
"database_row_id": 100,
"utterance_index": 0,
"speaker_name_resolved": "narrator",
"utterance_type": "narration",
"text": "The ship was large.",
},
"utt-101": {
"id": "utt-101",
"database_row_id": 101,
"utterance_index": 1,
"speaker_name_resolved": "narrator",
"utterance_type": "narration",
"text": "The designer had laid it out carefully.",
},
"utt-102": {
"id": "utt-102",
"database_row_id": 102,
"utterance_index": 2,
"speaker_name_resolved": "Kainda Walters Foster Murphy",
"utterance_type": "dialogue",
"text": "I want to see the bridge.",
},
}
def paragraph_fixture() -> dict:
return {
"id": "ch-1-p0000",
"speaker": "narrator",
"type": "narration",
"text": "The ship was large. The designer had laid it out carefully.",
"source_indices": [0, 1],
"source_ids": ["utt-100", "utt-101"],
}
class LanguageTests(unittest.TestCase):
def test_locale_becomes_base_code(self) -> None:
self.assertEqual(normalise_target_language("zh-CN"), "zh")
self.assertEqual(normalise_target_language("es-ES"), "es")
def test_row_id_is_parsed_from_the_bundle_id(self) -> None:
self.assertEqual(parse_row_id("utt-83578"), 83578)
self.assertIsNone(parse_row_id("utt-abc"))
class SentenceTests(unittest.TestCase):
def test_chinese_sentences_keep_their_terminator(self) -> None:
self.assertEqual(
split_target_sentences("这艘船很大。设计师很用心。"),
["这艘船很大。", "设计师很用心。"],
)
def test_closing_quote_stays_with_its_sentence(self) -> None:
self.assertEqual(split_target_sentences("“你好。”然后她走了。"), ["“你好。”", "然后她走了。"])
def test_text_without_a_terminator_is_one_sentence(self) -> None:
self.assertEqual(split_target_sentences("没有句号"), ["没有句号"])
class MechanicalSplitTests(unittest.TestCase):
def test_exact_sentence_count(self) -> None:
text = "第一句。第二句。"
self.assertEqual(mechanical_split(text, 2), ["第一句。", "第二句。"])
def test_too_many_sentences_are_merged(self) -> None:
text = "第一句。第二句。第三句。"
parts = mechanical_split(text, 2)
self.assertIsNotNone(parts)
assert parts is not None
self.assertEqual(len(parts), 2)
self.assertEqual("".join(parts), text)
def test_too_few_sentences_are_split_at_a_comma(self) -> None:
text = "第一句很长,包含很多细节。"
parts = mechanical_split(text, 2)
self.assertIsNotNone(parts)
assert parts is not None
self.assertEqual(len(parts), 2)
self.assertEqual("".join(parts), text)
def test_nothing_is_lost(self) -> None:
text = "第一句。第二句。第三句。第四句。"
parts = mechanical_split(text, 3)
assert parts is not None
self.assertEqual("".join(parts), text)
class RepartitionTests(unittest.TestCase):
def test_gaps_go_to_the_preceding_part(self) -> None:
translation = "第一句。第二句。"
parts = repartition(translation, ["第一句。", "第二句。"])
assert parts is not None
self.assertEqual("".join(parts), translation)
def test_the_result_reproduces_the_translation(self) -> None:
translation = "这艘船很大。设计师很用心。"
parts = repartition(translation, ["这艘船很大。", "设计师很用心。"])
assert parts is not None
self.assertEqual("".join(parts), translation)
def test_a_missing_anchor_is_rejected(self) -> None:
self.assertIsNone(repartition("这艘船很大。", ["完全不同的句子。"]))
def test_whitespace_differences_are_tolerated(self) -> None:
translation = "第一句。 第二句。"
parts = repartition(translation, ["第一句。", "第二句。"])
assert parts is not None
# Each part is stripped, so the whitespace between them is dropped.
# The invariant is that nothing but whitespace is lost.
self.assertEqual("".join(parts).replace(" ", ""), translation.replace(" ", ""))
class ParseSegmentsTests(unittest.TestCase):
def setUp(self) -> None:
self.sources = source_utterances(paragraph_fixture(), bundle_fixture())
self.translation = "这艘船很大。设计师很用心。"
def test_a_valid_answer_is_accepted(self) -> None:
data = {
"segments": [
{"source_id": "utt-100", "target_text": "这艘船很大。"},
{"source_id": "utt-101", "target_text": "设计师很用心。"},
]
}
segments, problems = parse_segments(data, self.sources, self.translation)
self.assertEqual(problems, [])
self.assertEqual([s.text for s in segments], ["这艘船很大。", "设计师很用心。"])
def test_a_missing_segment_is_rejected(self) -> None:
data = {"segments": [{"source_id": "utt-100", "target_text": "这艘船很大。"}]}
_, problems = parse_segments(data, self.sources, self.translation)
self.assertTrue(problems)
def test_a_duplicated_source_id_is_rejected(self) -> None:
data = {
"segments": [
{"source_id": "utt-100", "target_text": "这艘船很大。"},
{"source_id": "utt-100", "target_text": "设计师很用心。"},
]
}
_, problems = parse_segments(data, self.sources, self.translation)
self.assertTrue(problems)
def test_text_that_is_not_in_the_translation_is_rejected(self) -> None:
data = {
"segments": [
{"source_id": "utt-100", "target_text": "这艘船很大。"},
{"source_id": "utt-101", "target_text": "凭空多出来的一句。"},
]
}
_, problems = parse_segments(data, self.sources, self.translation)
self.assertTrue(problems)
def test_a_merge_is_accepted_and_marked_not_voiced(self) -> None:
data = {
"segments": [
{"source_id": "utt-100", "target_text": "这艘船很大。设计师很用心。"},
{"source_id": "utt-101", "target_text": "", "merged_into": "utt-100"},
]
}
segments, problems = parse_segments(data, self.sources, self.translation)
self.assertEqual(problems, [])
self.assertTrue(segments[0].voiced)
self.assertFalse(segments[1].voiced)
self.assertEqual(segments[1].merged_into, "utt-100")
def test_a_line_cannot_be_handed_to_another_speaker(self) -> None:
paragraph = {
"id": "ch-1-p0001",
"speaker": "narrator",
"type": "narration",
"text": "The ship was large. I want to see the bridge.",
"source_indices": [0, 2],
"source_ids": ["utt-100", "utt-102"],
}
sources = source_utterances(paragraph, bundle_fixture())
data = {
"segments": [
{"source_id": "utt-100", "target_text": "这艘船很大。我想看看舰桥。"},
{"source_id": "utt-102", "target_text": "", "merged_into": "utt-100"},
]
}
_, problems = parse_segments(data, sources, "这艘船很大。我想看看舰桥。")
self.assertTrue(problems)
self.assertTrue(any("different speaker" in p for p in problems))
def test_a_merge_chain_is_rejected(self) -> None:
data = {
"segments": [
{"source_id": "utt-100", "target_text": "这艘船很大。"},
{"source_id": "utt-101", "target_text": "", "merged_into": "utt-101"},
]
}
_, problems = parse_segments(data, self.sources, self.translation)
self.assertTrue(problems)
class AlignParagraphTests(unittest.TestCase):
def test_a_single_source_utterance_is_copied(self) -> None:
paragraph = {
"id": "ch-1-p0002",
"speaker": "Kainda Walters Foster Murphy",
"type": "dialogue",
"text": "I want to see the bridge.",
"source_indices": [2],
"source_ids": ["utt-102"],
}
alignment = align_paragraph(paragraph, "我想看看舰桥。", bundle_fixture())
self.assertEqual(alignment.method, "one_to_one")
self.assertEqual(alignment.segments[0].text, "我想看看舰桥。")
def test_a_scene_break_has_no_translation(self) -> None:
paragraph = {
"id": "ch-1-break1",
"speaker": "",
"type": "scene_break",
"text": "* * *",
"source_indices": [3],
"source_ids": ["utt-103"],
}
alignment = align_paragraph(paragraph, "* * *", bundle_fixture())
self.assertEqual(alignment.method, "scene_break")
self.assertEqual(alignment.segments, [])
def test_without_a_model_the_split_is_mechanical_and_flagged(self) -> None:
alignment = align_paragraph(paragraph_fixture(), "这艘船很大。设计师很用心。", bundle_fixture())
self.assertEqual(alignment.method, "mechanical")
self.assertTrue(alignment.needs_review)
self.assertEqual(len(alignment.segments), 2)
def test_a_model_partition_is_used_and_verified(self) -> None:
class FakeLLM:
def __init__(self) -> None:
self.calls = 0
def chat_json(self, system: str, user: str, **kwargs: object) -> tuple[dict, object]:
self.calls += 1
return (
{
"segments": [
{"source_id": "utt-100", "target_text": "这艘船很大。"},
{"source_id": "utt-101", "target_text": "设计师很用心。"},
]
},
None,
)
llm = FakeLLM()
alignment = align_paragraph(paragraph_fixture(), "这艘船很大。设计师很用心。", bundle_fixture(), llm=llm)
self.assertEqual(alignment.method, "llm")
self.assertFalse(alignment.needs_review)
self.assertEqual("".join(s.text for s in alignment.segments), "这艘船很大。设计师很用心。")
self.assertEqual(llm.calls, 1)
class DeliveryTests(unittest.TestCase):
def test_the_payload_carries_the_database_row_id(self) -> None:
alignment = align_paragraph(paragraph_fixture(), "这艘船很大。设计师很用心。", bundle_fixture())
book = {"target_language": "zh-CN"}
delivery = build_delivery(
book,
[({"id": "ch-1", "paragraphs": [paragraph_fixture()]}, [alignment])],
bundle_fixture(),
)
self.assertEqual(delivery["target_language"], "zh")
self.assertEqual([u["utterance_id"] for u in delivery["utterances"]], [100, 101])
self.assertTrue(all(u["voiced"] for u in delivery["utterances"]))
self.assertEqual(delivery["unmatched"], [])
def test_a_collapsed_utterance_is_delivered_as_not_voiced(self) -> None:
alignment = align_paragraph(paragraph_fixture(), "这艘船很大。设计师很用心。", bundle_fixture())
alignment.segments = [
Segment(source_id="utt-100", text="这艘船很大。设计师很用心。"),
Segment(
source_id="utt-101",
text="",
voiced=False,
merged_into="utt-100",
reason="absorbed into utt-100 in the translated paragraph",
),
]
delivery = build_delivery(
{"target_language": "zh"},
[({"id": "ch-1", "paragraphs": [paragraph_fixture()]}, [alignment])],
bundle_fixture(),
)
collapsed = [u for u in delivery["utterances"] if not u["voiced"]]
self.assertEqual(len(collapsed), 1)
self.assertEqual(collapsed[0]["utterance_id"], 101)
self.assertIn("absorbed", collapsed[0]["reason"])
self.assertNotIn("text", collapsed[0])
if __name__ == "__main__":
unittest.main()