657 lines
22 KiB
Python
657 lines
22 KiB
Python
"""大纲管理API"""
|
|
from fastapi import APIRouter, Depends, HTTPException
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
from sqlalchemy import select, func, delete
|
|
from typing import List
|
|
import json
|
|
|
|
from app.database import get_db
|
|
from app.models.outline import Outline
|
|
from app.models.project import Project
|
|
from app.models.chapter import Chapter
|
|
from app.models.character import Character
|
|
from app.models.generation_history import GenerationHistory
|
|
from app.schemas.outline import (
|
|
OutlineCreate,
|
|
OutlineUpdate,
|
|
OutlineResponse,
|
|
OutlineListResponse,
|
|
OutlineGenerateRequest,
|
|
OutlineReorderRequest
|
|
)
|
|
from app.services.ai_service import ai_service
|
|
from app.services.prompt_service import prompt_service
|
|
from app.logger import get_logger
|
|
|
|
router = APIRouter(prefix="/outlines", tags=["大纲管理"])
|
|
logger = get_logger(__name__)
|
|
|
|
|
|
@router.post("", response_model=OutlineResponse, summary="创建大纲")
|
|
async def create_outline(
|
|
outline: OutlineCreate,
|
|
db: AsyncSession = Depends(get_db)
|
|
):
|
|
"""创建新的章节大纲,同时创建对应的章节记录"""
|
|
# 验证项目是否存在
|
|
result = await db.execute(
|
|
select(Project).where(Project.id == outline.project_id)
|
|
)
|
|
project = result.scalar_one_or_none()
|
|
if not project:
|
|
raise HTTPException(status_code=404, detail="项目不存在")
|
|
|
|
# 创建大纲
|
|
db_outline = Outline(**outline.model_dump())
|
|
db.add(db_outline)
|
|
|
|
# 同步创建对应的章节记录
|
|
chapter = Chapter(
|
|
project_id=outline.project_id,
|
|
chapter_number=outline.order_index,
|
|
title=outline.title,
|
|
summary=outline.content[:500] if len(outline.content) > 500 else outline.content,
|
|
status="draft"
|
|
)
|
|
db.add(chapter)
|
|
|
|
await db.commit()
|
|
await db.refresh(db_outline)
|
|
return db_outline
|
|
|
|
|
|
@router.get("", response_model=OutlineListResponse, summary="获取大纲列表")
|
|
async def get_outlines(
|
|
project_id: str,
|
|
db: AsyncSession = Depends(get_db)
|
|
):
|
|
"""获取指定项目的所有大纲"""
|
|
# 获取总数
|
|
count_result = await db.execute(
|
|
select(func.count(Outline.id)).where(Outline.project_id == project_id)
|
|
)
|
|
total = count_result.scalar_one()
|
|
|
|
# 获取大纲列表
|
|
result = await db.execute(
|
|
select(Outline)
|
|
.where(Outline.project_id == project_id)
|
|
.order_by(Outline.order_index)
|
|
)
|
|
outlines = result.scalars().all()
|
|
|
|
return OutlineListResponse(total=total, items=outlines)
|
|
|
|
|
|
@router.get("/project/{project_id}", response_model=OutlineListResponse, summary="获取项目的所有大纲")
|
|
async def get_project_outlines(
|
|
project_id: str,
|
|
db: AsyncSession = Depends(get_db)
|
|
):
|
|
"""获取指定项目的所有大纲(路径参数版本)"""
|
|
# 获取总数
|
|
count_result = await db.execute(
|
|
select(func.count(Outline.id)).where(Outline.project_id == project_id)
|
|
)
|
|
total = count_result.scalar_one()
|
|
|
|
# 获取大纲列表
|
|
result = await db.execute(
|
|
select(Outline)
|
|
.where(Outline.project_id == project_id)
|
|
.order_by(Outline.order_index)
|
|
)
|
|
outlines = result.scalars().all()
|
|
|
|
return OutlineListResponse(total=total, items=outlines)
|
|
|
|
|
|
@router.get("/{outline_id}", response_model=OutlineResponse, summary="获取大纲详情")
|
|
async def get_outline(
|
|
outline_id: str,
|
|
db: AsyncSession = Depends(get_db)
|
|
):
|
|
"""根据ID获取大纲详情"""
|
|
result = await db.execute(
|
|
select(Outline).where(Outline.id == outline_id)
|
|
)
|
|
outline = result.scalar_one_or_none()
|
|
|
|
if not outline:
|
|
raise HTTPException(status_code=404, detail="大纲不存在")
|
|
|
|
return outline
|
|
|
|
|
|
@router.put("/{outline_id}", response_model=OutlineResponse, summary="更新大纲")
|
|
async def update_outline(
|
|
outline_id: str,
|
|
outline_update: OutlineUpdate,
|
|
db: AsyncSession = Depends(get_db)
|
|
):
|
|
"""更新大纲信息,同步更新对应章节和structure字段"""
|
|
result = await db.execute(
|
|
select(Outline).where(Outline.id == outline_id)
|
|
)
|
|
outline = result.scalar_one_or_none()
|
|
|
|
if not outline:
|
|
raise HTTPException(status_code=404, detail="大纲不存在")
|
|
|
|
# 更新字段
|
|
update_data = outline_update.model_dump(exclude_unset=True)
|
|
for field, value in update_data.items():
|
|
setattr(outline, field, value)
|
|
|
|
# 如果修改了content或title,同步更新structure字段
|
|
if 'content' in update_data or 'title' in update_data:
|
|
try:
|
|
# 尝试解析现有的structure
|
|
if outline.structure:
|
|
structure_data = json.loads(outline.structure)
|
|
else:
|
|
structure_data = {}
|
|
|
|
# 更新structure中的对应字段
|
|
if 'title' in update_data:
|
|
structure_data['title'] = outline.title
|
|
if 'content' in update_data:
|
|
structure_data['summary'] = outline.content
|
|
structure_data['content'] = outline.content
|
|
|
|
# 保存更新后的structure
|
|
outline.structure = json.dumps(structure_data, ensure_ascii=False)
|
|
logger.info(f"同步更新大纲 {outline_id} 的structure字段")
|
|
except json.JSONDecodeError:
|
|
logger.warning(f"大纲 {outline_id} 的structure字段格式错误,跳过更新")
|
|
|
|
# 同步更新对应的章节标题和摘要
|
|
if 'title' in update_data or 'content' in update_data:
|
|
chapter_result = await db.execute(
|
|
select(Chapter).where(
|
|
Chapter.project_id == outline.project_id,
|
|
Chapter.chapter_number == outline.order_index
|
|
)
|
|
)
|
|
chapter = chapter_result.scalar_one_or_none()
|
|
|
|
if chapter:
|
|
if 'title' in update_data:
|
|
chapter.title = outline.title
|
|
if 'content' in update_data:
|
|
# 更新章节摘要(取content前500字符)
|
|
chapter.summary = outline.content[:500] if len(outline.content) > 500 else outline.content
|
|
logger.info(f"同步更新章节 {chapter.id} 的标题和摘要")
|
|
else:
|
|
logger.warning(f"未找到对应的章节记录 (order_index={outline.order_index})")
|
|
|
|
await db.commit()
|
|
await db.refresh(outline)
|
|
return outline
|
|
|
|
|
|
@router.delete("/{outline_id}", summary="删除大纲")
|
|
async def delete_outline(
|
|
outline_id: str,
|
|
db: AsyncSession = Depends(get_db)
|
|
):
|
|
"""删除大纲,同步删除章节,并重新排序后续项"""
|
|
result = await db.execute(
|
|
select(Outline).where(Outline.id == outline_id)
|
|
)
|
|
outline = result.scalar_one_or_none()
|
|
|
|
if not outline:
|
|
raise HTTPException(status_code=404, detail="大纲不存在")
|
|
|
|
project_id = outline.project_id
|
|
deleted_order = outline.order_index
|
|
|
|
# 删除对应的章节
|
|
await db.execute(
|
|
delete(Chapter).where(
|
|
Chapter.project_id == project_id,
|
|
Chapter.chapter_number == deleted_order
|
|
)
|
|
)
|
|
|
|
# 删除大纲
|
|
await db.delete(outline)
|
|
|
|
# 重新排序后续的大纲和章节(序号-1)
|
|
result = await db.execute(
|
|
select(Outline).where(
|
|
Outline.project_id == project_id,
|
|
Outline.order_index > deleted_order
|
|
)
|
|
)
|
|
subsequent_outlines = result.scalars().all()
|
|
|
|
for o in subsequent_outlines:
|
|
old_order = o.order_index
|
|
o.order_index -= 1
|
|
|
|
# 同步更新对应的章节
|
|
chapter_result = await db.execute(
|
|
select(Chapter).where(
|
|
Chapter.project_id == project_id,
|
|
Chapter.chapter_number == old_order
|
|
)
|
|
)
|
|
chapter = chapter_result.scalar_one_or_none()
|
|
if chapter:
|
|
chapter.chapter_number = old_order - 1
|
|
|
|
await db.commit()
|
|
|
|
return {"message": "大纲删除成功"}
|
|
|
|
|
|
@router.post("/reorder", summary="批量重排序大纲")
|
|
async def reorder_outlines(
|
|
request: OutlineReorderRequest,
|
|
db: AsyncSession = Depends(get_db)
|
|
):
|
|
"""
|
|
批量调整大纲顺序,同步更新章节序号
|
|
|
|
策略:先收集所有变更,最后一次性提交,避免临时冲突
|
|
"""
|
|
try:
|
|
# 第一步:收集所有大纲和对应的章节
|
|
outline_chapter_map = {} # {outline_id: (outline, chapter, old_order, new_order)}
|
|
|
|
for item in request.orders:
|
|
outline_id = item.id
|
|
new_order = item.order_index
|
|
|
|
# 获取大纲
|
|
result = await db.execute(
|
|
select(Outline).where(Outline.id == outline_id)
|
|
)
|
|
outline = result.scalar_one_or_none()
|
|
|
|
if not outline:
|
|
logger.warning(f"大纲 {outline_id} 不存在,跳过")
|
|
continue
|
|
|
|
old_order = outline.order_index
|
|
|
|
# 获取对应的章节(通过旧的chapter_number匹配)
|
|
chapter_result = await db.execute(
|
|
select(Chapter).where(
|
|
Chapter.project_id == outline.project_id,
|
|
Chapter.chapter_number == old_order
|
|
)
|
|
)
|
|
chapter = chapter_result.first()
|
|
chapter_obj = chapter[0] if chapter else None
|
|
|
|
outline_chapter_map[outline_id] = (outline, chapter_obj, old_order, new_order)
|
|
|
|
# 第二步:批量更新所有大纲和章节
|
|
updated_outlines = 0
|
|
updated_chapters = 0
|
|
|
|
for outline_id, (outline, chapter, old_order, new_order) in outline_chapter_map.items():
|
|
# 更新大纲
|
|
outline.order_index = new_order
|
|
updated_outlines += 1
|
|
|
|
# 更新章节
|
|
if chapter:
|
|
chapter.chapter_number = new_order
|
|
chapter.title = outline.title # 同步更新标题
|
|
updated_chapters += 1
|
|
else:
|
|
logger.warning(f"章节 {old_order} 不存在,跳过")
|
|
|
|
# 第三步:一次性提交所有更改
|
|
await db.commit()
|
|
|
|
logger.info(f"重排序成功:更新了 {updated_outlines} 个大纲,{updated_chapters} 个章节")
|
|
|
|
return {
|
|
"message": "重排序成功",
|
|
"updated_outlines": updated_outlines,
|
|
"updated_chapters": updated_chapters
|
|
}
|
|
|
|
except Exception as e:
|
|
await db.rollback()
|
|
logger.error(f"重排序失败: {str(e)}")
|
|
raise HTTPException(status_code=500, detail=f"重排序失败: {str(e)}")
|
|
|
|
|
|
@router.post("/generate", response_model=OutlineListResponse, summary="AI生成/续写大纲")
|
|
async def generate_outline(
|
|
request: OutlineGenerateRequest,
|
|
db: AsyncSession = Depends(get_db)
|
|
):
|
|
"""
|
|
使用AI生成或续写小说大纲 - 智能模式
|
|
|
|
支持三种模式:
|
|
- auto: 自动判断(无大纲→新建,有大纲→续写)
|
|
- new: 强制全新生成
|
|
- continue: 强制续写模式
|
|
"""
|
|
# 验证项目是否存在
|
|
result = await db.execute(
|
|
select(Project).where(Project.id == request.project_id)
|
|
)
|
|
project = result.scalar_one_or_none()
|
|
if not project:
|
|
raise HTTPException(status_code=404, detail="项目不存在")
|
|
|
|
try:
|
|
# 获取现有大纲(强制从数据库获取最新数据,包括用户手动修改的内容)
|
|
existing_result = await db.execute(
|
|
select(Outline)
|
|
.where(Outline.project_id == request.project_id)
|
|
.order_by(Outline.order_index)
|
|
.execution_options(populate_existing=True)
|
|
)
|
|
existing_outlines = existing_result.scalars().all()
|
|
|
|
# 判断实际执行模式
|
|
actual_mode = request.mode
|
|
if actual_mode == "auto":
|
|
actual_mode = "continue" if existing_outlines else "new"
|
|
logger.info(f"自动判断模式:{'续写' if existing_outlines else '新建'}")
|
|
|
|
# 模式:全新生成
|
|
if actual_mode == "new":
|
|
return await _generate_new_outline(
|
|
request, project, db
|
|
)
|
|
|
|
# 模式:续写
|
|
elif actual_mode == "continue":
|
|
if not existing_outlines:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail="续写模式需要已有大纲,当前项目没有大纲"
|
|
)
|
|
|
|
return await _continue_outline(
|
|
request, project, existing_outlines, db
|
|
)
|
|
|
|
else:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail=f"不支持的模式: {request.mode}"
|
|
)
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"生成大纲失败: {str(e)}")
|
|
raise HTTPException(status_code=500, detail=f"生成大纲失败: {str(e)}")
|
|
|
|
|
|
async def _generate_new_outline(
|
|
request: OutlineGenerateRequest,
|
|
project: Project,
|
|
db: AsyncSession
|
|
) -> OutlineListResponse:
|
|
"""全新生成大纲"""
|
|
logger.info(f"全新生成大纲 - 项目: {project.id}, keep_existing: {request.keep_existing}")
|
|
|
|
# 获取角色信息
|
|
characters_result = await db.execute(
|
|
select(Character).where(Character.project_id == project.id)
|
|
)
|
|
characters = characters_result.scalars().all()
|
|
characters_info = "\n".join([
|
|
f"- {char.name} ({'组织' if char.is_organization else '角色'}, {char.role_type}): "
|
|
f"{char.personality[:100] if char.personality else '暂无描述'}"
|
|
for char in characters
|
|
])
|
|
|
|
# 使用完整提示词
|
|
prompt = prompt_service.get_complete_outline_prompt(
|
|
title=project.title,
|
|
theme=request.theme or project.theme or "未设定",
|
|
genre=request.genre or project.genre or "通用",
|
|
chapter_count=request.chapter_count,
|
|
narrative_perspective=request.narrative_perspective,
|
|
target_words=request.target_words,
|
|
time_period=project.world_time_period or "未设定",
|
|
location=project.world_location or "未设定",
|
|
atmosphere=project.world_atmosphere or "未设定",
|
|
rules=project.world_rules or "未设定",
|
|
characters_info=characters_info or "暂无角色信息",
|
|
requirements=request.requirements or ""
|
|
)
|
|
|
|
# 调用AI
|
|
ai_response = await ai_service.generate_text(
|
|
prompt=prompt,
|
|
provider=request.provider,
|
|
model=request.model
|
|
)
|
|
|
|
# 解析响应
|
|
outline_data = _parse_ai_response(ai_response)
|
|
|
|
# 全新生成模式:必须删除旧大纲和章节
|
|
# 注意:这是"new"模式的核心逻辑,应该始终删除旧数据
|
|
logger.info(f"删除项目 {project.id} 的旧大纲和章节")
|
|
await db.execute(
|
|
delete(Outline).where(Outline.project_id == project.id)
|
|
)
|
|
await db.execute(
|
|
delete(Chapter).where(Chapter.project_id == project.id)
|
|
)
|
|
|
|
# 保存新大纲
|
|
outlines = await _save_outlines(
|
|
project.id, outline_data, db, start_index=1
|
|
)
|
|
|
|
# 记录历史
|
|
history = GenerationHistory(
|
|
project_id=project.id,
|
|
prompt=prompt,
|
|
generated_content=ai_response,
|
|
model=request.model or "default"
|
|
)
|
|
db.add(history)
|
|
|
|
await db.commit()
|
|
|
|
for outline in outlines:
|
|
await db.refresh(outline)
|
|
|
|
logger.info(f"全新生成完成 - {len(outlines)} 章")
|
|
return OutlineListResponse(total=len(outlines), items=outlines)
|
|
|
|
|
|
async def _continue_outline(
|
|
request: OutlineGenerateRequest,
|
|
project: Project,
|
|
existing_outlines: List[Outline],
|
|
db: AsyncSession
|
|
) -> OutlineListResponse:
|
|
"""续写大纲"""
|
|
logger.info(f"续写大纲 - 项目: {project.id}, 已有: {len(existing_outlines)} 章")
|
|
|
|
# 分析已有大纲
|
|
current_chapter_count = len(existing_outlines)
|
|
last_chapter_number = existing_outlines[-1].order_index
|
|
|
|
# 获取最近2章的剧情
|
|
recent_outlines = existing_outlines[-2:] if len(existing_outlines) >= 2 else existing_outlines
|
|
recent_plot = "\n".join([
|
|
f"第{o.order_index}章《{o.title}》: {o.content}"
|
|
for o in recent_outlines
|
|
])
|
|
# logger.debug(f"最近三章内容:{recent_plot}")
|
|
# 全部章节概览
|
|
all_chapters_brief = "\n".join([
|
|
f"第{o.order_index}章: {o.title}"
|
|
for o in existing_outlines
|
|
])
|
|
|
|
# 获取角色信息
|
|
characters_result = await db.execute(
|
|
select(Character).where(Character.project_id == project.id)
|
|
)
|
|
characters = characters_result.scalars().all()
|
|
characters_info = "\n".join([
|
|
f"- {char.name} ({'组织' if char.is_organization else '角色'}, {char.role_type}): "
|
|
f"{char.personality[:100] if char.personality else '暂无描述'}"
|
|
for char in characters
|
|
])
|
|
|
|
# 情节阶段指导
|
|
stage_instructions = {
|
|
"development": "继续展开情节,深化角色关系,推进主线冲突",
|
|
"climax": "进入故事高潮,矛盾激化,关键冲突爆发",
|
|
"ending": "解决主要冲突,收束伏笔,给出结局"
|
|
}
|
|
stage_instruction = stage_instructions.get(request.plot_stage, "")
|
|
|
|
# 使用标准续写提示词模板
|
|
prompt = prompt_service.get_outline_continue_prompt(
|
|
title=project.title,
|
|
theme=request.theme or project.theme or "未设定",
|
|
genre=request.genre or project.genre or "通用",
|
|
narrative_perspective=request.narrative_perspective,
|
|
chapter_count=request.chapter_count,
|
|
time_period=project.world_time_period or "未设定",
|
|
location=project.world_location or "未设定",
|
|
atmosphere=project.world_atmosphere or "未设定",
|
|
rules=project.world_rules or "未设定",
|
|
characters_info=characters_info or "暂无角色信息",
|
|
current_chapter_count=current_chapter_count,
|
|
all_chapters_brief=all_chapters_brief,
|
|
recent_plot=recent_plot,
|
|
plot_stage_instruction=stage_instruction,
|
|
start_chapter=last_chapter_number + 1,
|
|
story_direction=request.story_direction or "自然延续",
|
|
requirements=request.requirements or ""
|
|
)
|
|
|
|
# 调用AI
|
|
ai_response = await ai_service.generate_text(
|
|
prompt=prompt,
|
|
provider=request.provider,
|
|
model=request.model
|
|
)
|
|
|
|
# 解析响应
|
|
outline_data = _parse_ai_response(ai_response)
|
|
|
|
# 保存续写的大纲
|
|
new_outlines = await _save_outlines(
|
|
project.id, outline_data, db, start_index=last_chapter_number + 1
|
|
)
|
|
|
|
# 记录历史
|
|
history = GenerationHistory(
|
|
project_id=project.id,
|
|
prompt=prompt,
|
|
generated_content=ai_response,
|
|
model=request.model or "default"
|
|
)
|
|
db.add(history)
|
|
|
|
await db.commit()
|
|
|
|
for outline in new_outlines:
|
|
await db.refresh(outline)
|
|
|
|
# 返回所有大纲(包括旧的和新的)
|
|
all_result = await db.execute(
|
|
select(Outline)
|
|
.where(Outline.project_id == project.id)
|
|
.order_by(Outline.order_index)
|
|
)
|
|
all_outlines = all_result.scalars().all()
|
|
|
|
logger.info(f"续写完成 - 新增 {len(new_outlines)} 章,总计 {len(all_outlines)} 章")
|
|
return OutlineListResponse(total=len(all_outlines), items=all_outlines)
|
|
|
|
|
|
def _parse_ai_response(ai_response: str) -> list:
|
|
"""解析AI响应为章节数据列表"""
|
|
try:
|
|
# 清理响应文本
|
|
cleaned_text = ai_response.strip()
|
|
if cleaned_text.startswith('```json'):
|
|
cleaned_text = cleaned_text[7:]
|
|
if cleaned_text.startswith('```'):
|
|
cleaned_text = cleaned_text[3:]
|
|
if cleaned_text.endswith('```'):
|
|
cleaned_text = cleaned_text[:-3]
|
|
cleaned_text = cleaned_text.strip()
|
|
|
|
outline_data = json.loads(cleaned_text)
|
|
|
|
# 确保是列表格式
|
|
if not isinstance(outline_data, list):
|
|
# 如果是对象,尝试提取chapters字段
|
|
if isinstance(outline_data, dict):
|
|
outline_data = outline_data.get("chapters", [outline_data])
|
|
else:
|
|
outline_data = [outline_data]
|
|
|
|
return outline_data
|
|
|
|
except json.JSONDecodeError as e:
|
|
logger.error(f"AI响应解析失败: {e}")
|
|
# 返回一个包含原始内容的章节
|
|
return [{
|
|
"title": "AI生成的大纲",
|
|
"content": ai_response[:1000],
|
|
"summary": ai_response[:1000]
|
|
}]
|
|
|
|
|
|
async def _save_outlines(
|
|
project_id: str,
|
|
outline_data: list,
|
|
db: AsyncSession,
|
|
start_index: int = 1
|
|
) -> List[Outline]:
|
|
"""保存大纲到数据库"""
|
|
outlines = []
|
|
|
|
for idx, chapter_data in enumerate(outline_data):
|
|
order_idx = chapter_data.get("chapter_number", start_index + idx)
|
|
title = chapter_data.get("title", f"第{order_idx}章")
|
|
|
|
# 优先使用summary,其次content
|
|
content = chapter_data.get("summary") or chapter_data.get("content", "")
|
|
|
|
# 如果有额外信息,添加到内容中
|
|
if "key_events" in chapter_data:
|
|
content += f"\n\n关键事件:" + "、".join(chapter_data["key_events"])
|
|
if "characters_involved" in chapter_data:
|
|
content += f"\n涉及角色:" + "、".join(chapter_data["characters_involved"])
|
|
|
|
# 创建大纲
|
|
outline = Outline(
|
|
project_id=project_id,
|
|
title=title,
|
|
content=content,
|
|
structure=json.dumps(chapter_data, ensure_ascii=False),
|
|
order_index=order_idx
|
|
)
|
|
db.add(outline)
|
|
outlines.append(outline)
|
|
|
|
# 同步创建章节记录
|
|
chapter = Chapter(
|
|
project_id=project_id,
|
|
chapter_number=order_idx,
|
|
title=title,
|
|
summary=content[:500] if len(content) > 500 else content,
|
|
status="draft"
|
|
)
|
|
db.add(chapter)
|
|
|
|
return outlines |