剧情事件抽取
EVENT EXTRACTION I
状态:🟡 验证完成,待定范围 · 最后核对 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 仍要保留在记忆里 —— 那是给引用用的
("我在《怒号光明》里说过"),不是用来过滤的。