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

38
.gitignore vendored Normal file
View File

@@ -0,0 +1,38 @@
# Python
__pycache__/
*.py[cod]
*.egg-info/
dist/
build/
*.egg
# Environment
.env
.venv/
venv/
# Database
backend/data/
# Uploads
backend/uploads/
# Exports
backend/exports/
# Frontend
frontend/node_modules/
frontend/dist/
# IDE
.vscode/
.idea/
*.swp
*.swo
# OS
.DS_Store
Thumbs.db
# Logs
*.log

22
Dockerfile Normal file
View File

@@ -0,0 +1,22 @@
FROM python:3.11-slim
WORKDIR /app
# 安装系统依赖
RUN apt-get update && apt-get install -y --no-install-recommends \
gcc \
&& rm -rf /var/lib/apt/lists/*
# 安装 Python 依赖
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# 复制后端代码
COPY app/ ./app/
# 创建必要目录
RUN mkdir -p /app/uploads/transcripts /app/uploads/persons /app/exports /app/data
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

18
backend/.env.example Normal file
View File

@@ -0,0 +1,18 @@
# LLM 配置
LLM_PROVIDER=minimax
LLM_MODEL=MiniMax-M2.7
LLM_BASE_URL=https://api.minimax.chat/v1
LLM_API_KEY=your-api-key-here
# 提取参数
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
DB_DIR=/app/data

0
backend/app/__init__.py Normal file
View File

View File

195
backend/app/api/export.py Normal file
View File

@@ -0,0 +1,195 @@
import os
import json
import zipfile
from typing import Annotated
from fastapi import APIRouter, HTTPException, Depends
from fastapi.responses import FileResponse
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, text
from app.database import get_db
from app.models.story import Story
from app.models.person import Person
from app.config import settings
from app.services.docx_generator import generate_person_doc
router = APIRouter(prefix="/export", tags=["导出"])
UNKNOWN_PERSON_ID = "unknown"
UNKNOWN_PERSON_NAME = "暂无"
@router.get("/list")
async def export_list(db: Annotated[AsyncSession, Depends(get_db)]):
"""获取导出列表(所有已匹配的讲述者,含'暂无'"""
# 获取所有已匹配的故事按 person_id 分组
result = await db.execute(
select(Story.person_id)
.where(Story.match_status == "matched")
.distinct()
)
person_ids = [row[0] for row in result.all()]
items = []
for pid in person_ids:
if pid == UNKNOWN_PERSON_ID:
story_result = await db.execute(
select(Story).where(
Story.person_id == UNKNOWN_PERSON_ID,
Story.match_status == "matched",
)
)
stories = story_result.scalars().all()
items.append({
"person_id": UNKNOWN_PERSON_ID,
"person_name": UNKNOWN_PERSON_NAME,
"story_count": len(stories),
"has_photo": False,
})
else:
person = await db.get(Person, pid)
if not person:
continue
story_result = await db.execute(
select(Story).where(
Story.person_id == person.id,
Story.match_status == "matched",
)
)
stories = story_result.scalars().all()
items.append({
"person_id": person.id,
"person_name": person.name,
"story_count": len(stories),
"has_photo": bool(person.photo_path),
})
return items
@router.get("/preview/{person_id}")
async def preview_export(person_id: str, db: Annotated[AsyncSession, Depends(get_db)]):
"""预览某讲述者的文档内容"""
if person_id == UNKNOWN_PERSON_ID:
person_name = UNKNOWN_PERSON_NAME
person_info = ""
has_photo = False
else:
person = await db.get(Person, person_id)
if not person:
raise HTTPException(404, "讲述者不存在")
person_name = person.name
person_info = person.info
has_photo = bool(person.photo_path)
story_result = await db.execute(
select(Story).where(
Story.person_id == person_id,
Story.match_status == "matched",
)
)
stories = story_result.scalars().all()
return {
"person_name": person_name,
"person_info": person_info,
"has_photo": has_photo,
"stories": [{"title": s.title, "summary": s.summary, "tags": json.loads(s.tags) if s.tags else [], "content": s.content, "raw_material": s.raw_material or ""} for s in stories],
}
@router.get("/download/{person_id}")
async def download_one(person_id: str, db: Annotated[AsyncSession, Depends(get_db)]):
"""下载单个讲述者的 DOCX"""
if person_id == UNKNOWN_PERSON_ID:
person_name = UNKNOWN_PERSON_NAME
person_info = ""
photo_path = ""
else:
person = await db.get(Person, person_id)
if not person:
raise HTTPException(404, "讲述者不存在")
person_name = person.name
person_info = person.info
photo_path = person.photo_path or ""
story_result = await db.execute(
select(Story).where(
Story.person_id == person_id,
Story.match_status == "matched",
)
)
stories = story_result.scalars().all()
if not stories:
raise HTTPException(400, "该讲述者没有已匹配的故事")
output_path = os.path.join(settings.EXPORT_DIR, f"{person_name}_故事集.docx")
generate_person_doc(
person_name=person_name,
person_info=person_info,
photo_path=photo_path,
stories=[{"title": s.title, "summary": s.summary, "tags": json.loads(s.tags) if s.tags else [], "content": s.content} for s in stories],
output_path=output_path,
)
return FileResponse(
output_path,
media_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
filename=f"{person_name}_故事集.docx",
)
@router.get("/download-all")
async def download_all(db: Annotated[AsyncSession, Depends(get_db)]):
"""批量下载所有已匹配讲述者的文档ZIP"""
# 获取所有已匹配的 person_id
result = await db.execute(
select(Story.person_id)
.where(Story.match_status == "matched")
.distinct()
)
person_ids = [row[0] for row in result.all()]
if not person_ids:
raise HTTPException(400, "没有可导出的文档")
# 生成 ZIP
zip_path = os.path.join(settings.EXPORT_DIR, "故事集_全部.zip")
with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf:
for pid in person_ids:
if pid == UNKNOWN_PERSON_ID:
person_name = UNKNOWN_PERSON_NAME
person_info = ""
photo_path = ""
else:
person = await db.get(Person, pid)
if not person:
continue
person_name = person.name
person_info = person.info
photo_path = person.photo_path or ""
story_result = await db.execute(
select(Story).where(
Story.person_id == pid,
Story.match_status == "matched",
)
)
stories = story_result.scalars().all()
if not stories:
continue
output_path = os.path.join(settings.EXPORT_DIR, f"{person_name}_故事集.docx")
generate_person_doc(
person_name=person_name,
person_info=person_info,
photo_path=photo_path,
stories=[{"title": s.title, "summary": s.summary, "tags": json.loads(s.tags) if s.tags else [], "content": s.content} for s in stories],
output_path=output_path,
)
zf.write(output_path, f"{person_name}_故事集.docx")
return FileResponse(
zip_path,
media_type="application/zip",
filename="故事集_全部.zip",
)

View File

@@ -0,0 +1,112 @@
import asyncio
import json
from typing import Annotated, Optional
from fastapi import APIRouter, HTTPException, Depends, Query
from fastapi.responses import StreamingResponse
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.database import get_db
from app.models.transcript import Transcript
from app.state import task_manager
from app.services.extractor import run_extraction
router = APIRouter(prefix="/extraction", tags=["故事提取"])
@router.post("/start")
async def start_extraction(
task_id: Optional[str] = Query(None),
db: Annotated[AsyncSession, Depends(get_db)] = None,
):
"""启动提取任务,支持多用户并发"""
# 如果传了 task_id复用已有任务
if task_id:
state = task_manager.get_task(task_id)
if state and state.is_running:
return {"task_id": task_id, "message": "任务正在进行中"}
else:
state = None
# 检查是否有待处理的文字稿
result = await db.execute(select(Transcript).where(Transcript.status == "pending"))
pending = result.scalars().all()
if not pending:
# 没有 pending 的,把所有非 pending 的文件重置为 pending包括 completed/processing/error
result2 = await db.execute(
select(Transcript).where(Transcript.status != "pending")
)
others = result2.scalars().all()
if others:
for t in others:
t.status = "pending"
t.error_message = ""
await db.commit()
pending = others
else:
raise HTTPException(400, "没有可提取的文字稿,请先上传文件")
total = len(pending)
# 创建新任务
state = task_manager.create_task()
state.reset()
state.is_running = True
asyncio.create_task(run_extraction(state))
return {"task_id": state.task_id, "total_files": total}
@router.get("/status")
async def get_status(task_id: Optional[str] = Query(None)):
"""获取提取进度"""
if task_id:
state = task_manager.get_task(task_id)
if state:
return {**state.progress, "task_id": state.task_id}
return {"status": "idle", "percent": 0}
@router.get("/status/stream")
async def stream_status(task_id: Optional[str] = Query(None)):
"""SSE 实时进度推送(支持多用户)"""
if not task_id:
raise HTTPException(400, "缺少 task_id 参数")
state = task_manager.get_task(task_id)
if not state:
raise HTTPException(404, "任务不存在")
async def event_generator():
# 立即推送当前状态
yield f"event: progress\ndata: {json.dumps({**state.progress, 'task_id': state.task_id}, ensure_ascii=False)}\n\n"
if state.progress.get("status") in ("completed", "error", "stopped"):
return
while True:
data = await state.get_update()
yield f"event: progress\ndata: {json.dumps({**data, 'task_id': state.task_id}, ensure_ascii=False)}\n\n"
if data.get("status") in ("completed", "error", "stopped"):
break
return StreamingResponse(
event_generator(),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
)
@router.post("/stop")
async def stop_extraction(task_id: Optional[str] = Query(None)):
"""停止提取任务"""
if task_id:
state = task_manager.get_task(task_id)
else:
state = None
if not state or not state.is_running:
raise HTTPException(400, "没有正在进行的提取任务")
state.cancel_event.set()
return {"message": "正在停止...", "task_id": state.task_id}

136
backend/app/api/files.py Normal file
View File

@@ -0,0 +1,136 @@
import os
import uuid
from typing import Annotated
from fastapi import APIRouter, UploadFile, File, HTTPException, Depends
from fastapi.responses import FileResponse
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, delete
from app.database import get_db
from app.models.transcript import Transcript
from app.models.person import Person
from app.schemas.schemas import TranscriptOut, PersonOut
from app.services.file_service import file_service
from app.services.docx_parser import parse_transcript, parse_person_info
router = APIRouter(prefix="/files", tags=["文件管理"])
# ===== 文字稿 =====
@router.post("/transcripts", response_model=list[TranscriptOut])
async def upload_transcripts(files: list[UploadFile], db: Annotated[AsyncSession, Depends(get_db)]):
"""批量上传文字稿"""
results = []
for file in files:
if not file.filename.endswith(".docx"):
raise HTTPException(400, f"仅支持 .docx 格式: {file.filename}")
saved = await file_service.save_transcript(file)
# 解析行数
paragraphs = parse_transcript(saved["file_path"])
line_count = len(paragraphs)
record = Transcript(
id=saved["id"],
filename=saved["filename"],
file_path=saved["file_path"],
line_count=line_count,
file_size=saved["file_size"],
status="pending",
)
db.add(record)
results.append(record)
await db.commit()
for r in results:
await db.refresh(r)
return results
@router.get("/transcripts", response_model=list[TranscriptOut])
async def list_transcripts(db: Annotated[AsyncSession, Depends(get_db)]):
"""获取文字稿列表"""
result = await db.execute(select(Transcript).order_by(Transcript.uploaded_at.desc()))
return result.scalars().all()
@router.delete("/transcripts/{transcript_id}")
async def delete_transcript(transcript_id: str, db: Annotated[AsyncSession, Depends(get_db)]):
"""删除文字稿"""
record = await db.get(Transcript, transcript_id)
if not record:
raise HTTPException(404, "文字稿不存在")
file_service.delete_file(record.file_path)
await db.delete(record)
await db.commit()
return {"message": "已删除"}
# ===== 讲述者信息 =====
@router.post("/persons", response_model=list[PersonOut])
async def upload_persons(files: list[UploadFile], db: Annotated[AsyncSession, Depends(get_db)]):
"""批量上传讲述者信息"""
results = []
for file in files:
if not file.filename.endswith(".docx"):
raise HTTPException(400, f"仅支持 .docx 格式: {file.filename}")
saved = await file_service.save_person(file)
# 解析个人信息
info = parse_person_info(saved["file_path"])
record = Person(
id=saved["id"],
name=info["name"],
filename=saved["filename"],
file_path=saved["file_path"],
photo_path=info["photos"][0] if info["photos"] else "",
info=info["info_text"],
)
db.add(record)
results.append(record)
await db.commit()
for r in results:
await db.refresh(r)
return results
@router.get("/persons", response_model=list[PersonOut])
async def list_persons(db: Annotated[AsyncSession, Depends(get_db)]):
"""获取讲述者列表"""
result = await db.execute(select(Person).order_by(Person.uploaded_at.desc()))
return result.scalars().all()
@router.delete("/persons/{person_id}")
async def delete_person(person_id: str, db: Annotated[AsyncSession, Depends(get_db)]):
"""删除讲述者信息"""
record = await db.get(Person, person_id)
if not record:
raise HTTPException(404, "讲述者不存在")
file_service.delete_file(record.file_path)
if record.photo_path:
file_service.delete_file(record.photo_path)
await db.delete(record)
await db.commit()
return {"message": "已删除"}
@router.get("/persons/{person_id}/preview")
async def preview_person(person_id: str, db: Annotated[AsyncSession, Depends(get_db)]):
"""预览讲述者信息"""
record = await db.get(Person, person_id)
if not record:
raise HTTPException(404, "讲述者不存在")
return {
"id": record.id,
"name": record.name,
"nickname": record.nickname,
"info": record.info,
"photo_path": record.photo_path,
}

View File

@@ -0,0 +1,76 @@
from typing import Annotated
from fastapi import APIRouter, HTTPException, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.database import get_db
from app.models.story import Story
from app.models.person import Person
from app.schemas.schemas import MatchCreate, MatchOut
router = APIRouter(prefix="/matches", tags=["匹配管理"])
# 特殊讲述者 ID暂无
UNKNOWN_PERSON_ID = "unknown"
UNKNOWN_PERSON_NAME = "暂无"
@router.post("")
async def create_match(data: MatchCreate, db: Annotated[AsyncSession, Depends(get_db)]):
"""创建匹配关系(支持 person_id='unknown' 表示暂无讲述者)"""
story = await db.get(Story, data.story_id)
if not story or story.match_status == "deleted":
raise HTTPException(404, "故事不存在")
if data.person_id == UNKNOWN_PERSON_ID:
person_name = UNKNOWN_PERSON_NAME
else:
person = await db.get(Person, data.person_id)
if not person:
raise HTTPException(404, "讲述者不存在")
person_name = person.name
story.person_id = data.person_id
story.match_status = "matched"
await db.commit()
await db.refresh(story)
return {"message": "匹配成功", "story_id": story.id, "person_id": data.person_id, "person_name": person_name}
@router.delete("/{story_id}")
async def remove_match(story_id: str, db: Annotated[AsyncSession, Depends(get_db)]):
"""取消匹配"""
story = await db.get(Story, story_id)
if not story:
raise HTTPException(404, "故事不存在")
story.person_id = None
story.match_status = "pending"
await db.commit()
return {"message": "已取消匹配"}
@router.get("", response_model=list[MatchOut])
async def list_matches(db: Annotated[AsyncSession, Depends(get_db)]):
"""获取所有匹配关系"""
result = await db.execute(
select(Story, Person)
.join(Person, Story.person_id == Person.id, isouter=True)
.where(Story.match_status == "matched")
)
matches = []
for story, person in result.all():
if story.person_id == UNKNOWN_PERSON_ID:
person_name = UNKNOWN_PERSON_NAME
elif person:
person_name = person.name
else:
person_name = UNKNOWN_PERSON_NAME
matches.append({
"story_id": story.id,
"person_id": story.person_id,
"person_name": person_name,
"story_title": story.title,
})
return matches

View File

@@ -0,0 +1,70 @@
from typing import Annotated
from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.database import get_db
from app.models.config import Config
from app.schemas.schemas import SettingsOut, SettingsUpdate
from app.services.llm_client import llm_client
from app.config import settings
router = APIRouter(prefix="/settings", tags=["系统设置"])
async def _get_config_value(db: AsyncSession, key: str, default: str = "") -> str:
"""获取配置值"""
result = await db.execute(select(Config).where(Config.key == key))
config = result.scalar_one_or_none()
return config.value if config else default
@router.get("", response_model=SettingsOut)
async def get_settings(db: Annotated[AsyncSession, Depends(get_db)]):
"""获取系统配置"""
return SettingsOut(
llm_provider=await _get_config_value(db, "llm_provider", settings.LLM_PROVIDER),
llm_model=await _get_config_value(db, "llm_model", settings.LLM_MODEL),
llm_base_url=await _get_config_value(db, "llm_base_url", settings.LLM_BASE_URL),
llm_api_key=await _get_config_value(db, "llm_api_key", "****"),
segment_max_lines=int(await _get_config_value(db, "segment_max_lines", str(settings.SEGMENT_MAX_LINES))),
story_min_lines=int(await _get_config_value(db, "story_min_lines", str(settings.STORY_MIN_LINES))),
confidence_threshold=float(await _get_config_value(db, "confidence_threshold", str(settings.CONFIDENCE_THRESHOLD))),
temperature=float(await _get_config_value(db, "temperature", str(settings.TEMPERATURE))),
)
@router.put("")
async def update_settings(data: SettingsUpdate, db: Annotated[AsyncSession, Depends(get_db)]):
"""更新系统配置"""
updates = data.model_dump(exclude_none=True)
for key, value in updates.items():
# API Key 脱敏处理
if key == "llm_api_key" and value == "****":
continue
existing = await db.execute(select(Config).where(Config.key == key))
config = existing.scalar_one_or_none()
if config:
config.value = str(value)
else:
db.add(Config(key=key, value=str(value)))
await db.commit()
# 更新 LLM 客户端配置
if "llm_base_url" in updates:
llm_client.update_config(base_url=updates["llm_base_url"])
if "llm_api_key" in updates:
llm_client.update_config(api_key=updates["llm_api_key"])
if "llm_model" in updates:
llm_client.update_config(model=updates["llm_model"])
return {"message": "配置已保存"}
@router.post("/test-llm")
async def test_llm():
"""测试 LLM 连接"""
result = await llm_client.test_connection()
return result

View File

@@ -0,0 +1,64 @@
from typing import Annotated
from fastapi import APIRouter, HTTPException, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, update
from app.database import get_db
from app.models.story import Story
from app.schemas.schemas import StoryOut, StoryEdit
router = APIRouter(prefix="/stories", tags=["故事管理"])
@router.get("", response_model=list[StoryOut])
async def list_stories(db: Annotated[AsyncSession, Depends(get_db)], status: str = "all"):
"""获取故事列表支持筛选all/matched/pending"""
query = select(Story).where(Story.match_status != "deleted")
if status == "matched":
query = query.where(Story.match_status == "matched")
elif status == "pending":
query = query.where(Story.match_status == "pending")
query = query.order_by(Story.created_at.desc())
result = await db.execute(query)
return result.scalars().all()
@router.get("/{story_id}", response_model=StoryOut)
async def get_story(story_id: str, db: Annotated[AsyncSession, Depends(get_db)]):
"""获取故事详情"""
story = await db.get(Story, story_id)
if not story or story.match_status == "deleted":
raise HTTPException(404, "故事不存在")
return story
@router.put("/{story_id}", response_model=StoryOut)
async def edit_story(story_id: str, data: StoryEdit, db: Annotated[AsyncSession, Depends(get_db)]):
"""编辑故事"""
story = await db.get(Story, story_id)
if not story or story.match_status == "deleted":
raise HTTPException(404, "故事不存在")
if data.title is not None:
story.title = data.title
if data.summary is not None:
story.summary = data.summary
if data.content is not None:
story.content = data.content
await db.commit()
await db.refresh(story)
return story
@router.delete("/{story_id}")
async def delete_story(story_id: str, db: Annotated[AsyncSession, Depends(get_db)]):
"""删除故事(标记为 deleted"""
story = await db.get(Story, story_id)
if not story or story.match_status == "deleted":
raise HTTPException(404, "故事不存在")
story.match_status = "deleted"
story.person_id = None
await db.commit()
return {"message": "已删除"}

47
backend/app/config.py Normal file
View File

@@ -0,0 +1,47 @@
import os
import sys
from dotenv import load_dotenv
load_dotenv()
def _get_base_dir() -> str:
"""获取基础目录(支持 PyInstaller 打包后的路径)"""
if getattr(sys, 'frozen', False):
# PyInstaller 打包后exe 所在目录
return os.path.dirname(sys.executable)
else:
# 开发模式backend 目录
return os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
BASE_DIR = _get_base_dir()
class Settings:
# App
APP_HOST: str = os.getenv("APP_HOST", "0.0.0.0")
APP_PORT: int = int(os.getenv("APP_PORT", "8000"))
UPLOAD_DIR: str = os.getenv("UPLOAD_DIR", os.path.join(BASE_DIR, "uploads"))
EXPORT_DIR: str = os.getenv("EXPORT_DIR", os.path.join(BASE_DIR, "exports"))
DB_DIR: str = os.getenv("DB_DIR", os.path.join(BASE_DIR, "data"))
# LLM
LLM_PROVIDER: str = os.getenv("LLM_PROVIDER", "xiaomi")
LLM_MODEL: str = os.getenv("LLM_MODEL", "MiMo-V2-Flash")
LLM_BASE_URL: str = os.getenv("LLM_BASE_URL", "https://api.xiaomimimo.com/v1")
LLM_API_KEY: str = os.getenv("LLM_API_KEY", "")
# Extraction
SEGMENT_MAX_LINES: int = int(os.getenv("SEGMENT_MAX_LINES", "500"))
STORY_MIN_LINES: int = int(os.getenv("STORY_MIN_LINES", "50"))
CONFIDENCE_THRESHOLD: float = float(os.getenv("CONFIDENCE_THRESHOLD", "0.5"))
TEMPERATURE: float = float(os.getenv("TEMPERATURE", "0.3"))
@property
def database_url(self) -> str:
os.makedirs(self.DB_DIR, exist_ok=True)
return f"sqlite+aiosqlite:///{self.DB_DIR}/story_extractor.db"
settings = Settings()

26
backend/app/database.py Normal file
View File

@@ -0,0 +1,26 @@
from typing import AsyncGenerator
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
from sqlalchemy.orm import DeclarativeBase
from sqlalchemy import text
from app.config import settings
engine = create_async_engine(settings.database_url, echo=False, connect_args={"check_same_thread": False})
async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
class Base(DeclarativeBase):
pass
async def init_db():
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
# 启用 WAL 模式,提升并发读写性能
await conn.execute(text("PRAGMA journal_mode=WAL"))
await conn.execute(text("PRAGMA busy_timeout=5000"))
async def get_db() -> AsyncGenerator[AsyncSession, None]:
async with async_session() as session:
yield session

64
backend/app/main.py Normal file
View File

@@ -0,0 +1,64 @@
import os
import sys
import logging
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from fastapi.middleware.cors import CORSMiddleware
from app.config import settings, BASE_DIR
from app.database import init_db
from app.api import files, extraction, stories, matching, export, settings as settings_api
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
def _get_frontend_dir() -> str:
"""获取前端静态文件目录(支持 PyInstaller 打包)"""
if getattr(sys, 'frozen', False):
# 打包后exe 同级目录下的 frontend/dist
return os.path.join(BASE_DIR, "frontend", "dist")
else:
# 开发模式:项目根目录下的 frontend/dist
return os.path.join(os.path.dirname(BASE_DIR), "frontend", "dist")
@asynccontextmanager
async def lifespan(app: FastAPI):
"""应用生命周期:启动时初始化数据库"""
logger.info("初始化数据库...")
await init_db()
logger.info("数据库初始化完成")
yield
logger.info("应用关闭")
app = FastAPI(
title="Story Extractor",
description="直播文字稿故事提取工具",
version="1.0.0",
lifespan=lifespan,
)
# CORS
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# 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_api.router, prefix="/api/v1")
# 托管前端静态文件(放在最后,作为 fallback
frontend_dist = _get_frontend_dir()
if os.path.exists(frontend_dist):
app.mount("/", StaticFiles(directory=frontend_dist, html=True), name="static")

View File

View File

@@ -0,0 +1,11 @@
from sqlalchemy import Column, String, Text
from sqlalchemy.sql import func
from app.database import Base
class Config(Base):
__tablename__ = "config"
key = Column(String, primary_key=True)
value = Column(Text, nullable=False)

View File

@@ -0,0 +1,17 @@
from sqlalchemy import Column, String, Text, DateTime
from sqlalchemy.sql import func
from app.database import Base
class Person(Base):
__tablename__ = "persons"
id = Column(String, primary_key=True)
name = Column(String, nullable=False)
nickname = Column(String, default="") # 文字稿中的昵称
filename = Column(String, nullable=False)
file_path = Column(String, nullable=False)
photo_path = Column(String, default="")
info = Column(Text, default="") # JSON
uploaded_at = Column(DateTime, server_default=func.now())

View File

@@ -0,0 +1,26 @@
import uuid
from sqlalchemy import Column, String, Integer, Float, Text, DateTime, ForeignKey
from sqlalchemy.sql import func
from app.database import Base
class Story(Base):
__tablename__ = "stories"
id = Column(String, primary_key=True, default=lambda: uuid.uuid4().hex)
title = Column(String, nullable=False)
summary = Column(Text, default="")
tags = Column(Text, default="") # JSON: ["标签1", "标签2", ...]
content = Column(Text, default="")
raw_material = Column(Text, default="") # JSON: 第四步重组后的素材
speaker_nickname = Column(String, default="")
source_transcript_id = Column(String, ForeignKey("transcripts.id"), nullable=False)
source_lines = Column(Text, default="") # JSON: {start, end}
duration_minutes = Column(Float, default=0)
confidence = Column(Float, default=0)
confidence_level = Column(String, default="medium") # high/medium/low
person_id = Column(String, ForeignKey("persons.id"), nullable=True)
match_status = Column(String, default="pending") # pending/matched/deleted
created_at = Column(DateTime, server_default=func.now())

View File

@@ -0,0 +1,17 @@
from sqlalchemy import Column, String, Integer, Float, Text, DateTime
from sqlalchemy.sql import func
from app.database import Base
class Transcript(Base):
__tablename__ = "transcripts"
id = Column(String, primary_key=True)
filename = Column(String, nullable=False)
file_path = Column(String, nullable=False)
line_count = Column(Integer, default=0)
file_size = Column(Integer, default=0)
status = Column(String, default="pending") # pending/processing/done/error
error_message = Column(Text, default="")
uploaded_at = Column(DateTime, server_default=func.now())

View File

View File

@@ -0,0 +1,100 @@
from pydantic import BaseModel
from datetime import datetime
class TranscriptOut(BaseModel):
id: str
filename: str
file_path: str
line_count: int
file_size: int
status: str
error_message: str
uploaded_at: datetime
class Config:
from_attributes = True
class PersonOut(BaseModel):
id: str
name: str
nickname: str
filename: str
file_path: str
photo_path: str
info: str
uploaded_at: datetime
class Config:
from_attributes = True
class StoryOut(BaseModel):
id: str
title: str
summary: str
content: str
raw_material: str
speaker_nickname: str
source_transcript_id: str
source_lines: str
duration_minutes: float
confidence: float
confidence_level: str
person_id: str | None
match_status: str
created_at: datetime
class Config:
from_attributes = True
class StoryEdit(BaseModel):
title: str | None = None
summary: str | None = None
content: str | None = None
class MatchCreate(BaseModel):
story_id: str
person_id: str
class MatchOut(BaseModel):
story_id: str
person_id: str
person_name: str
story_title: str
class Config:
from_attributes = True
class ExportItemOut(BaseModel):
person_id: str
person_name: str
story_count: int
has_photo: bool
class SettingsOut(BaseModel):
llm_provider: str
llm_model: str
llm_base_url: str
llm_api_key: str
segment_max_lines: int
story_min_lines: int
confidence_threshold: float
temperature: float
class SettingsUpdate(BaseModel):
llm_provider: str | None = None
llm_model: str | None = None
llm_base_url: str | None = None
llm_api_key: str | None = None
segment_max_lines: int | None = None
story_min_lines: int | None = None
confidence_threshold: float | None = None
temperature: float | None = None

View File

View File

@@ -0,0 +1,166 @@
import os
import re
from docx import Document
from docx.shared import Pt, Inches, Cm, RGBColor
from docx.enum.text import WD_ALIGN_PARAGRAPH
from docx.oxml.ns import qn
def generate_person_doc(person_name: str, person_info: str, photo_path: str,
stories: list[dict], output_path: str):
"""
为一位讲述者生成最终 DOCX 文档
person_info: 个人信息文本
photo_path: 照片路径(可为空)
stories: [{title, content}]
"""
doc = Document()
# 设置默认字体
style = doc.styles['Normal']
font = style.font
font.name = '宋体'
font.size = Pt(12)
style.element.rPr.rFonts.set(qn('w:eastAsia'), '宋体')
# 照片
if photo_path and os.path.exists(photo_path):
try:
doc.add_picture(photo_path, width=Inches(1.5))
last_paragraph = doc.paragraphs[-1]
last_paragraph.alignment = WD_ALIGN_PARAGRAPH.CENTER
except Exception:
pass
# 姓名
name_para = doc.add_paragraph()
name_para.alignment = WD_ALIGN_PARAGRAPH.CENTER
name_run = name_para.add_run(person_name)
name_run.font.size = Pt(18)
name_run.font.bold = True
name_run.font.name = '黑体'
name_run.element.rPr.rFonts.set(qn('w:eastAsia'), '黑体')
# 个人信息
if person_info:
info_para = doc.add_paragraph()
info_para.alignment = WD_ALIGN_PARAGRAPH.CENTER
info_run = info_para.add_run(person_info.strip())
info_run.font.size = Pt(11)
info_run.font.color.rgb = RGBColor(0x66, 0x66, 0x66)
# 分隔线
doc.add_paragraph("" * 30).alignment = WD_ALIGN_PARAGRAPH.CENTER
# 故事
for i, story in enumerate(stories):
# 故事标题
title_para = doc.add_paragraph()
title_run = title_para.add_run(story.get("title", f"故事 {i + 1}"))
title_run.font.size = Pt(14)
title_run.font.bold = True
title_run.font.name = '黑体'
title_run.element.rPr.rFonts.set(qn('w:eastAsia'), '黑体')
# 故事梗概
summary = story.get("summary", "")
if summary:
p = doc.add_paragraph()
run = p.add_run("【故事梗概】")
run.font.size = Pt(11)
run.font.bold = True
run.font.color.rgb = RGBColor(0x33, 0x33, 0x99)
p = doc.add_paragraph(summary.strip())
p.paragraph_format.first_line_indent = Cm(0.74)
p.paragraph_format.line_spacing = 1.5
for run in p.runs:
run.font.size = Pt(11)
# 关键词标签
tags = story.get("tags", [])
if tags:
p = doc.add_paragraph()
run = p.add_run("【关键词标签】")
run.font.size = Pt(11)
run.font.bold = True
run.font.color.rgb = RGBColor(0x33, 0x33, 0x99)
tag_text = "".join(tags)
p = doc.add_paragraph(tag_text)
p.paragraph_format.line_spacing = 1.5
for run in p.runs:
run.font.size = Pt(10)
run.font.color.rgb = RGBColor(0x66, 0x66, 0x66)
# 故事内容(支持 Markdown 格式)
content = story.get("content", "")
if content:
_add_markdown_content(doc, content)
# 多个故事之间加分隔
if i < len(stories) - 1:
doc.add_paragraph("" * 30).alignment = WD_ALIGN_PARAGRAPH.CENTER
# 保存
os.makedirs(os.path.dirname(output_path), exist_ok=True)
doc.save(output_path)
return output_path
def _add_markdown_content(doc, content: str):
"""将 Markdown 格式的内容添加到 DOCX支持标题、引用、列表等"""
lines = content.split("\n")
i = 0
while i < len(lines):
line = lines[i].strip()
if not line:
i += 1
continue
# ## 二级标题
if line.startswith("## "):
title = line[3:].strip()
p = doc.add_paragraph()
run = p.add_run(title)
run.font.size = Pt(13)
run.font.bold = True
run.font.name = '黑体'
run.element.rPr.rFonts.set(qn('w:eastAsia'), '黑体')
p.paragraph_format.space_before = Pt(12)
# ### 三级标题
elif line.startswith("### "):
title = line[4:].strip()
p = doc.add_paragraph()
run = p.add_run(title)
run.font.size = Pt(12)
run.font.bold = True
run.font.name = '黑体'
run.element.rPr.rFonts.set(qn('w:eastAsia'), '黑体')
p.paragraph_format.space_before = Pt(6)
# > 引用
elif line.startswith("> "):
quote_text = line[2:].strip()
p = doc.add_paragraph()
p.paragraph_format.left_indent = Cm(1)
run = p.add_run(quote_text)
run.font.italic = True
run.font.color.rgb = RGBColor(0x55, 0x55, 0x55)
run.font.size = Pt(11)
# - 列表项
elif line.startswith("- "):
item_text = line[2:].strip()
p = doc.add_paragraph(style='List Bullet')
run = p.add_run(item_text)
run.font.size = Pt(12)
# 普通段落
else:
p = doc.add_paragraph(line)
p.paragraph_format.first_line_indent = Cm(0.74)
p.paragraph_format.line_spacing = 1.5
i += 1

View File

@@ -0,0 +1,93 @@
import re
from docx import Document
def parse_transcript(file_path: str) -> list[dict]:
"""
解析直播回放文字稿 DOCX
格式:发言者(时间戳): 内容
返回:[{index, speaker, timestamp, content, raw_text}]
"""
doc = Document(file_path)
paragraphs = []
for i, para in enumerate(doc.paragraphs):
text = para.text.strip()
if not text:
continue
# 解析格式:发言者(时间戳): 内容
match = re.match(r'^(.+?)\((\d{2}:\d{2}:\d{2})\)\s*[:]\s*(.*)', text)
if match:
speaker = match.group(1).strip()
timestamp = match.group(2)
content = match.group(3).strip()
else:
# 尝试格式:发言者: 内容
match2 = re.match(r'^(.+?)\s*[:]\s*(.*)', text)
if match2:
speaker = match2.group(1).strip()
timestamp = ""
content = match2.group(2).strip()
else:
speaker = ""
timestamp = ""
content = text
paragraphs.append({
"index": i,
"speaker": speaker,
"timestamp": timestamp,
"content": content,
"raw_text": text,
})
return paragraphs
def is_teacher(speaker: str) -> bool:
"""判断是否为老师/助教发言"""
teacher_keywords = ["卢慧老师", "静静", "督导", "老师"]
return any(kw in speaker for kw in teacher_keywords)
def parse_person_info(file_path: str) -> dict:
"""
解析讲述者个人信息 DOCX
返回:{name, info_text, photos: [path]}
"""
doc = Document(file_path)
texts = []
photos = []
# 提取文本
for para in doc.paragraphs:
text = para.text.strip()
if text:
texts.append(text)
# 提取图片
import os
photo_dir = os.path.join(os.path.dirname(file_path), "photos")
os.makedirs(photo_dir, exist_ok=True)
for i, rel in enumerate(doc.part.rels.values()):
if "image" in rel.reltype:
image = rel.target_part
photo_name = f"{os.path.splitext(os.path.basename(file_path))[0]}_photo_{i}.{image.content_type.split('/')[-1]}"
photo_path = os.path.join(photo_dir, photo_name)
with open(photo_path, "wb") as f:
f.write(image.blob)
photos.append(photo_path)
# 从文件名提取姓名
filename = os.path.basename(file_path)
name = re.sub(r'[-_].*$', '', filename).strip()
# 去掉扩展名
name = os.path.splitext(name)[0].strip()
return {
"name": name,
"info_text": "\n".join(texts),
"photos": photos,
}

View File

@@ -0,0 +1,957 @@
"""
故事提取器 - v4 五步流水线策略
第一步:代码预处理(识别老师→过滤杂音→时间范围截取→过滤短段落/非主讲述人)
第二步:判断故事 + 提取事实(短段落直接提取,长段落先判断再切片提取)
第三步:切片提取事实(按自然段切分,并行提取)
第四步:重组(纯代码,事件/情感/原话/人物去重)
第五步生成最终故事基于素材生成5部分结构化故事
"""
import json
import asyncio
import logging
from sqlalchemy import select
from app.database import async_session
from app.models.story import Story
from app.models.transcript import Transcript
from app.services.docx_parser import parse_transcript
from app.services.preprocessor import Preprocessor
from app.services.llm_client import llm_client
from app.state import TaskState
logger = logging.getLogger(__name__)
# 并发控制
MAX_CONCURRENT_FILES = 3
MAX_CONCURRENT_SEGMENTS = 5
# 短段落阈值(字数):<=4000 字直接提取事实,>4000 字先判断是否有故事
SHORT_THRESHOLD = 4000
# 切片参数
CHUNK_MAX_CHARS = 3000
CHUNK_OVERLAP_PARAGRAPHS = 5
# ============================================================
# 主入口
# ============================================================
async def run_extraction(state: TaskState):
"""主提取流程:文件间串行(避免 API 限流),文件内各步骤并行"""
state.is_running = True
try:
async with async_session() as db:
result = await db.execute(
select(Transcript).where(Transcript.status == "pending")
)
transcripts = result.scalars().all()
if not transcripts:
state.update(status="completed", percent=100)
return
file_total = len(transcripts)
state.update(
status="running", percent=0,
files_total=file_total, files_completed=0,
stories_found=0,
current_file="", action=f"共发现 {file_total} 份文字稿,开始处理...",
phase="preprocess", phase_percent=0, phase_detail="准备中",
)
total_stories = [0]
# 文件间串行处理(避免 API 限流)
for file_index, transcript in enumerate(transcripts):
await _process_file(state, file_index, file_total, transcript, total_stories)
state.update(
status="completed", percent=100,
files_completed=file_total,
stories_found=total_stories[0],
current_file="", action="全部处理完成!",
phase="preprocess", phase_percent=100, phase_detail="全部完成",
)
state.is_running = False
except asyncio.CancelledError:
state.update(status="stopped", action="提取已停止")
state.is_running = False
except Exception as e:
logger.exception("Extraction failed")
state.update(status="error", action=str(e))
state.is_running = False
# ============================================================
# 单文件处理(五步流水线)
# ============================================================
async def _process_file(state: TaskState, file_index: int, file_total: int,
transcript: Transcript, total_stories: list):
"""处理单个文件 - 五步流水线"""
try:
async with async_session() as db:
t = await db.get(Transcript, transcript.id)
if not t:
_increment_files_completed(state)
return
t.status = "processing"
await db.commit()
# 计算该文件在总进度中的权重区间
# 预处理 0-5%, 判断+提取 5-30%, 切片提取 30-55%, 重组 55-60%, 生成 60-95%, 完成 100%
# 每个文件占 95/file_total 的权重
file_weight = 95.0 / max(file_total, 1)
file_start_percent = file_index * file_weight
def _file_percent(phase_start_ratio, phase_end_ratio, inner_ratio=1.0):
"""计算当前文件在总进度中的百分比"""
phase_range = phase_end_ratio - phase_start_ratio
return min(99, int(file_start_percent + file_weight * (phase_start_ratio + phase_range * inner_ratio)))
state.update(
current_file=t.filename, action="正在读取文件...",
phase="preprocess", phase_percent=0, phase_detail="读取文件",
)
# 解析 DOCX
lines = await asyncio.to_thread(parse_transcript, t.file_path)
if not lines:
t.status = "completed"
t.error_message = "文件为空"
await db.commit()
_increment_files_completed(state)
return
# ========== 第一步:代码预处理(纯代码,不用 LLM==========
state.update(
current_file=t.filename, action="正在分析对话结构...",
phase="preprocess", phase_percent=10, phase_detail="统计说话人",
)
preprocessor = Preprocessor()
filtered_lines = [p for p in lines if not preprocessor.is_management_line(p)]
if not filtered_lines:
t.status = "completed"
t.error_message = "过滤后无有效对话内容"
await db.commit()
_increment_files_completed(state)
return
segments, teacher = _preprocess_segments(filtered_lines)
logger.info(f"预处理完成: 识别老师={teacher}, 有效段落={len(segments)}")
if not segments:
t.status = "completed"
t.error_message = "未发现学员对话段落"
await db.commit()
_increment_files_completed(state)
return
state.update(
current_file=t.filename,
action=f"发现 {len(segments)} 个学员段落,开始识别故事...",
phase="preprocess", phase_percent=100, phase_detail="预处理完成",
percent=_file_percent(0, 0.05),
)
# ========== 第二步:判断故事 + 提取事实(并行)==========
state.update(
current_file=t.filename,
action=f"正在判断 {len(segments)} 个段落是否包含故事...",
phase="identify", phase_percent=0,
phase_detail=f"准备处理 {len(segments)} 个段落",
)
seg_semaphore = asyncio.Semaphore(MAX_CONCURRENT_SEGMENTS)
identify_results = [None] * len(segments)
identify_errors = []
async def process_segment_for_identify(seg_idx, speaker, seg):
"""对单个段落进行判断+提取"""
async with seg_semaphore:
text = _segment_to_text(seg)
text_len = len(text)
# 更新进度
inner_pct = int((seg_idx + 1) / len(segments) * 100)
state.update(
phase="identify", phase_percent=inner_pct,
phase_detail=f"正在处理段落 {seg_idx + 1}/{len(segments)}{speaker}, {text_len}字)",
percent=_file_percent(0.05, 0.30, (seg_idx + 1) / len(segments)),
)
if text_len <= SHORT_THRESHOLD:
# 短段落:跳过判断,直接提取事实
logger.info(f" 段落 {seg_idx}: {speaker} ({text_len}字) - 短段落,直接提取事实")
facts = await _extract_facts_from_chunk(text, "")
if facts:
hints = facts.get("story_hints", "") or f"{speaker}的故事"
return (speaker, seg, hints, [facts])
else:
logger.info(f" 段落 {seg_idx}: 提取失败,跳过")
return None
else:
# 长段落:先判断是否有故事
is_story, hints = await _identify_story(text)
logger.info(f" 段落 {seg_idx}: {speaker} ({text_len}字) - is_story={is_story}, hints={hints}")
if is_story:
return (speaker, seg, hints, None) # None 表示需要第三步切片提取
else:
logger.info(f" 段落 {seg_idx}: 无故事,跳过")
return None
tasks = [
process_segment_for_identify(i, speaker, seg)
for i, (speaker, seg) in enumerate(segments)
]
results = await asyncio.gather(*tasks, return_exceptions=True)
# 收集结果:分为两类
# - 直接提取到事实的(短段落)
# - 需要切片提取的(长段落,有故事)
all_facts = [] # 最终的 (speaker, seg, hints, facts_list) 列表
needs_chunking = [] # 需要进入第三步的 (speaker, seg, hints)
for i, r in enumerate(results):
if isinstance(r, Exception):
error_str = str(r)
logger.warning(f"段落 {i} 处理异常: {error_str}")
identify_errors.append(error_str)
if "529" in error_str or "overloaded" in error_str.lower() or "timed out" in error_str.lower():
raise r
continue
if r is None:
continue
speaker, seg, hints, facts_list = r
if facts_list is not None:
# 短段落已直接提取到事实
all_facts.append((speaker, seg, hints, facts_list))
else:
# 长段落有故事,需要切片提取
needs_chunking.append((speaker, seg, hints))
if not all_facts and not needs_chunking:
t.status = "completed"
t.error_message = "未发现包含个人故事的段落"
await db.commit()
_increment_files_completed(state)
state.update(
percent=_file_percent(0.05, 0.30, 1.0),
action="未发现故事",
)
return
logger.info(f"第二步完成: 直接提取={len(all_facts)}, 需切片={len(needs_chunking)}")
# 第二步完成时已知道发现了多少个故事,立即更新
discovered = len(all_facts) + len(needs_chunking)
total_stories[0] += discovered
state.update(stories_found=total_stories[0])
# ========== 第三步:切片提取事实(并行)==========
if needs_chunking:
state.update(
current_file=t.filename,
action=f"正在切片提取 {len(needs_chunking)} 个故事...",
phase="chunk", phase_percent=0,
phase_detail=f"准备切片 {len(needs_chunking)} 个故事段落",
)
chunk_semaphore = asyncio.Semaphore(MAX_CONCURRENT_SEGMENTS)
async def process_chunked_story(story_idx, speaker, seg, hints):
"""对单个长段落进行切片提取"""
async with chunk_semaphore:
text = _segment_to_text(seg)
chunks = _chunk_text_by_paragraphs(text, max_chars=CHUNK_MAX_CHARS, overlap_paragraphs=CHUNK_OVERLAP_PARAGRAPHS)
logger.info(f" 故事 {story_idx}: {speaker} - 切成 {len(chunks)}")
facts_list = []
chunk_facts = await asyncio.gather(
*[_extract_facts_from_chunk(chunk, hints) for chunk in chunks],
return_exceptions=True,
)
for j, facts in enumerate(chunk_facts):
inner_pct = int((story_idx * 10 + j + 1) / (len(needs_chunking) * 10) * 100)
state.update(
phase="chunk", phase_percent=inner_pct,
phase_detail=f"正在切片提取 故事{story_idx + 1}/{len(needs_chunking)}{j + 1}/{len(chunks)}",
percent=_file_percent(0.30, 0.55, (story_idx + j / max(len(chunks), 1)) / len(needs_chunking)),
)
if isinstance(facts, Exception):
logger.warning(f"{j}提取异常: {facts}")
continue
if facts:
facts_list.append(facts)
return (speaker, seg, hints, facts_list)
chunk_tasks = [
process_chunked_story(i, speaker, seg, hints)
for i, (speaker, seg, hints) in enumerate(needs_chunking)
]
chunk_results = await asyncio.gather(*chunk_tasks, return_exceptions=True)
for r in chunk_results:
if isinstance(r, Exception):
error_str = str(r)
logger.warning(f"切片提取异常: {error_str}")
if "529" in error_str or "overloaded" in error_str.lower() or "timed out" in error_str.lower():
raise r
continue
if r is not None:
all_facts.append(r)
logger.info(f"第三步完成: 总共 {len(all_facts)} 个故事段落完成事实提取")
state.update(
current_file=t.filename,
action="切片提取完成,正在重组素材...",
phase="chunk", phase_percent=100,
phase_detail="切片提取完成",
percent=_file_percent(0.30, 0.55, 1.0),
)
# ========== 第四步:重组(纯代码,无需 LLM==========
state.update(
current_file=t.filename,
action="正在重组素材...",
phase="merge", phase_percent=50,
phase_detail="去重合并素材",
percent=_file_percent(0.55, 0.60, 0.5),
)
merged = _merge_facts(all_facts)
logger.info(f"第四步完成: 重组出 {len(merged)} 个故事素材")
if not merged:
t.status = "completed"
t.error_message = "素材重组后为空"
await db.commit()
_increment_files_completed(state)
return
state.update(
current_file=t.filename,
action=f"重组完成,正在生成 {len(merged)} 个故事...",
phase="merge", phase_percent=100,
phase_detail="重组完成",
percent=_file_percent(0.55, 0.60, 1.0),
)
# ========== 第五步:生成最终故事(并行)==========
state.update(
current_file=t.filename,
action=f"正在生成 {len(merged)} 个故事...",
phase="generate", phase_percent=0,
phase_detail=f"准备生成 {len(merged)} 个故事",
percent=_file_percent(0.60, 0.95, 0),
)
gen_semaphore = asyncio.Semaphore(MAX_CONCURRENT_SEGMENTS)
async def generate_one_story(story_idx, speaker, seg, material):
"""生成单个故事并保存到数据库"""
async with gen_semaphore:
inner_pct = int((story_idx + 1) / len(merged) * 100)
state.update(
phase="generate", phase_percent=inner_pct,
phase_detail=f"正在生成故事 {story_idx + 1}/{len(merged)}{speaker}",
percent=_file_percent(0.60, 0.95, (story_idx + 1) / len(merged)),
)
story_data = await _generate_story(material)
if not story_data:
logger.warning(f" 故事 {story_idx}: 生成失败")
return None
# 保存到数据库
raw_content = story_data.get("content", {})
if isinstance(raw_content, dict):
content_str = _format_story_content(raw_content)
else:
content_str = str(raw_content)
story = Story(
title=story_data.get("title", "未命名故事"),
summary=story_data.get("summary", ""),
tags=json.dumps(story_data.get("tags", []), ensure_ascii=False),
content=content_str,
raw_material=json.dumps(material, ensure_ascii=False),
speaker_nickname=story_data.get("speaker_nickname", ""),
source_transcript_id=t.id,
source_lines=json.dumps([l.get("index", 0) for l in seg if isinstance(l, dict)]),
duration_minutes=_estimate_duration(seg),
confidence=story_data.get("confidence", 0.7),
confidence_level=_confidence_level(story_data.get("confidence", 0.7)),
)
db.add(story)
await db.commit()
state.update(
action=f"生成第 {total_stories[0]} 个故事:{story.title}",
)
logger.info(f" 故事 {story_idx}: 保存成功 - {story.title}")
return story
gen_tasks = [
generate_one_story(i, speaker, seg, material)
for i, (speaker, seg, material) in enumerate(merged)
]
gen_results = await asyncio.gather(*gen_tasks, return_exceptions=True)
for r in gen_results:
if isinstance(r, Exception):
error_str = str(r)
logger.warning(f"故事生成异常: {error_str}")
if "529" in error_str or "overloaded" in error_str.lower() or "timed out" in error_str.lower():
raise r
t.status = "completed"
await db.commit()
_increment_files_completed(state)
state.update(
current_file=t.filename,
action=f"文件处理完成,共生成 {total_stories[0]} 个故事",
phase="generate", phase_percent=100,
phase_detail="文件处理完成",
percent=_file_percent(0.60, 0.95, 1.0),
)
except Exception as e:
error_str = str(e)
logger.exception(f"Error processing file {transcript.filename}")
try:
async with async_session() as db:
t = await db.get(Transcript, transcript.id)
if t:
t.status = "error"
t.error_message = error_str
await db.commit()
except Exception:
pass
_increment_files_completed(state)
if "529" in error_str or "overloaded" in error_str.lower():
state.update(status="error", error="AI 服务暂时过载529请稍后再试")
elif "timed out" in error_str.lower():
state.update(status="error", error="AI 服务请求超时,请稍后再试")
else:
state.update(status="error", error=f"处理文件时出错:{error_str[:200]}")
# ============================================================
# 第一步:代码预处理
# ============================================================
def _preprocess_segments(lines: list[dict]) -> tuple[list[tuple[str, list[dict]]], str]:
"""
代码预处理v3时间范围截取允许重叠
识别老师 → 过滤杂音(<20句→ 按时间范围截取 → 过滤短段落(<15句/<80字→ 过滤非主讲述人(占比<10%
返回: (segments, teacher)
segments: [(speaker, segment_lines), ...]
teacher: 老师名称
"""
# 1. 统计每个说话人
speaker_stats = {}
for i, line in enumerate(lines):
speaker = line.get("speaker", "")
if not speaker:
continue
if speaker not in speaker_stats:
speaker_stats[speaker] = {"lines": 0, "chars": 0, "first": i, "last": i}
speaker_stats[speaker]["lines"] += 1
speaker_stats[speaker]["chars"] += len(line.get("content", ""))
speaker_stats[speaker]["last"] = i
# 2. 识别老师(发言最多的人)
teacher = max(speaker_stats, key=lambda s: speaker_stats[s]["lines"])
logger.info(f"识别老师: {teacher} ({speaker_stats[teacher]['lines']}行, {speaker_stats[teacher]['chars']}字)")
# 3. 过滤杂音(< 20句的说话人+ 排除老师
students = []
noise_speakers = []
for s, stats in speaker_stats.items():
if s == teacher:
continue
if stats["lines"] < 20:
noise_speakers.append(f"{s} ({stats['lines']}句)")
continue
students.append(s)
if noise_speakers:
logger.info(f"杂音过滤: {', '.join(noise_speakers)}")
logger.info(f"有效学员: {students}")
# 4. 按时间范围截取:每个学员从第一句到最后一句,中间所有行全部截取
segments = []
for speaker in students:
stats = speaker_stats[speaker]
first_idx = stats["first"]
last_idx = stats["last"]
seg = lines[first_idx:last_idx + 1]
segments.append((speaker, seg))
# 5. 过滤:短段落 + 当事人占比过低(非主讲述人)
final_segments = []
for speaker, seg in segments:
student_lines = [l for l in seg if l.get("speaker", "") == speaker]
if len(student_lines) < 15:
logger.info(f"过滤: {speaker} ({len(student_lines)}句<15)")
continue
student_chars = sum(len(l.get("content", "")) for l in student_lines)
if student_chars < 80:
logger.info(f"过滤: {speaker} ({student_chars}字<80)")
continue
# 当事人说话占比 < 10% → 非主讲述人,丢弃
ratio = len(student_lines) / len(seg) if seg else 0
if ratio < 0.1:
logger.info(f"过滤: {speaker} (占比{ratio:.1%}<10%)")
continue
final_segments.append((speaker, seg))
logger.info(f"最终: {len(final_segments)} 个学员段落")
for i, (speaker, seg) in enumerate(final_segments):
student_lines = [l for l in seg if l.get("speaker", "") == speaker]
student_chars = sum(len(l.get("content", "")) for l in student_lines)
ratio = len(student_lines) / len(seg) if seg else 0
logger.info(f" 段落{i}: {speaker}, 截取{len(seg)}行, 学员{len(student_lines)}句/{student_chars}字, 占比{ratio:.1%}")
return final_segments, teacher
# ============================================================
# 第二步:判断故事
# ============================================================
async def _identify_story(text: str) -> tuple[bool, str]:
"""判断对话段落是否包含个人故事"""
prompt = """判断这段对话是否包含学员的个人故事。直接输出JSON不要分析。
个人故事特征满足至少2条学员讲述具体经历、包含时间地点人物细节、表达深层情感、老师深入引导疗愈、揭示心理或家庭问题。
非故事内容:纯课堂讲解、简单问答寒暄、课堂管理、纯疗愈引导词。
直接输出JSON
{"is_story": true或false, "confidence": 0.0到1.0, "story_hints": "一句话描述故事主题和讲述者"}
对话内容:
```
""" + text[:4000] + """
```"""
try:
result = await llm_client.chat_json([{"role": "user", "content": prompt}], temperature=0.2)
if result:
is_story = result.get("is_story", False)
confidence = result.get("confidence", 0)
hints = result.get("story_hints", "")
logger.info(f"Story identification: is_story={is_story}, confidence={confidence}, hints={hints[:80]}")
return bool(is_story) and confidence >= 0.5, hints
except Exception as e:
logger.warning(f"Story identification error: {e}")
if "529" in str(e) or "overloaded" in str(e).lower() or "timed out" in str(e).lower():
raise
return False, ""
# ============================================================
# 第三步:切片提取事实
# ============================================================
def _chunk_text_by_paragraphs(text: str, max_chars: int = 3000, overlap_paragraphs: int = 5) -> list[str]:
"""按自然段切分,在 max_chars 附近找段落结尾切,重叠 overlap_paragraphs 个自然段"""
paragraphs = [p for p in text.split("\n") if p.strip()]
if not paragraphs:
return [text]
if len(text) <= max_chars:
return [text]
chunks = []
i = 0
while i < len(paragraphs):
# 从当前位置开始,累加段落直到超过 max_chars
current = []
current_len = 0
j = i
while j < len(paragraphs):
para = paragraphs[j]
# 超过 max_chars 且已有内容 → 在这个段落前切断(段落结尾切)
if current_len + len(para) > max_chars and current:
break
current.append(para)
current_len += len(para)
j += 1
chunks.append("\n".join(current))
# 到末尾了就结束
if j >= len(paragraphs):
break
# 下一次起点:当前切到的位置 - 重叠段落数
next_i = j - overlap_paragraphs
# 防止死循环确保至少前进1个段落
if next_i <= i:
next_i = i + 1
i = next_i
return chunks
async def _extract_facts_from_chunk(text: str, hints: str) -> dict | None:
"""从对话切片中提取关键信息(事件、情感、原话、人物)"""
prompt = f"""从这段对话中提取关键信息。只输出JSON。
故事主题:{hints}
提取以下内容:
- events: 发生的事件(尽量详细,保留具体细节)
- emotions: 表达的情感
- quotes: 讲述者的原话(保留原话,不要改写,尽量多提取;完全相同的重复语句只保留一条)
- key_people: 提到的人物
{{"events":["事件1","事件2"],"emotions":["情感1","情感2"],"quotes":["原话1","原话2","原话3"],"key_people":["人物1"]}}
对话内容:
```
{text}
```"""
try:
result = await llm_client.chat_json([{"role": "user", "content": prompt}], temperature=0.1)
if not result:
return None
# chat_json 可能返回 list解析错误需要转为 dict
if isinstance(result, list):
for item in result:
if isinstance(item, dict) and ("events" in item or "quotes" in item):
result = item
break
else:
logger.warning(f"chat_json returned list, cannot convert to dict: {result[:3]}")
return None
return result
except Exception as e:
logger.warning(f"Extract facts error: {e}")
if "529" in str(e) or "overloaded" in str(e).lower() or "timed out" in str(e).lower():
raise
return None
# ============================================================
# 第四步:重组(纯代码,无需 LLM
# ============================================================
def _fuzzy_dedup_quotes(quotes: list[str]) -> list[str]:
"""
模糊去重:对高度相似的语句只保留第一条。
策略:提取每条语句的核心部分(去掉常见前缀后缀),相似度超过阈值的视为重复。
"""
import re
def normalize(s: str) -> str:
"""提取语句核心,去掉语气词和细微差异"""
s = s.strip()
# 去掉「」包裹
if s.startswith("") and s.endswith(""):
s = s[1:-1]
# 去掉常见前缀
for prefix in ["", "", "", "就是", "然后", "那个", "所以"]:
if s.startswith(prefix):
s = s[len(prefix):].strip()
# 去掉常见尾缀
for suffix in ["", "", "", "", "", ""]:
if s.endswith(suffix) and len(s) > 4:
s = s[:-1]
# 去掉多余空白和重复字(如"我我我"、"的的的"
s = re.sub(r'(.)\1{2,}', r'\1', s)
return s
def similarity(a: str, b: str) -> float:
"""简单的字符级相似度Jaccard on character bigrams"""
if not a or not b:
return 0.0
a_bigrams = set(a[i:i+2] for i in range(len(a)-1))
b_bigrams = set(b[i:i+2] for i in range(len(b)-1))
if not a_bigrams or not b_bigrams:
return 0.0
intersection = a_bigrams & b_bigrams
union = a_bigrams | b_bigrams
return len(intersection) / len(union)
kept = []
kept_normalized = []
for q in quotes:
nq = normalize(q)
is_dup = False
for kn in kept_normalized:
# 短语句用高阈值,长语句用低阈值
threshold = 0.8 if len(nq) < 15 else 0.7
if similarity(nq, kn) >= threshold:
is_dup = True
break
if not is_dup:
kept.append(q)
kept_normalized.append(nq)
removed = len(quotes) - len(kept)
if removed > 0:
logger.info(f" 模糊去重: {len(quotes)} -> {len(kept)} (去除 {removed} 条相似重复)")
return kept
def _merge_facts(all_facts: list) -> list[tuple[str, list, dict]]:
"""
重组素材Reduce
对每个故事段落的所有切片事实进行合并去重:
- 事件按原文顺序保留
- 情感去重
- 原话去重(不限制数量)
- 人物去重
输入: [(speaker, seg, hints, facts_list), ...]
输出: [(speaker, seg, material), ...]
material: {"events": [...], "emotions": [...], "quotes": [...], "key_people": [...], "story_hints": "..."}
"""
merged = []
for i, (speaker, seg, hints, facts_list) in enumerate(all_facts):
all_events, all_emotions, all_quotes, all_people = [], [], [], []
for facts in facts_list:
if isinstance(facts, dict):
all_events.extend(facts.get("events", []))
all_emotions.extend(facts.get("emotions", []))
all_quotes.extend(facts.get("quotes", []))
all_people.extend(facts.get("key_people", []))
# 情感去重(保持顺序)
all_emotions = list(dict.fromkeys(all_emotions))
# 人物去重(保持顺序)
all_people = list(dict.fromkeys(all_people))
# 原话去重(保持顺序,不限制数量)
# 第一轮:精确去重
seen_quotes = set()
unique_quotes = [q for q in all_quotes if q not in seen_quotes and not seen_quotes.add(q)]
# 第二轮:模糊去重(去除重复模式的语句,如释放语句只保留一条)
unique_quotes = _fuzzy_dedup_quotes(unique_quotes)
material = {
"events": all_events,
"emotions": all_emotions,
"quotes": unique_quotes,
"key_people": all_people,
"story_hints": hints,
}
logger.info(f" 重组故事 {i}: {speaker} - 事件:{len(all_events)}, 情感:{len(all_emotions)}, "
f"原话:{len(unique_quotes)}, 人物:{len(all_people)}")
merged.append((speaker, seg, material))
return merged
# ============================================================
# 第五步:生成最终故事
# ============================================================
async def _generate_story(material: dict) -> dict | None:
"""基于素材生成第三人称故事5部分结构化输出"""
prompt = f"""基于素材生成第三人称故事。只输出JSON不要其他内容。
素材:
主题:{material['story_hints']}
事件:{json.dumps(material['events'], ensure_ascii=False)}
情感:{json.dumps(material['emotions'], ensure_ascii=False)}
原话:{json.dumps(material['quotes'], ensure_ascii=False)}
人物:{json.dumps(material['key_people'], ensure_ascii=False)}
写作手法(非常重要):
【双时空结构】
故事有两个时空层:
- "当下时空":学员在课堂上与卢慧老师的对话——这是故事的框架,用对话体呈现,保留师生互动的完整脉络
- "过往时空":学员讲述的过去经历——这是故事的核心,用"场景重建"手法呈现,让读者穿越到那个时空
【场景重建手法(用于"过往的故事"部分)】
当学员讲述过去的经历时,不要写成"她说她小时候…"这种转述体,而是直接把读者拉进那个时空:
- 直接切入场景,用过去时态叙述。比如不要写"文晔回忆说她的儿子15岁去了美国",而要写"十五岁那年,文晔的儿子独自登上了飞往美国的航班"
- 用五感描写构建画面:她看到了什么、听到了什么、身体感受到了什么(比如"手心出汗""脚发凉"这些身体反应要融入场景中)
- 用环境细节暗示情绪:不要直接说"她很痛苦",而是通过场景让读者感受到痛苦
- 关键情感爆发处用直接引语(「」包裹原话),其他地方用间接引语和叙述自然融合
- 场景之间要有过渡,比如"然而,命运的转折发生在前年冬天——"
【对话体手法(用于"当下的困境""转变"部分)】
- 保留师生对话的现场感,写出谁说了什么、对方如何回应
- 在对话中穿插学员的内心活动、身体反应、情绪变化
- 老师的引导语要完整保留,这是疗愈过程的关键
【通用要求】
- 每个事件都要展开写,写出具体过程和细节,不要一句话概括
- 事件之间要有过渡和因果逻辑,不能跳跃
- 情感变化要写出过程:从什么情感转变到什么情感,中间经历了什么
- 不编造素材中没有的内容,但可以根据素材合理补充场景细节(如环境、氛围)
- 每个部分充分展开,不要限制长度,越详细越好
- 原文金句数组中的每条金句必须用英文双引号包裹,如 ["金句1", "金句2"],不要用「」替代双引号
禁止事项:
- 禁止用一句话概括多个事件(如"她倾诉了挣扎""她经历了疗愈过程"
- 禁止省略素材中的具体细节
- 禁止省略老师的引导和回应
- 禁止事件之间跳跃,要保持时间顺序和因果逻辑
- 禁止过度总结或抽象化描述
- 禁止在"过往的故事"部分全程使用转述体("她说…她回忆…"),必须用场景重建手法让读者身临其境
- 禁止为了简洁而牺牲内容的丰富性
输出格式:{{"title":"标题","summary":"80字摘要","tags":["标签"],"speaker_nickname":"昵称","content":{{"讲述者":"...","当下的困境":"...","过往的故事":"...","转变":"...","原文金句":["原话1","原话2","原话3"]}}}}"""
try:
result = await llm_client.chat_json(
[{"role": "user", "content": prompt}],
temperature=0.7,
)
if not result:
return None
# chat_json 可能返回 list解析错误需要转为 dict
if isinstance(result, list):
for item in result:
if isinstance(item, dict) and "title" in item:
result = item
break
else:
logger.warning(f"chat_json returned list, cannot convert to dict: {result[:3]}")
return None
# 解析 content 纯文本为结构化字段
raw_content = result.get("content", "")
if isinstance(raw_content, str) and "|||" in raw_content:
parts = raw_content.split("|||")
content_dict = {}
keys = ["讲述者", "当下的困境", "过往的故事", "转变", "原文金句"]
for idx, key in enumerate(keys):
content_dict[key] = parts[idx].strip() if idx < len(parts) else ""
result["content"] = content_dict
elif isinstance(raw_content, dict):
for key in ["讲述者", "当下的困境", "过往的故事", "转变", "原文金句"]:
if key not in raw_content:
raw_content[key] = ""
result["content"] = raw_content
else:
result["content"] = {"完整故事": str(raw_content)}
if not result.get("tags"):
result["tags"] = []
return result
except Exception as e:
logger.warning(f"Generate story error: {e}")
if "529" in str(e) or "overloaded" in str(e).lower() or "timed out" in str(e).lower():
raise
return None
# ============================================================
# 工具函数
# ============================================================
def _segment_to_text(segment_lines: list) -> str:
"""将段落行列表转为对话文本"""
parts = []
for line in segment_lines:
speaker = line.get("speaker", "")
content = line.get("content", "")
if speaker:
parts.append(f"{speaker}: {content}")
else:
parts.append(content)
return "\n".join(parts)
def _increment_files_completed(state: TaskState):
"""增加已完成文件计数"""
p = state.progress
files_completed = p.get("files_completed", 0) + 1
files_total = p.get("files_total", 1)
state.update(files_completed=files_completed)
def _estimate_duration(segment_lines: list) -> float:
"""根据时间戳估算故事时长(分钟)"""
timestamps = []
for line in segment_lines:
if isinstance(line, dict) and line.get("timestamp"):
try:
parts = line["timestamp"].split(":")
if len(parts) >= 2:
minutes = int(parts[-2]) + int(parts[-1]) / 60
if len(parts) == 3:
minutes += int(parts[0]) * 60
timestamps.append(minutes)
except (ValueError, IndexError):
continue
if len(timestamps) >= 2:
return round(max(timestamps) - min(timestamps), 1)
return 0
def _confidence_level(confidence: float) -> str:
if confidence >= 0.8:
return "high"
elif confidence >= 0.6:
return "medium"
return "low"
def _format_story_content(content_dict: dict) -> str:
"""将 LLM 返回的结构化故事内容转为可读的 Markdown 格式"""
parts = []
# 按顺序输出各个章节
sections = ["讲述者", "当下的困境", "过往的故事", "转变"]
for section_name in sections:
if content_dict.get(section_name):
parts.append(f"## {section_name}\n\n{content_dict[section_name]}")
# 原文金句
quotes = content_dict.get("原文金句", [])
if not quotes:
quotes = content_dict.get("金句摘录", [])
if quotes and isinstance(quotes, list):
parts.append("## 原文金句")
for q in quotes:
parts.append(f"\n> {q}")
# 兼容旧格式:故事梗概
if content_dict.get("故事梗概") and "讲述者" not in content_dict:
parts.insert(0, f"## 故事梗概\n\n{content_dict['故事梗概']}")
# 兼容旧格式:关键要素
elements = content_dict.get("关键要素", [])
if elements and isinstance(elements, list) and "讲述者" not in content_dict:
parts.append("## 关键要素")
for elem in elements:
if isinstance(elem, dict):
title = elem.get("标题", "")
desc = elem.get("描述", "")
parts.append(f"\n### {title}\n\n{desc}")
# 如果上面都没匹配到,按通用 key-value 格式输出
if not parts:
for key, value in content_dict.items():
if isinstance(value, str):
parts.append(f"## {key}\n\n{value}")
elif isinstance(value, list):
parts.append(f"## {key}")
for item in value:
if isinstance(item, dict):
for k, v in item.items():
parts.append(f"\n**{k}**{v}")
else:
parts.append(f"\n- {item}")
return "\n\n".join(parts) if parts else json.dumps(content_dict, ensure_ascii=False)

View File

@@ -0,0 +1,63 @@
import os
import uuid
import shutil
from fastapi import UploadFile
from app.config import settings
class FileService:
"""文件上传、存储、删除"""
def __init__(self):
os.makedirs(settings.UPLOAD_DIR, exist_ok=True)
os.makedirs(os.path.join(settings.UPLOAD_DIR, "transcripts"), exist_ok=True)
os.makedirs(os.path.join(settings.UPLOAD_DIR, "persons"), exist_ok=True)
os.makedirs(settings.EXPORT_DIR, exist_ok=True)
async def save_transcript(self, file: UploadFile) -> dict:
"""保存上传的文字稿"""
file_id = str(uuid.uuid4())
ext = os.path.splitext(file.filename)[1]
save_name = f"{file_id}{ext}"
save_dir = os.path.join(settings.UPLOAD_DIR, "transcripts")
save_path = os.path.join(save_dir, save_name)
content = await file.read()
with open(save_path, "wb") as f:
f.write(content)
return {
"id": file_id,
"filename": file.filename,
"file_path": save_path,
"file_size": len(content),
}
async def save_person(self, file: UploadFile) -> dict:
"""保存上传的讲述者信息"""
file_id = str(uuid.uuid4())
ext = os.path.splitext(file.filename)[1]
save_name = f"{file_id}{ext}"
save_dir = os.path.join(settings.UPLOAD_DIR, "persons")
save_path = os.path.join(save_dir, save_name)
content = await file.read()
with open(save_path, "wb") as f:
f.write(content)
return {
"id": file_id,
"filename": file.filename,
"file_path": save_path,
"file_size": len(content),
}
def delete_file(self, file_path: str) -> bool:
"""删除文件"""
if os.path.exists(file_path):
os.remove(file_path)
return True
return False
file_service = FileService()

View File

@@ -0,0 +1,287 @@
import json
import logging
import re
import time
from openai import AsyncOpenAI
import anthropic
from app.config import settings
logger = logging.getLogger(__name__)
class LLMClient:
"""LLM 客户端,支持 OpenAI 和 Anthropic SDK"""
def __init__(self):
self._openai_client = None
self._anthropic_client = None
self._api_key = settings.LLM_API_KEY
self._base_url = settings.LLM_BASE_URL
self._model = settings.LLM_MODEL
self._provider = settings.LLM_PROVIDER
def _get_openai_client(self) -> AsyncOpenAI:
if self._openai_client is None:
self._openai_client = AsyncOpenAI(
api_key=self._api_key,
base_url=self._base_url,
max_retries=0,
timeout=300.0,
)
return self._openai_client
def _get_anthropic_client(self) -> anthropic.AsyncAnthropic:
if self._anthropic_client is None:
# MiniMax Anthropic 端点
base_url = self._base_url.replace("/v1", "/anthropic").replace("api.minimax.chat", "api.minimaxi.com")
if not base_url.endswith("/anthropic"):
# 如果 base_url 不是标准格式,手动拼接
base_url = "https://api.minimaxi.com/anthropic"
self._anthropic_client = anthropic.AsyncAnthropic(
api_key=self._api_key,
base_url=base_url,
timeout=300.0,
)
return self._anthropic_client
def _use_anthropic(self) -> bool:
"""判断是否使用 Anthropic SDK"""
return False # MiniMax thinking 占用太多 token统一用 OpenAI SDK
def update_config(self, base_url: str = None, api_key: str = None, model: str = None):
"""更新配置(设置页面保存后调用)"""
if base_url:
self._base_url = base_url
if api_key:
self._api_key = api_key
if model:
self._model = model
# 重置客户端
self._openai_client = None
self._anthropic_client = None
# 更新 provider 判断
if base_url:
self._provider = "minimax" if "minimax" in base_url.lower() else "openai"
async def chat(self, messages: list[dict], temperature: float = None, max_tokens: int = 16384, timeout: float = 300.0) -> str:
"""发送对话请求,返回文本内容(不含思考过程)"""
if temperature is None:
temperature = settings.TEMPERATURE
if self._use_anthropic():
return await self._chat_anthropic(messages, temperature, max_tokens, timeout)
else:
result = await self._chat_openai(messages, temperature, max_tokens, timeout)
return result["content"]
async def chat_detail(self, messages: list[dict], temperature: float = None, max_tokens: int = 16384, timeout: float = 300.0) -> dict:
"""发送对话请求返回详细信息content + reasoning + finish_reason + usage + elapsed"""
if temperature is None:
temperature = settings.TEMPERATURE
if self._use_anthropic():
# Anthropic 暂不支持 detail
content = await self._chat_anthropic(messages, temperature, max_tokens, timeout)
return {"content": content, "reasoning_content": "", "finish_reason": "stop", "usage": {}, "elapsed": 0}
else:
return await self._chat_openai(messages, temperature, max_tokens, timeout)
async def _chat_openai(self, messages: list[dict], temperature: float, max_tokens: int, timeout: float = 300.0) -> dict:
"""OpenAI SDK 调用,返回详细信息 dict"""
client = self._get_openai_client()
t0 = time.time()
response = await client.chat.completions.create(
model=self._model,
messages=messages,
temperature=temperature,
max_tokens=max_tokens,
timeout=timeout,
)
elapsed = time.time() - t0
content = response.choices[0].message.content or ""
# 提取 reasoning_contentthinking
reasoning = ""
msg = response.choices[0].message
if hasattr(msg, 'reasoning_content') and msg.reasoning_content:
reasoning = msg.reasoning_content
# 有些 SDK 把 reasoning 放在 model_extra 里
if not reasoning and hasattr(msg, 'model_extra') and isinstance(msg.model_extra, dict):
reasoning = msg.model_extra.get("reasoning_content", "")
# 检查是否有多个 choice
if len(response.choices) > 1:
print(f"[LLM] 警告: 返回了 {len(response.choices)} 个 choices")
# 检查 finish_reason
reason = response.choices[0].finish_reason if response.choices else "unknown"
if reason != "stop":
print(f"[LLM] 警告: finish_reason={reason}, 可能被截断")
# usage 信息
usage = {}
if hasattr(response, 'usage') and response.usage:
usage = {
"prompt_tokens": response.usage.prompt_tokens,
"completion_tokens": response.usage.completion_tokens,
"total_tokens": response.usage.total_tokens,
}
if hasattr(response.usage, 'completion_tokens_details') and response.usage.completion_tokens_details:
details = response.usage.completion_tokens_details
usage["reasoning_tokens"] = getattr(details, 'reasoning_tokens', 0) or 0
# 保存完整响应到文件,供调试
try:
resp_dict = response.model_dump() if hasattr(response, 'model_dump') else str(response)
debug_path = f"/tmp/llm_response_{id(response) % 100000}.json"
with open(debug_path, "w", encoding="utf-8") as _f:
json.dump(resp_dict, _f, ensure_ascii=False, indent=2, default=str)
print(f"[LLM] 完整响应已保存: {debug_path}")
except Exception:
pass
return {
"content": content,
"reasoning_content": reasoning,
"finish_reason": reason,
"usage": usage,
"elapsed": round(elapsed, 2),
}
async def _chat_anthropic(self, messages: list[dict], temperature: float, max_tokens: int, timeout: float = 300.0) -> str:
"""Anthropic SDK 调用 - thinking 和 text 自动分离"""
client = self._get_anthropic_client()
# 转换消息格式OpenAI -> Anthropic
system_msg = ""
anthropic_messages = []
for msg in messages:
role = msg.get("role", "user")
content = msg.get("content", "")
if role == "system":
system_msg = content
elif role in ("user", "assistant"):
anthropic_messages.append({"role": role, "content": content})
kwargs = {
"model": self._model,
"max_tokens": max_tokens,
"messages": anthropic_messages,
"thinking": {"type": "disabled"}, # 禁用 thinking避免占用 token
}
if system_msg:
kwargs["system"] = system_msg
if temperature is not None:
kwargs["temperature"] = temperature
response = await client.messages.create(**kwargs)
# 从 response 中提取文本内容thinking 自动分离在 block.thinking 中
text_parts = []
for block in response.content:
if hasattr(block, 'text') and block.type == 'text':
text_parts.append(block.text)
# thinking 内容自动忽略
result = "\n".join(text_parts)
return result
async def chat_json(self, messages: list[dict], temperature: float = None, max_tokens: int = 16384) -> dict:
"""发送对话请求,期望返回 JSON"""
raw = await self.chat(messages, temperature, max_tokens=max_tokens)
return _parse_json(raw)
async def test_connection(self) -> dict:
"""测试 LLM 连接"""
try:
response = await self.chat([{"role": "user", "content": "请回复:连接成功"}])
return {"success": True, "message": f"连接成功,模型回复:{response[:50]}"}
except Exception as e:
return {"success": False, "message": f"连接失败:{str(e)}"}
def _parse_json(raw: str) -> dict:
"""健壮的 JSON 解析,处理 LLM 返回的各种格式问题"""
print(f"[_parse_json] raw长度={len(raw)}, 前100字={raw[:100]}")
print(f"[_parse_json] raw后100字={raw[-100:]}")
raw = raw.strip()
# 去掉 markdown 代码块
if raw.startswith("```"):
raw = re.sub(r'^```\w*\n?', '', raw)
raw = re.sub(r'\n?```$', '', raw)
raw = raw.strip()
# 找 JSON 结构的位置
# 优先找第一个 { }JSON 对象),用括号匹配确保完整
first_brace = raw.find('{')
last_bracket = raw.rfind('[')
start = -1
if first_brace >= 0:
start = first_brace
# 从第一个 { 开始,找匹配的 }
depth = 0
end = -1
for i in range(start, len(raw)):
if raw[i] == '{':
depth += 1
elif raw[i] == '}':
depth -= 1
if depth == 0:
end = i + 1
break
if end <= start:
start = -1
elif last_bracket >= 0:
start = last_bracket
end = raw.rfind(']') + 1
if end <= start:
start = -1
if start < 0:
start = raw.find('{')
end = raw.rfind('}') + 1
if start < 0 or end <= start:
raise ValueError(f"无法找到 JSON: {raw[:200]}")
json_str = raw[start:end]
# 修复常见问题中文引号替换为英文引号在JSON值内部的需要转义
# 先把 JSON 结构中的英文引号保护起来,再替换中文引号
json_str = json_str.replace('\u201c', '\u201c').replace('\u201d', '\u201d') # 先保持不变
json_str = json_str.replace('\u2018', "'").replace('\u2019', "'")
json_str = re.sub(r'[\x00-\x08\x0b\x0c\x0e-\x1f]', '', json_str)
json_str = re.sub(r',\s*([}\]])', r'\1', json_str)
try:
return json.loads(json_str)
except json.JSONDecodeError as e:
# 如果失败,尝试将中文引号替换为转义的英文引号
json_str2 = json_str.replace('\u201c', '\\"').replace('\u201d', '\\"')
try:
return json.loads(json_str2)
except json.JSONDecodeError:
pass
# 尝试把换行替换为 \n
json_str2 = json_str.replace('\n', '\\n').replace('\r', '\\r')
json_str2 = json_str2.replace('\\\\n', '\\n')
try:
return json.loads(json_str2)
except json.JSONDecodeError:
pass
# 尝试自动补全缺失的闭合括号(模型输出被截断时常见)
for extra in ['}', '}}', '}}}', '}]', '}]}']:
try:
return json.loads(json_str + extra)
except json.JSONDecodeError:
continue
raise ValueError(f"无法解析 JSON: {json_str[:300]}")
llm_client = LLMClient()

View File

@@ -0,0 +1,220 @@
import re
class Preprocessor:
"""
预处理器:从原始对话中提取包含故事的段落
核心思路:
1. 过滤纯课堂管理内容(签到、纪律等)
2. 识别"个案对话段"——老师与某位学员的深度互动
3. 保留完整对话上下文(师生互动),不过度过滤
4. 按话题边界切分为合理的段落
"""
# 课堂管理关键词(出现这些词的整行直接过滤)
MANAGEMENT_KEYWORDS = ["签到", "打卡", "禁言", "扣三个一", "进组", "会议室链接"]
# 纯课堂管理发言模式(整条消息都是管理内容)
MANAGEMENT_PATTERNS = [
re.compile(r"大家.*签到"),
re.compile(r"请.*进组"),
re.compile(r"会议室"),
re.compile(r"纪律"),
re.compile(r"昵称.*改为"),
re.compile(r"欢迎.*进入"),
]
# 老师关键词
TEACHER_KEYWORDS = ["卢慧老师", "静静", "督导"]
def is_teacher(self, speaker: str) -> bool:
"""判断是否为老师/助教"""
return any(kw in speaker for kw in self.TEACHER_KEYWORDS)
def is_management_line(self, line: dict) -> bool:
"""判断是否为课堂管理行"""
content = line.get("content", "")
# 短内容 + 管理关键词 = 管理行
if len(content) < 30:
return any(kw in content for kw in self.MANAGEMENT_KEYWORDS)
return any(p.search(content) for p in self.MANAGEMENT_PATTERNS)
def preprocess(self, paragraphs: list[dict]) -> list[dict]:
"""
完整预处理流程:
1. 过滤课堂管理
2. 识别个案对话段(保留师生互动)
3. 按主要讲述者拆分(每个段落只包含一个主要讲述者)
"""
# 第一步:过滤课堂管理行
filtered = [p for p in paragraphs if not self.is_management_line(p)]
# 第二步:识别个案对话段
segments = self._extract_story_segments(filtered)
# 第三步:按主要讲述者拆分
segments = self._split_by_speaker(segments)
return segments
def _split_by_speaker(self, segments: list[list[dict]]) -> list[list[dict]]:
"""
将每个段落按讲述者拆分成子段落。
同一个讲述者的所有对话(即使中间隔了其他人的插话)收集在一起,
老师发言分配给前一个活跃的学员作为上下文。
"""
result = []
for seg in segments:
# 按讲述者收集对话,保留老师发言作为上下文
speaker_blocks = {} # speaker -> [lines]
last_student = None
for line in seg:
speaker = line.get("speaker", "")
if self.is_teacher(speaker):
# 老师发言归入前一个活跃的学员
if last_student and last_student in speaker_blocks:
speaker_blocks[last_student].append(line)
else:
if speaker not in speaker_blocks:
speaker_blocks[speaker] = []
speaker_blocks[speaker].append(line)
last_student = speaker
# 每个讲述者的对话作为一个子段落
for speaker, lines in speaker_blocks.items():
student_content = sum(
len(l.get("content", ""))
for l in lines
if not self.is_teacher(l.get("speaker", ""))
)
# 过滤掉内容太少的(可能是插话而非讲述者)
if student_content > 80 and len(lines) >= 3:
# 计算该讲述者在自己段落中的字数占比
# 如果老师发言占比超过50%,说明这主要是老师在讲课,不是学员故事
teacher_chars = sum(
len(l.get("content", ""))
for l in lines
if self.is_teacher(l.get("speaker", ""))
)
total_chars = student_content + teacher_chars
if total_chars > 0 and teacher_chars / total_chars > 0.5:
continue # 老师讲太多,跳过
result.append(lines)
return result
def _extract_story_segments(self, paragraphs: list[dict]) -> list[list[dict]]:
"""
从过滤后的对话中提取可能包含故事的段落。
策略:
- 找到学员的"长发言">50字作为故事线索
- 向前向后扩展,包含老师的引导和互动
- 直到遇到另一位学员的长发言或长时间沉默
- 最小段落 10 行,最大段落 200 行
"""
segments = []
i = 0
used = set() # 已分配的行索引
while i < len(paragraphs):
p = paragraphs[i]
# 跳过老师单独发言(非互动)
if self.is_teacher(p.get("speaker", "")):
i += 1
continue
# 跳过短发言(< 30字不太可能开启故事
if len(p.get("content", "")) < 30:
i += 1
continue
# 跳过已分配的行
if p.get("index", i) in used:
i += 1
continue
# 找到一个学员发言,开始扩展
segment_start = i
segment_end = i
# 向前扩展(找老师之前的引导)
expand_start = max(0, i - 5)
for j in range(i - 1, expand_start - 1, -1):
if j in used:
break
prev = paragraphs[j]
# 如果前一个是老师发言,包含
if self.is_teacher(prev.get("speaker", "")):
segment_start = j
# 如果前一个是同一学员的发言,包含
elif prev.get("speaker") == p.get("speaker"):
segment_start = j
else:
# 其他学员发言,停止
break
# 向后扩展(找老师的回应和后续互动)
current_speaker = p.get("speaker", "")
for j in range(i + 1, min(len(paragraphs), i + 200)):
if j in used:
segment_end = j - 1
break
curr = paragraphs[j]
curr_speaker = curr.get("speaker", "")
curr_content = curr.get("content", "")
# 老师发言:包含(互动引导)
if self.is_teacher(curr_speaker):
segment_end = j
continue
# 同一学员继续发言:包含
if curr_speaker == current_speaker:
segment_end = j
continue
# 其他学员短回应(< 20字可能是插话跳过但不终止
if len(curr_content) < 20:
continue
# 其他学员长发言:新的话题开始,终止
break
# 提取段落
segment = paragraphs[segment_start:segment_end + 1]
# 过滤掉太短的段落(< 8行
if len(segment) >= 8:
# 检查是否有足够的学员实质性内容(非纯疗愈练习)
student_content_len = sum(
len(s.get("content", ""))
for s in segment
if not self.is_teacher(s.get("speaker", ""))
and len(s.get("content", "")) > 15
)
# 学员实质内容 > 100字才认为是故事
if student_content_len > 100:
segments.append(segment)
for s in segment:
used.add(s.get("index", 0))
i = segment_end + 1
continue
i += 1
return segments
def is_teacher(speaker: str) -> bool:
"""判断是否为老师/助教发言"""
teacher_keywords = ["卢慧老师", "静静", "督导", "老师"]
return any(kw in speaker for kw in teacher_keywords)
preprocessor = Preprocessor()

95
backend/app/state.py Normal file
View File

@@ -0,0 +1,95 @@
import asyncio
import json
import uuid
from dataclasses import dataclass, field
from typing import Optional
@dataclass
class TaskState:
"""单个提取任务的状态"""
task_id: str = ""
is_running: bool = False
cancel_event: asyncio.Event = field(default_factory=asyncio.Event)
progress: dict = field(default_factory=lambda: {
"percent": 0,
"status": "idle",
"current_file": "",
"action": "",
"files_total": 0,
"files_completed": 0,
"stories_found": 0,
"phase": "",
"phase_percent": 0,
"phase_detail": "",
})
_queue: asyncio.Queue = field(default_factory=asyncio.Queue)
def update(self, **kwargs):
"""更新进度,同时推送到 SSE 队列"""
self.progress.update(kwargs)
try:
self._queue.put_nowait(self.progress.copy())
except asyncio.QueueFull:
pass
async def get_update(self) -> dict:
"""SSE 消费端:等待新的进度更新"""
return await self._queue.get()
def reset(self):
"""重置状态"""
self.is_running = False
self.cancel_event = asyncio.Event()
self.progress = {
"percent": 0,
"status": "idle",
"current_file": "",
"action": "",
"files_total": 0,
"files_completed": 0,
"stories_found": 0,
"phase": "",
"phase_percent": 0,
"phase_detail": "",
}
self._queue = asyncio.Queue()
class TaskManager:
"""多任务管理器:支持多个用户同时提取"""
def __init__(self):
self._tasks: dict[str, TaskState] = {}
def create_task(self) -> TaskState:
"""创建新任务"""
task_id = uuid.uuid4().hex[:12]
state = TaskState(task_id=task_id)
self._tasks[task_id] = state
return state
def get_task(self, task_id: str) -> Optional[TaskState]:
"""获取任务状态"""
return self._tasks.get(task_id)
def remove_task(self, task_id: str):
"""清理已完成的任务"""
self._tasks.pop(task_id, None)
def cleanup_old_tasks(self):
"""清理所有非运行中的任务"""
to_remove = [
tid for tid, state in self._tasks.items()
if not state.is_running
]
for tid in to_remove:
self._tasks.pop(tid, None)
# 全局任务管理器
task_manager = TaskManager()
# 向后兼容:默认任务(单用户场景)
extraction_state = TaskState()

9
backend/requirements.txt Normal file
View File

@@ -0,0 +1,9 @@
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
python-dotenv>=1.0.0

144
build.py Normal file
View File

@@ -0,0 +1,144 @@
"""
故事提取工具 - 打包构建脚本
使用方法: python build.py
"""
import os
import sys
import shutil
import subprocess
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
BACKEND_DIR = os.path.join(BASE_DIR, "backend")
FRONTEND_DIR = os.path.join(BASE_DIR, "frontend")
DIST_DIR = os.path.join(BASE_DIR, "dist")
BUILD_DIR = os.path.join(BASE_DIR, "build")
def step(msg: str):
print(f"\n{'='*50}")
print(f" {msg}")
print(f"{'='*50}")
def build_frontend():
"""构建前端"""
step("1/4 构建前端...")
os.chdir(FRONTEND_DIR)
subprocess.run([sys.executable, "-m", "npm", "run", "build"], check=True, shell=False)
os.chdir(BASE_DIR)
print("前端构建完成!")
def create_package_structure():
"""创建打包目录结构"""
step("2/4 准备打包文件...")
# 清理旧的 dist
if os.path.exists(DIST_DIR):
shutil.rmtree(DIST_DIR)
os.makedirs(DIST_DIR, exist_ok=True)
# 复制 backend 目录
pkg_backend = os.path.join(DIST_DIR, "backend")
if os.path.exists(pkg_backend):
shutil.rmtree(pkg_backend)
shutil.copytree(BACKEND_DIR, pkg_backend)
# 复制 frontend/dist
pkg_frontend = os.path.join(DIST_DIR, "frontend", "dist")
os.makedirs(pkg_frontend, exist_ok=True)
src_dist = os.path.join(FRONTEND_DIR, "dist")
if os.path.exists(src_dist):
for item in os.listdir(src_dist):
s = os.path.join(src_dist, item)
d = os.path.join(pkg_frontend, item)
if os.path.isdir(s):
shutil.copytree(s, d)
else:
shutil.copy2(s, d)
# 复制 run.py
shutil.copy2(os.path.join(BASE_DIR, "run.py"), os.path.join(DIST_DIR, "run.py"))
print("打包文件准备完成!")
def build_exe():
"""使用 PyInstaller 打包"""
step("3/4 打包成 exe...")
os.chdir(DIST_DIR)
cmd = [
sys.executable, "-m", "PyInstaller",
"--name=故事提取工具",
"--onefile",
"--windowed",
"--noconfirm",
"--clean",
# 隐藏导入
"--hidden-import=uvicorn.logging",
"--hidden-import=uvicorn.loops",
"--hidden-import=uvicorn.loops.auto",
"--hidden-import=uvicorn.protocols",
"--hidden-import=uvicorn.protocols.http",
"--hidden-import=uvicorn.protocols.http.auto",
"--hidden-import=uvicorn.protocols.websockets",
"--hidden-import=uvicorn.protocols.websockets.auto",
"--hidden-import=uvicorn.lifespan",
"--hidden-import=uvicorn.lifespan.on",
"--hidden-import=multipart",
# 收集数据文件
f"--add-data=backend;backend",
f"--add-data=frontend;frontend",
# 入口
"run.py",
]
subprocess.run(cmd, check=True, shell=False)
os.chdir(BASE_DIR)
print("打包完成!")
def copy_output():
"""复制最终输出"""
step("4/4 整理输出...")
exe_name = "故事提取工具.exe"
src = os.path.join(DIST_DIR, "dist", exe_name)
dst = os.path.join(BASE_DIR, exe_name)
if os.path.exists(src):
shutil.copy2(src, dst)
size_mb = os.path.getsize(dst) / (1024 * 1024)
print(f"\n✅ 打包成功!")
print(f" 输出文件: {dst}")
print(f" 文件大小: {size_mb:.1f} MB")
print(f"\n 使用方法:")
print(f" 1. 将 {exe_name} 发送给用户")
print(f" 2. 用户双击运行,自动打开浏览器")
print(f" 3. 首次使用需要在设置页面配置 API Key")
else:
print(f"\n❌ 打包失败,未找到输出文件: {src}")
def main():
print("故事提取工具 - 打包构建")
print("=" * 50)
# 检查 PyInstaller
try:
import PyInstaller
except ImportError:
print("正在安装 PyInstaller...")
subprocess.run([sys.executable, "-m", "pip", "install", "pyinstaller", "--break-system-packages"],
check=True, shell=False)
build_frontend()
create_package_structure()
build_exe()
copy_output()
if __name__ == "__main__":
main()

14
docker-compose.yml Normal file
View File

@@ -0,0 +1,14 @@
version: '3.8'
services:
app:
build: .
ports:
- "80:8000"
volumes:
- ./data/uploads:/app/uploads
- ./data/exports:/app/exports
- ./data/db:/app/data
env_file:
- backend/.env
restart: unless-stopped

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 时,应该考虑主题连贯性,将与当前学员主题相关的
其他学员段落中的老师对话也纳入合并范围。

24
frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

5
frontend/README.md Normal file
View File

@@ -0,0 +1,5 @@
# Vue 3 + Vite
This template should help get you started developing with Vue 3 in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
Learn more about IDE Support for Vue in the [Vue Docs Scaling up Guide](https://vuejs.org/guide/scaling-up/tooling.html#ide-support).

15
frontend/index.html Normal file
View File

@@ -0,0 +1,15 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Story Extractor - 直播文字稿故事提取工具</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap" rel="stylesheet">
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet">
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

1407
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

20
frontend/package.json Normal file
View File

@@ -0,0 +1,20 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"axios": "^1.15.1",
"vue": "^3.5.32",
"vue-router": "^4.6.4"
},
"devDependencies": {
"@vitejs/plugin-vue": "^6.0.6",
"vite": "^8.0.9"
}
}

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

24
frontend/public/icons.svg Normal file
View File

@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

78
frontend/src/App.vue Normal file
View File

@@ -0,0 +1,78 @@
<template>
<div id="app-root">
<AppHeader @open-settings="showSettings = true" />
<AppStepper v-if="currentStep > 0" :current-step="currentStep" />
<main class="main-area">
<router-view v-slot="{ Component }">
<transition name="fade-slide" mode="out-in">
<component :is="Component" ref="currentViewRef" @next="handleNext" @restart="handleRestart" />
</transition>
</router-view>
</main>
<BottomNav v-if="currentStep > 0" :current-step="currentStep" :hint="stepHints[currentStep]"
:disabled="isExtracting" @next="handleNext" />
<SettingsModal v-if="showSettings" @close="showSettings = false" />
<Toast />
</div>
</template>
<script setup>
import { ref, computed, provide } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import AppHeader from './components/AppHeader.vue'
import AppStepper from './components/AppStepper.vue'
import BottomNav from './components/BottomNav.vue'
import SettingsModal from './components/SettingsModal.vue'
import Toast from './components/Toast.vue'
const router = useRouter()
const route = useRoute()
const showSettings = ref(false)
const currentViewRef = ref(null)
const isExtracting = ref(false)
provide('setExtracting', (val) => { isExtracting.value = val })
const stepMap = { '/': 0, '/upload': 1, '/extract': 2, '/match': 3, '/export': 4 }
const currentStep = computed(() => stepMap[route.path] ?? 0)
const stepHints = {
0: '欢迎使用故事提取工具',
1: '上传文字稿和讲述者信息文件后进入下一步',
2: 'AI 将自动从文字稿中提取个人故事',
3: '将提取的故事与讲述者一一对应',
4: '预览最终文档效果并导出',
}
function handleNext() {
if (isExtracting.value) return
if (currentStep.value === 4) {
if (currentViewRef.value && currentViewRef.value.handleDownloadAll) {
currentViewRef.value.handleDownloadAll()
}
} else if (currentStep.value < 4) {
router.push(['/', '/upload', '/extract', '/match', '/export'][currentStep.value + 1])
}
}
function handleRestart() {
if (isExtracting.value) return
localStorage.removeItem('extraction_task_id')
router.push('/')
}
</script>
<style>
.fade-slide-enter-active,
.fade-slide-leave-active {
transition: all 0.3s ease;
}
.fade-slide-enter-from {
opacity: 0;
transform: translateY(12px);
}
.fade-slide-leave-to {
opacity: 0;
transform: translateY(-12px);
}
</style>

60
frontend/src/api/index.js Normal file
View File

@@ -0,0 +1,60 @@
import axios from 'axios'
const api = axios.create({
baseURL: '/api/v1',
timeout: 300000,
})
// ===== Files =====
export const uploadTranscripts = (files) => {
const formData = new FormData()
files.forEach(f => formData.append('files', f))
return api.post('/files/transcripts', formData, { headers: { 'Content-Type': 'multipart/form-data' } })
}
export const uploadPersons = (files) => {
const formData = new FormData()
files.forEach(f => formData.append('files', f))
return api.post('/files/persons', formData, { headers: { 'Content-Type': 'multipart/form-data' } })
}
export const getTranscripts = () => api.get('/files/transcripts')
export const getPersons = () => api.get('/files/persons')
export const deleteTranscript = (id) => api.delete(`/files/transcripts/${id}`)
export const deletePerson = (id) => api.delete(`/files/persons/${id}`)
export const previewPerson = (id) => api.get(`/files/persons/${id}/preview`)
// ===== Extraction =====
export const startExtraction = (taskId) => api.post('/extraction/start', null, { params: taskId ? { task_id: taskId } : {} })
export const getExtractionStatus = (taskId) => api.get('/extraction/status', { params: taskId ? { task_id: taskId } : {} })
export const streamExtractionStatus = (taskId) => {
const url = taskId
? `/api/v1/extraction/status/stream?task_id=${taskId}`
: '/api/v1/extraction/status/stream'
return new EventSource(url)
}
export const stopExtraction = (taskId) => api.post('/extraction/stop', null, { params: taskId ? { task_id: taskId } : {} })
// ===== Stories =====
export const getStories = (status = 'all') => api.get('/stories', { params: { status } })
export const getStory = (id) => api.get(`/stories/${id}`)
export const editStory = (id, data) => api.put(`/stories/${id}`, data)
export const deleteStory = (id) => api.delete(`/stories/${id}`)
// ===== Matching =====
export const createMatch = (storyId, personId) => api.post('/matches', { story_id: storyId, person_id: personId })
export const removeMatch = (storyId) => api.delete(`/matches/${storyId}`)
export const getMatches = () => api.get('/matches')
// ===== Export =====
export const getExportList = () => api.get('/export/list')
export const previewExport = (personId) => api.get(`/export/preview/${personId}`)
export const downloadOne = (personId) => api.get(`/export/download/${personId}`, { responseType: 'blob' })
export const downloadAll = () => api.get('/export/download-all', { responseType: 'blob' })
// ===== Settings =====
export const getSettings = () => api.get('/settings')
export const updateSettings = (data) => api.put('/settings', data)
export const testLlm = () => api.post('/settings/test-llm')
export default api

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

View File

@@ -0,0 +1,936 @@
* { font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; box-sizing: border-box; }
:root {
--primary: #6366f1;
--primary-light: #818cf8;
--primary-dark: #4f46e5;
--primary-bg: #eef2ff;
--accent: #f59e0b;
--success: #10b981;
--success-bg: #ecfdf5;
--danger: #ef4444;
--bg: #f1f5f9;
--card: #ffffff;
--border: #e2e8f0;
--border-light: #f1f5f9;
--text: #0f172a;
--text-secondary: #475569;
--text-muted: #94a3b8;
}
body {
background: var(--bg);
color: var(--text);
margin: 0;
min-height: 100vh;
}
/* ===== HEADER ===== */
.app-header {
background: white;
border-bottom: 1px solid var(--border);
padding: 0 40px;
height: 64px;
display: flex;
align-items: center;
justify-content: space-between;
position: sticky;
top: 0;
z-index: 100;
}
.app-logo {
display: flex;
align-items: center;
gap: 12px;
}
.app-logo-icon {
width: 36px;
height: 36px;
border-radius: 10px;
background: linear-gradient(135deg, var(--primary), var(--primary-dark));
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 16px;
}
.app-logo-text {
font-size: 17px;
font-weight: 700;
color: var(--text);
}
.app-logo-text span {
color: var(--text-muted);
font-weight: 400;
font-size: 13px;
margin-left: 8px;
}
.header-actions {
display: flex;
align-items: center;
gap: 8px;
}
.header-btn {
width: 36px;
height: 36px;
border-radius: 10px;
border: 1px solid var(--border);
background: white;
cursor: pointer;
color: var(--text-secondary);
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
transition: all 0.2s;
}
.header-btn:hover {
border-color: var(--primary-light);
color: var(--primary);
background: var(--primary-bg);
}
/* ===== STEPPER ===== */
.stepper-wrapper {
background: white;
border-bottom: 1px solid var(--border);
padding: 0 40px;
}
.stepper {
display: flex;
align-items: center;
max-width: 800px;
margin: 0 auto;
padding: 20px 0;
}
.stepper-step {
display: flex;
flex-direction: column;
align-items: center;
flex: 1;
position: relative;
cursor: pointer;
}
.stepper-step:not(:last-child)::after {
content: '';
position: absolute;
top: 18px;
left: calc(50% + 24px);
width: calc(100% - 48px);
height: 3px;
background: var(--border);
border-radius: 2px;
transition: background 0.4s;
}
.stepper-step.completed:not(:last-child)::after {
background: var(--success);
}
.stepper-step.active:not(:last-child)::after {
background: linear-gradient(90deg, var(--success) 50%, var(--border) 50%);
}
.step-circle {
width: 36px;
height: 36px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
font-weight: 700;
background: var(--border-light);
color: var(--text-muted);
border: 3px solid white;
box-shadow: 0 0 0 1px var(--border);
transition: all 0.3s;
position: relative;
z-index: 2;
}
.stepper-step.active .step-circle {
background: var(--primary);
color: white;
box-shadow: 0 0 0 3px var(--primary-bg), 0 4px 12px rgba(99,102,241,0.25);
}
.stepper-step.completed .step-circle {
background: var(--success);
color: white;
box-shadow: 0 0 0 3px var(--success-bg);
}
.step-label {
margin-top: 8px;
font-size: 13px;
font-weight: 600;
color: var(--text-muted);
transition: color 0.3s;
}
.stepper-step.active .step-label {
color: var(--primary);
}
.stepper-step.completed .step-label {
color: var(--success);
}
/* ===== MAIN CONTENT ===== */
.main-area {
max-width: 1100px;
margin: 0 auto;
padding: 28px 40px 120px;
}
.step-page { display: none; }
.step-page.active { display: block; animation: fadeSlideIn 0.35s ease; }
@keyframes fadeSlideIn {
from { opacity: 0; transform: translateY(12px); }
to { opacity: 1; transform: translateY(0); }
}
/* ===== BOTTOM NAV ===== */
.bottom-nav {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background: white;
border-top: 1px solid var(--border);
padding: 14px 40px;
display: flex;
justify-content: space-between;
align-items: center;
z-index: 100;
}
.bottom-nav-hint {
font-size: 13px;
color: var(--text-muted);
}
.bottom-nav-actions {
display: flex;
gap: 10px;
}
/* ===== BUTTONS ===== */
.btn {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 10px 22px;
border-radius: 10px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
border: none;
white-space: nowrap;
}
.btn-primary {
background: var(--primary);
color: white;
}
.btn-primary:hover {
background: var(--primary-dark);
box-shadow: 0 4px 14px rgba(99,102,241,0.3);
}
.btn-success {
background: var(--success);
color: white;
}
.btn-success:hover { background: #059669; }
.btn-outline {
background: white;
color: var(--text-secondary);
border: 1px solid var(--border);
}
.btn-outline:hover {
border-color: var(--primary-light);
color: var(--primary);
background: var(--primary-bg);
}
.btn-ghost {
background: transparent;
color: var(--text-secondary);
padding: 10px 16px;
}
.btn-ghost:hover { color: var(--primary); background: var(--primary-bg); }
.btn-sm { padding: 7px 14px; font-size: 13px; border-radius: 8px; }
.btn-lg { padding: 12px 28px; font-size: 15px; border-radius: 12px; }
/* ===== CARDS ===== */
.card {
background: var(--card);
border: 1px solid var(--border);
border-radius: 16px;
padding: 24px;
margin-bottom: 20px;
}
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20px;
}
.card-title {
font-size: 16px;
font-weight: 700;
color: var(--text);
display: flex;
align-items: center;
gap: 10px;
}
.card-title i {
width: 32px;
height: 32px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
}
.card-title .icon-upload { background: #dbeafe; color: #2563eb; }
.card-title .icon-person { background: #fce7f3; color: #db2777; }
.card-title .icon-magic { background: #ede9fe; color: #7c3aed; }
.card-title .icon-link { background: #fef3c7; color: #d97706; }
.card-title .icon-export { background: #d1fae5; color: #059669; }
.card-badge {
font-size: 12px;
color: var(--text-muted);
font-weight: 500;
}
/* ===== UPLOAD ZONE ===== */
.upload-zone {
border: 2px dashed var(--border);
border-radius: 14px;
padding: 36px;
text-align: center;
cursor: pointer;
transition: all 0.3s;
background: #fafbfd;
}
.upload-zone:hover {
border-color: var(--primary-light);
background: var(--primary-bg);
}
.upload-zone i.upload-icon {
font-size: 32px;
color: var(--text-muted);
margin-bottom: 10px;
display: block;
}
.upload-zone p { color: var(--text-secondary); font-size: 14px; margin: 3px 0; }
.upload-zone .hint { color: var(--text-muted); font-size: 12px; }
/* ===== FILE LIST ===== */
.file-list { margin-top: 14px; }
.file-item {
display: flex;
align-items: center;
gap: 14px;
padding: 12px 14px;
border: 1px solid var(--border-light);
border-radius: 10px;
margin-bottom: 6px;
transition: all 0.2s;
}
.file-item:hover {
background: #fafbfd;
border-color: var(--border);
}
.file-icon {
width: 40px;
height: 40px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
flex-shrink: 0;
}
.file-icon.docx { background: #dbeafe; color: #2563eb; }
.file-icon.person { background: #fce7f3; color: #db2777; }
.file-info { flex: 1; min-width: 0; }
.file-name { font-size: 14px; font-weight: 600; color: var(--text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.file-meta { font-size: 12px; color: var(--text-muted); margin-top: 2px; }
.file-action-btn {
width: 30px; height: 30px; border-radius: 8px; border: 1px solid transparent;
background: transparent; cursor: pointer; color: var(--text-muted);
display: flex; align-items: center; justify-content: center; font-size: 13px;
transition: all 0.2s;
}
.file-action-btn:hover { border-color: var(--border); background: white; color: var(--text-secondary); }
.file-action-btn.danger:hover { border-color: #fecaca; background: #fef2f2; color: var(--danger); }
/* ===== STATS ROW ===== */
.stats-row {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 14px;
margin-bottom: 24px;
}
.stat-item {
background: white;
border: 1px solid var(--border);
border-radius: 12px;
padding: 16px 18px;
display: flex;
align-items: center;
gap: 14px;
}
.stat-icon {
width: 42px;
height: 42px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
flex-shrink: 0;
}
.stat-icon.blue { background: #eef2ff; color: var(--primary); }
.stat-icon.amber { background: #fffbeb; color: var(--accent); }
.stat-icon.green { background: #ecfdf5; color: var(--success); }
.stat-icon.rose { background: #fff1f2; color: #e11d48; }
.stat-value { font-size: 22px; font-weight: 800; color: var(--text); line-height: 1; }
.stat-label { font-size: 12px; color: var(--text-muted); margin-top: 2px; }
/* ===== TWO COL ===== */
.two-col {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
}
/* ===== TAG CHIPS ===== */
.chip {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 14px;
border-radius: 8px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.chip.selected { background: var(--primary-bg); border: 1px solid #c7d2fe; color: var(--primary); }
.chip.unselected { background: white; border: 1px solid var(--border); color: var(--text-secondary); }
.chip.unselected:hover { border-color: var(--primary-light); color: var(--primary); }
/* ===== PROGRESS ===== */
.progress-bar {
height: 8px;
background: var(--border-light);
border-radius: 4px;
overflow: hidden;
}
.progress-fill {
height: 100%;
border-radius: 4px;
background: linear-gradient(90deg, var(--primary), var(--primary-light));
transition: width 0.5s ease;
}
.extraction-status {
display: flex;
align-items: center;
gap: 12px;
padding: 14px 18px;
background: #eff6ff;
border: 1px solid #bfdbfe;
border-radius: 12px;
}
.status-dot {
width: 10px;
height: 10px;
border-radius: 50%;
flex-shrink: 0;
}
.status-dot.running { background: var(--primary); animation: pulse 1.5s infinite; }
.status-dot.done { background: var(--success); }
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.3; }
}
/* ===== STORY CARDS ===== */
.story-card {
background: white;
border: 1px solid var(--border);
border-radius: 14px;
padding: 18px 20px;
margin-bottom: 10px;
transition: all 0.2s;
}
.story-card:hover {
border-color: #cbd5e1;
box-shadow: 0 2px 12px rgba(0,0,0,0.04);
}
.story-title {
font-size: 15px;
font-weight: 600;
color: var(--text);
}
.story-tag {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 3px 10px;
border-radius: 6px;
font-size: 11px;
font-weight: 600;
margin-top: 6px;
}
.story-tag.pending { background: #fef3c7; color: #92400e; }
.story-tag.matched { background: #d1fae5; color: #065f46; }
.story-summary {
font-size: 13px;
color: var(--text-secondary);
line-height: 1.6;
margin-top: 8px;
}
.story-source {
font-size: 12px;
color: var(--text-muted);
margin-top: 8px;
display: flex;
align-items: center;
gap: 4px;
}
.story-actions {
display: flex;
gap: 6px;
margin-top: 10px;
}
/* ===== TABS ===== */
.tabs {
display: flex;
gap: 4px;
background: var(--border-light);
padding: 4px;
border-radius: 10px;
margin-bottom: 16px;
}
.tab {
padding: 7px 16px;
border-radius: 8px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
color: var(--text-muted);
transition: all 0.2s;
border: none;
background: transparent;
}
.tab.active { background: white; color: var(--primary); box-shadow: 0 1px 3px rgba(0,0,0,0.06); }
.tab:hover:not(.active) { color: var(--text-secondary); }
/* ===== MATCH PANEL ===== */
.match-panel {
display: grid;
grid-template-columns: 1fr 60px 1fr;
gap: 0;
align-items: start;
}
.match-center-col {
display: flex;
flex-direction: column;
align-items: center;
padding-top: 60px;
}
.match-arrow-btn {
width: 44px;
height: 44px;
border-radius: 50%;
background: var(--primary);
color: white;
border: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
box-shadow: 0 4px 14px rgba(99,102,241,0.3);
transition: all 0.2s;
}
.match-arrow-btn:hover {
transform: scale(1.1);
box-shadow: 0 6px 20px rgba(99,102,241,0.4);
}
.match-hint {
font-size: 11px;
color: var(--text-muted);
margin-top: 8px;
text-align: center;
line-height: 1.4;
}
.person-card {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 14px;
border: 1px solid var(--border-light);
border-radius: 10px;
margin-bottom: 6px;
cursor: pointer;
transition: all 0.2s;
}
.person-card:hover { border-color: var(--border); background: #fafbfd; }
.person-card.selected { border-color: var(--primary); background: var(--primary-bg); }
.person-avatar {
width: 42px;
height: 42px;
border-radius: 10px;
background: linear-gradient(135deg, #e0e7ff, #c7d2fe);
display: flex;
align-items: center;
justify-content: center;
color: var(--primary);
font-size: 16px;
font-weight: 700;
flex-shrink: 0;
}
.person-name { font-size: 14px; font-weight: 600; color: var(--text); }
.person-meta { font-size: 12px; color: var(--text-muted); margin-top: 1px; }
/* ===== EXPORT ===== */
.export-item {
display: flex;
align-items: center;
gap: 14px;
padding: 14px 16px;
border: 1px solid var(--border-light);
border-radius: 12px;
margin-bottom: 8px;
transition: all 0.2s;
}
.export-item:hover { border-color: var(--border); background: #fafbfd; }
.export-check {
width: 26px;
height: 26px;
border-radius: 50%;
background: #d1fae5;
color: var(--success);
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
flex-shrink: 0;
}
.export-check.pending { background: var(--border-light); color: var(--text-muted); }
/* ===== PREVIEW DOC ===== */
.preview-doc {
background: white;
border: 1px solid var(--border);
border-radius: 14px;
overflow: hidden;
box-shadow: 0 8px 30px rgba(0,0,0,0.06);
}
.preview-doc-header {
background: linear-gradient(135deg, var(--primary), var(--primary-dark));
padding: 28px 24px;
color: white;
text-align: center;
}
.preview-doc-avatar {
width: 68px;
height: 68px;
border-radius: 50%;
border: 3px solid rgba(255,255,255,0.3);
margin: 0 auto 12px;
background: rgba(255,255,255,0.2);
display: flex;
align-items: center;
justify-content: center;
font-size: 26px;
}
.preview-doc-name { font-size: 18px; font-weight: 700; }
.preview-doc-info { font-size: 13px; opacity: 0.8; margin-top: 4px; }
.preview-doc-body { padding: 24px; }
.preview-doc-body h3 {
font-size: 15px;
font-weight: 700;
color: var(--text);
margin-bottom: 12px;
padding-bottom: 8px;
border-bottom: 2px solid var(--primary);
display: inline-block;
}
.preview-doc-body p {
font-size: 13px;
color: var(--text-secondary);
line-height: 1.8;
}
/* ===== SETTINGS MODAL ===== */
.modal-overlay {
display: none;
position: fixed;
inset: 0;
background: rgba(0,0,0,0.4);
z-index: 200;
align-items: center;
justify-content: center;
}
.modal-overlay.open { display: flex; }
.modal {
background: white;
border-radius: 18px;
width: 560px;
max-height: 80vh;
overflow-y: auto;
box-shadow: 0 20px 60px rgba(0,0,0,0.15);
animation: fadeSlideIn 0.25s ease;
}
.modal-header {
padding: 20px 24px;
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
justify-content: space-between;
}
.modal-header h2 { font-size: 17px; font-weight: 700; margin: 0; }
.modal-close {
width: 32px; height: 32px; border-radius: 8px; border: none;
background: var(--border-light); cursor: pointer; color: var(--text-muted);
display: flex; align-items: center; justify-content: center; font-size: 14px;
transition: all 0.2s;
}
.modal-close:hover { background: #fee2e2; color: var(--danger); }
.modal-body { padding: 24px; }
.form-group { margin-bottom: 16px; }
.form-label {
font-size: 13px;
font-weight: 600;
color: var(--text);
display: block;
margin-bottom: 6px;
}
.form-input {
width: 100%;
padding: 9px 14px;
border: 1px solid var(--border);
border-radius: 10px;
font-size: 14px;
color: var(--text);
transition: border-color 0.2s;
}
.form-input:focus { outline: none; border-color: var(--primary); box-shadow: 0 0 0 3px var(--primary-bg); }
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 14px;
}
.modal-footer {
padding: 16px 24px;
border-top: 1px solid var(--border);
display: flex;
justify-content: flex-end;
gap: 8px;
}
/* ===== SCROLLBAR ===== */
::-webkit-scrollbar { width: 5px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: #94a3b8; }
/* ===== MISC ===== */
.text-muted { color: var(--text-muted); }
.text-sm { font-size: 13px; }
.mt-2 { margin-top: 8px; }
.mt-4 { margin-top: 16px; }
.mb-4 { margin-bottom: 16px; }
.flex { display: flex; }
.items-center { align-items: center; }
.gap-2 { gap: 8px; }
.gap-3 { gap: 12px; }
.flex-1 { flex: 1; }
.text-right { text-align: right; }
.match-highlight {
border-color: var(--accent) !important;
background: #fffbeb !important;
}
.scrollable { max-height: 65vh; overflow-y: auto; }
/* ===== WELCOME PAGE ===== */
.welcome-page {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: calc(100vh - 240px);
text-align: center;
padding: 40px 20px;
}
.welcome-icon {
width: 88px;
height: 88px;
border-radius: 24px;
background: linear-gradient(135deg, var(--primary), #8b5cf6);
display: flex;
align-items: center;
justify-content: center;
font-size: 36px;
color: white;
margin-bottom: 28px;
box-shadow: 0 12px 40px rgba(99,102,241,0.3);
}
.welcome-title {
font-size: 32px;
font-weight: 800;
color: var(--text);
margin-bottom: 12px;
letter-spacing: -0.5px;
}
.welcome-desc {
font-size: 16px;
color: var(--text-secondary);
line-height: 1.7;
max-width: 480px;
margin-bottom: 36px;
}
.welcome-steps {
display: flex;
gap: 32px;
margin-bottom: 40px;
}
.welcome-step-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
width: 120px;
}
.welcome-step-icon {
width: 48px;
height: 48px;
border-radius: 14px;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
}
.welcome-step-icon.s1 { background: #dbeafe; color: #2563eb; }
.welcome-step-icon.s2 { background: #ede9fe; color: #7c3aed; }
.welcome-step-icon.s3 { background: #fef3c7; color: #d97706; }
.welcome-step-icon.s4 { background: #d1fae5; color: #059669; }
.welcome-step-label {
font-size: 13px;
font-weight: 600;
color: var(--text-secondary);
}
.welcome-step-num {
font-size: 11px;
color: var(--text-muted);
}
/* ===== TOAST ===== */
.toast-container {
position: fixed;
top: 80px;
left: 50%;
transform: translateX(-50%);
z-index: 300;
pointer-events: none;
}
.toast {
background: #1e293b;
color: white;
padding: 12px 20px;
border-radius: 10px;
font-size: 14px;
font-weight: 500;
display: flex;
align-items: center;
gap: 10px;
box-shadow: 0 8px 30px rgba(0,0,0,0.2);
animation: toastIn 0.3s ease, toastOut 0.3s ease 2.5s forwards;
pointer-events: auto;
}
.toast i { color: var(--accent); font-size: 16px; }
@keyframes toastIn {
from { opacity: 0; transform: translateY(-12px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes toastOut {
from { opacity: 1; transform: translateY(0); }
to { opacity: 0; transform: translateY(-12px); }
}

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>

After

Width:  |  Height:  |  Size: 496 B

View File

@@ -0,0 +1,22 @@
<template>
<header class="app-header">
<div class="app-logo">
<div class="app-logo-icon">
<i class="fas fa-book-open"></i>
</div>
<div class="app-logo-text">
Story Extractor
<span>直播文字稿故事提取工具</span>
</div>
</div>
<div class="header-actions">
<button class="header-btn" title="设置" @click="$emit('open-settings')">
<i class="fas fa-gear"></i>
</button>
</div>
</header>
</template>
<script setup>
defineEmits(['open-settings'])
</script>

View File

@@ -0,0 +1,30 @@
<template>
<div class="stepper-wrapper">
<div class="stepper">
<div
v-for="(step, index) in steps"
:key="index"
class="stepper-step"
:class="{ active: currentStep === index + 1, completed: currentStep > index + 1 }"
>
<div class="step-circle">
<template v-if="currentStep > index + 1">
<i class="fas fa-check"></i>
</template>
<template v-else>
{{ index + 1 }}
</template>
</div>
<span class="step-label">{{ step }}</span>
</div>
</div>
</div>
</template>
<script setup>
defineProps({
currentStep: { type: Number, required: true }
})
const steps = ['上传文件', '提取故事', '匹配确认', '预览导出']
</script>

View File

@@ -0,0 +1,37 @@
<template>
<div class="bottom-nav">
<div class="bottom-nav-hint">
<span>{{ hint }}</span>
</div>
<div class="bottom-nav-actions">
<button
v-if="currentStep === 4"
class="btn btn-success"
:disabled="disabled"
@click="$emit('next')"
>
<i class="fas fa-download"></i>
全部导出
</button>
<button
v-else
class="btn btn-primary"
:disabled="disabled"
@click="$emit('next')"
>
下一步
<i class="fas fa-chevron-right"></i>
</button>
</div>
</div>
</template>
<script setup>
defineProps({
currentStep: { type: Number, required: true },
hint: { type: String, default: '' },
disabled: { type: Boolean, default: false },
})
defineEmits(['next'])
</script>

View File

@@ -0,0 +1,191 @@
<template>
<div class="modal-overlay open" @click.self="$emit('close')">
<div class="modal">
<div class="modal-header">
<h2><i class="fas fa-cog" style="color:var(--primary); margin-right:8px;"></i>系统设置</h2>
<button class="modal-close" @click="$emit('close')">
<i class="fas fa-times"></i>
</button>
</div>
<div class="modal-body">
<!-- LLM 配置 section -->
<div class="settings-section">
<div class="settings-section-header">
<i class="fas fa-robot"></i>
<span>LLM 配置</span>
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">API 提供商</label>
<select v-model="form.llm_provider" class="form-input">
<option value="openai">OpenAI (GPT-4)</option>
<option value="deepseek">DeepSeek</option>
<option value="tongyi">通义千问</option>
<option value="minimax">MiniMax</option>
<option value="custom">自定义兼容 API</option>
</select>
</div>
<div class="form-group">
<label class="form-label">模型名称</label>
<input v-model="form.llm_model" type="text" class="form-input" placeholder="gpt-4o" />
</div>
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">API Base URL</label>
<input v-model="form.llm_base_url" type="text" class="form-input" placeholder="https://api.openai.com/v1" />
</div>
<div class="form-group">
<label class="form-label">API Key</label>
<input v-model="form.llm_api_key" type="password" class="form-input" placeholder="输入 API Key" />
</div>
</div>
</div>
<!-- 分隔线 -->
<div class="settings-separator"></div>
<!-- 提取参数 section -->
<div class="settings-section">
<div class="settings-section-header">
<i class="fas fa-sliders-h"></i>
<span>提取参数</span>
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">话题分段最大长度行数</label>
<input v-model.number="form.segment_max_lines" type="number" class="form-input" placeholder="500" />
</div>
<div class="form-group">
<label class="form-label">故事最小长度行数</label>
<input v-model.number="form.story_min_lines" type="number" class="form-input" placeholder="50" />
</div>
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">置信度阈值</label>
<input v-model.number="form.confidence_threshold" type="number" class="form-input" step="0.1" min="0" max="1" placeholder="0.6" />
</div>
<div class="form-group">
<label class="form-label">Temperature</label>
<input v-model.number="form.temperature" type="number" class="form-input" step="0.1" min="0" max="1" placeholder="0.3" />
</div>
</div>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-outline" @click="$emit('close')">取消</button>
<button class="btn btn-outline" :disabled="testing" @click="handleTest">
<i v-if="testing" class="fas fa-spinner fa-spin"></i>
<i v-else class="fas fa-plug"></i>
{{ testing ? '测试中...' : '测试连接' }}
</button>
<button class="btn btn-primary" :disabled="saving" @click="handleSave">
<i v-if="saving" class="fas fa-spinner fa-spin"></i>
<i v-else class="fas fa-save"></i>
{{ saving ? '保存中...' : '保存' }}
</button>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { getSettings, updateSettings, testLlm } from '../api'
import { useToast } from '../composables/useToast'
const { showToast } = useToast()
const emit = defineEmits(['close'])
const saving = ref(false)
const testing = ref(false)
const defaultForm = {
llm_provider: 'openai',
llm_model: '',
llm_base_url: '',
llm_api_key: '',
segment_max_lines: 500,
story_min_lines: 50,
confidence_threshold: 0.6,
temperature: 0.3,
}
const form = ref({ ...defaultForm })
onMounted(async () => {
try {
const { data } = await getSettings()
form.value = {
llm_provider: data.llm_provider || defaultForm.llm_provider,
llm_model: data.llm_model || '',
llm_base_url: data.llm_base_url || '',
llm_api_key: data.llm_api_key || '',
segment_max_lines: data.segment_max_lines ?? defaultForm.segment_max_lines,
story_min_lines: data.story_min_lines ?? defaultForm.story_min_lines,
confidence_threshold: data.confidence_threshold ?? defaultForm.confidence_threshold,
temperature: data.temperature ?? defaultForm.temperature,
}
} catch (e) {
showToast('获取设置失败: ' + (e.response?.data?.detail || e.message), 'error')
}
})
async function handleSave() {
saving.value = true
try {
await updateSettings(form.value)
showToast('设置已保存', 'success')
emit('close')
} catch (e) {
showToast('保存失败: ' + (e.response?.data?.detail || e.message), 'error')
} finally {
saving.value = false
}
}
async function handleTest() {
testing.value = true
try {
const res = await testLlm()
if (res.data && res.data.success) {
showToast('连接测试成功: ' + (res.data.message || ''), 'success')
} else {
showToast('连接测试失败: ' + (res.data?.message || '未知错误'), 'error')
}
} catch (e) {
showToast('连接测试失败: ' + (e.response?.data?.detail || e.message), 'error')
} finally {
testing.value = false
}
}
</script>
<style scoped>
.settings-section {
margin-bottom: 4px;
}
.settings-section-header {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
font-weight: 700;
color: var(--text);
margin-bottom: 16px;
}
.settings-section-header i {
color: var(--primary);
font-size: 15px;
}
.settings-separator {
height: 1px;
background: var(--border);
margin: 20px 0;
}
</style>

View File

@@ -0,0 +1,30 @@
<template>
<div class="toast-container">
<div v-for="t in toasts" :key="t.id" class="toast" :class="'toast-' + t.type">
<i :class="iconClass(t.type)"></i>
{{ t.message }}
</div>
</div>
</template>
<script setup>
import { useToast } from '../composables/useToast'
const { toasts } = useToast()
function iconClass(type) {
switch (type) {
case 'success':
return 'fas fa-check-circle'
case 'error':
return 'fas fa-times-circle'
default:
return 'fas fa-triangle-exclamation'
}
}
</script>
<style scoped>
.toast-warning i { color: var(--accent); }
.toast-success i { color: var(--success); }
.toast-error i { color: var(--danger); }
</style>

View File

@@ -0,0 +1,15 @@
import { ref } from 'vue'
const toasts = ref([])
let id = 0
export function useToast() {
function showToast(message, type = 'warning') {
const t = { id: ++id, message, type }
toasts.value.push(t)
setTimeout(() => {
toasts.value = toasts.value.filter(x => x.id !== t.id)
}, 3000)
}
return { toasts, showToast }
}

8
frontend/src/main.js Normal file
View File

@@ -0,0 +1,8 @@
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
import './assets/styles/global.css'
const app = createApp(App)
app.use(router)
app.mount('#app')

View File

@@ -0,0 +1,16 @@
import { createRouter, createWebHistory } from 'vue-router'
const routes = [
{ path: '/', name: 'welcome', component: () => import('../views/WelcomeView.vue') },
{ path: '/upload', name: 'upload', component: () => import('../views/UploadView.vue') },
{ path: '/extract', name: 'extract', component: () => import('../views/ExtractView.vue') },
{ path: '/match', name: 'match', component: () => import('../views/MatchView.vue') },
{ path: '/export', name: 'export', component: () => import('../views/ExportView.vue') },
]
const router = createRouter({
history: createWebHistory(),
routes,
})
export default router

View File

@@ -0,0 +1,316 @@
<template>
<div class="step-page active">
<div class="two-col">
<!-- Left column: Export list -->
<div class="card">
<div class="card-header">
<div class="card-title">
<i class="fas fa-file-export icon-export"></i>
导出列表
</div>
<span class="card-badge" v-if="exportList.length">
{{ exportList.length }}
</span>
</div>
<div v-if="loading" class="text-muted text-sm" style="text-align: center; padding: 32px 0;">
<i class="fas fa-spinner fa-spin"></i> 加载中...
</div>
<div v-else-if="!exportList.length" class="text-muted text-sm" style="text-align: center; padding: 32px 0;">
暂无导出数据请先完成故事匹配
</div>
<div v-else class="scrollable">
<div
v-for="item in exportList"
:key="item.person_id"
class="export-item"
:class="{ active: selectedPersonId === item.person_id }"
>
<div class="export-check" :class="{ pending: !item.story_count }">
<i :class="item.story_count ? 'fas fa-check' : 'fas fa-minus'"></i>
</div>
<div class="export-item-info" style="flex: 1; min-width: 0;">
<div class="export-item-name">{{ item.person_name }}</div>
<div class="export-item-meta">
{{ item.story_count }} 个故事
<span v-if="item.has_photo" style="color: var(--success);"> · 有照片</span>
<span v-else style="color: var(--text-muted);"> · 无照片</span>
</div>
</div>
<div class="export-item-actions" style="display: flex; gap: 6px; flex-shrink: 0;">
<button
class="file-action-btn"
title="预览"
:disabled="!item.story_count"
@click="handlePreview(item.person_id)"
>
<i class="fas fa-eye"></i>
</button>
<button
class="file-action-btn"
title="下载"
:disabled="!item.story_count"
@click="handleDownload(item.person_id, item.person_name)"
>
<i class="fas fa-download"></i>
</button>
</div>
</div>
</div>
</div>
<!-- Right column: Preview -->
<div class="card" style="margin-bottom: 0;">
<div class="card-header">
<div class="card-title">
<i class="fas fa-eye icon-export"></i>
文档预览
</div>
<div v-if="previewData" style="display: flex; gap: 6px;">
<button
class="btn btn-ghost btn-sm"
:disabled="!hasPrev"
@click="navigatePreview(-1)"
>
<i class="fas fa-chevron-left"></i>
</button>
<button
class="btn btn-ghost btn-sm"
:disabled="!hasNext"
@click="navigatePreview(1)"
>
<i class="fas fa-chevron-right"></i>
</button>
</div>
</div>
<div v-if="previewLoading" class="text-muted text-sm" style="text-align: center; padding: 48px 0;">
<i class="fas fa-spinner fa-spin"></i> 加载预览中...
</div>
<div v-else-if="!previewData" class="text-muted text-sm" style="text-align: center; padding: 48px 0;">
<i class="fas fa-file-alt" style="font-size: 28px; display: block; margin-bottom: 12px; color: var(--border);"></i>
选择一位讲述者以预览其文档
</div>
<div v-else class="preview-doc scrollable">
<div class="preview-doc-header">
<div class="preview-doc-avatar">
<i class="fas fa-user"></i>
</div>
<div class="preview-doc-name">{{ previewData.person_name }}</div>
<div class="preview-doc-info">{{ previewData.person_info || '暂无个人信息' }}</div>
</div>
<div class="preview-doc-body">
<div v-for="(story, idx) in previewData.stories" :key="idx" style="margin-bottom: 24px;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;">
<h3 style="font-size: 16px; font-weight: 700;">{{ story.title }}</h3>
<button
v-if="story.raw_material"
class="btn btn-ghost btn-sm"
style="font-size: 12px; color: var(--primary);"
@click="toggleMaterial(idx)"
>
<i :class="showMaterialIdx === idx ? 'fas fa-eye-slash' : 'fas fa-database'"></i>
{{ showMaterialIdx === idx ? '收起素材' : '查看素材' }}
</button>
</div>
<!-- 素材 JSON 展示 -->
<div v-if="showMaterialIdx === idx && story.raw_material" style="margin-bottom: 12px;">
<div style="background: #1e1e2e; border-radius: 8px; padding: 12px; max-height: 400px; overflow: auto;">
<pre style="margin: 0; font-size: 12px; line-height: 1.5; color: #cdd6f4; white-space: pre-wrap; word-break: break-all;">{{ formatMaterial(story.raw_material) }}</pre>
</div>
</div>
<!-- 故事梗概 -->
<div v-if="story.summary" style="margin-bottom: 8px;">
<span style="font-weight: 600; color: #335599; font-size: 13px;">故事梗概</span>
<p style="margin: 4px 0 8px; text-indent: 2em; line-height: 1.6; color: var(--text-secondary);">{{ story.summary }}</p>
</div>
<!-- 关键词标签 -->
<div v-if="story.tags && story.tags.length" style="margin-bottom: 12px;">
<span style="font-weight: 600; color: #335599; font-size: 13px;">关键词标签</span>
<p style="margin: 4px 0 8px; color: var(--text-muted); font-size: 13px;">{{ story.tags.join('、') }}</p>
</div>
<!-- 故事内容Markdown 渲染 -->
<div v-if="story.content" class="story-content-preview" v-html="renderMarkdown(story.content)"></div>
</div>
<div v-if="!previewData.stories || !previewData.stories.length" class="text-muted text-sm">
暂无故事内容
</div>
</div>
</div>
</div>
</div>
<!-- Restart Button -->
<div style="text-align: center; margin-top: 20px;">
<button class="btn btn-outline" @click="$emit('restart')">
<i class="fas fa-rotate-right"></i>
再来一次
</button>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { getExportList, previewExport, downloadOne, downloadAll } from '../api'
// Markdown 渲染(简易版,支持 ## 标题、> 引用、- 列表)
function renderMarkdown(text) {
if (!text) return ''
return text
.split('\n')
.map(line => {
const trimmed = line.trim()
if (!trimmed) return '<br>'
if (trimmed.startsWith('## ')) return `<h4 style="font-size:14px;font-weight:700;margin:12px 0 6px;">${trimmed.slice(3)}</h4>`
if (trimmed.startsWith('### ')) return `<h5 style="font-size:13px;font-weight:600;margin:8px 0 4px;">${trimmed.slice(4)}</h5>`
if (trimmed.startsWith('> ')) return `<blockquote style="border-left:3px solid #ddd;margin:6px 0;padding:4px 12px;color:#666;font-style:italic;">${trimmed.slice(2)}</blockquote>`
if (trimmed.startsWith('- ')) return `<div style="padding-left:16px;margin:2px 0;">• ${trimmed.slice(2)}</div>`
return `<p style="text-indent:2em;line-height:1.8;margin:4px 0;">${trimmed}</p>`
})
.join('')
}
import { useToast } from '../composables/useToast'
const emit = defineEmits(['next', 'restart'])
const { showToast } = useToast()
const exportList = ref([])
const loading = ref(false)
const selectedPersonId = ref(null)
const previewData = ref(null)
const previewLoading = ref(false)
const showMaterialIdx = ref(null)
function toggleMaterial(idx) {
showMaterialIdx.value = showMaterialIdx.value === idx ? null : idx
}
function formatMaterial(raw) {
try {
const obj = typeof raw === 'string' ? JSON.parse(raw) : raw
return JSON.stringify(obj, null, 2)
} catch {
return raw
}
}
function triggerDownload(blob, filename) {
const url = window.URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = filename
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
window.URL.revokeObjectURL(url)
}
async function fetchExportList() {
loading.value = true
try {
const res = await getExportList()
exportList.value = res.data || []
} catch (e) {
showToast('获取导出列表失败: ' + (e.response?.data?.detail || e.message), 'danger')
} finally {
loading.value = false
}
}
async function handlePreview(personId) {
selectedPersonId.value = personId
previewLoading.value = true
previewData.value = null
try {
const res = await previewExport(personId)
previewData.value = res.data
} catch (e) {
showToast('加载预览失败: ' + (e.response?.data?.detail || e.message), 'danger')
} finally {
previewLoading.value = false
}
}
async function handleDownload(personId, personName) {
try {
const res = await downloadOne(personId)
triggerDownload(res.data, `${personName}.docx`)
showToast(`${personName} 的文档已开始下载`, 'success')
} catch (e) {
showToast('下载失败: ' + (e.response?.data?.detail || e.message), 'danger')
}
}
const selectedIndex = computed(() => {
if (!selectedPersonId.value) return -1
return exportList.value.findIndex(item => item.person_id === selectedPersonId.value)
})
const hasPrev = computed(() => selectedIndex.value > 0)
const hasNext = computed(() => selectedIndex.value >= 0 && selectedIndex.value < exportList.value.length - 1)
function navigatePreview(direction) {
const newIndex = selectedIndex.value + direction
if (newIndex >= 0 && newIndex < exportList.value.length) {
const item = exportList.value[newIndex]
if (item.story_count) {
handlePreview(item.person_id)
}
}
}
async function handleDownloadAll() {
try {
const res = await downloadAll()
triggerDownload(res.data, '全部故事文档.zip')
showToast('全部文档已开始下载', 'success')
} catch (e) {
showToast('批量下载失败: ' + (e.response?.data?.detail || e.message), 'danger')
}
}
// Expose handleDownloadAll so the parent can call it via the BottomNav "全部导出" button
defineExpose({ handleDownloadAll })
onMounted(() => {
fetchExportList()
})
</script>
<style scoped>
.export-item.active {
border-color: var(--primary);
background: var(--primary-bg);
}
.export-item-name {
font-size: 14px;
font-weight: 600;
color: var(--text);
}
.export-item-meta {
font-size: 12px;
color: var(--text-muted);
margin-top: 2px;
}
.file-action-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.file-action-btn:disabled:hover {
border-color: transparent;
background: transparent;
color: var(--text-muted);
}
</style>

View File

@@ -0,0 +1,410 @@
<template>
<div class="step-page active">
<!-- Progress Card -->
<div class="card" v-if="!isCompleted && !hasError">
<div class="card-header">
<div class="card-title">
<i class="fas fa-brain" style="width:32px;height:32px;border-radius:8px;display:flex;align-items:center;justify-content:center;font-size:14px;background:#ede9fe;color:#7c3aed;"></i>
AI 正在工作中
</div>
<span class="card-badge" :style="{ fontSize: '18px', fontWeight: '800', color: isCompleted ? 'var(--success)' : 'var(--primary)' }">
{{ progress }}%
</span>
</div>
<!-- Progress Bar -->
<div class="progress-bar" style="height: 10px; border-radius: 5px;">
<div class="progress-fill" :style="{ width: progress + '%', transition: 'width 1s ease' }"></div>
</div>
<!-- Current File Info -->
<div style="margin-top: 20px;">
<div style="display: flex; align-items: center; gap: 12px; margin-bottom: 14px;">
<div class="status-dot" :class="isCompleted ? 'done' : 'running'"></div>
<div style="flex: 1;">
<div style="font-size: 14px; font-weight: 600; color: var(--text);">{{ displayFile }}</div>
<div style="font-size: 12px; color: var(--text-muted); margin-top: 2px;">{{ displayAction }}</div>
</div>
</div>
<!-- Phase Progress -->
<div v-if="phase" style="margin-bottom: 16px;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px;">
<span style="font-size: 12px; font-weight: 600; color: var(--text);">{{ phaseLabel }}</span>
<span style="font-size: 12px; color: var(--text-muted);">{{ phasePercent }}%</span>
</div>
<div class="progress-bar" style="height: 4px; border-radius: 2px;">
<div class="progress-fill" :style="{ width: phasePercent + '%', transition: 'width 0.5s ease', background: 'var(--primary)' }"></div>
</div>
<div v-if="phaseDetail" style="font-size: 11px; color: var(--text-muted); margin-top: 4px;">{{ phaseDetail }}</div>
</div>
<!-- Stats Grid -->
<div style="display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; margin-top: 16px;">
<div style="background: var(--border-light); border-radius: 10px; padding: 12px 14px; text-align: center;">
<div style="font-size: 11px; color: var(--text-muted); margin-bottom: 4px;">文件进度</div>
<div style="font-size: 13px; font-weight: 700; color: var(--text);">{{ filesCompleted }} / {{ totalFiles || '...' }}</div>
</div>
<div style="background: var(--border-light); border-radius: 10px; padding: 12px 14px; text-align: center;">
<div style="font-size: 11px; color: var(--text-muted); margin-bottom: 4px;">当前阶段</div>
<div style="font-size: 13px; font-weight: 700;" :style="{ color: phase ? 'var(--primary)' : 'var(--text)' }">
{{ phaseLabelShort || '等待中' }}
</div>
</div>
<div style="background: var(--border-light); border-radius: 10px; padding: 12px 14px; text-align: center;">
<div style="font-size: 11px; color: var(--text-muted); margin-bottom: 4px;">故事发现</div>
<div style="font-size: 13px; font-weight: 700;" :style="{ color: storiesFound > 0 ? 'var(--success)' : 'var(--text)' }">
{{ storiesFound }}
</div>
</div>
</div>
</div>
</div>
<!-- Error Card -->
<div class="card" v-if="hasError">
<div class="card-header">
<div class="card-title">
<i class="fas fa-circle-exclamation" style="width:32px;height:32px;border-radius:8px;display:flex;align-items:center;justify-content:center;font-size:14px;background:#fee2e2;color:#ef4444;"></i>
无法提取故事
</div>
</div>
<p class="text-sm" style="color: var(--danger); margin-bottom: 16px;">{{ errorMessage }}</p>
<p class="text-sm" style="color: var(--text-muted); margin-bottom: 16px;">
请检查上传的文件是否包含学员与老师的对话记录而非纯课程内容
</p>
<div style="display: flex; gap: 12px;">
<button class="btn btn-outline" @click="$emit('go-to', 'upload')">
<i class="fas fa-arrow-left"></i>
返回重选文件
</button>
<button class="btn btn-primary" @click="retryExtraction">
<i class="fas fa-rotate-right"></i>
重新提取
</button>
</div>
</div>
<!-- Results Card -->
<div class="card" v-if="isCompleted">
<div class="card-header">
<div class="card-title">
<i class="icon-magic"><i class="fas fa-list"></i></i>
已提取的故事
</div>
<span class="card-badge"> {{ stories.length }} 个故事</span>
</div>
<div v-if="stories.length === 0" style="text-align: center; padding: 32px 0;">
<i class="fas fa-inbox" style="font-size: 28px; display: block; margin-bottom: 12px; color: var(--border);"></i>
<p class="text-muted text-sm" style="margin-bottom: 16px;">未发现任何故事</p>
<p class="text-muted text-sm" style="margin-bottom: 16px;">
可能原因文件中没有学员的深度对话 AI 服务在提取过程中出错
</p>
<div style="display: flex; gap: 12px; justify-content: center;">
<button class="btn btn-outline" @click="$emit('go-to', 'upload')">
<i class="fas fa-arrow-left"></i>
返回重选文件
</button>
<button class="btn btn-primary" @click="retryExtraction">
<i class="fas fa-rotate-right"></i>
重新提取
</button>
</div>
</div>
<div v-for="story in stories" :key="story.id" class="story-card">
<div style="display: flex; justify-content: space-between; align-items: start;">
<div style="flex: 1; min-width: 0;">
<div class="story-title">{{ story.title }}</div>
<div class="story-tag" :class="story.match_status === 'matched' ? 'matched' : 'pending'">
<i class="fas" :class="story.match_status === 'matched' ? 'fa-check' : 'fa-clock'"></i>
{{ story.match_status === 'matched' ? '已匹配' : '待匹配' }}
</div>
</div>
<div class="story-actions">
<button class="btn btn-outline btn-sm" @click="viewStory(story)" title="查看">
<i class="fas fa-eye"></i>
</button>
<button class="btn btn-ghost btn-sm" style="color: var(--danger);" @click="handleDeleteStory(story)" title="删除">
<i class="fas fa-trash-alt"></i>
</button>
</div>
</div>
<div class="story-summary">{{ story.summary }}</div>
<div class="story-source">
<i class="fas fa-file-alt"></i>
{{ formatSource(story) }}
</div>
</div>
</div>
<!-- Story Detail Modal -->
<div v-if="viewingStory" class="modal-overlay" @click.self="viewingStory = null">
<div class="card" style="max-width: 640px; width: 90%; max-height: 80vh; overflow-y: auto; position: relative;">
<div class="card-header">
<div class="card-title">{{ viewingStory.title }}</div>
<button class="btn btn-ghost btn-sm" @click="viewingStory = null" style="font-size: 18px; color: var(--text-muted);">&times;</button>
</div>
<div style="margin-bottom: 12px;">
<span v-for="tag in (viewingStory.tags_json || [])" :key="tag" style="display: inline-block; background: var(--border-light); border-radius: 12px; padding: 2px 10px; font-size: 12px; color: var(--text-muted); margin-right: 6px; margin-bottom: 4px;">{{ tag }}</span>
</div>
<div style="font-size: 13px; color: var(--text-muted); margin-bottom: 16px;">{{ viewingStory.summary }}</div>
<div v-html="formattedContent" style="font-size: 14px; line-height: 1.7; color: var(--text);"></div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, inject, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import { startExtraction, streamExtractionStatus, getStories, deleteStory, getExtractionStatus } from '../api'
import { useToast } from '../composables/useToast'
const router = useRouter()
const { showToast } = useToast()
const setExtracting = inject('setExtracting', () => {})
defineEmits(['next', 'prev', 'go-to'])
const taskId = ref(null)
// Progress state
const progress = ref(0)
const currentFile = ref('')
const currentAction = ref('')
const filesCompleted = ref(0)
const totalFiles = ref(0)
const storiesFound = ref(0)
const phase = ref('')
const phasePercent = ref(0)
const phaseDetail = ref('')
// Results state
const isCompleted = ref(false)
const hasError = ref(false)
const errorMessage = ref('')
const stories = ref([])
const viewingStory = ref(null)
const formattedContent = computed(() => {
if (!viewingStory.value) return ''
return viewingStory.value.content || ''
})
let eventSource = null
const phaseLabels = {
preprocess: '预处理分析',
identify: '识别故事',
chunk: '切片提取',
merge: '素材重组',
generate: '生成故事',
}
const phaseLabel = computed(() => phaseLabels[phase.value] || '')
const phaseLabelShort = computed(() => phaseLabels[phase.value] || '')
const displayFile = computed(() => {
if (isCompleted.value) return '全部处理完成!'
if (currentFile.value) return '正在处理:' + currentFile.value
return '准备中...'
})
const displayAction = computed(() => {
if (isCompleted.value) return `共处理 ${totalFiles.value} 份文字稿,发现 ${storiesFound.value} 个故事`
if (currentAction.value) return currentAction.value
return '初始化...'
})
function connectSSE() {
if (eventSource) {
eventSource.close()
}
eventSource = streamExtractionStatus(taskId.value)
eventSource.addEventListener('progress', (event) => {
try {
const data = JSON.parse(event.data)
currentFile.value = data.current_file || ''
currentAction.value = data.action || ''
filesCompleted.value = data.files_completed || 0
totalFiles.value = data.files_total || 0
storiesFound.value = data.stories_found || 0
progress.value = Math.round(data.percent || 0)
phase.value = data.phase || ''
phasePercent.value = Math.round(data.phase_percent || 0)
phaseDetail.value = data.phase_detail || ''
if (data.status === 'completed') {
isCompleted.value = true
progress.value = 100
phasePercent.value = 100
setExtracting(false)
closeSSE()
loadStories()
} else if (data.status === 'error' || data.error) {
hasError.value = true
errorMessage.value = data.error || data.action || '提取过程中发生未知错误'
setExtracting(false)
closeSSE()
} else if (data.status === 'stopped') {
hasError.value = true
errorMessage.value = '提取已停止'
setExtracting(false)
closeSSE()
}
} catch (e) {
console.error('Failed to parse SSE data:', e)
}
})
eventSource.onerror = () => {
console.warn('SSE connection error')
}
}
function closeSSE() {
if (eventSource) {
eventSource.close()
eventSource = null
}
}
async function loadStories() {
try {
const res = await getStories()
stories.value = res.data || []
} catch (e) {
console.error('Failed to load stories:', e)
showToast('加载故事列表失败', 'warning')
}
}
async function handleDeleteStory(story) {
try {
await deleteStory(story.id)
stories.value = stories.value.filter(s => s.id !== story.id)
storiesFound.value = Math.max(0, storiesFound.value - 1)
showToast('故事已删除', 'warning')
} catch (e) {
console.error('Failed to delete story:', e)
showToast('删除故事失败', 'warning')
}
}
function viewStory(story) {
// 解析 tags JSON
try {
story.tags_json = typeof story.tags === 'string' ? JSON.parse(story.tags) : (story.tags || [])
} catch {
story.tags_json = []
}
viewingStory.value = story
}
async function retryExtraction() {
hasError.value = false
errorMessage.value = ''
isCompleted.value = false
progress.value = 0
currentFile.value = ''
currentAction.value = ''
filesCompleted.value = 0
totalFiles.value = 0
storiesFound.value = 0
phase.value = ''
phasePercent.value = 0
phaseDetail.value = ''
stories.value = []
await initiateExtraction()
}
async function initiateExtraction() {
setExtracting(true)
try {
const res = await startExtraction(taskId.value)
if (res.data?.task_id) {
taskId.value = res.data.task_id
localStorage.setItem('extraction_task_id', res.data.task_id)
}
connectSSE()
} catch (e) {
if (e.response && e.response.status === 400) {
showToast('提取任务正在进行中', 'warning')
connectSSE()
} else {
hasError.value = true
errorMessage.value = e.response?.data?.detail || '无法启动提取任务'
setExtracting(false)
showToast('启动提取失败', 'warning')
}
}
}
function formatSource(story) {
const parts = []
if (story.source_lines) {
try {
const lines = typeof story.source_lines === 'string' ? JSON.parse(story.source_lines) : story.source_lines
if (Array.isArray(lines) && lines.length > 0) {
const min = Math.min(...lines)
const max = Math.max(...lines)
parts.push(`${min.toLocaleString()}-${max.toLocaleString()}`)
}
} catch (e) {
// ignore
}
}
if (story.duration_minutes) {
parts.push(`${Math.round(story.duration_minutes)} 分钟`)
}
return parts.join(' · ') || '未知来源'
}
onMounted(async () => {
const savedTaskId = localStorage.getItem('extraction_task_id')
if (savedTaskId) {
taskId.value = savedTaskId
try {
const { data } = await getExtractionStatus(savedTaskId)
if (data.status === 'completed') {
isCompleted.value = true
progress.value = 100
await loadStories()
return
}
if (data.status === 'running') {
setExtracting(true)
connectSSE()
return
}
} catch (e) {
// 任务不存在,清除并重新开始
}
}
await initiateExtraction()
})
onUnmounted(() => {
closeSSE()
setExtracting(false)
})
</script>
<style scoped>
.modal-overlay {
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.4);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
</style>

View File

@@ -0,0 +1,288 @@
<template>
<div class="step-page active">
<!-- Header Card -->
<div class="card">
<div class="card-header">
<div class="card-title">
<i class="fas fa-link icon-link"></i>
故事匹配
</div>
<div class="card-badge" v-if="stories.length">
{{ matchedStories.length }} / {{ stories.length }} 已匹配
</div>
</div>
<p class="text-muted text-sm">将提取的故事与讲述者进行一一对应选择左侧故事选择右侧讲述者点击中间箭头完成匹配</p>
</div>
<!-- Match Panel: 3 columns -->
<div class="match-panel">
<!-- Left Column: Pending Stories -->
<div class="card" style="margin-bottom: 0;">
<div class="card-header">
<div class="card-title">
<i class="fas fa-book icon-magic"></i>
待匹配故事
</div>
<div class="card-badge" v-if="pendingStories.length">
{{ pendingStories.length }}
</div>
</div>
<div class="scrollable">
<template v-if="pendingStories.length">
<div
v-for="story in pendingStories"
:key="story.id"
class="story-card"
:class="{ 'match-highlight': selectedStory?.id === story.id }"
@click="selectStory(story)"
>
<div class="story-title">{{ story.title }}</div>
<div class="story-tag pending">
<i class="fas fa-clock"></i> 待匹配
</div>
<div class="story-summary" v-if="story.summary">
{{ truncateText(story.summary, 80) }}
</div>
<div class="story-source">
<i class="fas fa-file-lines"></i>
{{ story.speaker_nickname || '未知讲述者' }}
<span v-if="story.duration_minutes"> · {{ story.duration_minutes }}分钟</span>
</div>
</div>
</template>
<p v-else class="text-muted text-sm" style="text-align: center; padding: 24px 0;">
<i class="fas fa-check-circle" style="color: var(--success); margin-right: 4px;"></i>
所有故事均已匹配完成
</p>
</div>
</div>
<!-- Center Column: Arrow Button -->
<div class="match-center-col">
<button
class="match-arrow-btn"
title="匹配"
:disabled="!selectedStory || !selectedPerson || matching"
@click="handleMatch"
>
<i class="fas fa-arrow-right" v-if="!matching"></i>
<i class="fas fa-spinner fa-spin" v-else></i>
</button>
<span class="match-hint">
<template v-if="!selectedStory && !selectedPerson">选择故事后<br>点击匹配</template>
<template v-else-if="!selectedPerson">请选择<br>讲述者</template>
<template v-else-if="!selectedStory">请选择<br>故事</template>
<template v-else-if="matching">匹配中...</template>
<template v-else>点击匹配</template>
</span>
</div>
<!-- Right Column: Persons -->
<div class="card" style="margin-bottom: 0;">
<div class="card-header">
<div class="card-title">
<i class="fas fa-user-group icon-person"></i>
讲述者
</div>
<div class="card-badge">{{ persons.length + 1 }} </div>
</div>
<div class="scrollable">
<!-- 默认暂无选项始终置顶 -->
<div
class="person-card"
:class="{ selected: selectedPerson?.id === 'unknown' }"
@click="selectPerson({ id: 'unknown', name: '暂无' })"
>
<div class="person-avatar" style="background: linear-gradient(135deg, #f1f5f9, #e2e8f0); color: var(--text-muted);">
<i class="fas fa-question" style="font-size: 14px;"></i>
</div>
<div>
<div class="person-name">暂无</div>
<div class="person-meta">{{ getUnknownMatchCount() }}</div>
</div>
</div>
<!-- 真实讲述者列表 -->
<template v-if="persons.length">
<div
v-for="person in persons"
:key="person.id"
class="person-card"
:class="{ selected: selectedPerson?.id === person.id }"
@click="selectPerson(person)"
>
<div class="person-avatar">{{ person.name?.charAt(0) || '?' }}</div>
<div>
<div class="person-name">{{ person.name }}</div>
<div class="person-meta">{{ getPersonMatchCount(person.id) }}</div>
</div>
</div>
</template>
<p v-else class="text-muted text-sm" style="text-align: center; padding: 16px 0;">
暂无讲述者信息
</p>
</div>
</div>
</div>
<!-- Matched Stories Section -->
<div class="card mt-4" v-if="matchedStories.length">
<div class="card-header">
<div class="card-title">
<i class="fas fa-check-double icon-export"></i>
已匹配故事
</div>
<div class="card-badge">{{ matchedStories.length }} </div>
</div>
<div class="scrollable" style="max-height: 40vh;">
<div
v-for="story in matchedStories"
:key="story.id"
class="story-card"
>
<div class="story-title">{{ story.title }}</div>
<div class="story-tag matched">
<i class="fas fa-check"></i> 已匹配
</div>
<div class="story-summary" v-if="story.summary">
{{ truncateText(story.summary, 80) }}
</div>
<div class="story-source">
<i class="fas fa-user"></i>
{{ getPersonName(story.person_id) }}
</div>
<div class="story-actions">
<button
class="btn btn-sm btn-outline"
style="color: var(--danger); border-color: #fecaca;"
@click="handleUnmatch(story)"
:disabled="unmatchingId === story.id"
>
<i class="fas fa-link-slash" v-if="unmatchingId !== story.id"></i>
<i class="fas fa-spinner fa-spin" v-else></i>
取消匹配
</button>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { getStories, getPersons, createMatch, removeMatch } from '../api'
import { useToast } from '../composables/useToast'
const emit = defineEmits(['next', 'prev', 'go-to'])
const { showToast } = useToast()
// Data
const stories = ref([])
const persons = ref([])
const selectedStory = ref(null)
const selectedPerson = ref(null)
const matching = ref(false)
const unmatchingId = ref(null)
const loading = ref(false)
// Computed
const pendingStories = computed(() =>
stories.value.filter(s => s.match_status === 'pending')
)
const matchedStories = computed(() =>
stories.value.filter(s => s.match_status === 'matched')
)
// Methods
function truncateText(text, maxLen) {
if (!text) return ''
return text.length > maxLen ? text.slice(0, maxLen) + '...' : text
}
function getPersonName(personId) {
if (personId === 'unknown') return '暂无'
const person = persons.value.find(p => p.id === personId)
return person?.name || '未知讲述者'
}
function getPersonMatchCount(personId) {
const count = stories.value.filter(s => s.person_id === personId && s.match_status === 'matched').length
return count > 0 ? `已匹配 ${count} 个故事` : '未匹配'
}
function getUnknownMatchCount() {
const count = stories.value.filter(s => s.person_id === 'unknown' && s.match_status === 'matched').length
return count > 0 ? `已匹配 ${count} 个故事` : '未知讲述者可用'
}
function selectStory(story) {
selectedStory.value = selectedStory.value?.id === story.id ? null : story
}
function selectPerson(person) {
selectedPerson.value = selectedPerson.value?.id === person.id ? null : person
}
async function fetchAllData() {
loading.value = true
try {
const [storiesRes, personsRes] = await Promise.all([
getStories('all'),
getPersons()
])
stories.value = storiesRes.data || []
persons.value = personsRes.data || []
} catch (err) {
console.error('Failed to fetch data:', err)
showToast('加载数据失败,请稍后重试', 'error')
} finally {
loading.value = false
}
}
async function handleMatch() {
if (!selectedStory.value || !selectedPerson.value) {
showToast('请同时选择一个故事和一个讲述者', 'warning')
return
}
if (matching.value) return
matching.value = true
try {
await createMatch(selectedStory.value.id, selectedPerson.value.id)
showToast(`已将「${selectedStory.value.title}」匹配给「${selectedPerson.value.name}`, 'success')
selectedStory.value = null
selectedPerson.value = null
await fetchAllData()
} catch (err) {
console.error('Match failed:', err)
const msg = err.response?.data?.detail || '匹配失败,请稍后重试'
showToast(msg, 'error')
} finally {
matching.value = false
}
}
async function handleUnmatch(story) {
if (unmatchingId.value) return
unmatchingId.value = story.id
try {
await removeMatch(story.id)
showToast(`已取消「${story.title}」的匹配`, 'success')
await fetchAllData()
} catch (err) {
console.error('Unmatch failed:', err)
const msg = err.response?.data?.detail || '取消匹配失败,请稍后重试'
showToast(msg, 'error')
} finally {
unmatchingId.value = null
}
}
onMounted(() => {
fetchAllData()
})
</script>

View File

@@ -0,0 +1,292 @@
<template>
<div class="step-page active">
<div class="two-col">
<!-- Left Column: Transcripts -->
<div class="card">
<div class="card-header">
<div class="card-title">
<i class="icon-upload"><i class="fas fa-file-alt"></i></i>
直播文字稿
</div>
<span class="card-badge">DOCX 格式</span>
</div>
<div
class="upload-zone"
:class="{ 'is-dragging': transcriptDragging }"
@click="triggerTranscriptInput"
@dragover.prevent="transcriptDragging = true"
@dragleave.prevent="transcriptDragging = false"
@drop.prevent="handleTranscriptDrop"
>
<i class="fas fa-cloud-upload-alt upload-icon"></i>
<p>拖拽文件到此处或点击上传</p>
<p class="hint">支持批量上传 · 仅限 .docx 格式</p>
</div>
<input
ref="transcriptInputRef"
type="file"
accept=".docx"
multiple
style="display: none"
@change="handleTranscriptSelect"
/>
<div class="file-list">
<div v-if="transcriptUploading" class="upload-progress">
<div class="progress-bar">
<div class="progress-fill" :style="{ width: transcriptProgress + '%' }"></div>
</div>
<p class="text-sm text-muted mt-2">正在上传...</p>
</div>
<div
v-for="item in transcripts"
:key="item.id"
class="file-item"
>
<div class="file-icon docx">
<i class="fas fa-file-word"></i>
</div>
<div class="file-info">
<div class="file-name">{{ item.filename }}</div>
<div class="file-meta">
{{ formatLineCount(item.line_count) }} · {{ formatFileSize(item.file_size) }}
</div>
</div>
<button
class="file-action-btn danger"
title="删除"
@click="handleDeleteTranscript(item.id)"
>
<i class="fas fa-trash-alt"></i>
</button>
</div>
</div>
</div>
<!-- Right Column: Persons -->
<div class="card">
<div class="card-header">
<div class="card-title">
<i class="icon-person"><i class="fas fa-user"></i></i>
讲述者信息
</div>
<span class="card-badge">一人一个 DOCX</span>
</div>
<div
class="upload-zone"
:class="{ 'is-dragging': personDragging }"
@click="triggerPersonInput"
@dragover.prevent="personDragging = true"
@dragleave.prevent="personDragging = false"
@drop.prevent="handlePersonDrop"
>
<i class="fas fa-user-plus upload-icon"></i>
<p>拖拽文件到此处或点击上传</p>
<p class="hint">每个讲述者一个 DOCX · 包含个人信息和照片</p>
</div>
<input
ref="personInputRef"
type="file"
accept=".docx"
multiple
style="display: none"
@change="handlePersonSelect"
/>
<div class="file-list">
<div v-if="personUploading" class="upload-progress">
<div class="progress-bar">
<div class="progress-fill" :style="{ width: personProgress + '%' }"></div>
</div>
<p class="text-sm text-muted mt-2">正在上传...</p>
</div>
<div
v-for="item in persons"
:key="item.id"
class="file-item"
>
<div class="file-icon person">
<i class="fas fa-user"></i>
</div>
<div class="file-info">
<div class="file-name">{{ item.filename }}</div>
<div class="file-meta">
{{ item.has_photo ? '含照片' : '无照片' }} · {{ formatFileSize(item.file_size) }}
</div>
</div>
<button
class="file-action-btn danger"
title="删除"
@click="handleDeletePerson(item.id)"
>
<i class="fas fa-trash-alt"></i>
</button>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import {
uploadTranscripts,
uploadPersons,
getTranscripts,
getPersons,
deleteTranscript,
deletePerson,
} from '../api'
import { useToast } from '../composables/useToast'
defineEmits(['next', 'prev', 'go-to'])
const { showToast } = useToast()
// ===== Transcript state =====
const transcripts = ref([])
const transcriptDragging = ref(false)
const transcriptUploading = ref(false)
const transcriptProgress = ref(0)
const transcriptInputRef = ref(null)
// ===== Person state =====
const persons = ref([])
const personDragging = ref(false)
const personUploading = ref(false)
const personProgress = ref(0)
const personInputRef = ref(null)
// ===== Format helpers =====
function formatFileSize(bytes) {
if (!bytes || bytes === 0) return '0 B'
if (bytes < 1024) return bytes + ' B'
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
}
function formatLineCount(count) {
if (count == null) return '-'
return count.toLocaleString('en-US')
}
// ===== Transcript upload =====
function triggerTranscriptInput() {
transcriptInputRef.value?.click()
}
function handleTranscriptSelect(e) {
const files = Array.from(e.target.files || [])
if (files.length > 0) uploadTranscriptFiles(files)
e.target.value = ''
}
function handleTranscriptDrop(e) {
transcriptDragging.value = false
const files = Array.from(e.dataTransfer.files || []).filter(f =>
f.name.toLowerCase().endsWith('.docx')
)
if (files.length > 0) {
uploadTranscriptFiles(files)
} else {
showToast('请上传 .docx 格式的文件', 'error')
}
}
async function uploadTranscriptFiles(files) {
transcriptUploading.value = true
transcriptProgress.value = 0
try {
await uploadTranscripts(files)
transcriptProgress.value = 100
const res = await getTranscripts()
transcripts.value = res.data || []
} catch (err) {
showToast(err.response?.data?.detail || '上传文字稿失败', 'error')
} finally {
transcriptUploading.value = false
setTimeout(() => { transcriptProgress.value = 0 }, 500)
}
}
async function handleDeleteTranscript(id) {
try {
await deleteTranscript(id)
transcripts.value = transcripts.value.filter(t => t.id !== id)
} catch (err) {
showToast(err.response?.data?.detail || '删除失败', 'error')
}
}
// ===== Person upload =====
function triggerPersonInput() {
personInputRef.value?.click()
}
function handlePersonSelect(e) {
const files = Array.from(e.target.files || [])
if (files.length > 0) uploadPersonFiles(files)
e.target.value = ''
}
function handlePersonDrop(e) {
personDragging.value = false
const files = Array.from(e.dataTransfer.files || []).filter(f =>
f.name.toLowerCase().endsWith('.docx')
)
if (files.length > 0) {
uploadPersonFiles(files)
} else {
showToast('请上传 .docx 格式的文件', 'error')
}
}
async function uploadPersonFiles(files) {
personUploading.value = true
personProgress.value = 0
try {
await uploadPersons(files)
personProgress.value = 100
const res = await getPersons()
persons.value = res.data || []
} catch (err) {
showToast(err.response?.data?.detail || '上传讲述者信息失败', 'error')
} finally {
personUploading.value = false
setTimeout(() => { personProgress.value = 0 }, 500)
}
}
async function handleDeletePerson(id) {
try {
await deletePerson(id)
persons.value = persons.value.filter(p => p.id !== id)
} catch (err) {
showToast(err.response?.data?.detail || '删除失败', 'error')
}
}
// ===== Load existing data =====
onMounted(async () => {
try {
const [transcriptRes, personRes] = await Promise.all([
getTranscripts(),
getPersons(),
])
transcripts.value = transcriptRes.data || []
persons.value = personRes.data || []
} catch (err) {
showToast('加载文件列表失败', 'error')
}
})
</script>
<style scoped>
.upload-zone.is-dragging {
border-color: var(--primary);
background: var(--primary-bg);
}
.upload-progress {
padding: 12px 0;
}
</style>

View File

@@ -0,0 +1,40 @@
<template>
<div class="welcome-page">
<div class="welcome-icon">
<i class="fas fa-book-open"></i>
</div>
<h1 class="welcome-title">Story Extractor</h1>
<p class="welcome-desc">
从口述历史文字稿中利用 AI 自动提取个人故事并与讲述者匹配最终生成结构化文档
</p>
<div class="welcome-steps">
<div class="welcome-step-item">
<div class="welcome-step-icon s1"><i class="fas fa-cloud-upload-alt"></i></div>
<span class="welcome-step-label">上传文件</span>
<span class="welcome-step-num">Step 1</span>
</div>
<div class="welcome-step-item">
<div class="welcome-step-icon s2"><i class="fas fa-magic"></i></div>
<span class="welcome-step-label">提取故事</span>
<span class="welcome-step-num">Step 2</span>
</div>
<div class="welcome-step-item">
<div class="welcome-step-icon s3"><i class="fas fa-link"></i></div>
<span class="welcome-step-label">匹配确认</span>
<span class="welcome-step-num">Step 3</span>
</div>
<div class="welcome-step-item">
<div class="welcome-step-icon s4"><i class="fas fa-file-export"></i></div>
<span class="welcome-step-label">预览导出</span>
<span class="welcome-step-num">Step 4</span>
</div>
</div>
<button class="btn btn-primary btn-lg" @click="$emit('next')">
<i class="fas fa-rocket"></i> 开始使用
</button>
</div>
</template>
<script setup>
defineEmits(['next', 'prev', 'go-to'])
</script>

15
frontend/vite.config.js Normal file
View File

@@ -0,0 +1,15 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
server: {
port: 3000,
proxy: {
'/api': {
target: 'http://localhost:8000',
changeOrigin: true,
}
}
}
})

126
run.py Normal file
View File

@@ -0,0 +1,126 @@
"""
故事提取工具 - 入口脚本
支持开发模式和 PyInstaller 打包模式
"""
import os
import sys
import shutil
import socket
import webbrowser
import threading
import time
def get_base_dir() -> str:
"""获取基础目录"""
if getattr(sys, 'frozen', False):
return os.path.dirname(sys.executable)
else:
return os.path.dirname(os.path.abspath(__file__))
def cleanup_data(base_dir: str):
"""启动时清理数据库缓存和临时文件"""
print("正在清理上次运行的数据...")
# 实际数据目录(开发模式下在 backend/ 下,打包后在 exe 同级目录下)
if getattr(sys, 'frozen', False):
data_dir = base_dir
else:
data_dir = os.path.join(base_dir, "backend")
# 清理数据库
db_dir = os.path.join(data_dir, "data")
if os.path.exists(db_dir):
for f in os.listdir(db_dir):
if f.endswith((".db", ".db-wal", ".db-shm", ".db-journal")):
try:
os.remove(os.path.join(db_dir, f))
except Exception:
pass
# 清理上传的文件
uploads_dir = os.path.join(data_dir, "uploads")
if os.path.exists(uploads_dir):
for subdir in ["transcripts", "persons", "photos"]:
sub = os.path.join(uploads_dir, subdir)
if os.path.exists(sub):
shutil.rmtree(sub, ignore_errors=True)
# 清理导出文件
exports_dir = os.path.join(data_dir, "exports")
if os.path.exists(exports_dir):
shutil.rmtree(exports_dir, ignore_errors=True)
# 重新创建目录结构
for d in [db_dir, uploads_dir, exports_dir]:
os.makedirs(d, exist_ok=True)
for sub in ["transcripts", "persons", "photos"]:
os.makedirs(os.path.join(uploads_dir, sub), exist_ok=True)
print("清理完成!")
def find_free_port(start_port: int = 8000) -> int:
"""找到一个可用端口"""
for port in range(start_port, start_port + 100):
try:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.bind(("127.0.0.1", port))
return port
except OSError:
continue
return start_port
def open_browser(port: int):
"""延迟打开浏览器"""
time.sleep(2)
url = f"http://localhost:{port}"
print(f"\n正在打开浏览器: {url}")
webbrowser.open(url)
def main():
base_dir = get_base_dir()
# 1. 清理数据
cleanup_data(base_dir)
# 2. 找可用端口
port = find_free_port(8000)
if port != 8000:
print(f"端口 8000 被占用,使用端口 {port}")
# 3. 启动服务
print(f"\n{'='*50}")
print(f" 故事提取工具")
print(f" 访问地址: http://localhost:{port}")
print(f" 按 Ctrl+C 停止服务")
print(f"{'='*50}\n")
# 延迟打开浏览器(不阻塞主线程)
threading.Thread(target=open_browser, args=(port,), daemon=True).start()
# 启动 uvicorn
os.environ["APP_PORT"] = str(port)
if getattr(sys, 'frozen', False):
# 打包模式:直接 import 并运行
import uvicorn
# 把 backend 目录加到 path让 app 包能被找到
backend_dir = os.path.join(base_dir, "backend")
if backend_dir not in sys.path:
sys.path.insert(0, backend_dir)
uvicorn.run("app.main:app", host="0.0.0.0", port=port, log_level="info")
else:
# 开发模式
import uvicorn
backend_dir = os.path.join(base_dir, "backend")
if backend_dir not in sys.path:
sys.path.insert(0, backend_dir)
uvicorn.run("app.main:app", host="0.0.0.0", port=port, log_level="info")
if __name__ == "__main__":
main()

733
test_pipeline.py Normal file
View File

@@ -0,0 +1,733 @@
"""
故事提取流程 v4 - 逐步测试脚本
用法python test_pipeline.py <docx文件路径> [步骤编号]
步骤编号1=预处理, 2=判断故事, 3=切片提取, 4=重组, 5=生成故事, all=全部执行
"""
import sys
import os
import json
import asyncio
import time
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "backend"))
from app.services.docx_parser import parse_transcript
from app.services.llm_client import llm_client, _parse_json
TEST_DIR = os.path.join(os.path.dirname(__file__), "backend", "uploads", "test")
def _save_json(filepath, data):
"""保存 JSON 到文件"""
os.makedirs(os.path.dirname(filepath), exist_ok=True)
with open(filepath, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
def _save_text(filepath, text):
"""保存文本到文件"""
os.makedirs(os.path.dirname(filepath), exist_ok=True)
with open(filepath, "w", encoding="utf-8") as f:
f.write(text)
# ============================================================
# 第一步代码预处理v3时间范围截取允许重叠
# ============================================================
def step1_preprocess(lines, output_dir, save=True):
"""代码预处理:识别老师 → 过滤杂音 → 按时间范围截取每个学员的全部内容"""
print("\n" + "=" * 60)
print("第一步代码预处理v3时间范围截取")
print("=" * 60)
# 1.1 统计每个说话人
print(f"总行数: {len(lines)}")
speaker_stats = {}
for i, line in enumerate(lines):
speaker = line.get("speaker", "")
if not speaker:
continue
if speaker not in speaker_stats:
speaker_stats[speaker] = {"lines": 0, "chars": 0, "first": i, "last": i}
speaker_stats[speaker]["lines"] += 1
speaker_stats[speaker]["chars"] += len(line.get("content", ""))
speaker_stats[speaker]["last"] = i
print(f"\n说话人统计:")
for s, stats in sorted(speaker_stats.items(), key=lambda x: -x[1]["lines"]):
print(f" {s}: {stats['lines']}行, {stats['chars']}字, 行范围[{stats['first']}-{stats['last']}]")
# 1.2 识别老师(发言最多的人)
teacher = max(speaker_stats, key=lambda s: speaker_stats[s]["lines"])
print(f"\n识别老师: {teacher} ({speaker_stats[teacher]['lines']}行)")
# 1.3 过滤杂音(< 20句的说话人+ 排除老师
students = []
noise_speakers = []
for s, stats in speaker_stats.items():
if s == teacher:
continue
if stats["lines"] < 20:
noise_speakers.append(f"{s} ({stats['lines']}句)")
continue
students.append(s)
if noise_speakers:
print(f"\n杂音过滤: {', '.join(noise_speakers)}")
print(f"\n有效学员: {students}")
# 1.4 按时间范围截取:每个学员从第一句到最后一句,中间所有行全部截取
segments = []
for speaker in students:
stats = speaker_stats[speaker]
first_idx = stats["first"]
last_idx = stats["last"]
seg = lines[first_idx:last_idx + 1]
# 统计该学员在此范围内的发言
student_lines = [l for l in seg if l.get("speaker", "") == speaker]
student_chars = sum(len(l.get("content", "")) for l in student_lines)
segments.append((speaker, seg))
print(f" {speaker}: 截取行[{first_idx}-{last_idx}], 共{len(seg)}行, 学员{len(student_lines)}句/{student_chars}")
# 1.5 过滤:短段落 + 当事人占比过低(非主讲述人)
final_segments = []
filtered = []
for speaker, seg in segments:
student_lines = [l for l in seg if l.get("speaker", "") == speaker]
if len(student_lines) < 15:
filtered.append(f"{speaker} ({len(student_lines)}句<15)")
continue
student_chars = sum(len(l.get("content", "")) for l in student_lines)
if student_chars < 80:
filtered.append(f"{speaker} ({student_chars}字<80)")
continue
# 当事人说话占比 < 1/10 → 非主讲述人,丢弃
ratio = len(student_lines) / len(seg) if seg else 0
if ratio < 0.1:
filtered.append(f"{speaker} (占比{ratio:.1%}<10%)")
continue
final_segments.append((speaker, seg))
if filtered:
print(f"\n过滤: {', '.join(filtered)}")
print(f"\n最终: {len(final_segments)} 个学员段落")
for i, (speaker, seg) in enumerate(final_segments):
student_lines = [l for l in seg if l.get("speaker", "") == speaker]
student_chars = sum(len(l.get("content", "")) for l in student_lines)
ratio = len(student_lines) / len(seg) if seg else 0
print(f" 段落{i}: {speaker}, 截取{len(seg)}行, 学员{len(student_lines)}句/{student_chars}字, 占比{ratio:.1%}")
# 保存结果
if save:
step1_dir = os.path.join(output_dir, "step1_预处理")
# 保存每个学员段落
for i, (speaker, seg) in enumerate(final_segments):
text = _segment_to_text(seg)
safe_name = speaker.replace("/", "_")
_save_text(os.path.join(step1_dir, f"segment_{i}_{safe_name}.txt"), text)
print(f" 保存: segment_{i}_{safe_name}.txt ({len(text)}字)")
# 保存统计摘要
summary = {
"total_lines": len(lines),
"teacher": teacher,
"noise_filtered": noise_speakers,
"final_filtered": filtered,
"segments": [
{
"speaker": s,
"total_lines": len(seg),
"student_lines": len([l for l in seg if l.get("speaker", "") == s]),
"student_chars": sum(len(l.get("content", "")) for l in seg if l.get("speaker", "") == s),
"ratio": round(len([l for l in seg if l.get("speaker", "") == s]) / len(seg), 3) if seg else 0,
}
for s, seg in final_segments
]
}
_save_json(os.path.join(step1_dir, "summary.json"), summary)
print(f" 保存: summary.json")
return final_segments, teacher
def _get_main_speaker(student_lines):
if not student_lines:
return "unknown"
counts = {}
for l in student_lines:
s = l.get("speaker", "")
counts[s] = counts.get(s, 0) + 1
return max(counts, key=counts.get)
def _segment_to_text(seg):
parts = []
for line in seg:
speaker = line.get("speaker", "")
content = line.get("content", "")
if speaker:
parts.append(f"{speaker}: {content}")
else:
parts.append(content)
return "\n".join(parts)
# ============================================================
# 第二步:判断是否包含故事 + 提取事实(短段落合并处理)
# ============================================================
async def step2_identify(segments, output_dir, save=True):
"""短段落直接提取事实,长段落先判断是否有故事再提取"""
print("\n" + "=" * 60)
print("第二步:判断故事 + 提取事实")
print("=" * 60)
SHORT_THRESHOLD = 4000 # 短段落阈值(字数)
all_facts = []
step2_results = [] # 记录每个段落的处理结果
step2_t0 = time.time()
# 构建所有段落的异步任务,并行执行
async def process_segment(i, speaker, seg):
text = _segment_to_text(seg)
safe_name = speaker.replace("/", "_")
prefix = f"seg{i}_{safe_name}_"
print(f"\n--- 段落 {i}: {speaker} ({len(text)} 字) ---")
result_record = {
"index": i,
"speaker": speaker,
"text_length": len(text),
"is_short": len(text) <= SHORT_THRESHOLD,
}
if len(text) <= SHORT_THRESHOLD:
print(f" 短段落,跳过判断,直接提取事实")
facts = await _extract_facts_from_chunk(text, "", output_dir, prefix)
if facts:
hints = facts.get("story_hints", "")
if not hints:
hints = f"{speaker}的故事"
print(f" 提取完成: hints={hints}")
result_record["status"] = "extracted"
result_record["hints"] = hints
return (speaker, seg, hints, [facts]), result_record
else:
print(f" 提取失败,跳过")
result_record["status"] = "extract_failed"
return None, result_record
else:
is_story, hints = await _identify_story(text, output_dir, prefix)
print(f" 判断结果: is_story={is_story}, hints={hints}")
result_record["is_story"] = is_story
result_record["hints"] = hints
if is_story:
story_segments_for_step3 = [(speaker, seg, hints)]
step3_result = await step3_extract_facts(story_segments_for_step3, output_dir, save)
result_record["status"] = "has_story"
return step3_result, result_record
else:
print(f" 无故事,跳过")
result_record["status"] = "no_story"
return None, result_record
tasks = [process_segment(i, speaker, seg) for i, (speaker, seg) in enumerate(segments)]
results = await asyncio.gather(*tasks)
step2_elapsed = round(time.time() - step2_t0, 2)
print(f"\n⏱ 第二步总耗时: {step2_elapsed}s")
# 收集结果
for i, (r, record) in enumerate(results):
if r is not None:
if isinstance(r, list):
all_facts.extend(r)
else:
all_facts.append(r)
step2_results.append(record)
print(f"\n{len(all_facts)} 个故事段落完成事实提取")
# 保存结果
if save:
step2_dir = os.path.join(output_dir, "step2_判断提取")
_save_json(os.path.join(step2_dir, "results.json"), step2_results)
print(f" 保存: results.json")
return all_facts
async def _identify_story(text, output_dir=None, log_prefix="", do_save=True):
prompt = """判断这段对话是否包含学员的个人故事。直接输出JSON不要分析。
个人故事特征满足至少2条学员讲述具体经历、包含时间地点人物细节、表达深层情感、老师深入引导疗愈、揭示心理或家庭问题。
非故事内容:纯课堂讲解、简单问答寒暄、课堂管理、纯疗愈引导词。
直接输出JSON
{"is_story": true或false, "confidence": 0.0到1.0, "story_hints": "一句话描述故事主题和讲述者"}
对话内容:
```
""" + text[:4000] + """
```"""
try:
detail = await llm_client.chat_detail([{"role": "user", "content": prompt}], temperature=0.2)
api_log = {
"type": "identify_story",
"prompt": prompt,
"content": detail.get("content", ""),
"reasoning_content": detail.get("reasoning_content", ""),
"finish_reason": detail.get("finish_reason", ""),
"usage": detail.get("usage", {}),
"elapsed": detail.get("elapsed", 0),
}
print(f" {log_prefix}API耗时: {api_log['elapsed']}s, finish_reason={api_log['finish_reason']}, "
f"tokens: {api_log['usage'].get('total_tokens', '?')} "
f"(reasoning={api_log['usage'].get('reasoning_tokens', 0)})")
if output_dir and do_save:
_save_json(os.path.join(output_dir, "api_logs", f"{log_prefix}identify.json"), api_log)
result = _parse_json(detail["content"])
if result:
return bool(result.get("is_story", False)) and result.get("confidence", 0) >= 0.5, result.get("story_hints", "")
except Exception as e:
print(f" {log_prefix}错误: {e}")
return False, ""
# ============================================================
# 第三步切片提取事实Map
# ============================================================
async def step3_extract_facts(story_segments, output_dir, save=True):
print("\n" + "=" * 60)
print("第三步切片提取事实Map")
print("=" * 60)
all_facts = []
step3_t0 = time.time()
for i, (speaker, seg, hints) in enumerate(story_segments):
text = _segment_to_text(seg)
safe_name = speaker.replace("/", "_")
print(f"\n--- 故事 {i}: {speaker} - {hints[:50]}... ({len(text)} 字) ---")
chunks = _chunk_text_by_paragraphs(text, max_chars=3000, overlap_paragraphs=5)
print(f" 切成 {len(chunks)}")
# 保存切片信息
chunk_records = []
for j, chunk in enumerate(chunks):
chunk_records.append({"chunk_index": j, "chars": len(chunk), "preview": chunk[:100] + "..."})
tasks = [_extract_facts_from_chunk(chunk, hints, output_dir, f"story{i}_{safe_name}_chunk{j}_") for j, chunk in enumerate(chunks)]
results = await asyncio.gather(*tasks, return_exceptions=True)
facts = []
for j, r in enumerate(results):
if isinstance(r, Exception):
print(f"{j} 错误: {r}")
chunk_records[j]["status"] = "error"
chunk_records[j]["error"] = str(r)
else:
print(f"{j}: {json.dumps(r, ensure_ascii=False)[:200]}")
facts.append(r)
chunk_records[j]["status"] = "ok"
chunk_records[j]["facts"] = r
all_facts.append((speaker, seg, hints, facts))
# 保存切片和提取结果
if save:
step3_dir = os.path.join(output_dir, "step3_切片提取", f"story_{i}_{safe_name}")
for j, chunk in enumerate(chunks):
_save_text(os.path.join(step3_dir, f"chunk_{j}.txt"), chunk)
_save_json(os.path.join(step3_dir, "chunks_info.json"), chunk_records)
print(f" 保存: {len(chunks)} 个切片文件 + chunks_info.json")
step3_elapsed = round(time.time() - step3_t0, 2)
print(f"\n⏱ 第三步总耗时: {step3_elapsed}s")
return all_facts
def _chunk_text_by_paragraphs(text, max_chars=3000, overlap_paragraphs=5):
"""按自然段切分,在 max_chars 附近找段落结尾切,重叠 overlap_paragraphs 个自然段"""
paragraphs = [p for p in text.split("\n") if p.strip()]
if not paragraphs:
return [text]
if len(text) <= max_chars:
return [text]
chunks = []
i = 0
while i < len(paragraphs):
# 从当前位置开始,累加段落直到超过 max_chars
current = []
current_len = 0
j = i
while j < len(paragraphs):
para = paragraphs[j]
# 超过 max_chars 且已有内容 → 在这个段落前切断(段落结尾切)
if current_len + len(para) > max_chars and current:
break
current.append(para)
current_len += len(para)
j += 1
chunks.append("\n".join(current))
# 到末尾了就结束
if j >= len(paragraphs):
break
# 下一次起点:当前切到的位置 - 重叠段落数
next_i = j - overlap_paragraphs
# 防止死循环确保至少前进1个段落
if next_i <= i:
next_i = i + 1
i = next_i
return chunks
async def _extract_facts_from_chunk(text, hints, output_dir=None, log_prefix="", do_save=True):
prompt = f"""从这段对话中提取关键信息。只输出JSON。
故事主题:{hints}
提取以下内容:
- events: 发生的事件(尽量详细,保留具体细节)
- emotions: 表达的情感
- quotes: 讲述者的原话(保留原话,不要改写,尽量多提取;完全相同的重复语句只保留一条)
- key_people: 提到的人物
{{"events":["事件1","事件2"],"emotions":["情感1","情感2"],"quotes":["原话1","原话2","原话3"],"key_people":["人物1"]}}
对话内容:
```
{text}
```"""
detail = await llm_client.chat_detail([{"role": "user", "content": prompt}], temperature=0.1)
api_log = {
"type": "extract_facts",
"prompt": prompt,
"content": detail.get("content", ""),
"reasoning_content": detail.get("reasoning_content", ""),
"finish_reason": detail.get("finish_reason", ""),
"usage": detail.get("usage", {}),
"elapsed": detail.get("elapsed", 0),
}
print(f" {log_prefix}API耗时: {api_log['elapsed']}s, finish_reason={api_log['finish_reason']}, "
f"tokens: {api_log['usage'].get('total_tokens', '?')} "
f"(reasoning={api_log['usage'].get('reasoning_tokens', 0)})")
if output_dir and do_save:
_save_json(os.path.join(output_dir, "api_logs", f"{log_prefix}extract.json"), api_log)
result = _parse_json(detail["content"])
return result
# ============================================================
# 模糊去重工具函数
# ============================================================
def _fuzzy_dedup_quotes(quotes: list[str]) -> list[str]:
"""
模糊去重:对高度相似的语句只保留第一条。
策略:提取每条语句的核心部分(去掉常见前缀后缀),相似度超过阈值的视为重复。
"""
import re
def normalize(s: str) -> str:
s = s.strip()
if s.startswith("") and s.endswith(""):
s = s[1:-1]
for prefix in ["", "", "", "就是", "然后", "那个", "所以"]:
if s.startswith(prefix):
s = s[len(prefix):].strip()
for suffix in ["", "", "", "", "", ""]:
if s.endswith(suffix) and len(s) > 4:
s = s[:-1]
s = re.sub(r'(.)\1{2,}', r'\1', s)
return s
def similarity(a: str, b: str) -> float:
if not a or not b:
return 0.0
a_bigrams = set(a[i:i+2] for i in range(len(a)-1))
b_bigrams = set(b[i:i+2] for i in range(len(b)-1))
if not a_bigrams or not b_bigrams:
return 0.0
intersection = a_bigrams & b_bigrams
union = a_bigrams | b_bigrams
return len(intersection) / len(union)
kept = []
kept_normalized = []
for q in quotes:
nq = normalize(q)
is_dup = False
for kn in kept_normalized:
threshold = 0.8 if len(nq) < 15 else 0.7
if similarity(nq, kn) >= threshold:
is_dup = True
break
if not is_dup:
kept.append(q)
kept_normalized.append(nq)
removed = len(quotes) - len(kept)
if removed > 0:
print(f" 模糊去重: {len(quotes)} -> {len(kept)} (去除 {removed} 条相似重复)")
return kept
# ============================================================
# 第四步重组Reduce
# ============================================================
def step4_merge(all_facts, output_dir, save=True):
print("\n" + "=" * 60)
print("第四步重组Reduce")
print("=" * 60)
merged = []
for i, (speaker, seg, hints, facts_list) in enumerate(all_facts):
print(f"\n--- 故事 {i}: {speaker} ---")
all_events, all_emotions, all_quotes, all_people = [], [], [], []
for facts in facts_list:
if isinstance(facts, dict):
all_events.extend(facts.get("events", []))
all_emotions.extend(facts.get("emotions", []))
all_quotes.extend(facts.get("quotes", []))
all_people.extend(facts.get("key_people", []))
all_emotions = list(dict.fromkeys(all_emotions))
all_people = list(dict.fromkeys(all_people))
seen = set()
unique_quotes = [q for q in all_quotes if q not in seen and not seen.add(q)]
# 模糊去重:去除相似重复语句(如多次释放语句只保留一条)
unique_quotes = _fuzzy_dedup_quotes(unique_quotes)
material = {
"events": all_events,
"emotions": all_emotions,
"quotes": unique_quotes,
"key_people": all_people,
"story_hints": hints,
}
print(f" 事件: {len(all_events)}, 情感: {len(all_emotions)}, 原话: {len(unique_quotes)}, 人物: {len(all_people)}")
print(f" 素材: {json.dumps(material, ensure_ascii=False)[:500]}")
merged.append((speaker, seg, material))
# 保存结果
if save:
step4_dir = os.path.join(output_dir, "step4_重组")
for i, (speaker, seg, material) in enumerate(merged):
safe_name = speaker.replace("/", "_")
_save_json(os.path.join(step4_dir, f"material_{i}_{safe_name}.json"), material)
print(f" 保存: material_{i}_{safe_name}.json")
return merged
# ============================================================
# 第五步:生成最终故事
# ============================================================
async def step5_generate(merged, output_dir, save=True):
print("\n" + "=" * 60)
print("第五步:生成最终故事(并行)")
print("=" * 60)
step5_t0 = time.time()
async def generate_one(i, speaker, seg, material):
print(f"\n--- 生成故事 {i}: {speaker} ---")
prompt = f"""基于素材生成第三人称故事。只输出JSON不要其他内容。
素材:
主题:{material['story_hints']}
事件:{json.dumps(material['events'], ensure_ascii=False)}
情感:{json.dumps(material['emotions'], ensure_ascii=False)}
原话:{json.dumps(material['quotes'], ensure_ascii=False)}
人物:{json.dumps(material['key_people'], ensure_ascii=False)}
写作手法(非常重要):
【双时空结构】
故事有两个时空层:
- "当下时空":学员在课堂上与卢慧老师的对话——这是故事的框架,用对话体呈现,保留师生互动的完整脉络
- "过往时空":学员讲述的过去经历——这是故事的核心,用"场景重建"手法呈现,让读者穿越到那个时空
【场景重建手法(用于"过往的故事"部分)】
当学员讲述过去的经历时,不要写成"她说她小时候…"这种转述体,而是直接把读者拉进那个时空:
- 直接切入场景,用过去时态叙述。比如不要写"文晔回忆说她的儿子15岁去了美国",而要写"十五岁那年,文晔的儿子独自登上了飞往美国的航班"
- 用五感描写构建画面:她看到了什么、听到了什么、身体感受到了什么(比如"手心出汗""脚发凉"这些身体反应要融入场景中)
- 用环境细节暗示情绪:不要直接说"她很痛苦",而是通过场景让读者感受到痛苦
- 关键情感爆发处用直接引语(「」包裹原话),其他地方用间接引语和叙述自然融合
- 场景之间要有过渡,比如"然而,命运的转折发生在前年冬天——"
【对话体手法(用于"当下的困境""转变"部分)】
- 保留师生对话的现场感,写出谁说了什么、对方如何回应
- 在对话中穿插学员的内心活动、身体反应、情绪变化
- 老师的引导语要完整保留,这是疗愈过程的关键
【通用要求】
- 每个事件都要展开写,写出具体过程和细节,不要一句话概括
- 事件之间要有过渡和因果逻辑,不能跳跃
- 情感变化要写出过程:从什么情感转变到什么情感,中间经历了什么
- 不编造素材中没有的内容,但可以根据素材合理补充场景细节(如环境、氛围)
- 每个部分充分展开,不要限制长度,越详细越好
- 原文金句数组中的每条金句必须用英文双引号包裹,如 ["金句1", "金句2"],不要用「」替代双引号
禁止事项:
- 禁止用一句话概括多个事件(如"她倾诉了挣扎""她经历了疗愈过程"
- 禁止省略素材中的具体细节
- 禁止省略老师的引导和回应
- 禁止事件之间跳跃,要保持时间顺序和因果逻辑
- 禁止过度总结或抽象化描述
- 禁止在"过往的故事"部分全程使用转述体("她说…她回忆…"),必须用场景重建手法让读者身临其境
- 禁止为了简洁而牺牲内容的丰富性
输出格式:{{"title":"标题","summary":"80字摘要","tags":["标签"],"speaker_nickname":"昵称","content":{{"讲述者":"...","当下的困境":"...","过往的故事":"...","转变":"...","原文金句":["原话1","原话2","原话3"]}}}}"""
try:
detail = await llm_client.chat_detail([{"role": "user", "content": prompt}], temperature=0.7, max_tokens=16384)
raw = detail["content"]
api_log = {
"type": "generate_story",
"speaker": speaker,
"prompt": prompt,
"content": raw,
"reasoning_content": detail.get("reasoning_content", ""),
"finish_reason": detail.get("finish_reason", ""),
"usage": detail.get("usage", {}),
"elapsed": detail.get("elapsed", 0),
}
print(f" API耗时: {api_log['elapsed']}s, finish_reason={api_log['finish_reason']}, "
f"tokens: {api_log['usage'].get('total_tokens', '?')} "
f"(reasoning={api_log['usage'].get('reasoning_tokens', 0)})")
print(f" 原始输出长度: {len(raw)}")
if save:
step5_dir = os.path.join(output_dir, "step5_生成故事")
os.makedirs(step5_dir, exist_ok=True)
_save_text(os.path.join(step5_dir, f"raw_{i}_{speaker}.txt"), raw)
_save_json(os.path.join(output_dir, "api_logs", f"story{i}_{speaker}_generate.json"), api_log)
result = _parse_json(raw)
except Exception as e:
print(f" 生成失败: {e}")
result = None
if result:
print(f" 标题: {result.get('title', '')}")
print(f" 摘要: {result.get('summary', '')[:100]}")
content = result.get('content', {})
if isinstance(content, dict):
for key, val in content.items():
print(f" {key}: {str(val)[:80]}...")
print(f"\n 完整JSON:")
print(json.dumps(result, ensure_ascii=False, indent=2))
else:
print(" 生成失败!")
return result
tasks = [generate_one(i, speaker, seg, material) for i, (speaker, seg, material) in enumerate(merged)]
results = await asyncio.gather(*tasks)
stories = [r for r in results if r is not None]
step5_elapsed = round(time.time() - step5_t0, 2)
print(f"\n⏱ 第五步总耗时: {step5_elapsed}s")
# 保存结果
if save:
step5_dir = os.path.join(output_dir, "step5_生成故事")
for i, story in enumerate(stories):
safe_name = story.get("speaker_nickname", f"story_{i}").replace("/", "_")
_save_json(os.path.join(step5_dir, f"story_{i}_{safe_name}.json"), story)
print(f" 保存: story_{i}_{safe_name}.json")
return stories
# ============================================================
# 主函数
# ============================================================
async def main():
if len(sys.argv) < 2:
print("用法: python test_pipeline.py <docx文件路径> [步骤编号]")
print(" 步骤编号: 1=预处理, 2=判断故事, 3=切片提取, 4=重组, 5=生成故事, all=全部")
sys.exit(1)
file_path = sys.argv[1]
step = sys.argv[2] if len(sys.argv) > 2 else "all"
if not os.path.exists(file_path):
print(f"文件不存在: {file_path}")
sys.exit(1)
# 根据文件名创建输出目录
basename = os.path.splitext(os.path.basename(file_path))[0]
output_dir = os.path.join(TEST_DIR, basename)
os.makedirs(output_dir, exist_ok=True)
print(f"文件: {file_path}")
print(f"输出目录: {output_dir}")
total_t0 = time.time()
lines = parse_transcript(file_path)
print(f"解析到 {len(lines)}")
# 如果只跑第5步从 step4 产物恢复
if step == "5":
step4_dir = os.path.join(output_dir, "step4_重组")
if os.path.exists(step4_dir):
print("从 step4 产物恢复...")
merged = []
for f in sorted(os.listdir(step4_dir)):
if f.endswith(".json"):
with open(os.path.join(step4_dir, f), "r", encoding="utf-8") as fp:
material = json.load(fp)
parts = f.replace("material_", "").replace(".json", "").split("_", 1)
speaker = parts[1] if len(parts) > 1 else f
merged.append((speaker, [], material))
print(f"恢复到 {len(merged)} 个故事")
stories = await step5_generate(merged, output_dir)
total_elapsed = round(time.time() - total_t0, 2)
print(f"\n{'=' * 60}")
print(f"✅ 全流程完成!总耗时: {total_elapsed}s")
print(f" 生成故事: {len(stories)}")
print(f"{'=' * 60}")
return
else:
print("step4 产物不存在请先跑前4步")
return
segments, teacher = step1_preprocess(lines, output_dir)
if step == "1":
return
all_facts = await step2_identify(segments, output_dir)
if step == "2":
return
if not all_facts:
print("没有发现故事段落,结束。")
return
merged = step4_merge(all_facts, output_dir)
if step == "4":
return
stories = await step5_generate(merged, output_dir)
if __name__ == "__main__":
asyncio.run(main())