758 lines
22 KiB
Markdown
758 lines
22 KiB
Markdown
# 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)
|
||
- ❌ 无 Nginx(FastAPI 托管静态文件)
|
||
- ✅ **单容器部署**,一键启动
|
||
|
||
### 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
|
||
- 架构设计已预留接口,业务逻辑层不需要改动
|