v0.9: parallelize phase4 and add model/search playbooks

This commit is contained in:
kai
2026-04-24 10:19:48 +08:00
parent c444007f04
commit c93fce5078
13 changed files with 626 additions and 100 deletions
+90 -41
View File
@@ -6,12 +6,12 @@
# 或:
uv run python scripts/translate.py projects/dual-target-rnai-pipeline-2026
断点续传:每块翻译完立即写入 `phase4/zh_chunks/<anchor>.md`术语表 patch。
断点续传:每块翻译完立即写入 `phase4/zh_chunks/<anchor>.md`术语表 patch 在本轮结束后统一合并
重跑时已存在的块直接跳过,只译缺的。
设计要点:
1. 切块按 H2 粒度,单块一般 <600 英文词,单次 API 调用远低于 Sonnet output token 上限
2. 术语表累积式更新:每块调用传入当前已知术语,译完回写 patch,保证全文一致
2. 并发翻译使用稳定术语表快照,译完后统一合并 glossary patch,避免多线程写冲突
3. 失败不会污染最终产物:块级文件独立,可重跑;汇总步骤独立
4. 日志完整:每次 API 调用写 `phase4/logs/translate.jsonl`
"""
@@ -19,6 +19,7 @@
from __future__ import annotations
import argparse
import concurrent.futures
import json
import sys
import time
@@ -29,7 +30,6 @@ 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
@@ -112,12 +112,19 @@ def save_glossary(path: Path, glossary: dict[str, str]) -> None:
)
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_hint = (
"\n".join(f"{en} || {zh}" for en, zh in sorted(glossary.items()))
if glossary
else "(none yet)"
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)"
)
@@ -195,6 +202,12 @@ def main() -> int:
default=None,
help="最多翻译前 N 个未缓存的块(调试用)",
)
parser.add_argument(
"--workers",
type=int,
default=4,
help="并发翻译 worker 数(默认 4;设为 1 回退串行)",
)
args = parser.parse_args()
load_secrets()
@@ -223,47 +236,83 @@ def main() -> int:
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}")
workers = max(1, args.workers)
print(f"Model: {args.model} | temperature: {args.temperature} | workers: {workers}")
print()
start = time.time()
translated_this_run = 0
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:
for b in blocks:
def run_one(b: MarkdownBlock) -> tuple[MarkdownBlock, str, dict[str, str], float]:
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
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")
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)")
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] = []