Files
Story-extractor/docs/Story-Extractor-后端架构设计文档.md
2026-04-24 16:02:16 +08:00

758 lines
22 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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
- 架构设计已预留接口,业务逻辑层不需要改动