#!/usr/bin/env python3 """Phase 4 英译中:章节级切块 → 逐块翻译 → 拼接 → 写盘。 用法: uv run python scripts/translate.py # 或: uv run python scripts/translate.py projects/dual-target-rnai-pipeline-2026 断点续传:每块翻译完立即写入 `phase4/zh_chunks/.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]]: """解析自定义分隔符格式的响应。 期望结构: <<>> ...markdown... <<>> <<>> English || 中文 ... <<>> Returns: (translation, glossary_patch) """ t_start = text.find("<<>>") t_end = text.find("<<>>") if t_start == -1 or t_end == -1 or t_end <= t_start: raise ValueError( f"missing <<>> markers in response: {text[:300]}" ) translation = text[t_start + len("<<>>"): t_end].strip("\r\n") g_start = text.find("<<>>") g_end = text.find("<<>>") patch: dict[str, str] = {} if g_start != -1 and g_end != -1 and g_end > g_start: body = text[g_start + len("<<>>"): 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())