docs: 归档翻译上下文/字幕审校方案的评审结论与实测数据

- 待办:会话式长上下文方案标记为已技术评审且不实施;补记问题 A 的阶段性结论(暂缓)
- 新增 docs/实验数据-字幕审校定位与修复实验.md:LLM 定位触发率/召回、合成正样本、逐条修复前后对照、成本与复现方式
- 新增 docs/实验数据-字幕重生成成本与耗时.md:单价口径、功率画像、全量推算
- decisions:补充两条决策(会话式长上下文否决、审校定位+定向修复暂缓)
- scripts:归档一次性探测脚本 probe_subtitle_review_stage0.py(含 rebuild 子步骤)
- AGENTS/README:补充实验与改动许可规则、文档索引
This commit is contained in:
2026-09-19 18:33:28 +08:00
parent 2c8bbb6469
commit 5b263171f2
7 changed files with 1187 additions and 0 deletions
+654
View File
@@ -0,0 +1,654 @@
"""字幕审校(问题定位 + 定向修复)可行性探测脚本(一次性实验,已归档)。
用途:在真实产物(库内已有的 `.JA.srt` / `.CN.srt`)上验证三件事——
① LLM 能否定位"译文有问题"的 cue(只给中文 / 中文+日文原文两档对照);
② 给足日文原文 + 邻句 + 系列词表后,定向修复能否把目标 cue 改对;
③ 合成正样本(把域词译文替换成中性词)的定位召回率。
另含 ④ 规则层(词表违背 / 近音匹配)对照,用于说明"硬规则泛化不了"
结论与实测数据见 docs/实验数据-字幕审校定位与修复实验.md;本脚本保留以便复现,
不参与生产流程,也没有对应测试(不是功能模块)。运行方式:
uv run python scripts/probe_subtitle_review_stage0.py --steps rules,locate,repair,synthetic
uv run python scripts/probe_subtitle_review_stage0.py --steps rebuild # 从 calls.jsonl 重算统计
产物默认写到 `data/experiments/review_stage0/out/`data/ 已 gitignore):
`calls.jsonl`(每次请求与响应原文,唯一的证据源)、`summary.json`、
`rebuilt_summary.json`、`glossary.json`、`rules.json`。
"""
from __future__ import annotations
import argparse
import json
import os
import random
import re
import sys
import time
import unicodedata
import urllib.error
import urllib.request
from collections import Counter
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
sys.path.insert(0, str(ROOT / "src"))
from dotenv import load_dotenv # noqa: E402
from nodes.proper_nouns import ( # noqa: E402
ADULT_EUPHEMISMS,
ONOMATOPOEIA,
PROPER_NOUNS,
)
from nodes.srt import parse_srt # noqa: E402
load_dotenv(ROOT / ".env")
# 决定性样本所在文件夹(日语 ASR + 中文译文均已由生产流程产出)。
DEFAULT_FOLDER = Path("/mnt/fnOS/123/kiwvr-886")
PARTS = (1, 2)
# 已记录的"语义错但字面通顺"样本(待办文档问题 A):part2 的 183/184/185。
TARGET_IDS = {2: [183, 184, 185], 1: []}
# 定位层每批条数(与生产 CHUNK_SIZE=20 同量级,便于成本外推)。
LOCATE_CHUNK = 30
# 修复窗口:目标 ±N 条。
REPAIR_WINDOW = 2
# 合成正样本条数。
SYNTHETIC_COUNT = 15
DEFAULT_OUT = ROOT / "data/experiments/review_stage0/out"
API_BASE = os.getenv("LLM_API_BASE", "https://api.siliconflow.cn/v1/chat/completions")
API_KEY = os.getenv("LLM_API_KEY", "")
MODEL = os.getenv("LLM_MODEL", "Qwen/Qwen3.5-35B-A3B")
TIMEOUT = float(os.getenv("LLM_TIMEOUT_SECONDS", "180"))
# 运行期全局:输出目录、素材文件夹、token 累计。
OUT = DEFAULT_OUT
FOLDER = DEFAULT_FOLDER
TOTAL_TOKENS = {"in": 0, "out": 0, "calls": 0, "failed": 0}
CALL_LOG = DEFAULT_OUT / "calls.jsonl"
def log(msg: str) -> None:
"""带时间戳打印进度(脚本长期后台运行,便于 tail 观察)。"""
print(f"[{time.strftime('%H:%M:%S')}] {msg}", flush=True)
# ---------------------------------------------------------------------------
# LLM 调用(与生产 nodes/llm.py 同构:enable_thinking=False、记录 usage
# ---------------------------------------------------------------------------
def call_llm(system: str, user: str, tag: str, max_tokens: int = 2048) -> str:
"""调用一次 OpenAI 兼容接口,返回 content;失败重试 3 次后抛异常。"""
body = {
"model": MODEL,
"messages": [
{"role": "system", "content": system},
{"role": "user", "content": user},
],
"enable_thinking": False,
"max_tokens": max_tokens,
}
headers = {"Content-Type": "application/json"}
if API_KEY:
headers["Authorization"] = f"Bearer {API_KEY}"
last_error = None
for attempt in range(3):
started = time.monotonic()
try:
request = urllib.request.Request(
API_BASE, data=json.dumps(body).encode("utf-8"),
headers=headers, method="POST",
)
with urllib.request.urlopen(request, timeout=TIMEOUT) as response:
payload = json.loads(response.read().decode("utf-8"))
content = payload["choices"][0]["message"]["content"]
usage = payload.get("usage") or {}
TOTAL_TOKENS["in"] += int(usage.get("prompt_tokens", 0) or 0)
TOTAL_TOKENS["out"] += int(usage.get("completion_tokens", 0) or 0)
TOTAL_TOKENS["calls"] += 1
with CALL_LOG.open("a", encoding="utf-8") as fh:
fh.write(json.dumps({
"tag": tag, "elapsed": round(time.monotonic() - started, 2),
"usage": usage, "system": system, "user": user,
"content": content,
}, ensure_ascii=False) + "\n")
return content
except (urllib.error.URLError, OSError, ValueError, KeyError) as exc:
last_error = exc
log(f" ! {tag}{attempt + 1} 次失败: {exc}")
time.sleep(2 + 2 * attempt)
TOTAL_TOKENS["failed"] += 1
raise RuntimeError(f"{tag} 调用失败: {last_error}")
def parse_json_loose(content: str):
"""从模型输出里抠出 JSON(容忍 ```json 围栏与前后解释文字)。"""
text = content.strip()
fenced = re.search(r"```(?:json)?\s*(.+?)```", text, re.S)
if fenced:
text = fenced.group(1).strip()
try:
return json.loads(text)
except json.JSONDecodeError:
pass
for opener, closer in (("[", "]"), ("{", "}")):
start, end = text.find(opener), text.rfind(closer)
if start != -1 and end > start:
try:
return json.loads(text[start:end + 1])
except json.JSONDecodeError:
continue
raise ValueError(f"无法解析 JSON: {text[:200]}")
# ---------------------------------------------------------------------------
# 数据与词表
# ---------------------------------------------------------------------------
def secs(timestamp: str) -> float:
"""SRT 时间戳 -> 秒。"""
hours, minutes, rest = timestamp.split(":")
seconds, millis = rest.split(",")
return int(hours) * 3600 + int(minutes) * 60 + int(seconds) + int(millis) / 1000
def load_pairs(part: int) -> list[dict]:
"""读取同一视频的 JA/CN 字幕,按位置配成 [{'id','start','ja','cn'}]。"""
ja = parse_srt((FOLDER / f"masex.tv@kiwvr00886_{part}_8k.JA.srt").read_text(encoding="utf-8"))
cn = parse_srt((FOLDER / f"masex.tv@kiwvr00886_{part}_8k.CN.srt").read_text(encoding="utf-8"))
assert len(ja) == len(cn), f"part{part} 条数不一致: {len(ja)} vs {len(cn)}"
return [
{"id": i, "start": a.start, "t": secs(a.start), "ja": a.text, "cn": b.text}
for i, (a, b) in enumerate(zip(ja, cn), 1)
]
def kana_norm(text: str) -> str:
"""片假名折成平假名并去掉标点,用于近音比较。"""
out = []
for ch in unicodedata.normalize("NFKC", text):
code = ord(ch)
# 片假名区(ァ-ヶ)平移到平假名区(ぁ-ゖ)。
if 0x30A1 <= code <= 0x30F6:
out.append(chr(code - 0x60))
elif ch in "、。!?…「」『』()()・,.:;!?  \n\t-—ー":
continue
else:
out.append(ch)
return "".join(out)
def levenshtein(a: str, b: str) -> int:
"""字符级编辑距离。"""
if not a:
return len(b)
if not b:
return len(a)
prev = list(range(len(b) + 1))
for i, ca in enumerate(a, 1):
cur = [i]
for j, cb in enumerate(b, 1):
cur.append(min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + (ca != cb)))
prev = cur
return prev[-1]
# 通用领域词(不依赖具体素材;用来从文件夹自己的字幕里挖出"本系列惯用译名")。
GENERIC_TERMS = [
"チンポ", "チンコ", "マンコ", "まんこ", "おまんこ", "おなほ", "オナホ",
"ちんちん", "バナナ", "マンゴー", "乳首", "金玉", "クリトリス", "おっぱい",
]
# 用于从中文译文里反推"本系列惯用译名"的中文候选词池。
CN_POOL = [
"小穴", "阴部", "肉棒", "鸡巴", "老二", "蛋蛋", "睾丸", "乳头",
"小鸡鸡", "鲍鱼", "下面", "妹妹", "蜜穴", "胸部", "阴蒂",
]
def near_miss(ja_text: str, term: str) -> list[dict]:
"""在 JA 里找与词条【同长度、编辑距离 ≤1】的窗口(近音听错候选)。
只用等长窗口 + 距离 ≤1:允许 ±1 长度与距离 ≤2 会让「まま」匹配上「マット」
这类毫无关系的片段,实测命中率高达 49%,无法作为判定依据。
"""
target = kana_norm(term)
ja = kana_norm(ja_text)
hits = []
size = len(target)
if size < 3 or size > len(ja):
return hits
for start in range(0, len(ja) - size + 1):
window = ja[start:start + size]
if window == target:
continue
distance = levenshtein(window, target)
if 0 < distance <= 1:
hits.append({"window": window, "distance": distance})
return hits
def build_glossary(parts: dict[int, list[dict]], min_ratio: float = 0.5) -> list[dict]:
"""从文件夹自己的字幕挖词表:候选日文词 + 由中文译文反推的惯用译名。
不调 LLM(成人语境词表容易被模型拒答),完全由库里已有产物推导;"惯用译名"
是这一系列实际用过的说法,正是文件夹级上下文要固定的东西。min_ratio 控制
映射可信度:只在含该词的 cue 里过半数出现的译名才被接受,否则留空
(低词频词的共现映射噪声很大,如 ビクビク 会被映射成"小穴")。
"""
all_cues = [cue for cues in parts.values() for cue in cues]
blob = "\n".join(cue["ja"] for cue in all_cues)
candidates = list(GENERIC_TERMS)
for table in (PROPER_NOUNS, ONOMATOPOEIA, ADULT_EUPHEMISMS):
candidates += list(table)
glossary = []
for term in dict.fromkeys(candidates):
count = blob.count(term)
if count == 0:
continue
votes: Counter = Counter()
for cue in all_cues:
if term in cue["ja"]:
for word in CN_POOL:
votes[word] += cue["cn"].count(word)
cn = ""
if votes:
word, votes_count = votes.most_common(1)[0]
if votes_count >= max(2 if min_ratio > 0 else 1, count * min_ratio):
cn = word
glossary.append({"term": term, "cn": cn, "count": count})
return glossary
# ---------------------------------------------------------------------------
# 定位层
# ---------------------------------------------------------------------------
LOCATE_SYSTEM = "你是成人向视频字幕的审校助手。只输出 JSON,不要解释。"
LOCATE_RULES_V1 = (
"下面是某系列成人视频某一片段的中文字幕(格式 `id|时间|译文`)。\n"
"请找出【译文有问题】的条目:\n"
"1. 译文与上下文明显不符、逻辑不通或自相矛盾;\n"
"2. 与前后条目的动作链/称呼不连贯;\n"
"3. 与本系列常用词表不一致(词表见下)。\n"
"注意:呻吟、短语气词、碎片句本身【不算】问题,不要把读得通的正常句列进来。\n"
"词表(日文→本系列惯用中文):{glossary}\n"
'只输出 JSON{{"flags":[{{"id":数字,"why":"一句话原因"}}]}},无问题输出 {{"flags":[]}}。\n\n'
"字幕:\n{lines}"
)
LOCATE_RULES_V2 = (
"下面是某系列成人视频某一片段的字幕(格式 `id|时间|日文原文|现有中文译文`)。\n"
"请对照日文原文找出【译文有问题】的条目:\n"
"1. 译文与日文原意不符(含近音听错导致的语义错);\n"
"2. 译文与上下文矛盾、称呼/术语前后不一致;\n"
"3. 与本系列常用词表不一致(词表见下)。\n"
"注意:呻吟、短语气词、碎片句本身【不算】问题;日文通顺但语义可疑的句子可以列入。\n"
"词表(日文→本系列惯用中文):{glossary}\n"
'只输出 JSON{{"flags":[{{"id":数字,"why":"一句话原因"}}]}},无问题输出 {{"flags":[]}}。\n\n'
"字幕:\n{lines}"
)
def glossary_table(glossary: list[dict], limit: int = 40) -> str:
"""词表渲染成提示词片段:带惯用译名的写映射,未固定的只给高频日文词。"""
items = []
for entry in glossary[:limit]:
if entry["cn"]:
items.append(f'{entry["term"]}{entry["cn"]}(本系列 {entry["count"]} 次)')
else:
items.append(f'{entry["term"]}(本系列高频 {entry["count"]} 次,译名未固定)')
return "".join(items)
def classify_rules(cue: dict, glossary: list[dict]) -> list[dict]:
"""规则层判定一条 cue:① 词表违背(字面命中但译文没体现)② 近音可疑。
两个规则都要求中文译文没体现该词的惯用译名,否则"本该出现却未出现"
失去约束力(实测只看近音匹配时命中率高达 49%)。
"""
findings = []
for entry in glossary:
term, cn = entry["term"], entry["cn"]
if not cn:
continue
if term in cue["ja"]:
if cn not in cue["cn"]:
findings.append({"kind": "词表违背", "term": term, "expected": cn})
continue
for hit in near_miss(cue["ja"], term):
if cn not in cue["cn"]:
findings.append({
"kind": "近音可疑", "term": term, "expected": cn,
"window": hit["window"], "distance": hit["distance"],
})
return findings
def locate(cues: list[dict], glossary: list[dict], variant: str, tag: str) -> dict:
"""按 LOCATE_CHUNK 分批做问题定位,返回 {id: why}。"""
table = glossary_table(glossary)
template = LOCATE_RULES_V1 if variant == "v1" else LOCATE_RULES_V2
flags: dict[int, str] = {}
chunks = [cues[i:i + LOCATE_CHUNK] for i in range(0, len(cues), LOCATE_CHUNK)]
for index, chunk in enumerate(chunks, 1):
lines = []
for cue in chunk:
if variant == "v1":
lines.append(f'{cue["id"]}|{cue["start"]}|{cue["cn"]}')
else:
lines.append(f'{cue["id"]}|{cue["start"]}|{cue["ja"]}|{cue["cn"]}')
prompt = template.format(glossary=table, lines="\n".join(lines))
try:
content = call_llm(LOCATE_SYSTEM, prompt, f"{tag}-chunk{index}")
payload = parse_json_loose(content)
except (ValueError, TypeError, RuntimeError) as exc:
log(f" ! {tag}{index} 批失败: {exc}")
continue
items = payload.get("flags") if isinstance(payload, dict) else payload
if not isinstance(items, list):
continue
for item in items:
if isinstance(item, dict) and isinstance(item.get("id"), int):
flags[item["id"]] = str(item.get("why", ""))[:120]
log(f" {tag}{index}/{len(chunks)} 批完成,累计命中 {len(flags)}")
time.sleep(0.3)
return flags
# ---------------------------------------------------------------------------
# 修复层
# ---------------------------------------------------------------------------
REPAIR_SYSTEM = "你是成人向视频字幕的译者与审校。只输出 JSON,不要解释。"
REPAIR_TEMPLATE = (
"这是某系列成人视频的一段字幕窗口(含日文原文与你此前的译文)。\n"
"审校意见:id={target} 的译文与语境不符,需要给出更合适的中文译文。\n"
"要求:\n"
"- 必须结合日文原文与上下文推断语义,不要照字面直译;\n"
"- 只改有问题的条目;为了让上下文连贯,可以顺带修正窗口内相邻条目,"
"但不得把本来正常的条目改坏;\n"
"- 条目数量、id 集合必须与输入完全一致,不得新增、删除或合并条目;\n"
"- 本系列常用词与惯用译名:{glossary}\n"
'只输出 JSON 数组:[{{"id":整数,"text":"译文"}}, ...],包含窗口内全部 id。\n\n'
"窗口:\n{lines}"
)
def repair(cues: list[dict], target_id: int, glossary: list[dict]) -> dict[int, str]:
"""对目标 cue 所在的 ±REPAIR_WINDOW 窗口做定向重译,返回 {id: 新译文}。"""
index = next(i for i, cue in enumerate(cues) if cue["id"] == target_id)
window = cues[max(0, index - REPAIR_WINDOW):index + REPAIR_WINDOW + 1]
table = glossary_table(glossary)
lines = "\n".join(f'{c["id"]}|{c["ja"]}|{c["cn"]}' for c in window)
prompt = REPAIR_TEMPLATE.format(target=target_id, glossary=table, lines=lines)
content = call_llm(REPAIR_SYSTEM, prompt, f"repair-{target_id}")
payload = parse_json_loose(content)
if not isinstance(payload, list):
raise ValueError(f"repair {target_id} 输出不是数组: {content[:120]}")
result = {}
for item in payload:
if not isinstance(item, dict):
continue
key, text = item.get("id"), item.get("text")
if type(key) is int and isinstance(text, str) and text.strip():
result[key] = text.strip()
expected = {c["id"] for c in window}
if set(result) != expected:
raise ValueError(f"repair {target_id} id 集合不符: 期望 {sorted(expected)} 实得 {sorted(result)}")
return result
# ---------------------------------------------------------------------------
# 合成正样本
# ---------------------------------------------------------------------------
NEUTRAL_WORDS = ["声音", "肚子", "照片", "芒果", "香蕉", "苹果", "时候", "地方"]
def make_synthetic(cues: list[dict], glossary: list[dict]) -> list[dict]:
"""把含域词的正常译文的域词替换成中性词,制造"字面通顺但语义错"的正样本。"""
pairs = [(g["term"], g["cn"]) for g in glossary if g["cn"] and len(g["cn"]) >= 2]
samples = []
for cue in cues:
if len(samples) >= SYNTHETIC_COUNT:
break
if len(cue["ja"]) < 4 or len(cue["cn"]) < 4:
continue
for jp, cn in pairs:
key = cn.split("")[0].split("")[0].strip()
if key and jp in cue["ja"] and key in cue["cn"]:
corrupted = cue["cn"].replace(key, random.choice(NEUTRAL_WORDS), 1)
if corrupted != cue["cn"]:
samples.append({
"id": cue["id"], "ja": cue["ja"],
"original": cue["cn"], "corrupted": corrupted,
"term": jp, "replaced": key,
})
break
return samples
# ---------------------------------------------------------------------------
# 从原始调用日志重算统计(步骤:rebuild)
# ---------------------------------------------------------------------------
def rebuild() -> dict:
"""从 calls.jsonl 重建定位/修复统计,避免为改口径而重跑烧 token。"""
records = [
json.loads(line)
for line in (OUT / "calls.jsonl").read_text(encoding="utf-8").splitlines()
if line.strip()
]
locate_flags: dict[str, dict[int, str]] = {"v1": {}, "v2": {}}
for rec in records:
matched = re.match(r"locate-(v1|v2)-chunk\d+", rec["tag"])
if not matched:
continue
payload = parse_json_loose(rec["content"])
items = payload.get("flags") if isinstance(payload, dict) else payload
for item in items or []:
if isinstance(item, dict) and isinstance(item.get("id"), int):
locate_flags[matched.group(1)][item["id"]] = str(item.get("why", ""))[:200]
summary: dict = {}
for variant in ("v1", "v2"):
flags = locate_flags[variant]
summary[f"locate_{variant}"] = {
"flagged": len(flags),
"trigger_rate": round(len(flags) / 504, 4),
"target_hits": {str(t): flags.get(t) for t in TARGET_IDS[2]},
"recall_on_targets": round(
sum(1 for t in TARGET_IDS[2] if t in flags) / len(TARGET_IDS[2]), 3),
"misheard_reason_count": sum(
1 for why in flags.values()
if any(word in why for word in ("误听", "听错", "听岔", "近音", "错听"))),
"flags": {str(k): v for k, v in sorted(flags.items())},
}
repairs = {}
for rec in records:
matched = re.match(r"repair-(\d+)", rec["tag"])
if not matched:
continue
payload = parse_json_loose(rec["content"])
if not isinstance(payload, list):
continue
repairs[matched.group(1)] = {
str(item["id"]): item.get("text") for item in payload
if isinstance(item, dict) and isinstance(item.get("id"), int)
}
summary["repair"] = repairs
summary["tokens"] = {
"calls": len(records),
"in": sum(int((r.get("usage") or {}).get("prompt_tokens", 0) or 0) for r in records),
"out": sum(int((r.get("usage") or {}).get("completion_tokens", 0) or 0) for r in records),
}
(OUT / "rebuilt_summary.json").write_text(
json.dumps(summary, ensure_ascii=False, indent=2), encoding="utf-8")
return summary
# ---------------------------------------------------------------------------
# 主流程
# ---------------------------------------------------------------------------
def main() -> None:
global OUT, FOLDER, CALL_LOG
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--steps", default="rules,locate,repair,synthetic",
help="rules,locate,repair,synthetic,rebuild 的逗号分隔子集")
parser.add_argument("--folder", default=str(DEFAULT_FOLDER), help="含 JA/CN 字幕的视频文件夹")
parser.add_argument("--out", default=str(DEFAULT_OUT), help="产物目录(默认 data/experiments/review_stage0/out")
parser.add_argument("--map-ratio", type=float, default=0.5,
help="词表映射可信度阈值(0=宽松,0.5=严格)")
args = parser.parse_args()
steps = set(args.steps.split(","))
OUT = Path(args.out)
FOLDER = Path(args.folder)
OUT.mkdir(parents=True, exist_ok=True)
CALL_LOG = OUT / "calls.jsonl"
random.seed(20260919)
if steps == {"rebuild"}:
summary = rebuild()
log(f"重建完成: {json.dumps({k: summary[k] for k in ('tokens',)}, ensure_ascii=False)}")
return
log(f"模型={MODEL} 端点={API_BASE} 素材={FOLDER} 输出={OUT}")
parts = {part: load_pairs(part) for part in PARTS}
all_ja = [c["ja"] for cues in parts.values() for c in cues]
summary: dict = {"model": MODEL, "folder": str(FOLDER), "target_ids": TARGET_IDS}
glossary = build_glossary(parts, min_ratio=args.map_ratio)
log(f"词表 {len(glossary)} 条: " + ", ".join(
'{t}{c}({n})'.format(t=g['term'], c=g['cn'], n=g['count']) for g in glossary))
summary["glossary"] = glossary
(OUT / "glossary.json").write_text(
json.dumps(glossary, ensure_ascii=False, indent=2), encoding="utf-8",
)
if "rules" in steps:
log("== 规则层:词表违背 / 近音可疑 ==")
# 键必须带 part:两个文件的 id 都从 1 开始,只用 id 会让 part1 的条目
# 冒充 part2 的同号条目(会直接算错召回率)。
rules = {}
for part, cues in parts.items():
for cue in cues:
findings = classify_rules(cue, glossary)
if findings:
rules[f"part{part}:{cue['id']}"] = {
"part": part, "id": cue["id"], "ja": cue["ja"],
"cn": cue["cn"], "findings": findings,
}
part2_rules = {k: v for k, v in rules.items() if v["part"] == 2}
kinds = Counter(f["kind"] for v in rules.values() for f in v["findings"])
summary["rules"] = {
"hit_count": len(rules),
"hit_rate": round(len(rules) / len(all_ja), 4),
"part2_hit_rate": round(len(part2_rules) / len(parts[2]), 4),
"kinds": dict(kinds),
"target_hits": {
str(t): rules.get(f"part2:{t}", {}).get("findings") for t in TARGET_IDS[2]},
"target_recall": round(
sum(1 for t in TARGET_IDS[2] if f"part2:{t}" in rules) / len(TARGET_IDS[2]), 3),
"sample": {k: rules[k] for k in sorted(rules)[:12]},
}
(OUT / "rules.json").write_text(
json.dumps(rules, ensure_ascii=False, indent=2), encoding="utf-8",
)
log(f"规则层命中 {len(rules)}/{len(all_ja)} 条(全局 {len(rules) / len(all_ja):.1%}"
f"part2 {len(part2_rules) / len(parts[2]):.1%}),类型 {dict(kinds)};目标命中 "
f"{ {t: f'part2:{t}' in rules for t in TARGET_IDS[2]} }")
if "locate" in steps:
for variant in ("v1", "v2"):
log(f"== 定位层 {variant}(全片 part2==")
cues = parts[2]
flags = locate(cues, glossary, variant, f"locate-{variant}")
target_hits = {t: flags.get(t) for t in TARGET_IDS[2]}
summary[f"locate_{variant}"] = {
"flagged": len(flags),
"trigger_rate": round(len(flags) / len(cues), 4),
"target_hits": target_hits,
"recall_on_targets": round(
sum(1 for t in TARGET_IDS[2] if t in flags) / len(TARGET_IDS[2]), 3),
"flags": {str(k): v for k, v in sorted(flags.items())},
}
log(f"{variant}: 命中 {len(flags)}/{len(cues)} 条(触发率 "
f"{len(flags) / len(cues):.1%}),目标命中 {target_hits}")
if "repair" in steps:
log("== 修复层:对目标 cue 定向重译 ==")
cues = parts[2]
repairs = {}
# 目标 cue + 定位层 v2 额外标记的条目(观察是否会把正常句改坏)。
repair_ids = list(TARGET_IDS[2])
locate_v2 = summary.get("locate_v2", {}).get("flags", {})
repair_ids += [int(k) for k in locate_v2 if int(k) not in repair_ids][:5]
for target_id in repair_ids:
try:
result = repair(cues, target_id, glossary)
except (ValueError, TypeError, RuntimeError) as exc:
log(f" ! 修复 {target_id} 失败: {exc}")
continue
before = {c["id"]: c["cn"] for c in cues
if abs(c["id"] - target_id) <= REPAIR_WINDOW}
changed_neighbors = [i for i in result if i != target_id and result[i] != before.get(i)]
repairs[str(target_id)] = {
"ja": next(c["ja"] for c in cues if c["id"] == target_id),
"before": before.get(target_id),
"after": result.get(target_id),
"neighbors_input": before,
"neighbors_output": result,
"changed_neighbors": changed_neighbors,
}
log(f" 修复 {target_id}: {before.get(target_id)} -> {result.get(target_id)}"
f"(邻句改动 {changed_neighbors}")
summary["repair"] = repairs
if "synthetic" in steps:
log("== 合成正样本召回 ==")
# 只用 part2 构造样本:两个文件的 id 都从 1 开始,混用会错配窗口。
cues = parts[2]
samples = make_synthetic(cues, glossary)
log(f"合成 {len(samples)} 条损坏译文,逐条放入 30 条真实窗口中定位")
hits = 0
detail = []
index_by_id = {c["id"]: i for i, c in enumerate(cues)}
for sample in samples:
index = index_by_id.get(sample["id"])
if index is None:
continue
window = [dict(c) for c in cues[max(0, index - LOCATE_CHUNK // 2):
index + LOCATE_CHUNK // 2 + 1]]
for c in window:
if c["id"] == sample["id"]:
c["cn"] = sample["corrupted"]
flags = locate(window, glossary, "v2", f"synth-{sample['id']}")
hit = sample["id"] in flags
hits += int(hit)
detail.append({**sample, "flagged": hit, "why": flags.get(sample["id"], "")})
log(f" 合成样本 id={sample['id']} 替换 {sample['term']}{sample['corrupted'][:18]} "
f"... 定位={'命中' if hit else ''}")
summary["synthetic"] = {
"count": len(detail), "hits": hits,
"recall": round(hits / len(detail), 3) if detail else None,
"detail": detail,
}
summary["tokens"] = TOTAL_TOKENS
(OUT / "summary.json").write_text(
json.dumps(summary, ensure_ascii=False, indent=2), encoding="utf-8",
)
log(f"完成。token 用量: {TOTAL_TOKENS}")
log(f"结果: {OUT / 'summary.json'}")
if __name__ == "__main__":
main()