This commit is contained in:
2026-04-24 16:02:16 +08:00
commit 1d6f0cc370
66 changed files with 9990 additions and 0 deletions

295
docs/extraction-design.md Normal file
View File

@@ -0,0 +1,295 @@
# 故事提取工具 - 提取逻辑设计文档 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 解析失败 |