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

View File

@@ -0,0 +1,410 @@
# Story Extractor — 产品设计文档
> 版本v1.1 · 2026-04-21
---
## 一、产品概述
### 1.1 产品定位
Story Extractor 是一个 **局域网部署的 Web 工具**,用于从直播回放文字稿中自动提取学员个人故事,与讲述者个人信息匹配后,为每位讲述者生成独立的 DOCX 文档。
### 1.2 核心价值
- **自动化**AI 自动从上万行口语化文字稿中提取学员故事,替代人工阅读筛选
- **结构化**:将散乱的直播内容转化为结构化的"人物 + 故事"文档
- **可复用**工具化交付支持后续扩展LLM 升级、搜索引擎等)
### 1.3 使用场景
| 维度 | 描述 |
|------|------|
| **部署环境** | 局域网,多人可访问 |
| **数据规模** | 20+ 份文字稿每份上万行8+ 位讲述者 |
| **用户角色** | 内容运营人员 |
---
## 二、文字稿特征分析
### 2.1 格式特征
每段格式为:**`发言者(时间戳): 内容`**,例如:
```
卢慧老师(00:05:38): 这个我们在我们的启蒙课里面...
14班+HCM(02:39:31): 就是要要打要骂,呵呵...
```
- 有明确的**说话人标记**(老师 / 学员昵称)
- 有**时间戳**,可用于判断发言时长
- 主讲老师占 60-70% 内容,学员发言散落其中
### 2.2 内容构成
| 内容类型 | 占比 | 说明 |
|----------|------|------|
| **课程讲授** | ~55% | 老师讲理论、概念、方法论 |
| **课堂管理** | ~10% | 改昵称、纪律提醒、进组讨论等技术性内容 |
| **疗愈练习** | ~15% | "我愿意看见且释放..." 引导式重复 |
| **学员故事** | ~15% | 学员讲述个人经历,老师追问 |
| **学员短回应** | ~5% | "好的""收到"等 |
### 2.3 故事的实际形态
故事**不是一段完整的自述**,而是**对话式展开**的:
- 老师提问/引导 → 学员回答 → 老师追问 → 学员深入讲述 → 可能穿插疗愈练习
- 一个故事可能跨 **20-30 段对话**,持续 15-30 分钟
- 故事之间没有明显分界,可能上一段还在讲理论,下一段就切入学员故事
### 2.4 典型故事示例
| 讲述者 | 故事概要 |
|--------|----------|
| **14班+HCM** | 从小被妈妈体罚(跪凳子),长大后用同样方式对待女儿,女儿出现躯体化症状 |
| **13-阿纳** | 姥姥因特殊年代藏首饰恐惧自杀,形成"不能有钱"的家族限制性信念 |
| **06班Rena** | 妈妈在怀自己前打掉一个男孩,爸爸重男轻女,自己被当男孩养,对亲密关系有破坏倾向 |
| **11班四维** | 女儿出柜说喜欢同性,追溯到自己出生时妈妈因生女孩而悲伤的家族重男轻女 |
---
## 三、用户流程
### 3.1 流程总览
```
欢迎页 → 上传文件 → 提取故事 → 匹配确认 → 预览导出
```
采用 **线性向导式** 交互,用户依次完成四个步骤。
### 3.2 步骤指示器
- 顶部居中展示 4 个步骤节点(上传文件 → 提取故事 → 匹配确认 → 预览导出)
- 当前步骤高亮(紫色),已完成步骤显示绿色 ✓
- 步骤之间用连线表示进度
- 欢迎页不显示步骤指示器
- 步骤节点可点击跳转(已完成的步骤)
### 3.3 底部导航栏
- 固定在页面底部
- 左侧:上一步按钮(第一步时隐藏)
- 中间:当前步骤提示文案
- 右侧:下一步按钮(最后一步变为"全部导出"
- 欢迎页不显示底部导航栏
---
## 四、页面详细设计
### 4.0 欢迎页
**目的**:产品介绍 + 引导用户开始
**布局**:页面垂直居中
**内容**
- 产品 Logo紫色渐变圆角方块 + 书本图标)
- 产品名称 "Story Extractor"
- 一句话介绍:"从直播回放文字稿中AI 自动提取个人故事,与讲述者信息匹配,一键生成精美文档。"
- 四步流程预览(图标 + 标题 + 副标题):
- 📤 上传文件 — 文字稿 + 个人信息
- 🪄 提取故事 — AI 智能识别
- 🔗 匹配确认 — 人工核对
- 📄 预览导出 — 生成 DOCX
- 大号 CTA 按钮:"开始使用"
**交互**
- 点击"开始使用"进入第一步(上传文件)
- 不显示步骤指示器和底部导航栏
---
### 4.1 第一步:上传文件
**目的**:上传直播文字稿和讲述者个人信息
**布局**:左右两栏
**左栏 — 直播文字稿**
- 标题:直播文字稿(蓝色图标)+ 右侧标注 "DOCX 格式"
- 上传区域(虚线边框拖拽区):
- 图标 + "拖拽文件到此处,或点击上传"
- 提示:支持批量上传 · 仅限 .docx 格式
- 已上传文件列表:
- 每个文件显示:文件图标(蓝色 DOCX+ 文件名 + 元信息(行数、大小)
- 右侧删除按钮
**右栏 — 讲述者信息**
- 标题:讲述者信息(粉色图标)+ 右侧标注 "一人一个 DOCX"
- 上传区域(虚线边框拖拽区):
- 图标 + "拖拽文件到此处,或点击上传"
- 提示:每个讲述者一个 DOCX · 包含个人信息和照片
- 已上传文件列表:
- 每个文件显示:文件图标(粉色)+ 文件名 + 元信息(含几张照片)
- 右侧预览按钮 + 删除按钮
**交互**
- 底部导航提示:"上传文字稿和讲述者信息文件后进入下一步"
- 点击"下一步"进入第二步
---
### 4.2 第二步:提取故事
**目的**AI 自动从所有文字稿中提取学员个人故事
**进入方式**:从第一步点击"下一步"后自动进入,**自动开始提取**,无需手动触发
#### 阶段 A提取进度自动展示
**布局**:单卡片
**内容**
- 标题:"AI 正在工作中" + 右侧百分比(大号加粗)
- 进度条(粗圆角,紫色渐变填充)
- 当前处理状态:
- 运行状态指示灯(紫色脉冲动画)
- 当前文件名:"正在读取xxx.docx"
- 当前动作:
- "预处理:过滤课堂管理内容..."
- "预处理:过滤疗愈练习..."
- "智能分段:识别话题边界..."
- "故事识别:检测学员发言..."
- "故事提取:整理故事内容..."
- 三列状态卡片:
- 文件读取:`X / 23`(已读文件数 / 总文件数)
- 话题分段:`XX 段`(已分段数量)
- 故事发现:`X 个`(已发现故事数量,发现后变绿色)
**进度阶段**
1. 0-15%:读取文件,预处理(过滤课堂管理、短回应)
2. 15-40%:智能话题分段
3. 40-70%:识别学员故事片段
4. 70-95%:提取故事内容,生成标题和摘要
5. 95-100%:汇总结果
**完成时**
- 进度条变绿色
- 状态灯变绿色
- 显示"全部处理完成!共处理 X 份文字稿,发现 X 个故事"
- 1.2 秒后自动切换到故事列表
#### 阶段 B故事列表提取完成后展示
**布局**:单卡片
**内容**
- 标题:"已提取的故事" + 右侧 "共 X 个故事"
- 故事卡片列表,每个卡片包含:
- 故事标题(加粗)
- 讲述者昵称(从文字稿提取,如"14班+HCM"
- 故事摘要2-3 句话)
- 来源信息(文件名 + 行号范围 + 时长)
- 置信度标签(高/中/低)
- 操作按钮:查看原文、编辑、删除
**交互**
- 底部导航提示:"AI 已提取故事,下一步将故事与讲述者匹配"
- 点击"下一步"进入第三步(**无需全部匹配,可直接进入**
---
### 4.3 第三步:匹配确认
**目的**:将提取的故事与讲述者一一对应(可选操作)
**布局**:三栏(故事列表 | 匹配操作 | 讲述者列表)
**左栏 — 故事列表**
- 标题:"故事列表" + 右侧 "X 个故事"
- Tab 切换:全部 / 已匹配 / 未匹配
- 故事卡片列表:
- 故事标题
- 讲述者昵称(文字稿中的昵称,如"14班+HCM"
- 故事摘要(简短版)
- 匹配状态标签(已匹配显示讲述者姓名,未匹配显示"待匹配"
- 点击选中(高亮为橙色背景)
- 操作:删除(不满意的故事可直接删除)
**中间 — 匹配操作**
- 紫色圆形箭头按钮
- 提示文字:"选中故事和讲述者后点击匹配"
**右栏 — 讲述者列表**
- 标题:"讲述者" + 右侧 "共 X 人"
- 讲述者卡片列表:
- 头像(姓氏首字,紫色渐变背景)
- 姓名
- 文字稿昵称映射(如:张小花 → 14班+HCM
- 匹配状态(已匹配 X 个故事 / 未匹配)
- 点击选中(紫色边框高亮)
**交互**
- 选中左侧故事 + 选中右侧讲述者 → 点击中间箭头确认匹配
- 匹配完成后故事状态变为"已匹配"
- **可删除不满意的故事**AI 可能提取出无效故事)
- **无需全部匹配即可进入下一步**(未匹配的故事不会导出)
- 底部导航提示:"将需要的故事与讲述者匹配,未匹配的故事不会导出"
---
### 4.4 第四步:预览导出
**目的**:预览最终文档效果并导出
**布局**:左右两栏
**左栏 — 导出列表**
- 标题:"导出列表" + 右侧 "仅显示已匹配的讲述者"
- 导出项列表,每项包含:
- 状态图标(绿色 ✓ = 已匹配可导出)
- 文件名:"张小花 - 故事集.docx"
- 元信息:"X 个故事 · 含个人信息和照片"
- 预览按钮、下载按钮
- 未匹配的故事不会出现在导出列表中
**右栏 — 文档预览**
- 标题:"文档预览" + 上一个/下一个切换按钮
- 文档预览卡片(模拟 DOCX 效果):
- 顶部:紫色渐变头部
- 圆形头像占位
- 讲述者姓名
- 个人信息(性别 · 年龄 · 城市 · 职业)
- 正文区域:
- "她的故事" 标题(紫色下划线)
- 故事正文(多段落,如有多个故事依次排列)
**交互**
- 点击"上一个/下一个"切换预览不同讲述者的文档
- 点击单个下载按钮导出单个 DOCX
- 底部"全部导出"按钮(绿色)批量导出所有已匹配讲述者的文档
---
### 4.5 系统设置(弹窗)
**触发**:右上角齿轮图标
**布局**:居中弹窗 Modal
**内容**
**LLM 配置**
- API 提供商下拉选择OpenAI (GPT-4) / DeepSeek / 通义千问 / 本地 Ollama / 自定义兼容 API
- 模型名称(文本输入)
- API Base URL文本输入
- API Key密码输入
**提取参数**
- 话题分段最大长度(行数)
- 故事最小长度(行数)
- 置信度阈值(低于此值的故事标记为"低置信度"
- 并发请求数
- Temperature
**操作按钮**
- 取消、测试连接、保存
---
## 五、数据模型
### 5.1 文字稿Transcript
| 字段 | 类型 | 说明 |
|------|------|------|
| id | string | 唯一标识 |
| filename | string | 原始文件名 |
| file_path | string | 服务器存储路径 |
| line_count | int | 总行数 |
| file_size | int | 文件大小(字节) |
| status | enum | pending / processing / done / error |
| uploaded_at | datetime | 上传时间 |
### 5.2 讲述者Person
| 字段 | 类型 | 说明 |
|------|------|------|
| id | string | 唯一标识 |
| name | string | 真实姓名 |
| nickname | string | 文字稿中的昵称(如"14班+HCM" |
| filename | string | 原始 DOCX 文件名 |
| file_path | string | 服务器存储路径 |
| photo_path | string | 提取的照片路径 |
| info | json | 从 DOCX 提取的个人信息(年龄、性别、城市、职业等) |
| uploaded_at | datetime | 上传时间 |
### 5.3 故事Story
| 字段 | 类型 | 说明 |
|------|------|------|
| id | string | 唯一标识 |
| title | string | AI 生成的故事标题 |
| summary | string | AI 生成的故事摘要 |
| content | text | 完整故事内容(仅学员讲述部分,已过滤老师对话) |
| speaker_nickname | string | 文字稿中的讲述者昵称(如"14班+HCM" |
| source_transcript_id | string | 来源文字稿 ID |
| source_lines | json | 在原文中的行号范围 [{start, end}] |
| duration_minutes | float | 预估时长(分钟) |
| confidence | float | 置信度0.0-1.0 |
| person_id | string | 匹配的讲述者 ID可为空 |
| match_status | enum | pending / matched / deleted |
| created_at | datetime | 提取时间 |
### 5.4 系统配置Config
| 字段 | 类型 | 说明 |
|------|------|------|
| llm_provider | string | API 提供商 |
| llm_model | string | 模型名称 |
| llm_base_url | string | API Base URL |
| llm_api_key | string | API Key加密存储 |
| segment_max_lines | int | 话题分段最大行数 |
| story_min_lines | int | 故事最小行数 |
| confidence_threshold | float | 置信度阈值 |
| concurrency | int | 并发请求数 |
| temperature | float | Temperature |
---
## 六、输出文档模板
每个讲述者生成一份 DOCX结构如下
```
┌─────────────────────────────────┐
│ [照片] │
│ 姓名:张小花 │
│ 性别:女 年龄35岁 │
│ 城市:北京 职业:全职妈妈 │
├─────────────────────────────────┤
│ │
│ 她的故事 │
│ ───── │
│ 故事正文第一段... │
│ │
│ 故事正文第二段... │
│ │
│ (如有多个故事,依次排列) │
│ │
└─────────────────────────────────┘
```
- 简洁模板,不花哨
- 个人信息在顶部,照片居中
- 故事正文在下方(仅学员讲述内容,已过滤老师对话)
- 一位讲述者可能有多个故事,依次排列
---
## 七、非功能性需求
| 需求 | 说明 |
|------|------|
| **局域网访问** | 部署在局域网内,多人可通过浏览器访问 |
| **扩展性** | LLM 可替换,后续可扩展文章搜索引擎 |
| **数据安全** | API Key 加密存储,文件仅在服务器本地处理 |
| **并发处理** | 支持多文件并发提取,避免前端长时间等待 |
| **容错性** | 单个文件处理失败不影响其他文件 |

View File

@@ -0,0 +1,757 @@
# Story Extractor — 后端架构设计文档
> 版本v1.3 · 2026-04-21
---
## 一、架构总览
### 1.1 技术栈
| 层级 | 技术 | 选型理由 |
|------|------|----------|
| **Web 框架** | FastAPI | 异步支持、自动 API 文档、类型提示、高性能 |
| **前端** | Vue 3 + Element Plus | 组件丰富、中文生态好、适合管理类交互 |
| **数据库** | SQLite | 轻量零安装、局域网场景够用、后续可迁移 |
| **LLM 接口** | OpenAI Python SDK兼容协议 | 一套代码适配 OpenAI / DeepSeek / 通义千问 / Ollama |
| **DOCX 处理** | python-docx | 读写 DOCX 的标准 Python 库 |
| **部署** | Docker Compose | 一键启动,局域网分发方便 |
> **设计原则:极简架构**,不引入 Redis、Celery、Nginx 等外部依赖,全部基于 FastAPI 原生能力。
### 1.2 系统架构图
```
┌──────────────────┐
│ 浏览器 (Vue 3) │
└────────┬─────────┘
│ HTTP / SSE
┌────────┴─────────┐
│ │
┌──────┴──────┐ ┌────────┴──┐
│ FastAPI │ │ SQLite │
│ (Web + API │ │ (数据库) │
│ + 静态托管 │ │ │
│ + 后台任务) │ │ │
└──────┬──────┘ └───────────┘
┌───────────┼───────────┐
│ │ │
┌─────┴─────┐ ┌──┴───┐ ┌────┴────┐
│ 预处理器 │ │ LLM │ │ DOCX │
│ + 提取器 │ │ 服务 │ │ 生成器 │
└───────────┘ └──────┘ └─────────┘
```
**极简设计**
- ❌ 无 Redis进度追踪用内存
- ❌ 无 Celery后台任务用 asyncio
- ❌ 无 NginxFastAPI 托管静态文件)
-**单容器部署**,一键启动
### 1.3 项目目录结构
```
story-extractor/
├── docker-compose.yml
├── Dockerfile
├── requirements.txt
├── .env.example
├── backend/
│ ├── app/
│ │ ├── __init__.py
│ │ ├── main.py # FastAPI 入口路由注册SSE
│ │ ├── config.py # 配置管理(读取 .env
│ │ ├── database.py # SQLite 连接与 ORM
│ │ ├── state.py # 内存状态管理(进度追踪)
│ │ │
│ │ ├── api/ # API 路由层
│ │ │ ├── __init__.py
│ │ │ ├── files.py # 文件上传/删除/列表
│ │ │ ├── extraction.py # 提取任务管理
│ │ │ ├── stories.py # 故事 CRUD含删除
│ │ │ ├── matching.py # 匹配关系管理
│ │ │ ├── export.py # 预览与导出
│ │ │ └── settings.py # 系统配置
│ │ │
│ │ ├── models/ # 数据模型SQLAlchemy
│ │ │ ├── __init__.py
│ │ │ ├── transcript.py
│ │ │ ├── person.py
│ │ │ ├── story.py
│ │ │ └── config.py
│ │ │
│ │ ├── schemas/ # Pydantic 请求/响应模型
│ │ │ ├── __init__.py
│ │ │ ├── file.py
│ │ │ ├── story.py
│ │ │ ├── match.py
│ │ │ └── config.py
│ │ │
│ │ └── services/ # 业务逻辑层
│ │ ├── __init__.py
│ │ ├── file_service.py # 文件存储与管理
│ │ ├── docx_parser.py # DOCX 解析
│ │ ├── preprocessor.py # 预处理(过滤非故事内容)
│ │ ├── extractor.py # 故事提取核心(三阶段 Pipeline
│ │ ├── llm_client.py # LLM 抽象层
│ │ ├── match_service.py # 匹配关系管理
│ │ └── docx_generator.py # 最终 DOCX 生成
│ │
│ ├── uploads/ # 上传文件存储
│ │ ├── transcripts/
│ │ └── persons/
│ │
│ └── exports/ # 导出文件存储
├── frontend/ # Vue 3 前端项目
│ ├── src/
│ │ ├── views/
│ │ │ ├── WelcomeView.vue
│ │ │ ├── UploadView.vue
│ │ │ ├── ExtractView.vue
│ │ │ ├── MatchView.vue
│ │ │ └── ExportView.vue
│ │ ├── components/
│ │ │ ├── Stepper.vue
│ │ │ ├── BottomNav.vue
│ │ │ ├── UploadZone.vue
│ │ │ ├── StoryCard.vue
│ │ │ ├── PersonCard.vue
│ │ │ ├── MatchPanel.vue
│ │ │ ├── DocPreview.vue
│ │ │ ├── SettingsModal.vue
│ │ │ └── Toast.vue
│ │ ├── api/
│ │ │ └── index.js
│ │ ├── stores/
│ │ │ └── app.js
│ │ ├── App.vue
│ │ └── main.js
│ ├── package.json
│ └── vite.config.js
├── Dockerfile
├── docker-compose.yml
├── requirements.txt
└── .env.example
```
---
## 二、API 设计
### 2.1 API 总览
所有 API 以 `/api/v1` 为前缀。
| 模块 | 方法 | 路径 | 说明 |
|------|------|------|------|
| **文件** | POST | `/api/v1/files/transcripts` | 上传文字稿(支持批量) |
| | POST | `/api/v1/files/persons` | 上传讲述者信息(支持批量) |
| | GET | `/api/v1/files/transcripts` | 获取文字稿列表 |
| | GET | `/api/v1/files/persons` | 获取讲述者列表 |
| | DELETE | `/api/v1/files/transcripts/{id}` | 删除文字稿 |
| | DELETE | `/api/v1/files/persons/{id}` | 删除讲述者信息 |
| | GET | `/api/v1/files/persons/{id}/preview` | 预览讲述者信息 |
| **提取** | POST | `/api/v1/extraction/start` | 启动提取任务(后台异步) |
| | GET | `/api/v1/extraction/status` | 获取提取进度 |
| | GET | `/api/v1/extraction/status/stream` | SSE 实时进度推送 |
| | POST | `/api/v1/extraction/stop` | 停止提取任务 |
| **故事** | GET | `/api/v1/stories` | 获取故事列表(支持筛选) |
| | GET | `/api/v1/stories/{id}` | 获取故事详情 |
| | PUT | `/api/v1/stories/{id}` | 编辑故事 |
| | DELETE | `/api/v1/stories/{id}` | 删除故事 |
| **匹配** | POST | `/api/v1/matches` | 创建匹配关系 |
| | DELETE | `/api/v1/matches/{story_id}` | 取消匹配 |
| | GET | `/api/v1/matches` | 获取所有匹配关系 |
| **导出** | GET | `/api/v1/export/preview/{person_id}` | 预览文档 |
| | GET | `/api/v1/export/download/{person_id}` | 下载单个 DOCX |
| | GET | `/api/v1/export/download-all` | 批量下载 ZIP |
| | GET | `/api/v1/export/list` | 获取导出列表 |
| **设置** | GET | `/api/v1/settings` | 获取系统配置 |
| | PUT | `/api/v1/settings` | 更新系统配置 |
| | POST | `/api/v1/settings/test-llm` | 测试 LLM 连接 |
### 2.2 关键 API 详细设计
#### 启动提取任务
```
POST /api/v1/extraction/start
```
**响应**
```json
{
"task_id": "uuid",
"status": "started",
"total_files": 10
}
```
#### SSE 实时进度推送
```
GET /api/v1/extraction/status/stream
```
**实现方式**FastAPI `StreamingResponse` + `asyncio.Queue`
```python
@router.get("/extraction/status/stream")
async def stream_progress():
async def event_generator():
while True:
data = await extraction_state.get_update()
yield f"event: progress\ndata: {json.dumps(data)}\n\n"
if data.get("status") == "completed":
break
return StreamingResponse(event_generator(), media_type="text/event-stream")
```
**SSE 事件格式**
```
event: progress
data: {
"percent": 35,
"current_file": "2026年1月21日 第10营训练营.docx",
"action": "故事识别:检测学员发言...",
"files_read": 3,
"files_total": 10,
"segments_found": 42,
"stories_found": 2
}
event: story_found
data: {
"id": "uuid",
"title": "代际传承的体罚创伤",
"speaker_nickname": "14班+HCM",
"summary": "从小被妈妈体罚,长大后用同样方式对待女儿...",
"confidence": 0.85,
"source": "2026年1月21日 第10营训练营.docx"
}
event: completed
data: {
"total_files": 10,
"total_stories": 15,
"duration_seconds": 186
}
```
#### 停止提取任务
```
POST /api/v1/extraction/stop
```
**说明**:通过 `asyncio.Event` 取消后台任务。
---
## 三、核心服务设计
### 3.1 内存状态管理state.py
**替代 Redis 的方案**:用 Python 模块级变量 + `asyncio.Queue` 管理进度。
```python
class ExtractionState:
"""提取任务状态管理(内存中,无需 Redis"""
def __init__(self):
self.is_running: bool = False
self.cancel_event: asyncio.Event = asyncio.Event()
self.progress: dict = {
"percent": 0,
"current_file": "",
"action": "",
"files_read": 0,
"files_total": 0,
"segments_found": 0,
"stories_found": 0,
}
self.queue: asyncio.Queue = asyncio.Queue()
def update(self, **kwargs):
"""更新进度,同时推送到 SSE 队列"""
self.progress.update(kwargs)
self.queue.put_nowait(self.progress.copy())
async def get_update(self) -> dict:
"""SSE 消费端:等待新的进度更新"""
return await self.queue.get()
def reset(self):
"""重置状态"""
self.is_running = False
self.cancel_event.clear()
self.progress = {k: 0 if k != "" else "" for k in self.progress}
self.queue = asyncio.Queue()
# 全局单例
extraction_state = ExtractionState()
```
**局限性**
- 服务重启后进度丢失(可接受,重新提取即可)
- 单进程内有效FastAPI 单进程部署,无问题)
### 3.2 后台任务管理
**替代 Celery 的方案**FastAPI `asyncio.create_task()`
```python
# main.py
from app.services.extractor import run_extraction
@router.post("/extraction/start")
async def start_extraction():
if extraction_state.is_running:
return {"error": "提取任务正在进行中"}
extraction_state.reset()
extraction_state.is_running = True
asyncio.create_task(run_extraction(extraction_state))
return {"task_id": str(uuid4()), "status": "started"}
@router.post("/extraction/stop")
async def stop_extraction():
extraction_state.cancel_event.set()
return {"status": "stopping"}
```
```python
# extractor.py
async def run_extraction(state: ExtractionState):
"""后台提取任务主循环"""
try:
transcripts = get_all_transcripts()
state.update(files_total=len(transcripts))
for i, transcript in enumerate(transcripts):
if state.cancel_event.is_set():
break
state.update(
current_file=transcript.filename,
action="预处理:过滤课堂管理内容...",
files_read=i + 1,
)
paragraphs = parse_transcript(transcript.file_path)
filtered = preprocess(paragraphs)
segments = await segment_text(filtered, state)
stories = await identify_and_extract(segments, state)
save_stories(stories, transcript.id)
state.update(
percent=round((i + 1) / len(transcripts) * 100),
action=f"已完成:{transcript.filename}",
)
state.update(status="completed", percent=100)
except Exception as e:
state.update(status="error", action=f"错误:{str(e)}")
finally:
state.is_running = False
```
### 3.3 文字稿解析器docx_parser.py
```python
def parse_transcript(file_path: str) -> TranscriptData:
"""
解析直播回放文字稿 DOCX
格式:发言者(时间戳): 内容
返回:
- paragraphs: [{index, speaker, timestamp, content, raw_text}]
- total_lines: 总行数
"""
```
**关键处理**
- 解析每段的发言者、时间戳、内容
- 识别发言者类型老师卢慧老师、静静、督导vs 学员
- 建立行号映射关系
### 3.4 预处理器preprocessor.py
```python
class Preprocessor:
"""预处理:过滤非故事内容"""
def filter_classroom_management(self, paragraphs: list) -> list:
"""过滤课堂管理内容(改昵称、纪律提醒、进组讨论)"""
def filter_short_responses(self, paragraphs: list) -> list:
"""过滤学员短回应("好的""收到"等,长度<10字"""
def filter_healing_exercises(self, paragraphs: list) -> list:
"""过滤疗愈练习重复内容(连续"我愿意看见且释放"模式)"""
def extract_student_segments(self, paragraphs: list) -> list:
"""提取学员发言段落,保留上下文"""
```
**过滤规则**
- 课堂管理关键词:昵称、纪律、进组、会议室、扣三个一
- 短回应:长度 < 10 字,且不含情感词
- 疗愈练习:连续 3 段以上匹配"我愿意看见且释放"模式
### 3.5 故事提取器extractor.py
**核心算法:三阶段 Pipeline**
```
阶段1: 预处理 → 阶段2: 故事识别 → 阶段3: 故事提取
```
#### 阶段一:预处理(规则过滤)
```python
def preprocess(self, paragraphs: list) -> list:
"""
1. 过滤课堂管理内容
2. 过滤短回应
3. 过滤疗愈练习重复
4. 提取学员发言段落
"""
```
#### 阶段二故事识别LLM
**分块策略**
- 按 300-500 行分块,块间重叠 30 行
- 每块送入 LLM 判断是否包含学员故事
**Prompt 模板**
```
你是一个文本分析助手。以下是某心理成长训练营直播的文字稿片段。
每段格式为:发言者(时间戳): 内容
请判断该片段是否包含"学员个人故事"。
【学员个人故事】的定义:
1. 学员(非老师)讲述的自己或家人的真实经历
2. 涉及具体的事件、人物、时间线
3. 有情感色彩(痛苦、挣扎、觉醒等)
4. 主题通常围绕:原生家庭、亲子关系、婚姻情感、财富限制、身体健康、职业发展
【不属于学员故事】的内容:
1. 老师的理论讲解和方法论
2. 课堂管理(改昵称、纪律提醒)
3. 疗愈练习引导语("我愿意看见且释放..."
4. 学员的简短回应("好的""明白了"
5. 老师引用的案例(非现场学员讲述)
请返回 JSON
{
"has_story": true/false,
"story_speaker": "学员昵称如14班+HCM",
"story_topic": "一句话概括主题15字以内",
"story_start_line": 起始行号,
"story_end_line": 结束行号,
"confidence": 0.0-1.0
}
```
#### 阶段三故事提取LLM
**Prompt 模板**
```
以下是某学员在训练营中讲述的个人故事(对话片段)。
请完成以下任务:
1. 整理出完整的故事叙述(去掉老师的提问和引导语,只保留学员的讲述)
2. 为故事起一个简洁的标题10字以内
3. 写一段故事摘要80-150字第三人称
4. 识别故事的核心主题标签
【重要】只提取学员讲述的内容,过滤掉:
- 老师的提问、引导、追问
- 疗愈练习引导语
- 其他学员的发言
【输出格式】JSON
{
"title": "故事标题",
"summary": "故事摘要",
"content": "整理后的完整故事叙述(第一人称,仅学员讲述部分)",
"themes": ["原生家庭", "体罚", "代际传承"],
"emotion_tone": "痛苦/挣扎/觉醒/释然/..."
}
```
### 3.6 LLM 客户端llm_client.py
```python
class LLMClient:
"""LLM 统一调用接口"""
def __init__(self, config: LLMConfig):
self.client = AsyncOpenAI(
api_key=config.api_key,
base_url=config.base_url,
)
self.model = config.model
async def chat(self, messages: list[dict], temperature: float = 0.3) -> str:
"""发送对话请求"""
async def chat_json(self, messages: list[dict], temperature: float = 0.3) -> dict:
"""发送对话请求,期望返回 JSON"""
```
**支持的提供商**(通过 base_url 切换):
| 提供商 | Base URL |
|--------|----------|
| OpenAI | `https://api.openai.com/v1` |
| DeepSeek | `https://api.deepseek.com/v1` |
| 通义千问 | `https://dashscope.aliyuncs.com/compatible-mode/v1` |
| Ollama (本地) | `http://localhost:11434/v1` |
### 3.7 匹配服务match_service.py
```python
class MatchService:
"""匹配关系管理"""
def create_match(self, story_id: str, person_id: str) -> Match:
"""创建匹配关系"""
def remove_match(self, story_id: str) -> bool:
"""取消匹配"""
def get_matched_persons(self) -> list[Person]:
"""获取已匹配的讲述者列表(用于导出)"""
```
### 3.8 DOCX 生成器docx_generator.py
```python
def generate_person_doc(person: Person, stories: list[Story], output_path: str):
"""
为一位讲述者生成最终 DOCX 文档
结构:
┌─ 照片(居中)
├─ 个人信息(姓名、性别、年龄、城市、职业)
├─ 分隔线
└─ 故事正文(仅学员讲述内容,多个故事依次排列)
"""
```
---
## 四、错误处理
| 错误类型 | 处理方式 |
|----------|----------|
| 单个文字稿解析失败 | 记录错误,跳过,继续处理其他文件 |
| LLM API 调用失败 | 自动重试 3 次(指数退避),仍失败则标记该段为 error |
| LLM 返回格式异常 | 尝试修复 JSON无法修复则丢弃该段结果 |
| DOCX 文件损坏 | 提示用户文件异常,跳过 |
| 提取任务中断(服务重启) | 进度丢失,用户需重新启动提取 |
---
## 五、数据库设计
### 5.1 ER 关系
```
Transcript 1───* Story *───0..1 Person
Config (单例)
```
### 5.2 表结构
#### transcripts 表
```sql
CREATE TABLE transcripts (
id TEXT PRIMARY KEY,
filename TEXT NOT NULL,
file_path TEXT NOT NULL,
line_count INTEGER DEFAULT 0,
file_size INTEGER DEFAULT 0,
status TEXT DEFAULT 'pending',
error_message TEXT,
uploaded_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
```
#### persons 表
```sql
CREATE TABLE persons (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
nickname TEXT,
filename TEXT NOT NULL,
file_path TEXT NOT NULL,
photo_path TEXT,
info TEXT,
uploaded_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
```
#### stories 表
```sql
CREATE TABLE stories (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
summary TEXT,
content TEXT,
speaker_nickname TEXT,
source_transcript_id TEXT NOT NULL,
source_lines TEXT,
duration_minutes REAL,
confidence REAL,
confidence_level TEXT,
person_id TEXT,
match_status TEXT DEFAULT 'pending',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (source_transcript_id) REFERENCES transcripts(id),
FOREIGN KEY (person_id) REFERENCES persons(id)
);
```
#### config 表
```sql
CREATE TABLE config (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
```
---
## 六、部署方案
### 6.1 Docker Compose单容器
```yaml
version: '3.8'
services:
app:
build: .
ports:
- "80:8000"
volumes:
- ./data/uploads:/app/uploads
- ./data/exports:/app/exports
- ./data/db:/app/data
env_file: .env
```
### 6.2 FastAPI 托管静态文件
```python
# main.py
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
app = FastAPI()
# API 路由(先注册)
app.include_router(files_router, prefix="/api/v1")
app.include_router(extraction_router, prefix="/api/v1")
app.include_router(stories_router, prefix="/api/v1")
app.include_router(matching_router, prefix="/api/v1")
app.include_router(export_router, prefix="/api/v1")
app.include_router(settings_router, prefix="/api/v1")
# 托管前端静态文件(放在最后,作为 fallback
app.mount("/", StaticFiles(directory="frontend/dist", html=True), name="static")
```
### 6.3 环境变量(.env
```env
# LLM 配置
LLM_PROVIDER=openai
LLM_MODEL=gpt-4
LLM_BASE_URL=https://api.openai.com/v1
LLM_API_KEY=sk-xxx
# 提取参数
SEGMENT_MAX_LINES=500
STORY_MIN_LINES=50
CONFIDENCE_THRESHOLD=0.5
TEMPERATURE=0.3
# 应用
APP_HOST=0.0.0.0
APP_PORT=8000
UPLOAD_DIR=/app/uploads
EXPORT_DIR=/app/exports
```
### 6.4 启动流程
```bash
# 1. 配置环境变量
cp .env.example .env
vim .env
# 2. 构建前端
cd frontend && npm install && npm run build && cd ..
# 3. 一键启动(单容器)
docker-compose up -d
# 4. 访问
# http://<局域网IP>
```
### 6.5 依赖清单requirements.txt
```
fastapi>=0.110.0
uvicorn>=0.27.0
python-multipart>=0.0.9
sqlalchemy>=2.0
aiosqlite>=0.19.0
python-docx>=1.1.0
openai>=1.12.0
pydantic>=2.0
```
> 仅 8 个依赖,无 Redis、无 Celery。
---
## 七、扩展性设计
### 7.1 LLM 升级
- 所有 LLM 调用通过 `LLMClient` 抽象层
- 切换模型只需修改配置base_url + model + api_key
### 7.2 文章搜索引擎(预留)
- stories 表已存储完整内容 + 结构化元数据
- 后续可接入 SQLite FTS5 全文搜索或 Meilisearch
### 7.3 多用户支持(预留)
- 当前为单用户模式(局域网共享)
- 后续可加入用户认证JWT
### 7.4 从极简架构升级
如果后续需要更强的任务管理能力,可以平滑升级:
- `ExtractionState` → Redis
- `asyncio.create_task()` → Celery
- 架构设计已预留接口,业务逻辑层不需要改动

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 解析失败 |

View File

@@ -0,0 +1,167 @@
# 直播回放文字稿故事提取工具 — 方案设计文档
## 一、项目概述
### 1.1 背景
手头有大量直播回放导出的文字稿DOCX 格式),其中散落着一些个人生活故事(家长里短类),需要用 AI 自动提取整理,并与讲述者个人信息匹配,最终输出为独立的 DOCX 文档。
### 1.2 目标
构建一个 **Web 端可复用工具**,支持:
- 批量上传和管理文字稿文件
- AI 自动提取故事(过滤讲课/知识点内容)
- 人工确认故事与讲述者的匹配关系
- 为每个讲述者生成独立 DOCX个人信息 + 照片 + 故事正文)
### 1.3 使用场景
- **部署环境:** 局域网
- **用户规模:** 多人使用
- **数据规模:** 20+ 份文字稿,每份上万行
---
## 二、需求分析
### 2.1 输入
| 输入类型 | 格式 | 说明 |
|----------|------|------|
| 直播回放文字稿 | DOCX | 每份上万行,口语化,混合讲课/知识点内容,无明确说话人标记 |
| 讲述者个人信息 | DOCX一人一个 | 包含姓名、照片等个人信息 |
### 2.2 处理要求
1. **故事提取:** 从文字稿中识别并提取个人生活故事(如家庭、情感、生活等),过滤掉纯讲课/知识点内容
2. **长文本切分:** 上万行文字稿需智能切分,确保不截断完整故事(每个故事可能跨半小时到一小时,稀疏分布)
3. **匹配确认:** AI 提取故事后,由人工在界面上确认每个故事对应的讲述者
4. **文档生成:** 按简洁模板生成最终 DOCX
### 2.3 输出
每个讲述者一份 DOCX 文档,结构为:
```
┌─────────────────────────┐
│ 个人信息(含照片) │
├─────────────────────────┤
│ │
│ 故事正文 │
│ │
└─────────────────────────┘
```
### 2.4 扩展性要求
- LLM 可替换(支持升级/切换不同模型)
- 后续可扩展文章搜索引擎等功能
---
## 三、系统架构
```
┌─────────────────────────────────────────────────┐
│ 浏览器前端 │
│ ┌──────────┐ ┌──────────┐ ┌──────────────────┐ │
│ │ 文件管理 │ │ 故事提取 │ │ 匹配确认 & 导出 │ │
│ └──────────┘ └──────────┘ └──────────────────┘ │
└────────────────────┬────────────────────────────┘
│ HTTP API
┌────────────────────┴────────────────────────────┐
│ Python 后端 (FastAPI) │
│ ┌──────────┐ ┌──────────┐ ┌──────────────────┐ │
│ │ 文件处理 │ │ LLM 服务 │ │ DOCX 生成 │ │
│ └──────────┘ └──────────┘ └──────────────────┘ │
│ ┌──────────┐ ┌──────────┐ │
│ │ 任务队列 │ │ 数据存储 │ │
│ └──────────┘ └──────────┘ │
└─────────────────────────────────────────────────┘
```
---
## 四、用户交互流程
### Step 1上传文件
- 上传文字稿 DOCX支持批量显示上传列表
- 上传讲述者信息 DOCX支持批量显示上传列表 + 预览)
### Step 2提取故事
- 选择要处理的文字稿(可多选)
- 点击"开始提取",显示进度
- LLM 实时返回提取结果
- 展示故事列表(摘要 + 原文定位)
### Step 3匹配讲述者
- 左侧:故事列表
- 右侧:讲述者列表
- 拖拽/下拉匹配(或 AI 推荐候选)
- 确认匹配关系
### Step 4预览 & 导出
- 预览最终 DOCX 效果
- 单个导出 / 批量导出
- 下载 ZIP 包
---
## 五、技术栈
| 层级 | 技术 | 理由 |
|------|------|------|
| **前端** | Vue 3 + Element Plus | 组件丰富、中文生态好、拖拽匹配等交互容易实现 |
| **后端** | FastAPI | 异步支持好、自带 API 文档、适合 AI 任务 |
| **LLM** | 抽象接口层,先支持 OpenAI 兼容 API | 后续可无缝切换国产模型/本地模型 |
| **DOCX 处理** | python-docx | 读写 DOCX 的标准库 |
| **任务队列** | Celery + Redis或内置 asyncio | 20+ 文件处理需要异步,避免前端卡死 |
| **数据存储** | SQLite | 轻量、无需额外安装、局域网够用 |
| **部署** | Docker Compose | 一键启动,局域网分发方便 |
---
## 六、核心技术难点与解决方案
| 难点 | 解决方案 |
|------|----------|
| **长文本切分不截断故事** | 两阶段策略:先用 LLM 做"话题分段"(识别话题边界),再对每个话题段判断是否包含故事 |
| **故事 vs 知识点区分** | Prompt 工程 + Few-shot 示例,明确"个人生活故事"的定义和排除规则 |
| **无说话人标记** | 提取故事后展示摘要,由人工在 GUI 上匹配讲述者;后续可加入 AI 推荐候选 |
| **上万行文本的 LLM 上下文** | 话题分段后,每个段落在上下文窗口内处理;超长段落再按语义子段切分 |
---
## 七、项目结构
```
story-extractor/
├── frontend/ # Vue 3 前端
│ ├── src/
│ │ ├── views/ # 页面:文件管理、故事提取、匹配确认、导出
│ │ ├── components/ # 通用组件
│ │ └── api/ # 后端 API 调用
│ └── ...
├── backend/ # FastAPI 后端
│ ├── app/
│ │ ├── api/ # API 路由
│ │ ├── services/ # 业务逻辑
│ │ │ ├── extractor.py # 故事提取核心
│ │ │ ├── llm.py # LLM 抽象层
│ │ │ ├── docx_parser.py # DOCX 解析
│ │ │ └── docx_generator.py # DOCX 生成
│ │ ├── models/ # 数据模型
│ │ └── main.py
│ └── ...
├── docker-compose.yml
└── README.md
```
---
## 八、待确认事项
- [ ] LLM API 选型OpenAI / 国产模型 / 本地模型)
- [ ] 技术栈最终确认
- [ ] 开发节奏(先跑通核心流程 vs 逐步完善)
- [ ] 讲述者信息 DOCX 的具体字段结构(需看样例)
- [ ] 文字稿样例(需看实际内容以优化切分和提取策略)

View File

@@ -0,0 +1,133 @@
说话人04截取文件 vs 原文对比分析报告
==========================================
一、数据汇总
-----------
1. 原始 DOCX 文件总行数: 824行
2. 说话人04在原文中总行数: 84行 (原文行131-429)
3. 截取文件总行数: 167行
- 说话人04: 84行
- 说话人02(老师): 83行
4. 原文说话人04范围内(行131-429)的所有说话人:
- 说话人02(老师): 147行
- 说话人04: 84行
- 说话人05: 54行
- 说话人03: 8行
- 说话人06: 6行
- 总计: 299行
5. 截取文件中缺失的行: 121行
- 老师行: 61行
- 其他说话人行: 60行 (说话人05: 54行, 说话人03: 8行, 说话人06: 6行)
- 说话人04行: 0行 (全部保留)
二、说话人04的内容是否完整
-----------------------------
说话人04的84行内容在截取文件中 **全部保留**,没有任何丢失。
merge 逻辑对学员本人的内容没有造成丢失。
三、老师在说话人04发言期间的内容是否完整
-------------------------------------------
老师在说话人04范围内共有147行截取文件中只保留了83行。
**丢失了61行老师的对话。**
丢失的老师对话主要分布在说话人04的12个段落之间的"中间段落"中。
这些中间段落是其他学员(说话人03、说话人05、说话人06)发言时,
老师与他们的互动对话。
四、merge 逻辑丢失内容的根本原因
---------------------------------
_merge_by_speaker 的工作流程:
1. _split_by_student 按学员拆分: 遇到不同学员就断开
2. 每个段落只包含: 当前学员的行 + 老师的行
3. _merge_by_speaker 把同一个学员的多段 extend 合并
关键问题:
当说话人04的段落A和段落B之间夹了其他学员(如说话人05)的段落时:
- 说话人05的段落中包含老师与说话人05的互动对话
- 这些老师对话被归入说话人05的段落(而不是说话人04的段落)
- merge时只合并说话人04的段落说话人05段落中的老师对话就丢失了
但更关键的是: 说话人05在说话人04的发言期间插话
老师回应说话人05的这些对话实际上可能是在延续与说话人04的话题
(比如说话人05也在讨论高考漏题事件与说话人04的故事直接相关)。
这些内容在merge后完全丢失了。
五、具体丢失的重要内容
----------------------
以下是说话人04段落之间夹的中间段落中丢失的老师对话摘要:
1. 段落2和段落4之间(说话人03段落):
- 老师安排下一位连线(行140)
2. 段落4和段落7之间(说话人03+说话人05段落):
- 老师解释"且释放"的深层含义(行166)
- 老师提醒"且释放"有暗语(行168)
3. 段落7和段落9之间(说话人03段落):
- 老师引导关于孩子高考的觉察(行173-174)
*** 重要: 这是老师对说话人04议题的深入引导直接丢失***
4. 段落9和段落11之间(说话人03段落):
- 老师讲解教育系统的不公平和"漏中漏"概念(行224, 226)
*** 重要: 这是对说话人04故事的深化分析***
5. 段落11和段落14之间(说话人03+说话人05段落):
- 老师引导向内探索(行233, 235)
- 老师安排有觉知的呼吸(行237)
6. 段落14和段落16之间(说话人05段落) -- **最大丢失**:
- 19行老师对话包括:
- 老师引导说话人05表达"这不公平"的情绪(行282-296)
- 老师引导说话人05做愤怒释放(行298-318)
- 老师揭示"陪跑"真相(行318)
*** 非常重要: 说话人05也在处理高考漏题创伤与说话人04同主题***
7. 段落16和段落18之间(说话人06段落):
- 老师解释旁观者角度(行322)
8. 段落18和段落20之间(说话人05段落):
- 老师讲解下海捞明珠的比喻(行335)
9. 段落20和段落22之间(说话人05段落):
- 老师引导表达情绪(行349)
10. 段落24和段落30之间(说话人05+说话人03+说话人06段落) -- **第二大丢失**:
- 33行老师对话包括:
- 老师引导对父亲的指责释放(行359-387)
- 老师引导"我没考过"信念释放(行400-426)
- 老师揭示"考的是权力不是成绩"的真相(行408, 422, 424, 426)
*** 极其重要: 这是整个高考创伤疗愈的高潮部分!***
六、结论
-------
1. 说话人04本人的84行内容 **没有丢失**,全部保留在截取文件中。
2. 老师在说话人04发言期间的147行中只保留了83行(56%)
丢失了61行(44%)。
3. 丢失的61行老师对话中大部分是在说话人04的段落之间
老师与其他学员(主要是说话人05)互动时产生的。
4. **最严重的问题**: 说话人05与说话人04讨论的是同一个主题
(高考漏题创伤)老师在说话人05段落中的引导
(特别是"我没考过"信念释放和"考的是权力不是成绩"的真相揭示)
实际上是说话人04故事的延续和深化但这些内容全部丢失了。
5. merge 逻辑的设计缺陷:
- _split_by_student 严格按学员拆分,导致同一主题的对话被割裂
- _merge_by_speaker 只合并同一学员的段落,无法恢复跨学员的主题连贯性
- 建议: 在 merge 时,应该考虑主题连贯性,将与当前学员主题相关的
其他学员段落中的老师对话也纳入合并范围。