From 1d6f0cc370124864938b305fbc7fed32b4aeabe7 Mon Sep 17 00:00:00 2001 From: Nelson <1475262689@qq.com> Date: Fri, 24 Apr 2026 16:02:16 +0800 Subject: [PATCH] init --- .gitignore | 38 + Dockerfile | 22 + backend/.env.example | 18 + backend/app/__init__.py | 0 backend/app/api/__init__.py | 0 backend/app/api/export.py | 195 +++ backend/app/api/extraction.py | 112 ++ backend/app/api/files.py | 136 ++ backend/app/api/matching.py | 76 ++ backend/app/api/settings.py | 70 + backend/app/api/stories.py | 64 + backend/app/config.py | 47 + backend/app/database.py | 26 + backend/app/main.py | 64 + backend/app/models/__init__.py | 0 backend/app/models/config.py | 11 + backend/app/models/person.py | 17 + backend/app/models/story.py | 26 + backend/app/models/transcript.py | 17 + backend/app/schemas/__init__.py | 0 backend/app/schemas/schemas.py | 100 ++ backend/app/services/__init__.py | 0 backend/app/services/docx_generator.py | 166 +++ backend/app/services/docx_parser.py | 93 ++ backend/app/services/extractor.py | 957 ++++++++++++++ backend/app/services/file_service.py | 63 + backend/app/services/llm_client.py | 287 +++++ backend/app/services/preprocessor.py | 220 ++++ backend/app/state.py | 95 ++ backend/requirements.txt | 9 + build.py | 144 +++ docker-compose.yml | 14 + docs/Story-Extractor-产品设计文档.md | 410 ++++++ docs/Story-Extractor-后端架构设计文档.md | 757 +++++++++++ docs/extraction-design.md | 295 +++++ docs/故事提取工具-方案设计.md | 167 +++ docs/说话人04_对比分析报告.txt | 133 ++ frontend/.gitignore | 24 + frontend/README.md | 5 + frontend/index.html | 15 + frontend/package-lock.json | 1407 +++++++++++++++++++++ frontend/package.json | 20 + frontend/public/favicon.svg | 1 + frontend/public/icons.svg | 24 + frontend/src/App.vue | 78 ++ frontend/src/api/index.js | 60 + frontend/src/assets/hero.png | Bin 0 -> 13057 bytes frontend/src/assets/styles/global.css | 936 ++++++++++++++ frontend/src/assets/vite.svg | 1 + frontend/src/assets/vue.svg | 1 + frontend/src/components/AppHeader.vue | 22 + frontend/src/components/AppStepper.vue | 30 + frontend/src/components/BottomNav.vue | 37 + frontend/src/components/SettingsModal.vue | 191 +++ frontend/src/components/Toast.vue | 30 + frontend/src/composables/useToast.js | 15 + frontend/src/main.js | 8 + frontend/src/router/index.js | 16 + frontend/src/views/ExportView.vue | 316 +++++ frontend/src/views/ExtractView.vue | 410 ++++++ frontend/src/views/MatchView.vue | 288 +++++ frontend/src/views/UploadView.vue | 292 +++++ frontend/src/views/WelcomeView.vue | 40 + frontend/vite.config.js | 15 + run.py | 126 ++ test_pipeline.py | 733 +++++++++++ 66 files changed, 9990 insertions(+) create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 backend/.env.example create mode 100644 backend/app/__init__.py create mode 100644 backend/app/api/__init__.py create mode 100644 backend/app/api/export.py create mode 100644 backend/app/api/extraction.py create mode 100644 backend/app/api/files.py create mode 100644 backend/app/api/matching.py create mode 100644 backend/app/api/settings.py create mode 100644 backend/app/api/stories.py create mode 100644 backend/app/config.py create mode 100644 backend/app/database.py create mode 100644 backend/app/main.py create mode 100644 backend/app/models/__init__.py create mode 100644 backend/app/models/config.py create mode 100644 backend/app/models/person.py create mode 100644 backend/app/models/story.py create mode 100644 backend/app/models/transcript.py create mode 100644 backend/app/schemas/__init__.py create mode 100644 backend/app/schemas/schemas.py create mode 100644 backend/app/services/__init__.py create mode 100644 backend/app/services/docx_generator.py create mode 100644 backend/app/services/docx_parser.py create mode 100644 backend/app/services/extractor.py create mode 100644 backend/app/services/file_service.py create mode 100644 backend/app/services/llm_client.py create mode 100644 backend/app/services/preprocessor.py create mode 100644 backend/app/state.py create mode 100644 backend/requirements.txt create mode 100644 build.py create mode 100644 docker-compose.yml create mode 100644 docs/Story-Extractor-产品设计文档.md create mode 100644 docs/Story-Extractor-后端架构设计文档.md create mode 100644 docs/extraction-design.md create mode 100644 docs/故事提取工具-方案设计.md create mode 100644 docs/说话人04_对比分析报告.txt create mode 100644 frontend/.gitignore create mode 100644 frontend/README.md create mode 100644 frontend/index.html create mode 100644 frontend/package-lock.json create mode 100644 frontend/package.json create mode 100644 frontend/public/favicon.svg create mode 100644 frontend/public/icons.svg create mode 100644 frontend/src/App.vue create mode 100644 frontend/src/api/index.js create mode 100644 frontend/src/assets/hero.png create mode 100644 frontend/src/assets/styles/global.css create mode 100644 frontend/src/assets/vite.svg create mode 100644 frontend/src/assets/vue.svg create mode 100644 frontend/src/components/AppHeader.vue create mode 100644 frontend/src/components/AppStepper.vue create mode 100644 frontend/src/components/BottomNav.vue create mode 100644 frontend/src/components/SettingsModal.vue create mode 100644 frontend/src/components/Toast.vue create mode 100644 frontend/src/composables/useToast.js create mode 100644 frontend/src/main.js create mode 100644 frontend/src/router/index.js create mode 100644 frontend/src/views/ExportView.vue create mode 100644 frontend/src/views/ExtractView.vue create mode 100644 frontend/src/views/MatchView.vue create mode 100644 frontend/src/views/UploadView.vue create mode 100644 frontend/src/views/WelcomeView.vue create mode 100644 frontend/vite.config.js create mode 100644 run.py create mode 100644 test_pipeline.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..22f68af --- /dev/null +++ b/.gitignore @@ -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 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..fce86b7 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..0a3ee26 --- /dev/null +++ b/backend/.env.example @@ -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 diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/api/export.py b/backend/app/api/export.py new file mode 100644 index 0000000..bb0a175 --- /dev/null +++ b/backend/app/api/export.py @@ -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", + ) diff --git a/backend/app/api/extraction.py b/backend/app/api/extraction.py new file mode 100644 index 0000000..6cf26da --- /dev/null +++ b/backend/app/api/extraction.py @@ -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} diff --git a/backend/app/api/files.py b/backend/app/api/files.py new file mode 100644 index 0000000..5b98201 --- /dev/null +++ b/backend/app/api/files.py @@ -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, + } diff --git a/backend/app/api/matching.py b/backend/app/api/matching.py new file mode 100644 index 0000000..bcc599a --- /dev/null +++ b/backend/app/api/matching.py @@ -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 diff --git a/backend/app/api/settings.py b/backend/app/api/settings.py new file mode 100644 index 0000000..7ac5005 --- /dev/null +++ b/backend/app/api/settings.py @@ -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 diff --git a/backend/app/api/stories.py b/backend/app/api/stories.py new file mode 100644 index 0000000..b43949a --- /dev/null +++ b/backend/app/api/stories.py @@ -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": "已删除"} diff --git a/backend/app/config.py b/backend/app/config.py new file mode 100644 index 0000000..c23e347 --- /dev/null +++ b/backend/app/config.py @@ -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() diff --git a/backend/app/database.py b/backend/app/database.py new file mode 100644 index 0000000..51b82d4 --- /dev/null +++ b/backend/app/database.py @@ -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 diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..a6d431c --- /dev/null +++ b/backend/app/main.py @@ -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") diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/models/config.py b/backend/app/models/config.py new file mode 100644 index 0000000..1892858 --- /dev/null +++ b/backend/app/models/config.py @@ -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) diff --git a/backend/app/models/person.py b/backend/app/models/person.py new file mode 100644 index 0000000..36d83b9 --- /dev/null +++ b/backend/app/models/person.py @@ -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()) diff --git a/backend/app/models/story.py b/backend/app/models/story.py new file mode 100644 index 0000000..8cef3a1 --- /dev/null +++ b/backend/app/models/story.py @@ -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()) diff --git a/backend/app/models/transcript.py b/backend/app/models/transcript.py new file mode 100644 index 0000000..4185bb9 --- /dev/null +++ b/backend/app/models/transcript.py @@ -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()) diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/schemas/schemas.py b/backend/app/schemas/schemas.py new file mode 100644 index 0000000..bf4b8de --- /dev/null +++ b/backend/app/schemas/schemas.py @@ -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 diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/services/docx_generator.py b/backend/app/services/docx_generator.py new file mode 100644 index 0000000..e34da4f --- /dev/null +++ b/backend/app/services/docx_generator.py @@ -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 diff --git a/backend/app/services/docx_parser.py b/backend/app/services/docx_parser.py new file mode 100644 index 0000000..f14b651 --- /dev/null +++ b/backend/app/services/docx_parser.py @@ -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, + } diff --git a/backend/app/services/extractor.py b/backend/app/services/extractor.py new file mode 100644 index 0000000..f7893db --- /dev/null +++ b/backend/app/services/extractor.py @@ -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) diff --git a/backend/app/services/file_service.py b/backend/app/services/file_service.py new file mode 100644 index 0000000..d4349f2 --- /dev/null +++ b/backend/app/services/file_service.py @@ -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() diff --git a/backend/app/services/llm_client.py b/backend/app/services/llm_client.py new file mode 100644 index 0000000..b70dbd0 --- /dev/null +++ b/backend/app/services/llm_client.py @@ -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() diff --git a/backend/app/services/preprocessor.py b/backend/app/services/preprocessor.py new file mode 100644 index 0000000..28223c5 --- /dev/null +++ b/backend/app/services/preprocessor.py @@ -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() diff --git a/backend/app/state.py b/backend/app/state.py new file mode 100644 index 0000000..e352c49 --- /dev/null +++ b/backend/app/state.py @@ -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() diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..9673cf8 --- /dev/null +++ b/backend/requirements.txt @@ -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 diff --git a/build.py b/build.py new file mode 100644 index 0000000..496fe01 --- /dev/null +++ b/build.py @@ -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() diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..8024ce5 --- /dev/null +++ b/docker-compose.yml @@ -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 diff --git a/docs/Story-Extractor-产品设计文档.md b/docs/Story-Extractor-产品设计文档.md new file mode 100644 index 0000000..ebe8cba --- /dev/null +++ b/docs/Story-Extractor-产品设计文档.md @@ -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 加密存储,文件仅在服务器本地处理 | +| **并发处理** | 支持多文件并发提取,避免前端长时间等待 | +| **容错性** | 单个文件处理失败不影响其他文件 | diff --git a/docs/Story-Extractor-后端架构设计文档.md b/docs/Story-Extractor-后端架构设计文档.md new file mode 100644 index 0000000..9143190 --- /dev/null +++ b/docs/Story-Extractor-后端架构设计文档.md @@ -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 +- 架构设计已预留接口,业务逻辑层不需要改动 diff --git a/docs/extraction-design.md b/docs/extraction-design.md new file mode 100644 index 0000000..f9ce012 --- /dev/null +++ b/docs/extraction-design.md @@ -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 解析失败 | diff --git a/docs/故事提取工具-方案设计.md b/docs/故事提取工具-方案设计.md new file mode 100644 index 0000000..d0151bd --- /dev/null +++ b/docs/故事提取工具-方案设计.md @@ -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 的具体字段结构(需看样例) +- [ ] 文字稿样例(需看实际内容以优化切分和提取策略) diff --git a/docs/说话人04_对比分析报告.txt b/docs/说话人04_对比分析报告.txt new file mode 100644 index 0000000..fab05a2 --- /dev/null +++ b/docs/说话人04_对比分析报告.txt @@ -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 时,应该考虑主题连贯性,将与当前学员主题相关的 + 其他学员段落中的老师对话也纳入合并范围。 diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/frontend/.gitignore @@ -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? diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..1511959 --- /dev/null +++ b/frontend/README.md @@ -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 ` + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..d10f34b --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,1407 @@ +{ + "name": "frontend", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "frontend", + "version": "0.0.0", + "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" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@emnapi/core": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz", + "integrity": "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz", + "integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", + "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.126.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.126.0.tgz", + "integrity": "sha512-oGfVtjAgwQVVpfBrbtk4e1XDyWHRFta6BS3GWVzrF8xYBT2VGQAk39yJS/wFSMrZqoiCU4oghT3Ch0HaHGIHcQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.0-rc.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.16.tgz", + "integrity": "sha512-rhY3k7Bsae9qQfOtph2Pm2jZEA+s8Gmjoz4hhmx70K9iMQ/ddeae+xhRQcM5IuVx5ry1+bGfkvMn7D6MJggVSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.0-rc.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.16.tgz", + "integrity": "sha512-rNz0yK078yrNn3DrdgN+PKiMOW8HfQ92jQiXxwX8yW899ayV00MLVdaCNeVBhG/TbH3ouYVObo8/yrkiectkcQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.0-rc.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.16.tgz", + "integrity": "sha512-r/OmdR00HmD4i79Z//xO06uEPOq5hRXdhw7nzkxQxwSavs3PSHa1ijntdpOiZ2mzOQ3fVVu8C1M19FoNM+dMUQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.0-rc.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.16.tgz", + "integrity": "sha512-KcRE5w8h0OnjUatG8pldyD14/CQ5Phs1oxfR+3pKDjboHRo9+MkqQaiIZlZRpsxC15paeXme/I127tUa9TXJ6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.0-rc.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.16.tgz", + "integrity": "sha512-bT0guA1bpxEJ/ZhTRniQf7rNF8ybvXOuWbNIeLABaV5NGjx4EtOWBTSRGWFU9ZWVkPOZ+HNFP8RMcBokBiZ0Kg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.0-rc.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.16.tgz", + "integrity": "sha512-+tHktCHWV8BDQSjemUqm/Jl/TPk3QObCTIjmdDy/nlupcujZghmKK2962LYrqFpWu+ai01AN/REOH3NEpqvYQg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.0-rc.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.16.tgz", + "integrity": "sha512-3fPzdREH806oRLxpTWW1Gt4tQHs0TitZFOECB2xzCFLPKnSOy90gwA7P29cksYilFO6XVRY1kzga0cL2nRjKPg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.0-rc.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.16.tgz", + "integrity": "sha512-EKwI1tSrLs7YVw+JPJT/G2dJQ1jl9qlTTTEG0V2Ok/RdOenRfBw2PQdLPyjhIu58ocdBfP7vIRN/pvMsPxs/AQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.0-rc.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.16.tgz", + "integrity": "sha512-Uknladnb3Sxqu6SEcqBldQyJUpk8NleooZEc0MbRBJ4inEhRYWZX0NJu12vNf2mqAq7gsofAxHrGghiUYjhaLQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.0-rc.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.16.tgz", + "integrity": "sha512-FIb8+uG49sZBtLTn+zt1AJ20TqVcqWeSIyoVt0or7uAWesgKaHbiBh6OpA/k9v0LTt+PTrb1Lao133kP4uVxkg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.0-rc.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.16.tgz", + "integrity": "sha512-RuERhF9/EgWxZEXYWCOaViUWHIboceK4/ivdtQ3R0T44NjLkIIlGIAVAuCddFxsZ7vnRHtNQUrt2vR2n2slB2w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.0-rc.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.16.tgz", + "integrity": "sha512-mXcXnvd9GpazCxeUCCnZ2+YF7nut+ZOEbE4GtaiPtyY6AkhZWbK70y1KK3j+RDhjVq5+U8FySkKRb/+w0EeUwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.0-rc.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.16.tgz", + "integrity": "sha512-3Q2KQxnC8IJOLqXmUMoYwyIPZU9hzRbnHaoV3Euz+VVnjZKcY8ktnNP8T9R4/GGQtb27C/UYKABxesKWb8lsvQ==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.9.2", + "@emnapi/runtime": "1.9.2", + "@napi-rs/wasm-runtime": "^1.1.4" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.0-rc.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.16.tgz", + "integrity": "sha512-tj7XRemQcOcFwv7qhpUxMTBbI5mWMlE4c1Omhg5+h8GuLXzyj8HviYgR+bB2DMDgRqUE+jiDleqSCRjx4aYk/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.0-rc.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.16.tgz", + "integrity": "sha512-PH5DRZT+F4f2PTXRXR8uJxnBq2po/xFtddyabTJVJs/ZYVHqXPEgNIr35IHTEa6bpa0Q8Awg+ymkTaGnKITw4g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.13", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.13.tgz", + "integrity": "sha512-3ngTAv6F/Py35BsYbeeLeecvhMKdsKm4AoOETVhAA+Qc8nrA2I0kF7oa93mE9qnIurngOSpMnQ0x2nQY2FPviA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", + "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@vitejs/plugin-vue": { + "version": "6.0.6", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.6.tgz", + "integrity": "sha512-u9HHgfrq3AjXlysn0eINFnWQOJQLO9WN6VprZ8FXl7A2bYisv3Hui9Ij+7QZ41F/WYWarHjwBbXtD7dKg3uxbg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "1.0.0-rc.13" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.32.tgz", + "integrity": "sha512-4x74Tbtqnda8s/NSD6e1Dr5p1c8HdMU5RWSjMSUzb8RTcUQqevDCxVAitcLBKT+ie3o0Dl9crc/S/opJM7qBGQ==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.2", + "@vue/shared": "3.5.32", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.32.tgz", + "integrity": "sha512-ybHAu70NtiEI1fvAUz3oXZqkUYEe5J98GjMDpTGl5iHb0T15wQYLR4wE3h9xfuTNA+Cm2f4czfe8B4s+CCH57Q==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.32", + "@vue/shared": "3.5.32" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.32.tgz", + "integrity": "sha512-8UYUYo71cP/0YHMO814TRZlPuUUw3oifHuMR7Wp9SNoRSrxRQnhMLNlCeaODNn6kNTJsjFoQ/kqIj4qGvya4Xg==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.2", + "@vue/compiler-core": "3.5.32", + "@vue/compiler-dom": "3.5.32", + "@vue/compiler-ssr": "3.5.32", + "@vue/shared": "3.5.32", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.8", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.32.tgz", + "integrity": "sha512-Gp4gTs22T3DgRotZ8aA/6m2jMR+GMztvBXUBEUOYOcST+giyGWJ4WvFd7QLHBkzTxkfOt8IELKNdpzITLbA2rw==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.32", + "@vue/shared": "3.5.32" + } + }, + "node_modules/@vue/devtools-api": { + "version": "6.6.4", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz", + "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==", + "license": "MIT" + }, + "node_modules/@vue/reactivity": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.32.tgz", + "integrity": "sha512-/ORasxSGvZ6MN5gc+uE364SxFdJ0+WqVG0CENXaGW58TOCdrAW76WWaplDtECeS1qphvtBZtR+3/o1g1zL4xPQ==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.32" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.32.tgz", + "integrity": "sha512-pDrXCejn4UpFDFmMd27AcJEbHaLemaE5o4pbb7sLk79SRIhc6/t34BQA7SGNgYtbMnvbF/HHOftYBgFJtUoJUQ==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.32", + "@vue/shared": "3.5.32" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.32.tgz", + "integrity": "sha512-1CDVv7tv/IV13V8Nip1k/aaObVbWqRlVCVezTwx3K07p7Vxossp5JU1dcPNhJk3w347gonIUT9jQOGutyJrSVQ==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.32", + "@vue/runtime-core": "3.5.32", + "@vue/shared": "3.5.32", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.32.tgz", + "integrity": "sha512-IOjm2+JQwRFS7W28HNuJeXQle9KdZbODFY7hFGVtnnghF51ta20EWAZJHX+zLGtsHhaU6uC9BGPV52KVpYryMQ==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.32", + "@vue/shared": "3.5.32" + }, + "peerDependencies": { + "vue": "3.5.32" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.32.tgz", + "integrity": "sha512-ksNyrmRQzWJJ8n3cRDuSF7zNNontuJg1YHnmWRJd2AMu8Ij2bqwiiri2lH5rHtYPZjj4STkNcgcmiQqlOjiYGg==", + "license": "MIT" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.15.1.tgz", + "integrity": "sha512-WOG+Jj8ZOvR0a3rAn+Tuf1UQJRxw5venr6DgdbJzngJE3qG7X0kL83CZGpdHMxEm+ZK3seAbvFsw4FfOfP9vxg==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.10", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz", + "integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/rolldown": { + "version": "1.0.0-rc.16", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.16.tgz", + "integrity": "sha512-rzi5WqKzEZw3SooTt7cgm4eqIoujPIyGcJNGFL7iPEuajQw7vxMHUkXylu4/vhCkJGXsgRmxqMKXUpT6FEgl0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.126.0", + "@rolldown/pluginutils": "1.0.0-rc.16" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.0-rc.16", + "@rolldown/binding-darwin-arm64": "1.0.0-rc.16", + "@rolldown/binding-darwin-x64": "1.0.0-rc.16", + "@rolldown/binding-freebsd-x64": "1.0.0-rc.16", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.16", + "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.16", + "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.16", + "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.16", + "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.16", + "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.16", + "@rolldown/binding-linux-x64-musl": "1.0.0-rc.16", + "@rolldown/binding-openharmony-arm64": "1.0.0-rc.16", + "@rolldown/binding-wasm32-wasi": "1.0.0-rc.16", + "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.16", + "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.16" + } + }, + "node_modules/rolldown/node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.16", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.16.tgz", + "integrity": "sha512-45+YtqxLYKDWQouLKCrpIZhke+nXxhsw+qAHVzHDVwttyBlHNBVs2K25rDXrZzhpTp9w1FlAlvweV1H++fdZoA==", + "dev": true, + "license": "MIT" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/vite": { + "version": "8.0.9", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.9.tgz", + "integrity": "sha512-t7g7GVRpMXjNpa67HaVWI/8BWtdVIQPCL2WoozXXA7LBGEFK4AkkKkHx2hAQf5x1GZSlcmEDPkVLSGahxnEEZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.10", + "rolldown": "1.0.0-rc.16", + "tinyglobby": "^0.2.16" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vue": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.32.tgz", + "integrity": "sha512-vM4z4Q9tTafVfMAK7IVzmxg34rSzTFMyIe0UUEijUCkn9+23lj0WRfA83dg7eQZIUlgOSGrkViIaCfqSAUXsMw==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.32", + "@vue/compiler-sfc": "3.5.32", + "@vue/runtime-dom": "3.5.32", + "@vue/server-renderer": "3.5.32", + "@vue/shared": "3.5.32" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-router": { + "version": "4.6.4", + "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz", + "integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^6.6.4" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..7fd7a7b --- /dev/null +++ b/frontend/package.json @@ -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" + } +} diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg new file mode 100644 index 0000000..6893eb1 --- /dev/null +++ b/frontend/public/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/icons.svg b/frontend/public/icons.svg new file mode 100644 index 0000000..e952219 --- /dev/null +++ b/frontend/public/icons.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/App.vue b/frontend/src/App.vue new file mode 100644 index 0000000..64e1edd --- /dev/null +++ b/frontend/src/App.vue @@ -0,0 +1,78 @@ + + + + + diff --git a/frontend/src/api/index.js b/frontend/src/api/index.js new file mode 100644 index 0000000..4fa6e3c --- /dev/null +++ b/frontend/src/api/index.js @@ -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 diff --git a/frontend/src/assets/hero.png b/frontend/src/assets/hero.png new file mode 100644 index 0000000000000000000000000000000000000000..02251f4b956c55af2d76fd0788124d7eee2b45eb GIT binary patch literal 13057 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 P00000NkvXXu0mjft=yBf literal 0 HcmV?d00001 diff --git a/frontend/src/assets/styles/global.css b/frontend/src/assets/styles/global.css new file mode 100644 index 0000000..7deea0c --- /dev/null +++ b/frontend/src/assets/styles/global.css @@ -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); } +} diff --git a/frontend/src/assets/vite.svg b/frontend/src/assets/vite.svg new file mode 100644 index 0000000..5101b67 --- /dev/null +++ b/frontend/src/assets/vite.svg @@ -0,0 +1 @@ +Vite diff --git a/frontend/src/assets/vue.svg b/frontend/src/assets/vue.svg new file mode 100644 index 0000000..770e9d3 --- /dev/null +++ b/frontend/src/assets/vue.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/components/AppHeader.vue b/frontend/src/components/AppHeader.vue new file mode 100644 index 0000000..7111c3d --- /dev/null +++ b/frontend/src/components/AppHeader.vue @@ -0,0 +1,22 @@ + + + diff --git a/frontend/src/components/AppStepper.vue b/frontend/src/components/AppStepper.vue new file mode 100644 index 0000000..58dae09 --- /dev/null +++ b/frontend/src/components/AppStepper.vue @@ -0,0 +1,30 @@ + + + diff --git a/frontend/src/components/BottomNav.vue b/frontend/src/components/BottomNav.vue new file mode 100644 index 0000000..936db7d --- /dev/null +++ b/frontend/src/components/BottomNav.vue @@ -0,0 +1,37 @@ + + + diff --git a/frontend/src/components/SettingsModal.vue b/frontend/src/components/SettingsModal.vue new file mode 100644 index 0000000..d1d548e --- /dev/null +++ b/frontend/src/components/SettingsModal.vue @@ -0,0 +1,191 @@ + + + + + diff --git a/frontend/src/components/Toast.vue b/frontend/src/components/Toast.vue new file mode 100644 index 0000000..8705987 --- /dev/null +++ b/frontend/src/components/Toast.vue @@ -0,0 +1,30 @@ + + + + + diff --git a/frontend/src/composables/useToast.js b/frontend/src/composables/useToast.js new file mode 100644 index 0000000..6855ac1 --- /dev/null +++ b/frontend/src/composables/useToast.js @@ -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 } +} diff --git a/frontend/src/main.js b/frontend/src/main.js new file mode 100644 index 0000000..f96bbff --- /dev/null +++ b/frontend/src/main.js @@ -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') diff --git a/frontend/src/router/index.js b/frontend/src/router/index.js new file mode 100644 index 0000000..5ff2013 --- /dev/null +++ b/frontend/src/router/index.js @@ -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 diff --git a/frontend/src/views/ExportView.vue b/frontend/src/views/ExportView.vue new file mode 100644 index 0000000..cdd4b6d --- /dev/null +++ b/frontend/src/views/ExportView.vue @@ -0,0 +1,316 @@ + + + + + diff --git a/frontend/src/views/ExtractView.vue b/frontend/src/views/ExtractView.vue new file mode 100644 index 0000000..236d794 --- /dev/null +++ b/frontend/src/views/ExtractView.vue @@ -0,0 +1,410 @@ + + + + + diff --git a/frontend/src/views/MatchView.vue b/frontend/src/views/MatchView.vue new file mode 100644 index 0000000..07dea28 --- /dev/null +++ b/frontend/src/views/MatchView.vue @@ -0,0 +1,288 @@ + + + diff --git a/frontend/src/views/UploadView.vue b/frontend/src/views/UploadView.vue new file mode 100644 index 0000000..36c9d6e --- /dev/null +++ b/frontend/src/views/UploadView.vue @@ -0,0 +1,292 @@ + + + + + diff --git a/frontend/src/views/WelcomeView.vue b/frontend/src/views/WelcomeView.vue new file mode 100644 index 0000000..c8fc101 --- /dev/null +++ b/frontend/src/views/WelcomeView.vue @@ -0,0 +1,40 @@ + + + diff --git a/frontend/vite.config.js b/frontend/vite.config.js new file mode 100644 index 0000000..d62dc96 --- /dev/null +++ b/frontend/vite.config.js @@ -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, + } + } + } +}) diff --git a/run.py b/run.py new file mode 100644 index 0000000..7a0d20b --- /dev/null +++ b/run.py @@ -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() diff --git a/test_pipeline.py b/test_pipeline.py new file mode 100644 index 0000000..8c1c3d8 --- /dev/null +++ b/test_pipeline.py @@ -0,0 +1,733 @@ +""" +故事提取流程 v4 - 逐步测试脚本 +用法:python test_pipeline.py [步骤编号] + 步骤编号: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 [步骤编号]") + 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())