Files
UmbraTrans/tools/translation/prompts.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

459 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Copyright 2026 Umbra Tools (umbratools.com)
# SPDX-License-Identifier: Apache-2.0
"""Prompt construction for the multi-pass translation pipeline.
The prompts follow specs/27: analyse the scene before rendering it, give the
drafter the intent and the glossary, then check meaning with a different
model. They deliberately do not ask for word-for-word equivalence.
Every prompt is parameterised by the target language. The pipeline used to
be hard-coded to Simplified Chinese - the drafter was told to translate into
Chinese, the critic was told to compare English with Chinese, the glossary's
translated forms were read from a ``zh`` key - so a production that declared
``target_language: es-ES`` was translated into Chinese and filed under a
Spanish name. The target language now comes from
:class:`languages.TargetLanguage`, and the language's own script, punctuation
and glossary key follow from it.
The prompt scaffolding stays in Chinese. It is the language the pipeline was
developed in, the analyst model handles it well, and what the drafter has to
be told is *which* language to translate into - not a translation of the
instructions themselves.
"""
from __future__ import annotations
import json
from pathlib import Path
from languages import TargetLanguage
CORE_CHARACTERS = [
"Kainda Walters Foster Murphy",
"Princess Kainda",
"Duchess Victoria",
"Aunt Vicky",
"Deal Hunter",
"Hunter",
"Nanny",
"Trader Romano",
"Romano",
"B. D. Murphy",
"Umbra Audio",
]
CORE_TERMS = [
"star system",
"sector",
"space station",
"salvage",
"bounty",
"bounty hunter",
"jump",
"wake cycle",
"frigate",
"shuttle",
"bridge",
"engineering",
"cargo bay",
"dock",
"XO",
"captain",
"pirate",
"fugitive",
"coronation",
]
# The hard rules a language needs, as data rather than as code. The Chinese
# table is the one the pipeline has always run with, kept verbatim so the
# existing production is unaffected; a glossary may override it with its own
# "rules" list. A language with no built-in table falls back to rules derived
# from the glossary's own terms, and says so - a silently empty rule set is
# how the mislabelled Spanish production happened.
BUILTIN_RULES: dict[str, list[str]] = {
"zh": [
"- Deal Hunter 作为船名、AI 名或书名时保留英文,不译成中文;不要写成《交易猎人》",
"- Hunter 作为 AI 的简称保留英文,不译成“猎人”",
"- Umbra Audio 保留英文,不音译",
"- 只翻译原文出现的称谓;不要补全人物全名、头衔或身份",
"",
"【硬性术语:违反即为错译】",
"- 恒星系 system / star system = 星系;但 ship systems / audio system / computer system = 系统",
"- sector = 星区",
"- space station = 空间站;trade station = 贸易站",
"- jump = 跃迁(不译作“跳跃”)",
"- salvage = 打捞物 / 打捞(不译作“救援”)",
"- bounty = 赏金;bounty hunter = 赏金猎人",
"- XO = 副舰长;captain = 舰长",
],
}
# Rules that hold for every language of this book. They are about the book,
# not about a language: *Deal Hunter* is the ship's name, the AI's name and
# the book's title, so it stays in English in every edition, and a name the
# glossary does not carry is never transliterated into existence.
#
# The Chinese table above carries its own wording of these, so the Chinese
# prompt is byte-for-byte what it always was and the existing production
# stays reproducible.
GLOBAL_RULES = [
"- Deal Hunter 作为船名、AI 名或书名时保留英文,不译成{target_name}",
"- Hunter 作为 AI 的简称保留英文",
"- Umbra Audio 保留英文,不音译",
"- 只翻译原文出现的称谓;不要补全人物全名、头衔或身份",
"- 专名严格按术语表;术语表没有的名字保留原文,不要自行音译",
]
def load_glossary(path: Path) -> dict:
return json.loads(path.read_text(encoding="utf-8"))
def _translated(value: dict, target: TargetLanguage) -> str:
"""The target-language form of a glossary entry.
The key is the target's own: ``es`` for a Spanish glossary, ``zh`` for a
Chinese one. A glossary written for a different language is not silently
borrowed from - that is how Chinese text ends up under a Spanish name.
"""
for key in (target.glossary_key, "short", "target", "text"):
candidate = value.get(key)
if isinstance(candidate, str) and candidate.strip():
return candidate
return ""
def _pick(mapping: dict, keys: list[str], target: TargetLanguage) -> list[tuple[str, str]]:
out: list[tuple[str, str]] = []
for key in keys:
if key not in mapping:
continue
value = mapping[key]
if isinstance(value, dict):
rendered = _translated(value, target)
note = value.get("note")
if note:
rendered = f"{rendered}{target.paren_open}{note}{target.paren_close}"
else:
rendered = str(value)
if rendered:
out.append((key, rendered))
return out
def _rules(glossary: dict, target: TargetLanguage, term_pairs: list[tuple[str, str]]) -> list[str]:
"""The hard rules for the target language.
Three sources, in order: the glossary's own ``rules`` list, the
language's built-in table, and - for a language with no table - the
global rules of this book plus the glossary's own terms. A language never
gets an empty rule set, and never gets another language's.
"""
own = glossary.get("rules")
if isinstance(own, list) and own:
return [str(line) for line in own]
built_in = BUILTIN_RULES.get(target.base)
if built_in is not None:
return built_in
# No table for this language: the book's own rules, then the glossary's
# terms, so the model is still told what the hard vocabulary is.
derived = [line.format(target_name=target.chinese_name) for line in GLOBAL_RULES]
if term_pairs:
derived.append("")
derived.append("【硬性术语:违反即为错译】")
derived.extend(f"- {en} = {rendered}" for en, rendered in term_pairs)
return derived
def glossary_block(
glossary: dict,
speakers: set[str],
text: str,
target: TargetLanguage,
) -> str:
chars = dict(glossary.get("characters", {}))
roles = dict(glossary.get("roles", {}))
places = dict(glossary.get("places", {}))
terms = dict(glossary.get("terms", {}))
keys = list(CORE_CHARACTERS)
for s in sorted(speakers):
if s and s not in keys:
keys.append(s)
char_pairs = _pick({**chars, **roles}, keys, target)
term_keys = list(CORE_TERMS)
low = text.lower()
for key in terms:
if key.lower() in low and key not in term_keys:
term_keys.append(key)
term_pairs = _pick(terms, term_keys, target)
place_pairs = _pick(places, list(places), target)
title = glossary.get("title", {})
lines = ["【书名与专名规则】"]
if title:
en = title.get("en", "Deal Hunter")
sub = title.get(f"subtitle_{target.glossary_key}", "")
lines.append(
f"- 书名:{en};正文书名写作{target.title_open}{en}{target.title_close}"
+ (f",副标题可写{target.quote_open}{sub}{target.quote_close}" if sub else "")
)
lines += _rules(glossary, target, term_pairs)
lines += ["", "【人物与称谓】"]
lines += [f"- {en} = {rendered}" for en, rendered in char_pairs]
if place_pairs:
lines.append("【地名与舰名】")
lines += [f"- {en} = {rendered}" for en, rendered in place_pairs]
if term_pairs:
lines.append("【术语】")
lines += [f"- {en} = {rendered}" for en, rendered in term_pairs]
return "\n".join(lines)
def style_block(glossary: dict) -> str:
style = glossary.get("style", {})
return "\n".join(f"- {v}" for v in style.values() if isinstance(v, str))
def system_analyst(target: TargetLanguage) -> str:
return (
"""你是一位双语文学编辑,负责在翻译前分析英文小说的场景。
你的任务不是翻译,而是读出这一段文字的意图、语气、人物关系和可能被直译毁掉的地方。
摘要和术语建议请用"""
+ target.prompt_name
+ """写。
只输出 JSON,不要解释,不要 Markdown 代码围栏。"""
)
def system_drafter(target: TargetLanguage) -> str:
return f"""你是一位资深文学译者,把英文太空冒险小说译成{target.prompt_name}。
你的目标不是逐字对应,而是让{target.chinese_name}读者读到和英文读者相同的场景、语气、人物关系和潜台词。
可以重组句子、合并或拆分分句,但不得增删事实、不得替作者解释、不得加译注。
对话要像{target.chinese_name}人物在说话;叙述要流畅、克制、有画面感。
严格使用给出的术语表。未列入术语表的人名、地名、舰名保留原文,不要自行音译。
不要补全原文没有出现的全名、头衔或身份。
只输出 JSON,不要解释,不要 Markdown 代码围栏。"""
def system_critic(target: TargetLanguage) -> str:
return f"""你是一位严格的双语审校。对照英文原文和{target.prompt_name}译文,只找意义、指代、语气、术语和遗漏方面的问题。
不要因为“风格不同”而报错;只有当读者会因此理解错、或人物声音被抹平、或事实被改变时才报。
不要改写全文,只报告问题并给出最小修正建议。
只输出 JSON,不要解释,不要 Markdown 代码围栏。"""
def _paragraph_lines(paragraphs: list[dict]) -> str:
lines = []
for p in paragraphs:
if p.get("type") == "scene_break":
lines.append(f"[{p['id']}] (场景分隔)")
continue
speaker = p.get("speaker") or "narrator"
ptype = p.get("type") or "narration"
lines.append(f"[{p['id']}] ({ptype}; {speaker}) {p['text']}")
return "\n".join(lines)
def _draft_paragraph_lines(paragraphs: list[dict]) -> str:
"""Source lines for the drafter: id + text only.
Speaker/type labels are useful to the analyst but the drafter used to copy
them into the Chinese output ("(对话;凯因达)"). The analysis already
carries that information, so the draft prompt keeps the source clean.
"""
lines = []
for p in paragraphs:
if p.get("type") == "scene_break":
continue
lines.append(f"[{p['id']}] {p['text']}")
return "\n".join(lines)
def analysis_prompt(
*,
chapter_title: str,
scene_index: int,
chunk_index: int,
chunks_in_scene: int,
paragraphs: list[dict],
previous_summary: str,
glossary: dict,
target: TargetLanguage,
) -> str:
speakers = {p.get("speaker") or "narrator" for p in paragraphs}
text = " ".join(p.get("text", "") for p in paragraphs)
return f"""书名:Deal Hunter(交易猎人)
本章:{chapter_title}
场景 {scene_index + 1},第 {chunk_index + 1}/{chunks_in_scene} 段
上一段场景摘要:{previous_summary or "(无,本章开头)"}
{glossary_block(glossary, speakers, text, target)}
{style_block(glossary)}
请分析下面这段英文。输出 JSON:
{{
"summary": "1-2句{target.chinese_name}(80字以内),概括本段发生了什么、人物想要什么、语气如何;供下一段翻译参考",
"register": "正式/中性/口语(15字以内)",
"tone": "例如:紧张、戏谑、疲惫、克制(15字以内)",
"participants": ["本段实际说话或被点名的人物"],
"proposed_terms": [{{"en": "术语表里没有的专名", "{target.glossary_key}": "建议译名", "why": "简短理由"}}],
"risks": [{{"id": "段落id", "issue": "直译会错在哪里", "guidance": "译者应注意什么"}}]
}}
要求:risks 最多 4 条,只报真正会被直译毁掉的地方;没有就返回空数组。
proposed_terms 最多 5 条,不要重复术语表已有的词条。
英文原文:
{_paragraph_lines(paragraphs)}"""
def draft_prompt(
*,
chapter_title: str,
scene_index: int,
chunk_index: int,
chunks_in_scene: int,
paragraphs: list[dict],
analysis: dict,
previous_summary: str,
glossary: dict,
target: TargetLanguage,
) -> str:
speakers = {p.get("speaker") or "narrator" for p in paragraphs}
text = " ".join(p.get("text", "") for p in paragraphs)
analysis_text = json.dumps(analysis, ensure_ascii=False, indent=2)
return f"""书名:Deal Hunter(交易猎人)
本章:{chapter_title}
场景 {scene_index + 1},第 {chunk_index + 1}/{chunks_in_scene} 段
上一段场景摘要:{previous_summary or "(无,本章开头)"}
{glossary_block(glossary, speakers, text, target)}
{style_block(glossary)}
翻译前的场景分析(必须遵守其中的语气、人物关系和风险提示):
{analysis_text}
请把下面英文译为{target.prompt_name}。输出 JSON:
{{"paragraphs": [{{"id": "段落id", "{target.glossary_key}": "{target.chinese_name}译文"}}]}}
规则:
- 段落 id 必须与原文一一对应,数量和顺序不变。
- 每个 id 只输出一段译文;不要把两段合并成一个 id。
- **每个 id 的译文只能覆盖该 id 自己的英文。** 原文常在句子中间被切成多段
(例如一段是"The chair in the middle has",下一段是'"Captain"'),译文也必须
照这个切分来,哪怕某一段的译文读起来不完整。绝对不要把相邻段落的内容提前
写进前一个 id,也不要把本段的内容挪到下一个 id。宁可让某段译文是半句。
- 只输出译文。绝对不要把段落 id、说话人、类型、括号注释写进译文。
- 不要输出英文原文,不要输出解释。
- 不要把英文单词留在译文里(术语表允许保留的专名和书名除外);每个英文实词都必须译成{target.chinese_name}。
- 对话用{target.quote_open}{target.quote_close},并保证每个引号成对;不要把英文直引号 " 原样搬进译文。叙述中不要滥用引号。
- 如果原文有省略号、破折号、打断,保留其节奏;省略号用{target.ellipsis},破折号用{target.dash}。
- 专名严格按术语表;术语表没有的名字保留原文。
英文原文:
{_draft_paragraph_lines(paragraphs)}"""
def critic_prompt(
*,
chapter_title: str,
paragraphs: list[dict],
draft: dict[str, str],
glossary: dict,
target: TargetLanguage,
) -> str:
speakers = {p.get("speaker") or "narrator" for p in paragraphs}
text = " ".join(p.get("text", "") for p in paragraphs)
pairs = []
for p in paragraphs:
if p.get("type") == "scene_break":
continue
pairs.append(
{
"id": p["id"],
"speaker": p.get("speaker"),
"type": p.get("type"),
"en": p["text"],
target.glossary_key: draft.get(p["id"], ""),
}
)
return f"""书名:Deal Hunter(交易猎人)
本章:{chapter_title}
{glossary_block(glossary, speakers, text, target)}
请逐段对照英文与{target.chinese_name}译文,只报告会让读者理解错、或改变人物声音、或丢失/增加事实的问题。
输出 JSON:
{{
"verdict": "pass" 或 "revise",
"issues": [
{{
"id": "段落id",
"severity": "block|major|minor",
"type": "meaning|pronoun|irony|term|omission|addition|register|misalignment",
"problem": "{target.chinese_name}说明问题",
"source_excerpt": "英文片段(不超过30词)",
"target_excerpt": "{target.chinese_name}片段(不超过40字)",
"fix": "最小修正建议,不要重译整段"
}}
]
}}
严重度标准:
- block:事实被改变、整句遗漏或增加、说话人/指代错、关键术语错、段落错位。
- major:读者会明显理解错、反讽或人物声音被抹平、重要细节丢失。
- minor:细微语气或可选润色;不要为了凑数报风格偏好。
必须检查"段落 id 与译文是否对应"(类型 misalignment,severity 为 block):
原文在句子中间被切成多段时(相邻两段常常都不是完整句子),译文很容易跟着
自然语感重新分段,结果就是某一段的译文讲的是相邻那一段的内容。逐段确认:
这一段的译文讲的是这一段的英文吗?如果某段的译文其实对应前一段或后一段的
原文,报 misalignment,source_excerpt 取本段英文,target_excerpt 取错位的那句
译文。宁可多报,也不要让错位的译文通过审校。
id 必须使用输入中的段落 id(例如 ch-6-p0000),不要用句子序号。
键名必须与上面的 JSON 完全一致,不要自创 english_sentence_part 之类的字段。
没有问题时 issues 为空数组,verdict 为 pass。
对照材料:
{json.dumps(pairs, ensure_ascii=False, indent=2)}"""
def consistency_prompt(
*,
chapter_title: str,
paragraphs: list[dict],
draft: dict[str, str],
glossary: dict,
character_notes: str,
target: TargetLanguage,
) -> str:
speakers = {p.get("speaker") or "narrator" for p in paragraphs}
text = " ".join(p.get("text", "") for p in paragraphs)
pairs = [
{"id": p["id"], "speaker": p.get("speaker"), target.glossary_key: draft.get(p["id"], "")}
for p in paragraphs
if p.get("type") != "scene_break"
]
return f"""书名:Deal Hunter(交易猎人)
本章:{chapter_title}
{glossary_block(glossary, speakers, text, target)}
人物声音约束:
{character_notes}
请检查整章的译名、称谓、术语和人物声音是否一致。只报告需要统一的问题,不要重译。
输出 JSON:
{{"issues": [{{"ids": ["段落id"], "type": "name|term|register|voice", "problem": "...", "fix": "..."}}]}}
译文:
{json.dumps(pairs, ensure_ascii=False, indent=2)}"""