罗德岛通讯与技术部

B 层剧情关系抽取

RELATION EXTRACTION

1. 模型名该用 deepseek-flash(= DeepSeek-V4.1-Flash)。

状态:🟡 试验完成(v3 schema)· 最后核对 2026-09-11 范围:单个阶段(TO-ST-1)的抽取验证,不含全量不含归并 产物:scripts/extract_story_relations.pyscripts/review_story_relations.py

目标:在花大钱跑全量之前,先用一个阶段验证"从剧情原文抽可追溯的人物关系" 这条路走不走通,schema 对不对。

结论:走得通。但第一版 schema 有一个致命缺陷(basis 没有任何东西约束), 已改成"证据反推 + 在场校验"两道确定性闸门。 详见 §4。

0. 模型与 API 的两个坑(都是查官方文档才发现的)

  1. 模型名该用 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。 来源:模型 & 价格
  2. 思考模式默认开启,而 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 里 boolint 子类,不显式排除会静默通过)。

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. 仍未解决

  1. 旁白盲区(最要紧)。 见 §4.3。旁白里的关系拿不到 direct, 而且只按 speaker 建索引会整条漏边。要解决得识别旁白里的人称指代, 这是 NLP 活,不是加个字段能完事的。
  2. other 仍是兜底大头。 run4 里 other 已由模型改选更准的 委托/合作/同事,说明扩白名单有效;但白名单要扩到什么程度没有依据。
  3. 归并还没做。 10 条边里安洁莉娜出现 5 次。全量 61 阶段可能上万条边, 必须有确定性 key 去重,否则图谱会被稀释。
  4. evidence_ids 应能一路贯到 L3。 line_id 现在只存在这个 JSON 里, 没接进 pending.py / l3_long.pyevidence_ids 字段。
  5. 测试没有固化成 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
ASTR050644zf/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.jsonthresholds

{"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_1avg_172_svrash_1#1$1 这类资源 id,还专门研究了 char_291_aglina_1#3char_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_idTO-ST-1_NBT_000042)没这个问题。

对策:line_id 自带内容哈希 —— <stage_path>#<序号>:<sha1前8位>astr_convert.py --verify-dir <目录> 重取原文校验,不匹配就报"锚点失效"。 实测三种漂移都能检出:中间插入(后续全错位)、原地改写(该条失效)、 末尾删除(越界)。

⚠️ 序号只数文本元素,不数演出指令 —— 否则插一个 Blocker 就全体错位。

坑三:prop 大小写不统一、.json 后缀不能漏

  • 同一份数据里同时有 Dialog/dialogCharacter/character, 比对 prop 必须忽略大小写
  • 补 URL 时必须带 .json。漏掉后 R2 返回的是连接重置而不是 404, 报错看起来像网络故障而不是拼错路径 —— 我在这上面白查了一轮。

附带发现:分支是原生数据

单个 stage 里就有 Decision 节点(玩家选项 + targetLine 跳转), 一个文件里能有三四十个。这是"时间线分支"的原生数据 —— 之前我以为只能从对话里推,其实游戏原文里就有显式结构。

8. 测试固化(tests/test_story_relation_gates.py,27 条)

闸门必须行为级测试,不能只扫源码 —— 扫源码证明不了它挡得住东西。 全部用内存里构造的合成 stage,不碰 data/story_cache/(未入库,干净检出上没有); 只有两条真实数据定点 @ skipif,本地有缓存时才跑。

反向验证(做了,不是声称)——逐处破坏,确认对应测试变红:

破坏 变红的测试
maxmin(我埋的那个 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"),判定句用纯文本,并且有回归测试守着。