261 lines
9.4 KiB
Python
261 lines
9.4 KiB
Python
#!/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 <project_slug>
|
||
uv run python scripts/apply_glossary.py <project_slug> --dry-run
|
||
uv run python scripts/apply_glossary.py <project_slug> --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())
|