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

View File

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

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

View File

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

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

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

View File

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

View File

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

View File

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