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.
459 lines
18 KiB
Python
459 lines
18 KiB
Python
# 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)}"""
|