B 层剧情关系抽取
RELATION EXTRACTION
状态:🟡 试验完成(v3 schema)· 最后核对 2026-09-11 范围:单个阶段(TO-ST-1)的抽取验证,不含全量、不含归并 产物:
scripts/extract_story_relations.py、scripts/review_story_relations.py
目标:在花大钱跑全量之前,先用一个阶段验证"从剧情原文抽可追溯的人物关系" 这条路走不走通,schema 对不对。
结论:走得通。但第一版 schema 有一个致命缺陷(
basis没有任何东西约束), 已改成"证据反推 + 在场校验"两道确定性闸门。 详见 §4。
0. 模型与 API 的两个坑(都是查官方文档才发现的)
- 模型名该用
deepseek-flash(= DeepSeek-V4.1-Flash)。deepseek-chat已从官方模型表里消失;deepseek-v4-pro据官方说明 在 2026-09-14 12:00 后也会全部路由到 Flash 并按 Flash 计费。 价格:Flash 输入 ¥1–2/M、输出 ¥4–8/M;Pro 输入 ¥4.5–9/M、输出 ¥13.5–27/M。 Flash 便宜 3–4 倍,且官方称其在性能上已全面超越 Pro。 来源:模型 & 价格。 - 思考模式默认开启,而
temperature在思考模式下"不报错但不生效" (思考模式文档)。 这意味着在加thinking: disabled之前,我传的temperature: 0一直是被静默忽略的 —— 前几轮的"确定性"并不确定。 本脚本现在显式{"thinking": {"type": "disabled"}},temperature: 0才真正生效。
关掉思考模式的实测收益:
| 输出 token | 耗时 | 成本 | |
|---|---|---|---|
| 开思考(run2) | 7,745 | 33.6s | ¥0.073 |
| 关思考(run3) | 1,425 | 4.9s | ¥0.023 |
输出降 82%、耗时降 85%。多出来的那些就是思考 token,而我们按输出价计费。
1. 这一轮实际花了多少
| run | schema | in | out | 合计 | ¥ | 耗时 |
|---|---|---|---|---|---|---|
| run1 | v1 旧 prompt | 5376 | 4360 | 9736 | 0.0456 | — |
| run2 | v2 加整条 basis | 5679 | 7745 | 13424 | 0.0733 | 33.6s |
| run3 | v3 逐证据 + 关思考 | 5809 | 1425 | 7234 | 0.0230 | 4.9s |
| run4 | v3 + 在场规则 | 5809 | 1646 | 7455 | 0.0248 | 5.9s |
定稿版单阶段 ≈ ¥0.025。 全量 61 阶段按此推算 ≈ ¥1.5(含 prompt 重复开销)。 仍不构成障碍,但原估算(¥0.014)偏低一倍——真实输出/输入比是 0.28,不是我猜的 0.12。
2. 怎么跑
# .env 里的公用 key 已失效;key 从环境变量注入,不落盘
export LLM_API_KEY="<可用的 key>"
export LLM_MODEL=deepseek-flash
# 抽取(stage 参数可给文件名片段或 stage_code)
.venv/Scripts/python.exe scripts/extract_story_relations.py TO-ST-1
# 复核:把行号证据还原成带标记的原文上下文
.venv/Scripts/python.exe scripts/review_story_relations.py data/story_relations_to-st-1.json
extract 退出码非 0 = 有边被丢弃(编造引用/非法字段),是给 CI 用的信号。
3. schema(v3)
{
"subject": "巴尼特", "subject_kind": "character",
"predicate": "近亲", "object": "塔妮", "object_kind": "character",
"basis": "direct", // ← 脚本反推出来的,不是模型写的
"basis_claimed": "direct", // ← 模型自报值,保留以便对账
"basis_overclaimed": false, // ← 模型是否高报
"presence_ok": true, // ← 双方是否真的同场
"presence_downgraded": false,
"evidence": [
{"line_id": "TO-ST-1_NBT_000108", "line_no": 108, "supports": "direct", "note": "巴尼特称塔妮为小侄女"},
{"line_id": "TO-ST-1_NBT_000167", "line_no": 167, "supports": "direct", "note": "塔妮当面称呼巴尼特叔叔"}
],
"evidence_line_ids": ["TO-ST-1_NBT_000108", "..."],
"note": "叔侄"
}
三道闸门(都是实测逼出来的,不是设计时想到的)
① 行号 → line_id 由脚本自己映射,不采信模型复述的 id。
让模型逐字复述 TO-ST-1_NBT_000042 容易被改一个字符,而改错了看起来仍像对的,
无法确定性检出。行号是短整数,越界立刻判死。
实测能挡住:越界、空证据、字符串行号 "21"、以及 True
(Python 里 bool 是 int 子类,不显式排除会静默通过)。
② basis 逐条标 + 由证据反推,模型自报值不算数。
v1/v2 里 basis 只是模型写的一个字,没有任何东西约束它 —— 这是致命缺陷。
现在每条证据自带 supports,整条边的 basis 取最弱那一档
(有一条只是转述,整条就不能声称直接确认)。
⚠️ 实现时我第一版写成了 min(key=BASES.index),语义正好反了
("有一条直接证据就整条算直接"),闸门看着在工作、实际什么都没挡;
正确是 max。这个 bug 是靠构造"direct + reported 混合"的测试数据才暴露的,
而我第一次构造的测试数据本身是错的(两行说话人相同),测试通过是假阴性。
③ 在场校验:direct 的字面定义是"双方直接互动"。
模型区分不了"我和罗克的故事"(本人说)和"塔妮转述梅拉和罗克的婚姻史"(第三方说),
两行都被标成 direct。但这件事可以机械判定:
看证据行 ±2 行内,subject 和 object 有没有都作为说话人出现过。
这条规则的价值在于它的分辨力:
- 抓到
塔妮 --远亲--> 罗克(双方从不同场,唯一证据是梅拉的话)→ 降为 reported - 抓到
巴尼特 --近亲--> 小提摩西(小提摩西是个婴儿,从没说过话,模型却报 direct)→ 降为 reported - 没有误伤
梅拉 --近亲--> 罗克—— 尽管它有一条证据(L137)说话人对不上, 但 L150 两人确实同场。也就是说它区分了"某条证据标错"和"整条边不成立"。
实体分四类:character / court_seat(官职席位,会换人)/ role(职能代称)/ unknown_speaker。
4. 其它发现
4.1 亲属不能只用一个标签
run1 把「你小姨的亲哥哥的表姐」也写成 亲属,和「叔叔」同权重。
拆成 近亲/远亲 后自动分出远近。原文里梅拉是在攀亲
(那句话的修辞功能就是"证明我们算亲戚"),合成一个标签等于把这个区别丢掉。
4.2 「本段没台词」≠「不是节点」
模型最初把可露希尔、皮塔当成布景。实测两人在本事件里都有相当戏份:
皮塔说话 28 行(在 TO-1_beg)、可露希尔 10 行(在 TO-ST-2),
只是恰好在 TO-ST-1 里没台词。所以对它们下关系判断证据不足,
但不能据此推断它们不是节点。
4.3 旁白是真实的关系来源,而程序会漏掉它 —— 仍未解决
安洁莉娜 --> 玛蒂娜 的唯一证据在行 1–4 的旁白里
("亲爱的玛蒂娜:……你向我伸出手,将我引上信使这条路")。
玛蒂娜在本阶段只被提到 1 行,在别的阶段才说话(86 行)。
⚠️ run3 就把这条边整个丢了,run4 才回来。抽取不能只看 speaker。
而且在场校验对这条边是错的:旁白里的"你"就是玛蒂娜,
但旁白没有 speaker_name,所以判为"不同场"。这是刻意保守(宁可少认 direct),
但意味着旁白里的关系永远拿不到 direct。
4.4 原文自己会自相矛盾
巴尼特对安洁莉娜说「我们家刚出生的小提摩西」(行 54), 转头对塔妮说「你的表妹出生了」(行 166)。 同一个婴儿,跨亲属关系时称谓变形。不要把 note 当成权威事实——它是模型的解读。
5. 定稿结果(run4,10 条边)
| # | 边 | basis | 在场 | 备注 |
|---|---|---|---|---|
| 1 | 巴尼特 --近亲--> 塔妮 | direct | ✓ | 叔侄,双方互称 |
| 2 | 巴尼特 --近亲--> 小提摩西 | reported | ✗ | 婴儿无台词,已降级 |
| 3 | 塔妮 --远亲--> 梅拉 | direct | ✓ | 梅拉当面攀亲 |
| 4 | 塔妮 --远亲--> 罗克 | reported | ✗ | 双方从不同场,已降级 |
| 5 | 梅拉 --近亲--> 罗克 | direct | ✓ | 夫妻,双方互称 |
| 6 | 安洁莉娜 --同事--> 工程部干员 | direct | ✓ | |
| 7 | 安洁莉娜 --同事--> 皮塔 | reported | ✗ | 皮塔本段无台词 |
| 8 | 安洁莉娜 --委托--> 巴尼特 | direct | ✓ | |
| 9 | 安洁莉娜 --合作--> 塔妮 | direct | ✓ | 接线服务 |
| 10 | 巴尼特 --同事--> 好心的路人 | inferred | ✓ | 同矿工出身 |
10 条边里 4 条被降级(3 条在场降级 + 1 条模型自报降级)。 如果没这两道闸门,run1 会给你 9 条边全部看起来同等可信。
逐条带原文的复核报告:data/story_relations_to-st-1.review.txt
(未入库,data/story_relations_*.review.txt 已 gitignore)。
run1(旧 prompt)留档对照:data/story_relations_to-st-1.run1-oldprompt.json。
6. 仍未解决
- 旁白盲区(最要紧)。 见 §4.3。旁白里的关系拿不到 direct, 而且只按 speaker 建索引会整条漏边。要解决得识别旁白里的人称指代, 这是 NLP 活,不是加个字段能完事的。
other仍是兜底大头。 run4 里other已由模型改选更准的委托/合作/同事,说明扩白名单有效;但白名单要扩到什么程度没有依据。- 归并还没做。 10 条边里安洁莉娜出现 5 次。全量 61 阶段可能上万条边, 必须有确定性 key 去重,否则图谱会被稀释。
evidence_ids应能一路贯到 L3。line_id现在只存在这个 JSON 里, 没接进pending.py/l3_long.py的evidence_ids字段。测试没有固化成 tests/。已完成,见 §8。
7. 数据源:nideriji 只覆盖 3 个活动,ASTR 覆盖全游戏
2026-09-11 修正。本节第一版写错了 —— 当时我看到 nideriji 的
/content/events/index.json里恰好 3 个活动,就断言"3 个是数据源上限"。 那个结论对 nideriji 成立,但对"能拿到的剧情"不成立。
7.1 两条数据源
| 来源 | 覆盖 | 格式 |
|---|---|---|
arknights.nideriji.top |
3 个活动(TO/PA/TA,2026 年 4–8 月) | 已解析好,有 speaker_name/text |
ASTR → 050644zf/ArknightsStoryJson |
2,009 stage / 846 万字 | 游戏原生 storyList |
ASTR(astr.pages.dev,Arknights Story Text Reader)前端的数据配置:
bS = { m31ns: "https://r2.m31ns.top/", // 主源,Cloudflare R2,可直接取
github: "https://raw.githubusercontent.com/050644zf/ArknightsStoryJson/main/" }
⚠️ ArknightsAssets/ArknightsGamedata 没有剧情:它的 cn/gamedata/ 下
只有 excel/、config/、i18n/。两个仓库的 excel/ 逐字节相同
(activity_table.json 都是 19.15 MB),差别就是 ArknightsStoryJson
多了 story/。而且那两个仓库走 Git LFS,raw 直链会 ConnectionReset。
7.2 真实规模(wordcount.json 是权威的逐 stage 字数表)
2,009 个 stage / 846.5 万字符 / ≈564 万 token
| 目录 | 文件 | 字符 | 占比 |
|---|---|---|---|
obt/memory 干员密录 |
390 | 198.4 万 | 23.4% |
obt/main 主线(第 5–17 章) |
427 | 147.2 万 | 17.4% |
activities/* 76 个活动 |
~1,190 | ~500 万 | ~59% |
我们手上那份是 30.8 万字 —— 差 28 倍。
⚠️ 我先前说"密录只有 11 个角色"也是错的:那只是 nideriji 那 3 个活动的 活动密录。ASTR 有全游戏 390 个干员密录,它自己就是最大的一类。
7.3 成本重估
| 数据量 | 抽取成本 | |
|---|---|---|
| 我以为的"全量" | 30.8 万字 | ¥1.5 |
| 中途修正(含活动密录/回忆) | 41.6 万字 | ¥2 |
| 真实规模 | 846.5 万字 | ≈ ¥12 |
仍然是一次性固定成本,不随聊天增长。 但"¥1.5"这个数字是错的,别再引用。
7.4 上游自己给了主角门槛
/content/operators/index.json 的 thresholds:
{"dialogue_ratio": 0.04, "minimum_dialogue_count": 30}
"台词占比 ≥4% 且台词数 ≥30 行" = 主角级。这正好能回答 §6.2 "哪些角色值得建节点" —— 用上游的门槛,不自己拍。
7b. 换源 ASTR 的三个坑(scripts/astr_convert.py)
坑一:说话人就在台词元素上,别无他处
{"prop": "name", "attributes": {"content": "干员们,喷雾准备!", "name": "凯尔希"}}
attributes.content = 文本,attributes.name = 说话人中文名,就是文字。
⚠️ 我在这上面走了很长的弯路,记下来免得再犯。 最初我以为说话人要靠
"最近一个 prop="Character" 元素"推出来,于是去解 char_003_kalts_1、
avg_172_svrash_1#1$1 这类资源 id,还专门研究了
char_291_aglina_1#3 → char_291_aglina 的归一化顺序、#N 与 _N 的
剥离先后。全是错的:Character / charslot 是立绘定位
(谁站在屏幕哪个位置),与谁在说话无关 —— 实测 act29side_10_end
一个 Character 元素都没有,说话人照样一个不少。归一化后去
charinfo.json 查名字的命中率是 0%,因为那个值压根不是 id。
教训:先看清数据,再写解析。 我按"应该是这样"的假设写了解析器, 然后花了十几轮去查为什么覆盖率只有 29%。看清之后是 86%。
| 文件 | 文本数 | 带说话人(修正前 → 后) |
|---|---|---|
obt/memory/story_aglina_1_1 |
132 | 100% → 100% |
obt/memory/story_svrash_2_1 |
478 | 10% → 99% |
activities/act29side/level_act29side_10_end |
473 | 0% → 67% |
| 六文件合计 | 2,521 | 29% → 86% |
坑二:id 是数组下标,不是稳定标识 —— 换源把已解决的问题又引回来了
实测三个文件的 id 都是恰好 0..n-1 连续。文本一变(加一句旁白),
后面所有 id 全部错位,而错位是静默的。
nideriji 的 line_id(TO-ST-1_NBT_000042)没这个问题。
对策:line_id 自带内容哈希 —— <stage_path>#<序号>:<sha1前8位>。
astr_convert.py --verify-dir <目录> 重取原文校验,不匹配就报"锚点失效"。
实测三种漂移都能检出:中间插入(后续全错位)、原地改写(该条失效)、
末尾删除(越界)。
⚠️ 序号只数文本元素,不数演出指令 —— 否则插一个 Blocker 就全体错位。
坑三:prop 大小写不统一、.json 后缀不能漏
- 同一份数据里同时有
Dialog/dialog、Character/character, 比对 prop 必须忽略大小写。 - 补 URL 时必须带
.json。漏掉后 R2 返回的是连接重置而不是 404, 报错看起来像网络故障而不是拼错路径 —— 我在这上面白查了一轮。
附带发现:分支是原生数据
单个 stage 里就有 Decision 节点(玩家选项 + targetLine 跳转),
一个文件里能有三四十个。这是"时间线分支"的原生数据 ——
之前我以为只能从对话里推,其实游戏原文里就有显式结构。
8. 测试固化(tests/test_story_relation_gates.py,27 条)
闸门必须行为级测试,不能只扫源码 —— 扫源码证明不了它挡得住东西。
全部用内存里构造的合成 stage,不碰 data/story_cache/(未入库,干净检出上没有);
只有两条真实数据定点 @ skipif,本地有缓存时才跑。
反向验证(做了,不是声称)——逐处破坏,确认对应测试变红:
| 破坏 | 变红的测试 |
|---|---|
max → min(我埋的那个 bug) |
1 条,正是定点测试 test_mixed_evidence_downgrades_to_weakest |
| 禁用在场校验 | 3 条(含真实数据定点) |
| 在场规则改成"一律降级" | 5 条(全是防过度降级的) |
默认模型改回 deepseek-chat |
1 条 |
⚠️ 单一档 evidence 的测试测不出 min/max 写反 ——
两行支持度相同时 min 和 max 结果一致。定点测试必须是混合档
(一条 direct + 一条 reported)。我第一次用手写 fixture 验证时
就因为数据构造错了而得到假阴性:测试绿了,闸门其实是坏的。
顺带修掉一个我自己造成的文档问题
新增 md 会被两条既有守卫抓到,两条都做了处理:
Tools/check_docs.py要求前 12 行有状态:和「最后核对」日期 → 已补tests/test_docs_index.py要求docs/README.md里有链接 → 已登记
⚠️ 注意 test_docs_index.py 基线本来就是红的(缺 4 篇索引 + 17 条断链,
都是先前会话遗留)。我的文档一度把它从 4 篇推到 5 篇,
只比失败数字是看不出来的 —— 必须读断言里的名单。
现在已回到 4 篇,我的不在名单里。
9. 测试固化(tests/test_astr_convert.py,19 条)
换源之后可信度不能再靠"看起来对"。这组测试锁的是"转出来之后 说得出它什么时候不再可信",全部用合成数据、不联网。
反向验证(做了,不是声称):
| 破坏 | 变红的测试 |
|---|---|
| 说话人恒为空(退回我最初的错误理解) | 4 条 |
line_id 去掉哈希 |
5 条 —— 所有漂移检测全红 |
ordinal 改成数所有元素 |
1 条(定点) |
full_path 不补 .json |
4 条 |
第二条最能说明问题:去掉哈希,5 条漂移测试一起红 —— 说明那组测试确实在守着这个机制,不是摆设。
10. 交叉验证:两条独立管道内容一致(2026-09-11)
换源之后必须回答一个问题:新管道有没有系统性偏差? "能跑出东西"不等于"跑出来是对的" —— 漏段、错位、字段读错这类问题 在单源验收里是看不见的,产出看起来永远正常。
唯一能证伪的办法是拿第二个独立来源比对同一段内容:
nideriji : 抓 prts.wiki 的**渲染页**(HTML → 表格),已解析
ASTR : 抓游戏**原始 gamedata**(charinfo.json 的 storyTextAudio)
上游不同、解析路径不同。对得上才说明转换器可信。
脚本:scripts/crossvalidate_sources.py(可复现,--min 可调阈值)
结果
配成对角色 11/11。
| section | 段数 | ≥0.90 | =1.000 | 中位数 | 最低 |
|---|---|---|---|---|---|
personnel_files |
99 | 99 | 44 | 0.995 | 0.913 |
voice_records |
307 | 0 | 0 | 0.068 | — |
related_items |
22 | 0 | 0 | 0.110 | — |
modules |
9 | 0 | 0 | 0.045 | — |
⚠️ 后三类的 0 不是"数据不一致",是"ASTR 侧未加载"。
它们在 ASTR 的 charinfo.json 里根本不存在(应在 charword_table.json /
item_table.json / handbook_info_table.json),本脚本不加载。
把这三类混进总相似度会得出"77% 不匹配"的假象 —— 我第一版就是这么干的,
差点误报成数据问题。判据必须按 section 分开。
结论:personnel_files 99/99 达标,中位数 0.995 → 转换器没有系统性偏差。
一个差点误判的坑:必须按「异格」对齐
nideriji 的密录主角是异格角色(予愿安洁莉娜 / 赤刃明霄陈),
对应 ASTR 的 char_1015_aglna2 / char_1050_chen3,
不是基形态 char_291_aglina / char_010_chen。
我第一版拿基形态去比,得到 0.331 的"低相似度",差点写成"数据有出入"。 按名字对齐到异格后是 1.000。这是我比错了对象,不是数据的问题。
差异都是排版,不是内容
以 珊比 逐节为例,9 节里 4 节精确一致,其余 0.987–0.995:
| ASTR storyTitle | 相似度 | 差异原因 |
|---|---|---|
| 基础档案 / 客观履历 / 临床诊断分析 / 晋升记录 | 1.000 | — |
| 档案资料一~四 | 0.987–0.995 | 段间空行:nideriji ` |
vs ASTR
` |
| 综合体检测试 | 0.920 | ASTR 多一句「【补充】…」,nideriji 没有 |
所以判据不能是"== 1.000",而是"高相似度 + 差异只出现在空白/补充句"。
顺带确认:ASTR 的 charinfo 覆盖了全部 441 条
/zh_CN/charinfo.json 441 条,每一条都有可比较的档案文本。
这是"全游戏 390 个干员密录 + 档案"里档案部分的来源。
11. 测试固化(tests/test_crossvalidate_sources.py,11 条)
验证器自己最容易出的问题是它永远说 OK —— 那比没有验证更糟 (给了假的安心)。所以这组测试锁的是判据本身:
反向验证(做了):
| 破坏 | 变红的测试 |
|---|---|
| 角色对齐不优先精确名(回到我最初的错误) | 1 条(异格对齐定点) |
norm 变成恒等(不归一化空白) |
3 条 |
| 把三类无对应的 section 也算进判定 | 1 条 |
下面这条踩过一次,值得单独记:test_astr_by_char_skips_short_texts
第一版我写了 11 字的"足够长",没跨过 20 字阈值,测试红了而代码是对的。
和前面 min/max 那次(代码错而测试绿)正好相反 ——
两次都是测试数据的构造问题,不是逻辑问题。写断言时先把边界值算清楚。
⚠️ 另外修掉一个静默失效:Windows GBK 控制台打不出 emoji 会抛
UnicodeEncodeError,而它发生在 return 之前 ——
失败的验证会返回退出码 0,CI 读成成功。现在入口处
reconfigure(errors="replace"),判定句用纯文本,并且有回归测试守着。