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