#!/usr/bin/env python3 """根据 glossary 的核查结果,在 final_zh.md 上做精确的文本替换。 原理: - build_glossary.py 会给每条术语标 `zh`(正确中文)和 `issue`(有发现问题) - 本脚本扫描所有 `issue` 非空且 `confidence in {high, medium}` 的条目 - 对这些条目,在正文中把「当前错误译名」替换为「正确译名」 - 同时处理英文拼写错误(例如 Maywavee → Mabwell) 规则: - 安全第一:只做字面替换,不做上下文改写 - 明确可见:每一处替换都打印出来,方便 diff - 幂等:多次跑结果一致 - 支持 --dry-run 预览 用法: uv run python scripts/apply_glossary.py uv run python scripts/apply_glossary.py --dry-run uv run python scripts/apply_glossary.py --min-confidence high """ from __future__ import annotations import argparse import json import re import sys from dataclasses import dataclass from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) @dataclass class Correction: """一条要应用的修正。""" kind: str # "en" (英文拼写) / "zh" (中文译名) wrong: str # 当前文中的错误写法 correct: str # 正确写法 term_key: str # glossary 里这条的 key(人工 debug 用) confidence: str # high / medium / low reason: str # 为什么要改(从 issue 字段提取) _ISSUE_WRONG_EN_RE = re.compile( r"(?:'|\"|term\s*)([A-Z][A-Za-z0-9_\- ]{2,40})['\"]*\s*(?:为|是)[^。]*?" r"(?:拼写错误|拼写有误|应为|正确英文名为|正确为|正确拼写为)", ) _CORRECT_EN_RE = re.compile( r"(?:正确英文名为|正确为|正确拼写为|应为|正确写法为)\s*['\"]?([A-Z][A-Za-z0-9_\- ]{2,40})['\"]?", ) _WRONG_ZH_RE = re.compile( r"current_zh\s*['\"]?([\u4e00-\u9fff][^'\"。,;]{1,40})['\"]?\s*(?:为|是|存在)[^。]*?(?:错误|错译|误译|不准确|应为|应该)", ) def resolve_project(arg: str) -> Path: p = Path(arg) if p.is_dir(): return p cand = Path.cwd() / "projects" / arg if cand.is_dir(): return cand raise SystemExit(f"project not found: {arg}") def load_glossary(path: Path) -> dict: return json.loads(path.read_text(encoding="utf-8")) # 中文替换的保守性:只对"公司名、机构名、产品名"类这种具体化的词做替换 # 规避常见缩写词在不同上下文有不同含义的情况(如 PDE = 磷酸二酯酶 OR 允许日暴露量) _AMBIGUOUS_ABBREVS = { "PDE", "ASGPR", "BLA", "IND", "NDA", "CMC", "API", "QC", "QA", "ADC", "CRO", "CDMO", "CMO", "GMP", "PK", "PD", "TRL", "FDA", "EMA", "NMPA", "ICH", "WHO", "CFDA", "CDE", "LNP", "RNP", "AAV", "mRNA", "RNAi", "siRNA", "ASO", "DNA", "RNA", "cDNA", "dsRNA", "ssRNA", "OTP", "HCV", "HBV", "HPV", "HIV", } def _looks_like_proper_noun(term_key: str) -> bool: """判断这条术语是否是"专有名词"(公司/机构/产品/药物名)。""" # 全大写 2-4 字符缩写 → 视作通用缩写,跳过(歧义风险高) if term_key.upper() == term_key and 2 <= len(term_key) <= 5: return False if term_key in _AMBIGUOUS_ABBREVS: return False # 其他情况:视作专有名词 return True def extract_corrections(glossary: dict, min_conf: str) -> list[Correction]: """从 glossary 抽出可应用的修正。 保守策略: - 英文拼写错误(term_key 本身错):高信度直接修 - 中文译名错误(issue 里说 current_zh 错):仅对"专有名词"类(公司/产品/机构)修 - 缩写/通用术语(PDE/ASGPR 等):不自动修,避免上下文歧义 """ rank = {"high": 3, "medium": 2, "low": 1} threshold = rank.get(min_conf, 2) corrections: list[Correction] = [] for term_key, entry in glossary.items(): if not isinstance(entry, dict): continue conf = entry.get("confidence", "low") if rank.get(conf, 0) < threshold: continue issue = entry.get("issue", "") zh = entry.get("zh", "") en_full = entry.get("en_full", "") if not issue: continue # 1. 英文拼写错误:key 本身错了,en_full 是对的 if ( en_full and term_key != en_full and re.search(r"拼写错误|拼写有误|spelled|should be", issue, re.IGNORECASE) and term_key[0].isupper() # 通常是公司/产品名 ): # 排除太短的(容易误伤)或包含空格的原始 key if len(term_key) >= 4 and len(en_full) >= 4: corrections.append( Correction( kind="en", wrong=term_key, correct=en_full.split("(")[0].strip(), # 去掉括号内的法人全称 term_key=term_key, confidence=conf, reason=issue[:200], ) ) # 2. 中文译名错误:只对"专有名词"修(排除缩写/通用术语的歧义风险) if not _looks_like_proper_noun(term_key): continue m = _WRONG_ZH_RE.search(issue) if m and zh: wrong_zh = m.group(1).strip() if wrong_zh != zh and len(wrong_zh) >= 2: corrections.append( Correction( kind="zh", wrong=wrong_zh, correct=zh, term_key=term_key, confidence=conf, reason=issue[:200], ) ) return corrections def apply_corrections(text: str, corrections: list[Correction]) -> tuple[str, list[tuple[Correction, int]]]: """返回 (新文本, [(correction, 替换次数)])。""" results: list[tuple[Correction, int]] = [] new_text = text for c in corrections: # 英文术语用 \b 边界;中文直接替换 if c.kind == "en": pattern = r"\b" + re.escape(c.wrong) + r"\b" new_text, count = re.subn(pattern, c.correct, new_text) else: count = new_text.count(c.wrong) if count: new_text = new_text.replace(c.wrong, c.correct) results.append((c, count)) return new_text, results def main() -> int: parser = argparse.ArgumentParser(description="用 glossary 修正正文术语") parser.add_argument("project", help="项目 slug 或路径") parser.add_argument( "--input", default="phase4/final_zh.md", help="待修正的 Markdown", ) parser.add_argument( "--output", default=None, help="输出路径(默认原地覆盖)", ) parser.add_argument( "--glossary", default="phase4/glossary.json", help="glossary.json 路径", ) parser.add_argument( "--min-confidence", choices=["high", "medium", "low"], default="medium", help="只应用 >= 此置信度的修正(默认 medium)", ) parser.add_argument( "--dry-run", action="store_true", help="只打印会发生的修改,不实际写入", ) args = parser.parse_args() project_root = resolve_project(args.project) input_path = project_root / args.input output_path = project_root / (args.output or args.input) glossary_path = project_root / args.glossary if not input_path.exists(): raise SystemExit(f"输入文件不存在:{input_path}") if not glossary_path.exists(): raise SystemExit(f"glossary 不存在:{glossary_path}") glossary = load_glossary(glossary_path) corrections = extract_corrections(glossary, args.min_confidence) print(f"Project: {project_root.name}") print(f"Input: {input_path.relative_to(project_root)}") print(f"Output: {output_path.relative_to(project_root)}") print(f"Glossary: {glossary_path.relative_to(project_root)} ({len(glossary)} terms)") print(f"Min confidence: {args.min_confidence}") print(f"候选修正: {len(corrections)} 条") print() if not corrections: print("没有需要应用的修正。") return 0 original = input_path.read_text(encoding="utf-8") new_text, results = apply_corrections(original, corrections) applied = [(c, n) for c, n in results if n > 0] skipped = [(c, n) for c, n in results if n == 0] print(f"=== 已应用 {len(applied)} 条修正(共替换 {sum(n for _, n in applied)} 处)===") for c, n in sorted(applied, key=lambda x: -x[1]): marker = "EN" if c.kind == "en" else "ZH" print(f" [{marker}][{c.confidence}] {c.wrong!r} → {c.correct!r} (×{n})") print(f" 原因: {c.reason[:120]}") if skipped: print(f"\n=== 跳过 {len(skipped)} 条(正文未出现该错误写法)===") for c, _ in skipped[:10]: marker = "EN" if c.kind == "en" else "ZH" print(f" [{marker}] {c.wrong!r} → {c.correct!r} (0 hit)") if len(skipped) > 10: print(f" …还有 {len(skipped) - 10} 条") if args.dry_run: print("\n(--dry-run:未写入)") return 0 output_path.write_text(new_text, encoding="utf-8") delta = len(new_text) - len(original) print(f"\n✓ 已写入 {output_path.relative_to(project_root)} (字符变化 {delta:+d})") return 0 if __name__ == "__main__": sys.exit(main())