Files
deep_research/scripts/translate.py
T
kaiandUser <human> 1b47b50d3c v0.6-wip: Python-based Phase 4 translation pipeline
架构变更:把 dr-translator 从 opencode agent 降级为 Python 脚本编排下的 LLM
调用。根本原因是 agent 一次性处理 19k 英文词整文,单次 output token 接近
Sonnet 4.6 上限(~32k),多次重跑都卡在同一个坑里——问题是架构本身,不是
prompt。

新架构:

scripts/lib/zenmux_client.py     HTTP 客户端,指数退避重试、token 统计
                                  JSONL 日志、secrets.env 自动加载
scripts/lib/markdown_chunker.py   按 H1/H2 切块,稳定 anchor ID(order+title
                                  sha1),支持合并/统计
scripts/prompts/translate_system.txt  英译中 prompt,用自定义 <<<TRANSLATION>>>
                                       分隔符格式(规避 Markdown-in-JSON 问题)
scripts/prompts/polish_system.txt     中文润色 prompt(留给下一步 polish.py)
scripts/translate.py              主入口:章节级切块 → 逐块翻译 → 拼接

关键设计:
- 0 依赖 LLM 遵从性:Python 控制切块/循环/重试,LLM 只做单块翻译
- 断点续传:每块翻译完立即写 phase4/zh_chunks/<order>-<anchor>.md
- 术语表累积:每块的 glossary_patch 合并回 phase4/glossary.json
- 失败隔离:单块失败不影响其他块,重跑只补缺
- 调试友好:--only N,M / --limit K / --force

实测(dual-target-rnai-pipeline-2026):
- 63 块全部成功,17 分钟,$1.70
- 33,441 中文字(符合"研究类 ≥30,000 字"硬标准)
- 310 条双语术语
- 翻译质量:接近母语咨询分析师写作

下一步:polish.py(按 H2 section 润色)、merge_chapters.py(从 phase2/drafts
合并生成 final_en.md)、重构 dr-editor-in-chief 调度脚本、更新 /dr-finalize。

Co-authored-by: User <human>
2026-04-22 10:43:43 +08:00

311 lines
11 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.
#!/usr/bin/env python3
"""Phase 4 英译中:章节级切块 → 逐块翻译 → 拼接 → 写盘。
用法:
uv run python scripts/translate.py <project_slug>
# 或:
uv run python scripts/translate.py projects/dual-target-rnai-pipeline-2026
断点续传:每块翻译完立即写入 `phase4/zh_chunks/<anchor>.md` 和术语表 patch。
重跑时已存在的块直接跳过,只译缺的。
设计要点:
1. 切块按 H2 粒度,单块一般 <600 英文词,单次 API 调用远低于 Sonnet output token 上限
2. 术语表累积式更新:每块调用传入当前已知术语,译完回写 patch,保证全文一致
3. 失败不会污染最终产物:块级文件独立,可重跑;汇总步骤独立
4. 日志完整:每次 API 调用写 `phase4/logs/translate.jsonl`
"""
from __future__ import annotations
import argparse
import json
import sys
import time
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from scripts.lib.markdown_chunker import (
MarkdownBlock,
count_chinese_chars,
merge_blocks,
split_by_headers,
)
from scripts.lib.zenmux_client import ZenMuxClient, ZenMuxError, load_secrets
DEFAULT_MODEL = "anthropic/claude-sonnet-4.6"
MODEL_MAX_TOKENS = {
"anthropic/claude-sonnet-4.6": 32000,
"anthropic/claude-sonnet-4.5": 32000,
"anthropic/claude-opus-4.7": 32000,
"anthropic/claude-opus-4.6": 32000,
"anthropic/claude-haiku-4.5": 16000,
}
PROMPT_FILE = Path(__file__).parent / "prompts" / "translate_system.txt"
def resolve_project(arg: str) -> Path:
p = Path(arg)
if p.is_dir():
return p
here = Path.cwd()
cand = here / "projects" / arg
if cand.is_dir():
return cand
raise SystemExit(f"project not found: {arg}")
def parse_delimited_response(text: str) -> tuple[str, dict[str, str]]:
"""解析自定义分隔符格式的响应。
期望结构:
<<<TRANSLATION>>>
...markdown...
<<<END_TRANSLATION>>>
<<<GLOSSARY_PATCH>>>
English || 中文
...
<<<END_GLOSSARY_PATCH>>>
Returns:
(translation, glossary_patch)
"""
t_start = text.find("<<<TRANSLATION>>>")
t_end = text.find("<<<END_TRANSLATION>>>")
if t_start == -1 or t_end == -1 or t_end <= t_start:
raise ValueError(
f"missing <<<TRANSLATION>>> markers in response: {text[:300]}"
)
translation = text[t_start + len("<<<TRANSLATION>>>"): t_end].strip("\r\n")
g_start = text.find("<<<GLOSSARY_PATCH>>>")
g_end = text.find("<<<END_GLOSSARY_PATCH>>>")
patch: dict[str, str] = {}
if g_start != -1 and g_end != -1 and g_end > g_start:
body = text[g_start + len("<<<GLOSSARY_PATCH>>>"): g_end]
for line in body.splitlines():
line = line.strip()
if not line or "||" not in line:
continue
en, _, zh = line.partition("||")
en, zh = en.strip(), zh.strip()
if en and zh:
patch[en] = zh
return translation, patch
def load_glossary(path: Path) -> dict[str, str]:
if not path.exists():
return {}
try:
return json.loads(path.read_text(encoding="utf-8"))
except Exception:
return {}
def save_glossary(path: Path, glossary: dict[str, str]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(
json.dumps(glossary, ensure_ascii=False, indent=2, sort_keys=True) + "\n",
encoding="utf-8",
)
def build_user_prompt(block: MarkdownBlock, glossary: dict[str, str]) -> str:
glossary_hint = (
"\n".join(f"{en} || {zh}" for en, zh in sorted(glossary.items()))
if glossary
else "(none yet)"
)
level_hint = (
f"H{block.level}" if block.level >= 1 else "frontmatter (no heading)"
)
return (
"# GLOSSARY (English || Chinese, already used earlier in the document):\n"
f"{glossary_hint}\n\n"
f"# BLOCK TO TRANSLATE (Markdown, {level_hint}):\n"
"Translate the block between the markers below into Chinese, "
"following all rules in the system prompt. Output using the exact "
"delimiter format specified.\n\n"
"----- BEGIN BLOCK -----\n"
f"{block.content}\n"
"----- END BLOCK -----\n"
)
def translate_block(
client: ZenMuxClient,
block: MarkdownBlock,
glossary: dict[str, str],
*,
model: str,
system_prompt: str,
temperature: float,
) -> tuple[str, dict[str, str]]:
user = build_user_prompt(block, glossary)
max_tok = MODEL_MAX_TOKENS.get(model, 16000)
raw = client.chat_complete(
model=model,
system=system_prompt,
user=user,
temperature=temperature,
max_tokens=max_tok,
tag=f"translate:{block.anchor}",
)
try:
translation, patch = parse_delimited_response(raw)
except Exception as e:
raise RuntimeError(
f"bad response format for block {block.anchor}: {e}\nraw head: {raw[:300]}"
)
if not translation.strip():
raise RuntimeError(f"empty translation for block {block.anchor}")
return translation.rstrip(), patch
def main() -> int:
parser = argparse.ArgumentParser(description="Phase 4 英译中(章节级切块并行翻译)")
parser.add_argument("project", help="项目 slug 或完整路径")
parser.add_argument(
"--source",
default="phase4/final_en.md",
help="英文源文件(相对项目根,默认 phase4/final_en.md",
)
parser.add_argument(
"--output",
default="phase4/final_zh.md",
help="中文输出(默认 phase4/final_zh.md",
)
parser.add_argument("--model", default=DEFAULT_MODEL, help="翻译模型")
parser.add_argument("--temperature", type=float, default=0.3)
parser.add_argument(
"--force",
action="store_true",
help="忽略已有 zh_chunks 缓存,强制重翻",
)
parser.add_argument(
"--only",
default=None,
help="只翻译指定 order(逗号分隔的整数),其余跳过(调试用),例如 --only 0,1,7",
)
parser.add_argument(
"--limit",
type=int,
default=None,
help="最多翻译前 N 个未缓存的块(调试用)",
)
args = parser.parse_args()
load_secrets()
project_root = resolve_project(args.project)
src_path = project_root / args.source
out_path = project_root / args.output
if not src_path.exists():
raise SystemExit(f"source not found: {src_path}")
chunks_dir = project_root / "phase4" / "zh_chunks"
chunks_dir.mkdir(parents=True, exist_ok=True)
glossary_path = project_root / "phase4" / "glossary.json"
logs_dir = project_root / "phase4" / "logs"
log_file = logs_dir / "translate.jsonl"
system_prompt = PROMPT_FILE.read_text(encoding="utf-8")
text = src_path.read_text(encoding="utf-8")
blocks = split_by_headers(text, max_level=2)
glossary = load_glossary(glossary_path)
only_orders: set[int] | None = None
if args.only:
only_orders = {int(a.strip()) for a in args.only.split(",") if a.strip()}
total_en_words = sum(b.word_count for b in blocks)
print(f"Source: {src_path.relative_to(project_root)}")
print(f"Blocks: {len(blocks)} | total English words: {total_en_words:,}")
print(f"Glossary loaded: {len(glossary)} terms")
print(f"Model: {args.model} | temperature: {args.temperature}")
print()
start = time.time()
translated_this_run = 0
with ZenMuxClient(log_file=log_file) as client:
for b in blocks:
chunk_path = chunks_dir / f"{b.order:03d}-{b.anchor}.md"
if only_orders is not None and b.order not in only_orders:
continue
if chunk_path.exists() and not args.force:
print(f" [ok ] #{b.order:03d} {b.short_title} (cached)")
continue
if args.limit is not None and translated_this_run >= args.limit:
continue
label = f"#{b.order:03d} L{b.level} {b.word_count:>4}w {b.short_title}"
print(f" [... ] {label} ", end="", flush=True)
t0 = time.time()
try:
translation, patch = translate_block(
client,
b,
glossary,
model=args.model,
system_prompt=system_prompt,
temperature=args.temperature,
)
except (ZenMuxError, RuntimeError) as e:
print(f"\n [FAIL] {label}\n {e}")
continue
elapsed = time.time() - t0
chunk_path.write_text(translation + "\n", encoding="utf-8")
if patch:
for k, v in patch.items():
glossary.setdefault(k, v)
save_glossary(glossary_path, glossary)
cn = count_chinese_chars(translation)
translated_this_run += 1
print(f"\r [done] {label}{cn:>4}字 ({elapsed:4.1f}s, +{len(patch)} terms)")
# 汇总:按 order 拼接所有 chunk
merged: list[str] = []
missing: list[str] = []
for b in blocks:
chunk_path = chunks_dir / f"{b.order:03d}-{b.anchor}.md"
if not chunk_path.exists():
missing.append(f"#{b.order:03d} {b.short_title}")
continue
merged.append(chunk_path.read_text(encoding="utf-8").rstrip())
partial = only_orders is not None or args.limit is not None
if missing:
print(f"\n⚠ 缺失 {len(missing)} 块:")
for m in missing[:10]:
print(f" - {m}")
if len(missing) > 10:
print(f" ... 还有 {len(missing) - 10} 块")
if partial:
print("partial 模式:--only / --limit 生效,未生成 final_zh.md")
else:
print("重新运行本脚本即可补译(已译的会跳过)。")
print(client.usage.summary())
return 1
out_path.parent.mkdir(parents=True, exist_ok=True)
out_path.write_text("\n\n".join(merged) + "\n", encoding="utf-8")
# 统计
final_text = out_path.read_text(encoding="utf-8")
cn = count_chinese_chars(final_text)
total_time = time.time() - start
print()
print(f"✓ 输出:{out_path.relative_to(project_root)}")
print(f" 中文字数: {cn:,}")
print(f" 英文词数源: {total_en_words:,} 膨胀率: {cn / max(total_en_words,1):.2f}×")
print(f" 耗时: {total_time:.1f}s")
print(f" 术语表: {len(glossary)} 条 → {glossary_path.relative_to(project_root)}")
print(client.usage.summary())
return 0
if __name__ == "__main__":
sys.exit(main())