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
351 lines
13 KiB
Python
351 lines
13 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.
|
||
"""
|
||
|
||
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)}"""
|