罗德岛通讯与技术部

B 层剧情事件抽取

EVENT EXTRACTION II

全量抽取已跑完且产物完整(2009/2009、0 解析失败、_failed.json 为空)。

状态:🟢 全量完成(2009/2009)· 最后核对 2026-09-12 范围:data/story_astr/ 全部 2009 个剧本的单轮全量抽取,不含关系抽取与归并 产物:scripts/fetch_story_events.pydata/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.jsonevents: [],并被记进 _failed.json

根因:该剧本在源数据里是空占位条目lines: 0line_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.pycall_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