罗德岛通讯与技术部

剧情事件抽取

EVENT EXTRACTION I

⚠️ 一处方向性纠正:早先的计划是"先跑主线抽关系",那把 A 和 B 混了。

状态:🟡 验证完成,待定范围 · 最后核对 2026-09-12

目标:一个干员被问起自己在剧情中的经历时能准确回答。 脚本:scripts/extract_story_events.py 产物:data/story_events_<segment>.json(派生数据,可重跑)


一、目标拆成三个子系统(别再混着谈)

原话是三件事,而它们需要不同的数据、不同的抽取目标

# 需求 数据源 要不要抽取 状态
A 干员的剧情记忆 ASTR 原文 ✅ 抽事件(本脚本) 🟡 首轮验证完
B 人际关系 + 处境 关系图谱 + ASTR ✅ 抽关系(extract_story_relations.py 🟡 图谱 198 条/79 人,太少
C 剧情时间线 ASTR 元数据 不用抽 ❌ 全无

⚠️ 一处方向性纠正:早先的计划是"先跑主线抽关系",那把 A 和 B 混了。 关系抽取只服务 B;而 A(剧情记忆)和 C(时间线)它都不产出


二、首轮验证:跑通了,而且质量可用

样本obt__main__level_st_08-06(主线 END8-1「尾声,抑或开始」/怒号光明),653 行。

事件 27 · 涉及角色 36 个 · actor 位 58 · 有第一人称回忆 54(93%)
phase 分布: 经过 12 · 结果 10 · 转折 4 · 起因 1
耗时 29.3 秒 · 成本约 ¥0.01

关键设计:每个事件两份视角

客观 summary 不能直接喂给她 —— 那样她会用第三人称讲自己的经历, 听起来像在念别人的档案。所以每个 actor 有一份第一人称 memories

客观: 凯尔希抽了博士的血制成药剂注入阿米娅脖颈,阿米娅苏醒后哭泣,
      凯尔希要求陈看住塔露拉并准备撤离。

【凯尔希】我抽了博士的血制成药剂,注射进阿米娅的脖颈,她醒了过来。…
【阿米娅】我醒来时把凯尔希医生错认成了特蕾西娅小姐,然后忍不住哭了。博士就在旁边。
【陈】  凯尔希抽了博士的血救醒阿米娅,然后让我看住塔露拉,说之后会立刻收监她。
【博士】凯尔希让我伸出手,抽了我的血,把淡青色的成品输进阿米娅的脖颈,阿米娅醒了过来。

阿米娅那条正是目标形态:用"我",而且记住了错认成特蕾西娅这个细节 (原文 L19 确有 阿米娅: 啊……特蕾西娅小……小姐……?)。

⚠️ 第一人称必须按角色嵌套memories 是 dict),不能和 actors 平级: 平级时 summary: "凯尔希抽了我的血注入阿米娅"actors: [凯尔希, 阿米娅] 会读成"我 = 阿米娅",而它其实是凯尔希视角写的。

忠实度:逐条对过原文

抽 4 个事件(含纯旁白、含时间线索、含多 actor)逐行核对,没有编造

  • L201-210 是纯旁白 → actors: [] 正确;
  • time_hint 是从 L201/207/210 原文抽的原词(「在之后的三十六个小时里;两周后;数月之久」);
  • L1~L33 的四方视角都能回到原文行。

三、⚠️ 验证时修了 3 个真 bug(都是"拿原文核对"逼出来的)

读代码一个都发现不了 —— 三个的日志形态都相同,分不出是谁的错。

① 名字未归一化 → 真实在场的角色被误摘

原文里有的说话人写作 "皇帝的利刃"带引号,表示称号/掩饰身份), 模型输出 actors: ["皇帝的利刃"](不带引号)。不归一化就匹配不上, 把一个真正在场的角色摘掉 —— 而它的日志和"模型确实写错了人"一模一样

顺带要剥尾部问号:原文用它表示身份未确认(博士?/游客A?/ 卡西米尔市民?),而模型不会那样写。

② 只查说话人 → 玩家(博士)被误摘

博士在剧情里基本不作为 speaker_name 出现 —— 他的台词不在数据里, 只能从别人对他说话看出在场(原文用 Dr.{@nickname} 占位)。 实测该占位符出现在 570 行 / 197 个剧本

只查说话人时 actors: ["博士"] 会被判"不在场"而摘掉 —— 而模型写的是对的(那次凯尔希确实在对他说话并抽他的血)。 错的是校验,不是模型。 这条对"她与博士的关系"尤其要紧。

③ 呼语正则把台词碎片当人名 → 整个校验静默失效

单靠正则找「,XX。」这种呼语形状,会把 不要问把手伸出来先照顾阿米娅吧 都切出来当人名。它们混进在场集合后, presence 检查形同虚设(集合被污染 → 什么都"在场")。

修法:呼语候选必须过全库说话人名白名单speaker_explicit,6664 个)。 而那个白名单本身也不是可选的 —— 没有它,这一路只是给检查加噪音。


四、校验器带的两道守卫

守卫 防什么 行为
不在场不给回忆 「她不在场却记得」—— 这个系统最不能有的错 丢弃并出声
第一人称守卫 模型写回第三人称("她在念别人的档案") 只报告不改写

⚠️ 第一人称守卫不自动改写是刻意的:改写会引入原文没有的东西。 代价是群体自称「我们」会命中它皇帝的利刃/影卫/萨卡兹雇佣兵), 那是已知的合理误报


五、⚠️ 未决:抽取结果不可复现

同一剧本跑三次,事件切分不一样:19 / 28 / 27 个事件temperature=0 也不保证确定)。

这不是 bug,但它决定全量怎么跑:

方案 做法 代价
① 接受抖动 直接跑,产物就是那一次的结果 免费;但重跑会得到不同记忆
② 多跑取并集 同一剧本跑 N 次,合并事件 N 倍成本(¥27 → ¥54+),且要去重
③ 找确定性手段 查 API 是否支持 seed 未知;先验证
④ 固定"切分点"再抽 先用别的方式定场景边界,抽取只填内容 工作量最大

我的建议:先查 ③(API 有没有 seed),没有就按 ① 走 —— 记忆的"稳定"未必需要逐字复现,只要同一件事不会被记成两件不同的。 而 ② 的成本(¥27→¥54)其实也不贵。


六、成本与规模(实测基数)

ASTR 全量:   2009 个剧本 · 411,192 对白行
本轮样本:    653 行 · 29.3 秒 · ¥0.01(含第一人称,输出更长了)

全量估算(按实测基数外推):
  事件抽取   约 3 小时 · ¥15~30
  关系抽取   约 ¥19(另一脚本)
  时间线     ¥0(元数据直接用)
  ─────────────────────
  合计       约 ¥35~50 / 错峰约一半

⚠️ 全量之前,时间线方案(A/B/C)要先定 —— 见 关系RAG-可行性评估 与下面第七节。


七、时间线的落地路径(C)

好消息:元数据已经够了,不需要抽。 实测每个 ASTR 剧本自带:

{ "stage_code": "END8-1", "event_id": "main_8", "event_name": "怒号光明",
  "entry_type": "MAINLINE", "segment": "level_st_08-06", "story_info": "……官方剧情简介" }

entry_type 实测分布:ACTIVITY 1058 · MAINLINE 429 · NONE 390 · MINI_ACTIVITY 132。

排序线索:main_N(主线章节 0~17,可直接排)· act\d+(活动序号)· story(干员密录)。

⚠️ story_order 不存在(实测 0/2009,ASTR 原始数据里就没有)。 而 act 序号是发行顺序的近似不等于世界观内时间 —— 活动里有前传、有插叙。我不假装它是正确的时间顺序。

方案 做法 忠实度 成本
A. 章节级近似 main_N + act 序号排序 主线较准,活动可能错序
B. 人工校订顺序 你给一份"活动先后"名单 你的时间
C. 从文本抽时间线索 抽"三年前""翌日"跨剧本推理 最准 每剧本一次 LLM(而 time_hint 已经在事件里抽了)

⚠️ 注意 C 和事件抽取有重叠:本脚本已经在每个事件上抽了 time_hint。 所以"按时间排序"可能不需要额外抽取,只要把 time_hint 用起来 (实测样本里就抽到了「三十六个小时里 / 两周后 / 数月之久」)。 这条值得先试,成本为零。


八、下一步(按依赖排)

做什么 依赖
1 定"抖动"方案(第五节)
2 定时间线方案(第七节,建议先试"用 time_hint")
3 设计"事件 → 按干员反查"的存取 事件 schema 已定
4 跑全量事件抽取(~3 小时 / ¥15~30) 1、2
5 关系抽取补 B(现有脚本,~¥19) 独立
6 接进 prompt(含剧透边界 ⚠️ 见下

⚠️ 第 6 步的边界(已定)

已知全部剧情(2026-09-12 用户决定)→ 不需要"玩家剧情进度"存储,也不需要给记忆加过滤维度。

stage_code 仍要保留在记忆里 —— 那是给引用用的 ("我在《怒号光明》里说过"),不是用来过滤的。