Files
deep_research/scripts/translate.py
T

360 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.
#!/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. 并发翻译使用稳定术语表快照,译完后统一合并 glossary patch,避免多线程写冲突
3. 失败不会污染最终产物:块级文件独立,可重跑;汇总步骤独立
4. 日志完整:每次 API 调用写 `phase4/logs/translate.jsonl`
"""
from __future__ import annotations
import argparse
import concurrent.futures
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,
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 _glossary_value(value) -> str:
if isinstance(value, dict):
return str(value.get("zh") or value.get("中文") or "")
return str(value)
def build_user_prompt(block: MarkdownBlock, glossary: dict[str, str]) -> str:
glossary_lines = (
f"{en} || {_glossary_value(zh)}"
for en, zh in sorted(glossary.items())
if _glossary_value(zh)
)
glossary_hint = "\n".join(glossary_lines) or "(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 个未缓存的块(调试用)",
)
parser.add_argument(
"--workers",
type=int,
default=4,
help="并发翻译 worker 数(默认 4;设为 1 回退串行)",
)
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")
workers = max(1, args.workers)
print(f"Model: {args.model} | temperature: {args.temperature} | workers: {workers}")
print()
start = time.time()
todo: list[MarkdownBlock] = []
cached_count = 0
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:
cached_count += 1
print(f" [ok ] #{b.order:03d} {b.short_title} (cached)")
continue
if args.limit is not None and len(todo) >= args.limit:
continue
todo.append(b)
if todo:
print(f"To translate this run: {len(todo)} blocks | cached: {cached_count}")
print()
successful_patches: list[dict[str, str]] = []
with ZenMuxClient(log_file=log_file) as client:
def run_one(b: MarkdownBlock) -> tuple[MarkdownBlock, str, dict[str, str], float]:
chunk_path = chunks_dir / f"{b.order:03d}-{b.anchor}.md"
t0 = time.time()
translation, patch = translate_block(
client,
b,
glossary,
model=args.model,
system_prompt=system_prompt,
temperature=args.temperature,
)
chunk_path.write_text(translation + "\n", encoding="utf-8")
return b, translation, patch, time.time() - t0
if workers == 1:
for b in todo:
label = f"#{b.order:03d} L{b.level} {b.word_count:>4}w {b.short_title}"
print(f" [... ] {label} ", end="", flush=True)
try:
_block, translation, patch, elapsed = run_one(b)
except (ZenMuxError, RuntimeError) as e:
print(f"\n [FAIL] {label}\n {e}")
continue
successful_patches.append(patch)
cn = count_chinese_chars(translation)
print(f"\r [done] {label}{cn:>4}字 ({elapsed:4.1f}s, +{len(patch)} terms)")
else:
with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as pool:
futures = {pool.submit(run_one, b): b for b in todo}
for fut in concurrent.futures.as_completed(futures):
b = futures[fut]
label = f"#{b.order:03d} L{b.level} {b.word_count:>4}w {b.short_title}"
try:
_block, translation, patch, elapsed = fut.result()
except (ZenMuxError, RuntimeError) as e:
print(f" [FAIL] {label}\n {e}")
continue
successful_patches.append(patch)
cn = count_chinese_chars(translation)
print(f" [done] {label}{cn:>4}字 ({elapsed:4.1f}s, +{len(patch)} terms)")
glossary_conflicts: list[tuple[str, str, str]] = []
for patch in successful_patches:
for k, v in patch.items():
if k not in glossary:
glossary[k] = v
elif _glossary_value(glossary[k]) != v:
glossary_conflicts.append((k, _glossary_value(glossary[k]), v))
if successful_patches:
save_glossary(glossary_path, glossary)
if glossary_conflicts:
print(f" [WARN] glossary patch 冲突 {len(glossary_conflicts)} 条,保留既有译法")
# 汇总:按 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())