B 层剧情事件抽取
EVENT EXTRACTION II
状态:🟢 全量完成(2009/2009)· 最后核对 2026-09-12 范围:
data/story_astr/全部 2009 个剧本的单轮全量抽取,不含关系抽取与归并 产物:scripts/fetch_story_events.py→data/story_events/(2009 个 JSON,18,663 个事件) 相关:剧情关系抽取-首轮.md · 剧情数据管线-实施方案.md
读者定位:本文记的是这一次运行的过程与坑,不是管线设计。 若只想知道"现在到哪了",看 STATUS.md;若想知道"还差哪些没抽",直接跑
.venv/Scripts/python.exe scripts/fetch_story_events.py --dry-run。
0. 一句话
全量抽取已跑完且产物完整(2009/2009、0 解析失败、_failed.json 为空)。
唯一的"失败"是一个空剧本占位条目被误报;该误报的修复随后已随 d9b9697d 进入代码,
因此本文只做记录,不改动任何源码。
1. 最终结果
| 项 | 值 |
|---|---|
| 剧本数 | 2009 / 2009(无遗漏) |
| 累计事件数 | 18,663 |
| 平均每剧本 | 9.3 个事件(中位 9,最多 28,最少 0) |
| JSON 解析失败 | 0 |
_failed.json |
[] |
残留 .tmp |
0(原子写干净) |
| 脚本权威复检 | --dry-run 报 待跑 0 个剧本(共 2009) |
| 运行窗口 | 11:51:32 → 13:18:06(约 87 分钟) |
复检命令(不调 API,只看"还差哪些"):
cd F:/Rhodes-island
.venv/Scripts/python.exe scripts/fetch_story_events.py --dry-run
# 输出形如:[13:19:54] 待跑 0 个剧本(共 2009)· workers=3
2. 时间线(含并发与就地改码)
| 时刻 | 事件 |
|---|---|
| 11:51:30 | 第一组进程启动(pid 12820 → 25236),开始写 data/story_events_run.log |
| 11:51:32 | 日志首行:待跑 1709 个剧本(共 2009)· workers=6(1709 = 2009 − 当时已存在的产物) |
| 11:53:19 | 第二组进程启动(pid 5952 → 23360),同一命令行 --workers 6,重复劳动 |
| 12:40 前后 | 排查中终止第一组(taskkill /PID 25236 /T /F);写日志的就是它,日志随即停在 1090/1709 并写 EXIT=1 |
| 12:42–13:18 | 保留的 worker 继续产出,产物文件数持续增长至 2009 |
| 13:18:30 | scripts/fetch_story_events.py 被就地修改(+24 秒前任务刚结束) |
| 13:19:48 | 该修改随提交 d9b9697d 进入仓库 |
| 13:19 | 核对:发现空剧本误报失败,重建其产物、清空 _failed.json |
⚠️ 可复现性隐患:任务运行期间(以及刚结束时)源码被改动。Python 在启动时读入文件,运行中的进程仍执行内存里的旧代码——所以本次运行的行为属于已废弃的版本,不能用当前 HEAD 去复算这次的产物。要复现得先 checkout 到运行时的版本。
3. 唯一那个"失败":空剧本误报
产物里的 obt__main__level_main_14-20_beg.json 是 events: [],并被记进 _failed.json。
根因:该剧本在源数据里是空占位条目(lines: 0、line_count: 0)。全库 2009 个剧本里只有这 1 个是空的。核心脚本当时给出的判词是:
problems: ["原样 0 行 —— 空剧本,跳过 LLM"]
events: [] 是正确结论,不是抽取失败。
为什么它"永远修不掉":脚本的跳过判据是"产物文件存在就跳过",而这次运行写出了产物(空 events)。于是 --retry-failed 也不认:
[13:19:18] 待跑 0 个剧本(共 2009)· workers=3
[13:19:18] 没有要跑的
失败清单里留着它 → 每次 --retry-failed 白跑;而若真去重跑,又会真的调一次 LLM。
作者其实预见了一半这个陷阱,one() 里的原子写注释写着:
⚠️ 原子写:中途断掉不许留下半个 JSON(那会让"跳过"逻辑永久跳过它)
原子写挡住了"半个 JSON",但**"有效写入 + 语义失败"(空 events)漏掉了**。
4. 该问题已修复(无需再动手)
当前 HEAD 已包含空剧本处理,提交 d9b9697d(2026-09-12 13:19:48):
# scripts/fetch_story_events.py,one() 内、json.loads 之后
# ⚠️ 空剧本(0 行)不是失败 —— 数据里本来就没有内容。
if not (stage.get("lines") or []):
result = {
"schema_version": 1,
"stage": stage.get("stage") or {},
"source": stage.get("source"),
"model": env.get("LLM_MODEL", DEFAULT_MODEL),
"line_count": 0,
"generated_by": "scripts/fetch_story_events.py",
"generated_at": datetime.now(timezone.utc).isoformat(),
"events": [],
"unresolved": [],
"problems": ["原文 0 行 —— 空剧本,不调用 LLM"],
}
# (写产物并返回成功,不进 _failed.json)
判词已从运行时的 原样 0 行 改为 原文 0 行——这个字面差异正是区分"旧版/新版"的标记,也说明本次运行的产物来自旧版逻辑。
核对时已按这个结构重建了该产物并清空了 _failed.json,所以现有产物与新代码的结构一致。
5. 其他值得记的坑
5.1 重复跑(API 账单翻倍)
两组进程命令行完全相同(都是 --workers 6,无分片参数),说明是同一批 1709 个剧本被抽了两遍。产物文件名由剧本 key 决定,重复写只是覆盖,不会损坏结果——代价纯粹是 API 费用。
背景:第二组启动的原因是当时的会话误判第一组已死(日志里出现过"fetch 进程数: 0",其实进程还活着),于是重启了一次。
建议:脚本加一个单实例锁(PID 文件或端口占用),或至少启动时打印"已有同命令实例在跑"并退出。
5.2 日志被 > 截断
启动命令用了 > /data/story_events_run.log。若两次运行并发启动,后启动者的重定向会清空前者的日志(本次日志头部缺第二组的 待跑 行,就是这个原因)。用 >> 追加、或把日志名带时间戳。
5.3 单次请求超时 10 分钟,最坏一条 30 分钟
extract_story_events.py 的 call_llm() 用 urlopen(req, timeout=600),而 --retry 默认 3 次。最后两条是 --order size 排最前的大剧本,收尾阶段因此比中途估算晚了约 25 分钟(中途 ETA 报 12:54,实际 13:18 结束)。对长剧本,进度停顿几分钟是正常的,别误判成卡死。
5.4 未跟踪的运行日志会被误提交
?? data/story_events_run.log
.gitignore 只忽略了 data/logs/、data/story_cache/ 等,没有覆盖 data/*_run.log。建议补一条:
data/*_run.log
6. 核对方法(可复用)
判活:任务长时间没有输出时,看它自己的产物是否还在推进,不要只看 CPU——网络 I/O 密集的任务 CPU 本就接近 0。本仓库的产物指标有两个,--dry-run 那个是权威的:
# 权威:脚本自己算"还差多少"(不调 API)
.venv/Scripts/python.exe scripts/fetch_story_events.py --dry-run | head -1
注意不要拿"ls data/story_events/*.json | wc -l 对比 2009"来判进度:该目录混有非剧本产物(如 story_events_level_st_08-06.json),且历史运行的产物也在里面,计数会偏高——本次核对中就因此一度误判进度。
验完整性:
cd F:/Rhodes-island && .venv/Scripts/python.exe - <<'PY'
import json, pathlib
OUT = pathlib.Path('data/story_events'); bad = []; empty = []; events = 0; total = 0
for p in sorted(OUT.glob('*.json')):
if p.name.startswith('_'): continue
total += 1
try: d = json.loads(p.read_text(encoding='utf-8'))
except Exception: bad.append(p.name); continue
ev = d.get('events')
if not isinstance(ev, list): bad.append(p.name); continue
if not ev: empty.append(p.name)
events += len(ev)
print(f'文件 {total} · 解析失败 {len(bad)} · events 空 {len(empty)} {empty} · 事件总数 {events}')
PY
7. 收尾状态
- 产物:2009/2009 齐全、全部可解析,
_failed.json == [],无.tmp残留 - 进程:本轮提取的 python 进程已全部退出(无遗留 worker)
- 未改动脚本:第 4 节的修复已在 HEAD 中,本次核对没有修改任何源码
- 待办(建议,未执行):
5.1单实例锁、5.2日志追加/带时间戳、5.4补.gitignore