# 故事提取工具 - 提取逻辑设计文档 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) - 输出:结构化事实 ```json { "events": ["事件1(尽量详细)", "事件2"], "emotions": ["情感1", "情感2"], "quotes": ["讲述者原话1", "讲述者原话2", "讲述者原话3"], "key_people": ["相关人物1", "相关人物2"] } ``` - **关键约束**:每块只提取事实,不做写作。尽量详细、尽量多提取,保留具体细节 --- ### 第四步:重组(Reduce 阶段,纯代码) **目的**:把多个切片的提取结果合并成完整的素材。 - 输入:同一学员所有切片的提取结果 - 处理: - 事件:按原文出现顺序排列,去除重复 - 情感:去重 - 原话:去重(完全相同的去掉),不限制数量,不限制长度 - 人物:去重 - 输出:完整素材 ```json { "events": ["按时间排序的事件列表"], "emotions": ["去重后的情感列表"], "quotes": ["去重后的原话列表(全部保留)"], "key_people": ["去重后的相关人物列表"], "story_hints": "故事主题" } ``` --- ### 第五步:生成最终故事(并行执行) **目的**:基于重组后的素材,生成结构化的第三人称故事。**所有故事并行生成,互不阻塞。** **并行策略**:使用 `asyncio.gather` 同时生成所有故事,总耗时 ≈ 最慢那个故事的生成时间。 - 输入:重组后的完整素材(短文本,几百字) - 输出:最终故事 JSON ```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 解析失败 |