v0.6-wip: apply_glossary \u5c06\u672f\u8bed\u6838\u67e5\u7ed3\u679c\u56de\u5857\u5230\u6b63\u6587

scripts/apply_glossary.py\uff08\u65b0\u589e\uff09\uff1a
- \u4ece glossary \u7684 issue \u5b57\u6bb5\u6293\u53d6\u201c\u539f\u672c\u9519\u8bef / \u6b63\u786e\u5199\u6cd5\u201d\u5bf9\uff0c\u76f4\u63a5\u5728 Markdown \u6b63\u6587\u4e2d\u505a\u5b57\u9762\u66ff\u6362
- \u4fdd\u5b88\u7b56\u7565\uff1a
  * \u82f1\u6587\u62fc\u5199\u9519\u8bef\uff08high conf\uff09\u76f4\u63a5\u6539
  * \u4e2d\u6587\u8bd1\u540d\u9519\u8bef\u4ec5\u5bf9\u201c\u4e13\u6709\u540d\u8bcd\u201d\uff08\u516c\u53f8/\u673a\u6784/\u4ea7\u54c1\uff09\u6539
  * \u901a\u7528\u7f29\u5199\uff08PDE/ASGPR/LNP \u7b49\uff09\u6709\u9ed1\u540d\u5355\u62e6\u622a\uff0c\u907f\u514d\u4e0a\u4e0b\u6587\u6b67\u4e49
- dry-run \u6a21\u5f0f\u9884\u89c8
- \u5e42\u7b49

\u5728\u53cc\u9776\u70b9 RNAi \u9879\u76ee\u7684\u6210\u679c\uff1a
- build_glossary \u6210\u529f\u6838\u67e5 201/310 \u672f\u8bed\uff0c\u53d1\u73b0\u4e09\u6761\u4e25\u91cd\u9519\u8bef\uff1a
  * Maywavee \u2192 Mabwell\uff08\u8fc8\u5a01\u751f\u7269\uff09- \u82f1\u6587\u62fc\u5199\u9519\u8bef
  * Beyotime \u2192 '\u7891\u4e91\u5929' \u4e3a\u9519\u8bef\u8bd1\u540d\uff0c\u5e94\u4e3a '\u5fc5\u8d1d\u7279\u533b\u836f'
  * Aurigene \u2192 '\u5929\u6d25\u5965\u5229\u6cd5' \u5e94\u4e3a '\u5929\u6d25\u5965\u745e\u82bc\u751f\u7269\u533b\u836f\u6709\u9650\u516c\u53f8'
- apply_glossary \u5e72\u51c0\u4fee\u6b63 3 \u5904\uff0c\u6b63\u5728\u518d\u6b21\u751f\u6210 PDF + DOCX
- \u6210\u672c\uff1a0.1 \u7f8e\u5143\uff08Haiku + Exa\uff09

\u5df2\u77e5\u9650\u5236\uff1a
- Exa/\u4ee3\u7406\u7ec4\u5408\u5728 >6 \u5e76\u53d1\u4e0b\u4f1a\u51fa\u73b0 SSL EOF \u9519\u8bef\uff0c\u5931\u8d25 106 \u6761\u3002\u53ef\u91cd\u8dd1\u6216\u964d\u5230 3 workers\u3002
- \u672a\u6765\u53ef\u5c06 build_glossary \u524d\u79fb\u5230 Phase 2 \u65f6\u8fd0\u884c\uff0c\u6b63\u6587\u751f\u6210\u524d\u5c31\u62e6\u4f4f\u4fe1\u6e90\u4fa7\u9519\u8bef

Co-authored-by: User <human>
This commit is contained in:
kai
2026-04-22 13:22:00 +08:00
co-authored by User <human>
parent d3fde1cbb8
commit 7f1bcc69da
4 changed files with 2371 additions and 100 deletions
+260
View File
@@ -0,0 +1,260 @@
#!/usr/bin/env python3
"""根据 glossary 的核查结果,在 final_zh_polished.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_polished.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())