13 KiB
13 KiB
故事提取工具 - 提取逻辑设计文档 v4
一、目标
输入:录像转文字稿(回溯疗愈课堂的对话记录,一个或多个 DOCX 文件)
输出:每个学员一个结构化故事,包含 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 解析失败 |