Files
UmbraTrans/tools/translation/tests/test_translation.py
B.D. Murphy 3f05489e25 Parameterise the translation pipeline by target language
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.
2026-11-07 05:53:17 -08:00

487 lines
20 KiB
Python

# Copyright 2026 Umbra Tools (umbratools.com)
# SPDX-License-Identifier: Apache-2.0
"""Unit tests for the translation cleanup and QA helpers.
The pipeline calls an LLM, so these tests deliberately cover only the
deterministic parts: prompt-leak removal, quote normalisation, English
residue detection, JSON extraction and the mechanical QA checks.
"""
from __future__ import annotations
import json
import sys
import tempfile
import unittest
from pathlib import Path
HERE = Path(__file__).resolve().parent
sys.path.insert(0, str(HERE.parent))
from book import SUBTITLES, _title_for # noqa: E402
from languages import SCRIPT_LATIN, TargetLanguage, by_code, resolve # noqa: E402
from llm import LLMError, parse_json # noqa: E402
from pipeline import Runner, latin_residue, sanitize # noqa: E402
from prompts import critic_prompt, draft_prompt, glossary_block, system_critic, system_drafter # noqa: E402
from qa import check_chapter, forbidden_titles # noqa: E402
CHINESE = resolve("zh-Hans")
SPANISH = resolve("es-ES")
class PromptInstructionTests(unittest.TestCase):
"""The drafter and the critic must both know about paragraph drift.
Measured on the real Chinese edition: the drafter re-flows the passage
across paragraph ids wherever Pass 0 split the original mid-sentence, and
the alignment pass cannot see it - a one-to-one paragraph is a pure copy,
so there is nothing to divide and nothing to disagree. The prevention is
an instruction to the drafter; the detection is the alignment pass's
``--verify`` stage.
"""
def _draft(self, target: TargetLanguage | None = None) -> str:
return draft_prompt(
chapter_title="Salvage",
scene_index=0,
chunk_index=0,
chunks_in_scene=1,
paragraphs=[{"id": "ch-6-p0000", "speaker": "narrator", "text": "The chair has"}],
analysis={"summary": "摘要"},
previous_summary="",
glossary={},
target=target or CHINESE,
)
def _critic(self, target: TargetLanguage | None = None) -> str:
return critic_prompt(
chapter_title="Salvage",
paragraphs=[{"id": "ch-6-p0000", "speaker": "narrator", "text": "The chair has"}],
draft={"ch-6-p0000": "中间的椅子旁"},
glossary={},
target=target or CHINESE,
)
def test_the_drafter_is_told_to_keep_each_id_to_its_own_english(self) -> None:
prompt = self._draft()
self.assertIn("每个 id 的译文只能覆盖该 id 自己的英文", prompt)
def test_the_drafter_is_told_not_to_move_content_to_a_neighbour(self) -> None:
prompt = self._draft()
self.assertIn("不要把相邻段落的内容提前", prompt)
self.assertIn("不要把本段的内容挪到下一个 id", prompt)
def test_the_critic_is_asked_to_check_the_id_to_text_correspondence(self) -> None:
prompt = self._critic()
self.assertIn("misalignment", prompt)
self.assertIn("段落 id 与译文是否对应", prompt)
def test_a_misalignment_is_a_blocking_issue(self) -> None:
prompt = self._critic()
self.assertIn("段落错位", prompt)
class TargetLanguageTests(unittest.TestCase):
"""The pipeline must produce the language it was told to produce.
Measured on oldbox: the production directory ``deal-hunter-es`` declared
``target_language: es-ES`` and held 3,038 paragraphs of Chinese, because
the drafter, the critic and the glossary lookup were all hard-coded to
Simplified Chinese. These tests pin the parameterisation that fixes it.
"""
def test_a_language_is_found_by_tag_base_or_name(self) -> None:
self.assertEqual(by_code("es-ES").base, "es")
self.assertEqual(by_code("es").base, "es")
self.assertEqual(by_code("es_ES").base, "es")
self.assertEqual(by_code("Spanish").base, "es")
self.assertEqual(by_code("简体中文").base, "zh")
def test_an_unknown_language_falls_back_loudly(self) -> None:
# The fallback prints a warning; the point is that it does not
# silently pretend the language was recognised.
self.assertEqual(resolve("xx-YY").base, "zh")
def test_the_drafter_is_told_the_target_language(self) -> None:
prompt = draft_prompt(
chapter_title="Salvage",
scene_index=0,
chunk_index=0,
chunks_in_scene=1,
paragraphs=[{"id": "ch-6-p0000", "speaker": "narrator", "text": "The chair has"}],
analysis={"summary": "摘要"},
previous_summary="",
glossary={},
target=SPANISH,
)
self.assertIn("西班牙语", prompt)
self.assertIn("español", prompt)
self.assertNotIn("简体中文", prompt)
def test_the_drafter_asks_for_the_target_language_key(self) -> None:
prompt = draft_prompt(
chapter_title="Salvage",
scene_index=0,
chunk_index=0,
chunks_in_scene=1,
paragraphs=[{"id": "ch-6-p0000", "speaker": "narrator", "text": "The chair has"}],
analysis={"summary": "摘要"},
previous_summary="",
glossary={},
target=SPANISH,
)
self.assertIn('"es":', prompt)
self.assertNotIn('"zh":', prompt)
def test_the_critic_is_told_the_target_language(self) -> None:
prompt = critic_prompt(
chapter_title="Salvage",
paragraphs=[{"id": "ch-6-p0000", "speaker": "narrator", "text": "The chair has"}],
draft={"ch-6-p0000": "la silla"},
glossary={},
target=SPANISH,
)
self.assertIn("西班牙语", prompt)
self.assertNotIn("中文译文", prompt)
def test_the_glossary_reads_the_target_language_key(self) -> None:
glossary = {
"title": {"en": "Deal Hunter"},
"characters": {"Kainda": {"zh": "凯因达", "es": "Kainda"}},
"terms": {"bounty": {"zh": "赏金", "es": "recompensa"}},
}
spanish = glossary_block(glossary, set(), "bounty", SPANISH)
self.assertIn("recompensa", spanish)
self.assertNotIn("赏金", spanish)
def test_a_glossary_value_for_another_language_is_not_borrowed(self) -> None:
"""A Chinese-only glossary must not leak Chinese into a Spanish run."""
glossary = {
"characters": {"Kainda": {"zh": "凯因达"}},
"terms": {},
}
spanish = glossary_block(glossary, set(), "", SPANISH)
self.assertNotIn("凯因达", spanish)
def test_the_chinese_rules_are_kept_for_chinese(self) -> None:
"""The existing production must not lose its hard rules."""
glossary = {"characters": {}, "terms": {}, "places": {}}
chinese = glossary_block(glossary, set(), "jump", CHINESE)
self.assertIn("恒星系 system / star system = 星系", chinese)
self.assertIn("不译成中文", chinese)
def test_every_target_gets_the_books_own_rules(self) -> None:
"""The rules about this book are not Chinese's and not Spanish's.
*Deal Hunter* is the ship's name, the AI's name and the book's title,
so it stays in English in every edition. The Chinese table carries
its own wording of this rule, so a language with no table must not
lose it.
"""
glossary = {"characters": {}, "terms": {}, "places": {}}
for target in (CHINESE, SPANISH):
block = glossary_block(glossary, set(), "", target)
self.assertIn("Deal Hunter", block)
self.assertIn("保留英文", block)
self.assertIn("Hunter 作为 AI 的简称保留英文", block)
self.assertIn("Umbra Audio 保留英文", block)
def test_a_language_without_a_table_gets_no_other_languages_terms(self) -> None:
"""A Spanish run must not inherit the Chinese hard vocabulary."""
glossary = {"characters": {}, "terms": {}, "places": {}}
spanish = glossary_block(glossary, set(), "jump", SPANISH)
self.assertNotIn("星系", spanish)
self.assertNotIn("跃迁", spanish)
def test_a_glossary_can_supply_its_own_rules(self) -> None:
glossary = {
"characters": {},
"terms": {},
"places": {},
"rules": ["- Deal Hunter se conserva en inglés"],
}
spanish = glossary_block(glossary, set(), "", SPANISH)
self.assertIn("Deal Hunter se conserva en inglés", spanish)
def test_the_system_prompts_name_the_target_language(self) -> None:
self.assertIn("简体中文", system_drafter(CHINESE))
self.assertIn("西班牙语", system_drafter(SPANISH))
self.assertIn("Spanish", system_critic(SPANISH))
self.assertNotIn("中文译文", system_critic(SPANISH))
def test_quotes_are_normalised_to_the_target_language(self) -> None:
self.assertEqual(sanitize('"hola"', SPANISH), "“hola”")
self.assertEqual(sanitize('"hallo"', resolve("de-DE")), "„hallo“")
self.assertEqual(sanitize('"bonjour"', resolve("fr-FR")), "«bonjour»")
def test_the_ellipsis_is_the_target_language_s(self) -> None:
self.assertEqual(sanitize("...", CHINESE), "……")
self.assertEqual(sanitize("...", SPANISH), "…")
def test_the_residue_check_is_only_meaningful_for_a_non_latin_target(self) -> None:
"""A Spanish draft is full of Latin words; they are not residue."""
self.assertIn("silla", latin_residue("la silla del capitán")[0])
self.assertEqual(latin_residue("中间那把椅子")[0], [])
# So the repair is skipped for a Latin-script target: the check would
# otherwise flag the whole Spanish draft and "repair" it into
# nonsense.
self.assertIn(SCRIPT_LATIN, SPANISH.scripts)
self.assertNotIn(SCRIPT_LATIN, CHINESE.scripts)
class RunDirectoryGuardTests(unittest.TestCase):
"""A run directory belongs to one language.
Measured on oldbox: the production directory ``deal-hunter-es`` declared
``target_language: es-ES`` and held 3,038 paragraphs of Chinese, because
the pipeline caches by file existence and the run directory is chosen by
the caller. A Spanish run pointed at that directory would have skipped
drafting entirely and published the Chinese under a Spanish name.
The guard refuses that. It checks the manifest first (a claim) and then
measures the text that is actually there (the evidence).
"""
def _runner(self, run_dir: Path, target: str, **kwargs: object) -> Runner:
book = run_dir / "book.json"
glossary = run_dir / "glossary.json"
book.write_text(
json.dumps({"target_language": target, "chapters": []}),
encoding="utf-8",
)
glossary.write_text(json.dumps({}), encoding="utf-8")
return Runner(
book_path=book,
glossary_path=glossary,
run_dir=run_dir / "runs",
target_language=target,
**kwargs,
)
def test_a_run_directory_records_its_language(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
self._runner(root, "es-ES")
manifest = json.loads((root / "runs" / "run.json").read_text(encoding="utf-8"))
self.assertEqual(manifest["target_language"], "es-ES")
self.assertEqual(manifest["glossary_key"], "es")
def test_a_spanish_run_refuses_a_chinese_run_directory(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
self._runner(root, "zh-Hans")
with self.assertRaises(SystemExit) as caught:
self._runner(root, "es-ES")
self.assertIn("zh-Hans", str(caught.exception))
self.assertIn("es-ES", str(caught.exception))
def test_a_spanish_run_refuses_a_directory_holding_chinese_text(self) -> None:
"""No manifest - an older directory - so the text itself is measured."""
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
self._runner(root, "zh-Hans")
chapter = root / "runs" / "ch-1"
chapter.mkdir(parents=True, exist_ok=True)
(chapter / "translation.json").write_text(
json.dumps({"paragraphs": {"ch-1-p0000": "凯因达走到门前。"}}),
encoding="utf-8",
)
(root / "runs" / "run.json").unlink()
with self.assertRaises(SystemExit) as caught:
self._runner(root, "es-ES")
self.assertIn("not Spanish", str(caught.exception))
def test_a_spanish_run_accepts_a_spanish_run_directory(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
self._runner(root, "es-ES")
chapter = root / "runs" / "ch-1"
chapter.mkdir(parents=True, exist_ok=True)
(chapter / "translation.json").write_text(
json.dumps({"paragraphs": {"ch-1-p0000": "Kainda se acercó a la puerta."}}),
encoding="utf-8",
)
runner = self._runner(root, "es-ES")
self.assertEqual(runner.target.code, "es-ES")
def test_force_overrides_the_manifest(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
self._runner(root, "zh-Hans")
runner = self._runner(root, "es-ES", force=True)
self.assertEqual(runner.target.code, "es-ES")
def test_a_draft_in_the_wrong_language_is_refused(self) -> None:
"""The drafter has been known to ignore the target language."""
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
runner = self._runner(root, "es-ES")
with self.assertRaises(LLMError) as caught:
runner._normalise_draft(
{"paragraphs": [{"id": "ch-1-p0000", "es": "凯因达走到门前。"}]},
["ch-1-p0000"],
)
self.assertIn("wrong language", str(caught.exception))
class SanitizeTests(unittest.TestCase):
def test_strips_dialogue_label(self) -> None:
self.assertEqual(sanitize("(对话;凯因达)“我在游艇上。”"), "“我在游艇上。”")
def test_strips_narration_label(self) -> None:
self.assertEqual(sanitize("(旁白)凯因达登上了游艇。"), "凯因达登上了游艇。")
def test_strips_paragraph_id(self) -> None:
self.assertEqual(sanitize("[ch-6-p0000] “你好。”"), "“你好。”")
def test_title_and_brand(self) -> None:
self.assertEqual(sanitize("《交易猎人》"), "《Deal Hunter》")
self.assertEqual(sanitize("由乌姆布拉音频系统制作"), "由Umbra Audio制作")
def test_missing_hour(self) -> None:
self.assertEqual(sanitize("她在小时前登船。"), "她一小时前登船。")
def test_straight_quotes_become_chinese(self) -> None:
self.assertEqual(sanitize('"你好",她说。'), "“你好”,她说。")
def test_unmatched_closing_quote_removed(self) -> None:
self.assertEqual(sanitize("凯因达登上了游艇。”"), "凯因达登上了游艇。")
def test_unmatched_opening_quote_closed(self) -> None:
self.assertEqual(sanitize("“你好"), "“你好”")
def test_ellipsis_and_dash(self) -> None:
self.assertEqual(sanitize("等等... 然后--"), "等等…… 然后——")
class ResidueTests(unittest.TestCase):
def test_lowercase_english_is_flagged(self) -> None:
common, proper = latin_residue("她被 escorted 到了舱室。")
self.assertEqual(common, ["escorted"])
self.assertEqual(proper, [])
def test_allowed_names_are_ignored(self) -> None:
common, proper = latin_residue("Deal Hunter 和 Hunter 在 Umbra Audio。")
self.assertEqual(common, [])
self.assertEqual(proper, [])
def test_unknown_capitalised_name_is_reported(self) -> None:
common, proper = latin_residue("这是 Foo 的船。")
self.assertEqual(common, [])
self.assertEqual(proper, ["Foo"])
class JsonTests(unittest.TestCase):
def test_plain_json(self) -> None:
self.assertEqual(parse_json('{"a": 1}'), {"a": 1})
def test_fenced_json(self) -> None:
self.assertEqual(parse_json('```json\n{"a": 1}\n```'), {"a": 1})
def test_json_with_prose(self) -> None:
self.assertEqual(parse_json('结果如下:\n{"a": 1}\n完毕。'), {"a": 1})
class QaTests(unittest.TestCase):
def test_missing_paragraph_is_block(self) -> None:
chapter = {
"id": "ch-x",
"index": 1,
"title": "Test",
"paragraphs": [
{"id": "ch-x-p0000", "type": "narration", "text": "One."},
{"id": "ch-x-p0001", "type": "narration", "text": "Two."},
],
}
data = {"paragraphs": {"ch-x-p0000": "第一段。"}}
issues, metrics = check_chapter(chapter, data)
kinds = {i["kind"] for i in issues}
self.assertIn("missing", kinds)
self.assertEqual(metrics["translated"], 1)
def test_metadata_leak_and_residue(self) -> None:
chapter = {
"id": "ch-x",
"index": 1,
"title": "Test",
"paragraphs": [
{"id": "ch-x-p0000", "type": "narration", "text": "One."},
{"id": "ch-x-p0001", "type": "narration", "text": "The Albert system is far."},
],
}
data = {
"paragraphs": {
"ch-x-p0000": "(旁白)第一段 escorted。",
"ch-x-p0001": "阿尔伯特系统很远。",
}
}
issues, _ = check_chapter(chapter, data)
kinds = {i["kind"] for i in issues}
self.assertIn("metadata_leak", kinds)
self.assertIn("english_residue", kinds)
self.assertIn("system_mistranslated", kinds)
def test_ship_systems_are_not_star_systems(self) -> None:
chapter = {
"id": "ch-x",
"index": 1,
"title": "Test",
"paragraphs": [
{"id": "ch-x-p0000", "type": "narration", "text": "Keep away from the ship systems."},
],
}
data = {"paragraphs": {"ch-x-p0000": "远离飞船系统。"}}
issues, _ = check_chapter(chapter, data)
self.assertNotIn("system_mistranslated", {i["kind"] for i in issues})
class ChapterTitleTests(unittest.TestCase):
"""The chapter title is the target language's, or nothing else's."""
def test_a_language_with_no_table_falls_back_to_the_english_title(self) -> None:
"""Never to another language's title."""
self.assertEqual(_title_for("Salvage", SPANISH), "Salvage")
self.assertEqual(_title_for("Salvage", resolve("ja-JP")), "Salvage")
def test_a_language_with_a_table_uses_it(self) -> None:
self.assertEqual(_title_for("Salvage", CHINESE), "打捞")
def test_a_chapter_missing_from_the_table_is_marked_in_the_target_language(self) -> None:
"""A missing heading is a visible gap, not an English title that looks done."""
chinese = _title_for("Brand New Chapter", CHINESE)
self.assertIn("待译", chinese)
self.assertIn("Brand New Chapter", chinese)
def test_the_subtitle_is_the_target_language_s(self) -> None:
self.assertEqual(SUBTITLES["zh"], "交易猎人")
self.assertEqual(SUBTITLES["es"], "Cazador de Ofertas")
# A language with no subtitle gets none, not another language's.
self.assertNotIn("en", SUBTITLES)
class ForbiddenTitleTests(unittest.TestCase):
"""The translated title must not appear in the text, in any language."""
def test_the_chinese_form_is_forbidden(self) -> None:
self.assertIn("《交易猎人》", forbidden_titles(CHINESE))
def test_the_spanish_form_is_forbidden(self) -> None:
self.assertIn("«Cazador de Ofertas»", forbidden_titles(SPANISH))
def test_a_language_without_a_subtitle_forbids_nothing(self) -> None:
"""A language with no translated title has no forbidden form."""
language = TargetLanguage(
code="xx-XX",
base="xx",
name="Example",
native="example",
chinese_name="示例语",
scripts=(SCRIPT_LATIN,),
)
self.assertEqual(forbidden_titles(language), [])
if __name__ == "__main__":
unittest.main()