Files
UmbraTrans/tools/translation/prompts.py
umbra audio 3e0568b9d1 Generate backend endpoints for language-project workflow
Add REST endpoints and frontend wiring for per-language project actions:
- POST /api/workspace/{project}/translation/{lang}/queue — queue translation
- POST /api/workspace/{project}/audio/{lang}/queue — queue audio generation
- GET/POST /api/workspace/{project}/format/{lang} — format metadata (EPUB/PDF)
- GET /api/workspace/{project}/review/{lang}/chapters/{chapter} — utterance review
- GET/POST /api/workspace/{project}/front-matter/{lang} — front matter editor
- GET /api/workspace/{project}/chapters/{lang} — chapter list
- GET /api/workspace/{project}/chapter-content/{lang}/{chapter} — chapter text

Frontend fixes:
- Review page shows per-language text (English source, Chinese translation, etc.)
- Non-translated languages show 'Queue translation' button only
- Speaker data from English source matched by paragraph ID
- UmbraAudio column layout: Speaker, Variation, Text, Actions
- Chapter tree in left sidebar: Front matter, Back matter, Body
- Audio Format label at top of sidebar
- Pandoc formatting toolbar for front matter text editing
2026-10-25 11:04:53 -07:00

351 lines
13 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.
"""
from __future__ import annotations
import json
from pathlib import Path
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",
]
def load_glossary(path: Path) -> dict:
return json.loads(path.read_text(encoding="utf-8"))
def _pick(mapping: dict, keys: list[str]) -> list[tuple[str, str]]:
out: list[tuple[str, str]] = []
for key in keys:
if key in mapping:
value = mapping[key]
if isinstance(value, dict):
zh = value.get("zh") or value.get("short") or ""
note = value.get("note")
if note:
zh = f"{zh}({note})"
else:
zh = str(value)
if zh:
out.append((key, zh))
return out
def glossary_block(glossary: dict, speakers: set[str], text: str) -> 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)
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)
place_pairs = _pick(places, list(places))
title = glossary.get("title", {})
lines = ["【书名与专名规则】"]
if title:
en = title.get("en", "Deal Hunter")
sub = title.get("subtitle_zh", "")
lines.append(f"- 书名:{en};正文书名写作《{en}》" + (f",副标题可写“{sub}”" if sub else ""))
lines += [
"- 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 = 舰长",
"",
"【人物与称谓】",
]
lines += [f"- {en} = {zh}" for en, zh in char_pairs]
if place_pairs:
lines.append("【地名与舰名】")
lines += [f"- {en} = {zh}" for en, zh in place_pairs]
if term_pairs:
lines.append("【术语】")
lines += [f"- {en} = {zh}" for en, zh 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))
SYSTEM_ANALYST = """你是一位双语文学编辑,负责在翻译前分析英文小说的场景。
你的任务不是翻译,而是读出这一段文字的意图、语气、人物关系和可能被直译毁掉的地方。
只输出 JSON,不要解释,不要 Markdown 代码围栏。"""
SYSTEM_DRAFTER = """你是一位资深文学译者,把英文太空冒险小说译成简体中文。
你的目标不是逐字对应,而是让中文读者读到和英文读者相同的场景、语气、人物关系和潜台词。
可以重组句子、合并或拆分分句,但不得增删事实、不得替作者解释、不得加译注。
对话要像中文人物在说话;叙述要流畅、克制、有画面感。
严格使用给出的术语表。未列入术语表的人名、地名、舰名保留英文原文,不要自行音译。
不要补全原文没有出现的全名、头衔或身份;原文说“Princess Kainda”,不要写成“凯因达·沃尔特斯·福斯特·墨菲公主”。
Deal Hunter 作为船名、AI 名或书名保留英文;Hunter 作为简称保留英文;Umbra Audio 保留英文。
只输出 JSON,不要解释,不要 Markdown 代码围栏。"""
SYSTEM_CRITIC = """你是一位严格的双语审校。对照英文原文和中文译文,只找意义、指代、语气、术语和遗漏方面的问题。
不要因为“风格不同”而报错;只有当读者会因此理解错、或人物声音被抹平、或事实被改变时才报。
不要改写全文,只报告问题并给出最小修正建议。
只输出 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,
) -> 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)}
{style_block(glossary)}
请分析下面这段英文。输出 JSON:
{{
"summary_zh": "1-2句中文(80字以内),概括本段发生了什么、人物想要什么、语气如何;供下一段翻译参考",
"register": "正式/中性/口语(15字以内)",
"tone": "例如:紧张、戏谑、疲惫、克制(15字以内)",
"participants": ["本段实际说话或被点名的人物"],
"proposed_terms": [{{"en": "术语表里没有的专名", "zh": "建议译名", "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,
) -> 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)}
{style_block(glossary)}
翻译前的场景分析(必须遵守其中的语气、人物关系和风险提示):
{analysis_text}
请把下面英文译为简体中文。输出 JSON:
{{"paragraphs": [{{"id": "段落id", "zh": "中文译文"}}]}}
规则:
- 段落 id 必须与原文一一对应,数量和顺序不变。
- 每个 id 只输出一段中文;不要把两段合并成一个 id。
- 只输出译文。绝对不要把段落 id、说话人、类型、括号注释写进译文。
- 不要输出英文原文,不要输出解释。
- 不要把英文单词留在译文里(术语表允许保留的专名和书名除外);每个英文实词都必须译成中文。
- 对话用中文“”,并保证每个引号成对;不要把英文直引号 " 原样搬进译文。叙述中不要滥用引号。
- 如果原文有省略号、破折号、打断,保留其节奏;省略号用……,破折号用——。
- 专名严格按术语表;术语表没有的名字保留英文。
- 指恒星系时,system / star system 译作“星系”(如 Albert system = 阿尔伯特星系;236 systems = 236 个星系)。
- 指船载、音频、计算机等设备时,system 译作“系统”(如 ship systems = 飞船系统;Umbra Audio system = Umbra Audio 系统)。不要一律译作“星系”。
英文原文:
{_draft_paragraph_lines(paragraphs)}"""
def critic_prompt(
*,
chapter_title: str,
paragraphs: list[dict],
draft: dict[str, str],
glossary: dict,
) -> 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"],
"zh": draft.get(p["id"], ""),
}
)
return f"""书名:Deal Hunter(交易猎人)
本章:{chapter_title}
{glossary_block(glossary, speakers, text)}
请逐段对照英文与中文,只报告会让读者理解错、或改变人物声音、或丢失/增加事实的问题。
注意:原文 “236 systems in the Dial space sector” 中的 systems 指恒星系,译作“星系”是正确的,不要改成“系统”。ship systems / audio system 才译作“系统”。
输出 JSON:
{{
"verdict": "pass" 或 "revise",
"issues": [
{{
"id": "段落id",
"severity": "block|major|minor",
"type": "meaning|pronoun|irony|term|omission|addition|register",
"problem": "中文说明问题",
"source_excerpt": "英文片段(不超过30词)",
"target_excerpt": "中文片段(不超过40字)",
"fix": "最小修正建议,不要重译整段"
}}
]
}}
严重度标准:
- block:事实被改变、整句遗漏或增加、说话人/指代错、关键术语错。
- major:读者会明显理解错、反讽或人物声音被抹平、重要细节丢失。
- minor:细微语气或可选润色;不要为了凑数报风格偏好。
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,
) -> 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"), "zh": 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)}
人物声音约束:
{character_notes}
请检查整章的译名、称谓、术语和人物声音是否一致。只报告需要统一的问题,不要重译。
输出 JSON:
{{"issues": [{{"ids": ["段落id"], "type": "name|term|register|voice", "problem": "...", "fix": "..."}}]}}
译文:
{json.dumps(pairs, ensure_ascii=False, indent=2)}"""