Files
Story-extractor/docs/extraction-design.md
2026-04-24 16:02:16 +08:00

13 KiB
Raw Blame History

故事提取工具 - 提取逻辑设计文档 v4

一、目标

输入:录像转文字稿(回溯疗愈课堂的对话记录,一个或多个 DOCX 文件)

输出:每个学员一个结构化故事,包含 5 个部分:

  1. 讲述者简介
  2. 当下的困境
  3. 过往的故事
  4. 转变
  5. 原文金句

核心原则:把案主回溯的内容和面临的议题提取整理成第三人称故事。


二、整体流程

多个文档 → 按顺序逐个处理 → 每个文档独立输出故事 → 汇总展示
  • 多个文档按顺序处理,一个文档处理完再处理下一个(避免 API 限流)
  • 每个文档内部:代码预处理 → 并行判断/提取 → 并行切片提取 → 重组 → 并行生成故事
  • 核心优化:所有 LLM 调用均使用 asyncio.gather 并行执行,总耗时 ≈ 最慢的单次调用耗时,而非所有调用耗时之和

三、单文档处理流程(核心)

第一步:代码预处理(不用 LLM

目的:把原始对话记录拆分成"以学员为单位"的段落,每个段落包含该学员从第一次发言到最后一次发言之间的所有内容

不做的事情

  • 不过滤课堂管理内容(实测过滤掉的行很少,且关键词硬编码不通用,不值得做)

具体逻辑

1.1 统计说话人

遍历所有行,统计每个说话人的:

  • 发言行数
  • 总字数
  • 第一次出现的行号first
  • 最后一次出现的行号last

1.2 识别老师

  • 发言行数最多的人就是老师
  • 不依赖硬编码名字,适用于任何课程

1.3 过滤杂音

  • 老师本身不参与拆分
  • 发言 < 20 句的说话人视为杂音,直接跳过(通常是主持人、助教、偶尔插话的人)

1.4 按时间范围截取(核心逻辑)

对每个有效学员,从该学员第一次发言的行最后一次发言的行,中间所有行全部截取,形成一个完整段落。

关键特性:不同学员的段落之间允许重叠。

举例说明:

原文行号:  131  132  ...  167  168  ...  321  322  ...  429  430  ...  599  600  ...  800
说话人:    04   02   ...  05   02   ...  06   02   ...  04   02   ...  05   02   ...  07

说话人04段落截取行[131-429]包含04、02(老师)、05、06的所有对话
说话人05段落截取行[167-599]包含05、02(老师)、06、04的部分对话
说话人07段落截取行[603-800]包含07、02(老师)的对话

为什么允许重叠?

  • 课堂场景中多个学员可能围绕同一个主题互动比如说话人04和说话人05都在讨论高考漏题创伤
  • 老师在不同学员之间的引导是连贯的,不应该被割裂
  • 重叠部分的老师对话对两个学员的故事都有价值,应该分别保留

1.5 过滤

对截取后的段落进行两轮过滤:

过滤条件 A短段落

  • 学员发言 < 15 句 → 丢弃(不太可能有故事)
  • 学员总字数 < 80 字 → 丢弃

过滤条件 B非主讲述人

  • 当事人发言行数 / 段落总行数 < 10% → 丢弃
  • 含义:截取了很大一段范围,但里面绝大部分话不是该学员说的,说明该学员只是旁观者或偶尔插话,不是主讲述人
  • 举例说话人03截取了 658 行,但其中只有 21 行是03说的占比 3.2%说明03在整个课堂期间只是偶尔插话不是主讲述人

1.6 输出

  • 每个通过过滤的学员一个段落
  • 段落包含该学员时间范围内的所有对话(老师、其他学员、本人)
  • 段落按学员第一次出现的时间排序

第二步:判断故事 + 提取事实(并行执行)

目的:根据段落长度走不同路径,短段落省一次 LLM 调用。所有段落并行处理,互不阻塞。

并行策略:使用 asyncio.gather 同时处理所有段落,总耗时 ≈ 最慢那个段落的处理时间。

短段落(≤ 4000 字)

  • 跳过"判断故事"步骤,直接让 LLM 提取事实
  • 理由:短段落内容少,提取失败的成本低,省一次判断调用更划算

长段落(> 4000 字)

  • 先让 LLM 判断"是否包含个人故事"
  • 输出:{is_story: true/false, confidence: 0.0~1.0, story_hints: "一句话描述"}
  • confidence < 0.5 或 is_story = false → 跳过,不浪费后续切片提取的调用
  • 有故事 → 进入第三步切片提取

第三步切片提取事实Map 阶段,仅长段落执行)

目的:对确认有故事的长段落,切成小块并行提取关键信息,避免输出截断。

为什么拆成"提取事实"和"生成故事"两步?

  • 直接让 LLM 从长文本写故事 → 输出太长,必然截断
  • 先提取事实(输出短,不会截断)→ 再基于短素材写故事(输入短,输出可控)

切片策略

  • 以自然段为最小单位,累加到约 3000 字时,在最近的段落结尾处切断
  • 块之间重叠 5 个自然段(避免信息断裂)
  • 短段落(≤ 4000 字)在第二步已直接提取,不会进入此步

每块的提取任务(并行执行):

  • 输入:一块对话文本 + 该学员的故事主题(来自第二步的 hints
  • 输出:结构化事实
    {
      "events": ["事件1尽量详细", "事件2"],
      "emotions": ["情感1", "情感2"],
      "quotes": ["讲述者原话1", "讲述者原话2", "讲述者原话3"],
      "key_people": ["相关人物1", "相关人物2"]
    }
    
  • 关键约束:每块只提取事实,不做写作。尽量详细、尽量多提取,保留具体细节

第四步重组Reduce 阶段,纯代码)

目的:把多个切片的提取结果合并成完整的素材。

  • 输入:同一学员所有切片的提取结果
  • 处理:
    • 事件:按原文出现顺序排列,去除重复
    • 情感:去重
    • 原话:去重(完全相同的去掉),不限制数量,不限制长度
    • 人物:去重
  • 输出:完整素材
    {
      "events": ["按时间排序的事件列表"],
      "emotions": ["去重后的情感列表"],
      "quotes": ["去重后的原话列表(全部保留)"],
      "key_people": ["去重后的相关人物列表"],
      "story_hints": "故事主题"
    }
    

第五步:生成最终故事(并行执行)

目的:基于重组后的素材,生成结构化的第三人称故事。所有故事并行生成,互不阻塞。

并行策略:使用 asyncio.gather 同时生成所有故事,总耗时 ≈ 最慢那个故事的生成时间。

  • 输入:重组后的完整素材(短文本,几百字)
  • 输出:最终故事 JSON
    {
      "title": "标题",
      "summary": "80字摘要",
      "tags": ["标签1", "标签2"],
      "speaker_nickname": "讲述者昵称",
      "content": {
        "讲述者": "讲述者介绍(不限制长度,充分展开)",
        "当下的困境": "当前面临的困境(不限制长度,充分展开)",
        "过往的故事": "过去的经历故事(不限制长度,充分展开)",
        "转变": "疗愈过程中的转变(不限制长度,充分展开)",
        "原文金句": ["原话1", "原话2", "原话3"]
      }
    }
    
  • 关键LLM 的输入是"已提取好的素材"(几百字),不是原始长文本
  • LLM 的任务是"基于素材写作",不需要再从原文中提取信息
  • 不限制输出长度,要求素材中的具体经历、事件细节、情感变化尽量保留,让故事丰满
  • 原文金句为数组格式,避免字符串中的引号导致 JSON 解析失败

四、数据流图

原始 DOCX 文件
    │
    ▼
[代码预处理]
    │  1. 统计说话人(行数、字数、首末行号)
    │  2. 识别老师(发言最多的人)
    │  3. 过滤杂音(< 20句
    │  4. 按时间范围截取(第一句到最后一句,允许重叠)
    │  5. 过滤短段落(<15句 / <80字
    │  6. 过滤非主讲述人(当事人占比 < 10%
    ▼
学员段落列表: [学员A段落, 学员B段落, 学员C段落, ...]
    │
    ▼
[第二步:并行判断 + 提取] ── asyncio.gather 并行处理所有段落
    │  ├─ 短段落(≤4000字)直接提取事实省1次调用
    │  └─ 长段落(>4000字):先判断是否有故事 → 有故事则进入第三步
    │  ⏱ 总耗时 ≈ 最慢那个段落的处理时间
    │
    ▼ (仅长段落且有故事)
[第三步:并行切片提取] ── asyncio.gather 并行处理所有切片
    │  按3000字切块以自然段为单位重叠5个自然段
    │  ⏱ 总耗时 ≈ 最慢那个切片的提取时间
    │
    ▼
[第四步:代码重组] ── 去重、排序、合并(纯代码,无需 LLM
    │
    ▼
[第五步:并行生成故事] ── asyncio.gather 并行生成所有故事
    │  基于素材生成5部分结构化故事
    │  ⏱ 总耗时 ≈ 最慢那个故事的生成时间
    │
    ▼
最终故事列表

五、泛化性设计

环节 设计 为什么通用
识别老师 统计发言行数,最多的就是老师 不依赖硬编码名字
过滤杂音 发言 < 20句直接跳过 适应各种偶尔插话的人
按时间范围截取 从第一句到最后一句,中间所有内容 保留完整上下文,不丢失老师对话
允许重叠 不同学员段落可共享内容 课堂中多学员讨论同一主题时,各自保留完整对话
过滤非主讲述人 当事人占比 < 10% 丢弃 自动识别旁观者/插话者,只保留主讲述人
切片 3000字一块5个自然段重叠 适应任意长度

六、LLM 调用清单

步骤 调用次数 并行方式 输入大小 输出大小 用途
第二步:短段落提取 短段落各 1 次 并行asyncio.gather ≤4000 字 JSON 直接提取事实
第二步:长段落判断 长段落各 1 次 并行asyncio.gather ≤4000 字 ~100 字 JSON 过滤非故事段落
第三步:切片提取 每块 1 次 并行asyncio.gather ≤3000 字 JSON 提取事件/情感/原话
第五步:生成故事 每个故事 1 次 并行asyncio.gather 素材 ≤1000 字 JSON 生成最终故事

典型场景:一个文档有 4 个学员段落2短2长其中 3 个有故事:

  • 第二步2次短段落提取 + 2次长段落判断 = 4 次(并行,耗时 ≈ 1次调用时间
  • 第三步假设2个长段落都有故事各切2块 = 4 次(并行,耗时 ≈ 1次调用时间
  • 第五步3 次(并行,耗时 ≈ 1次调用时间
  • 总计11 次 LLM 调用

耗时对比

方案 第二步 第三步 第五步 总耗时估算
串行v3 ~4次 × 30s = 120s ~4次 × 30s = 120s ~3次 × 60s = 180s ~7 分钟
并行v4 ~30s并行 ~30s并行 ~60s并行 ~2 分钟

注:以上为单次调用约 30-60s 的粗略估算,实际耗时取决于模型响应速度和网络延迟。并行化后总耗时从分钟级降至约 2-3 分钟。


七、变更记录

变更 v2 v3 v4 原因
拆分方式 按学员拆分 + 同名合并 按时间范围截取(第一句到最后一句) 不变 v2 会丢失段落间的老师对话v3 保留完整上下文
段落互斥性 互斥(每个对话行只属于一个段落) 允许重叠(同一行可出现在多个段落) 不变 课堂中多学员讨论同一主题,老师对话对双方都有价值
杂音过滤 无(依赖短段落过滤) 发言 < 20句直接跳过 不变 提前排除主持人、助教等偶尔插话的人
非主讲述人过滤 当事人占比 < 10% 丢弃 不变 自动识别旁观者,只保留真正有故事的学员
判断故事 所有段落都先判断 短段落(≤4000字)跳过判断直接提取 不变 省一次 LLM 调用
切片重叠 200字重叠 5个自然段重叠 不变 按自然段重叠更合理,不会在句子中间断开
过滤管理行 不做 不做 不变 过滤掉的行很少,关键词不通用
识别老师 统计发言行数 统计发言行数(不变) 不变 通用性
提取故事 先提取事实,再基于素材写故事 先提取事实,再基于素材写故事(不变) 不变 避免输出截断
LLM 调用方式 串行 串行 全并行asyncio.gather 总耗时从 ~7 分钟降至 ~2 分钟
输出长度限制 有字数限制 有字数限制 不限制 故事更丰满,保留更多细节
原文金句格式 字符串 字符串 数组 避免引号导致 JSON 解析失败