feat: 初始提交
This commit is contained in:
301
backend/app/api/routers/novels.py
Normal file
301
backend/app/api/routers/novels.py
Normal file
@@ -0,0 +1,301 @@
|
||||
import json
|
||||
import logging
|
||||
from typing import Dict, List
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from ...core.dependencies import get_current_user
|
||||
from ...db.session import get_session
|
||||
from ...schemas.novel import (
|
||||
Blueprint,
|
||||
BlueprintGenerationResponse,
|
||||
BlueprintPatch,
|
||||
Chapter as ChapterSchema,
|
||||
ConverseRequest,
|
||||
ConverseResponse,
|
||||
NovelProject as NovelProjectSchema,
|
||||
NovelProjectSummary,
|
||||
NovelSectionResponse,
|
||||
NovelSectionType,
|
||||
)
|
||||
from ...schemas.user import UserInDB
|
||||
from ...services.llm_service import LLMService
|
||||
from ...services.novel_service import NovelService
|
||||
from ...services.prompt_service import PromptService
|
||||
from ...utils.json_utils import remove_think_tags, unwrap_markdown_json
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/novels", tags=["Novels"])
|
||||
|
||||
JSON_RESPONSE_INSTRUCTION = """
|
||||
IMPORTANT: 你的回复必须是合法的 JSON 对象,并严格包含以下字段:
|
||||
{
|
||||
"ai_message": "string",
|
||||
"ui_control": {
|
||||
"type": "single_choice | text_input | info_display",
|
||||
"options": [
|
||||
{"id": "option_1", "label": "string"}
|
||||
],
|
||||
"placeholder": "string"
|
||||
},
|
||||
"conversation_state": {},
|
||||
"is_complete": false
|
||||
}
|
||||
不要输出额外的文本或解释。
|
||||
"""
|
||||
|
||||
|
||||
def _ensure_prompt(prompt: str | None, name: str) -> str:
|
||||
if not prompt:
|
||||
raise HTTPException(status_code=500, detail=f"未配置名为 {name} 的提示词,请联系管理员")
|
||||
return prompt
|
||||
|
||||
|
||||
@router.post("", response_model=NovelProjectSchema, status_code=status.HTTP_201_CREATED)
|
||||
async def create_novel(
|
||||
title: str = Body(...),
|
||||
initial_prompt: str = Body(...),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
current_user: UserInDB = Depends(get_current_user),
|
||||
) -> NovelProjectSchema:
|
||||
"""为当前用户创建一个新的小说项目。"""
|
||||
novel_service = NovelService(session)
|
||||
project = await novel_service.create_project(current_user.id, title, initial_prompt)
|
||||
logger.info("用户 %s 创建项目 %s", current_user.id, project.id)
|
||||
return await novel_service.get_project_schema(project.id, current_user.id)
|
||||
|
||||
|
||||
@router.get("", response_model=List[NovelProjectSummary])
|
||||
async def list_novels(
|
||||
session: AsyncSession = Depends(get_session),
|
||||
current_user: UserInDB = Depends(get_current_user),
|
||||
) -> List[NovelProjectSummary]:
|
||||
"""列出用户的全部小说项目摘要信息。"""
|
||||
novel_service = NovelService(session)
|
||||
projects = await novel_service.list_projects_for_user(current_user.id)
|
||||
logger.info("用户 %s 获取项目列表,共 %s 个", current_user.id, len(projects))
|
||||
return projects
|
||||
|
||||
|
||||
@router.get("/{project_id}", response_model=NovelProjectSchema)
|
||||
async def get_novel(
|
||||
project_id: str,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
current_user: UserInDB = Depends(get_current_user),
|
||||
) -> NovelProjectSchema:
|
||||
novel_service = NovelService(session)
|
||||
logger.info("用户 %s 查询项目 %s", current_user.id, project_id)
|
||||
return await novel_service.get_project_schema(project_id, current_user.id)
|
||||
|
||||
|
||||
@router.get("/{project_id}/sections/{section}", response_model=NovelSectionResponse)
|
||||
async def get_novel_section(
|
||||
project_id: str,
|
||||
section: NovelSectionType,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
current_user: UserInDB = Depends(get_current_user),
|
||||
) -> NovelSectionResponse:
|
||||
novel_service = NovelService(session)
|
||||
logger.info("用户 %s 获取项目 %s 的 %s 区段", current_user.id, project_id, section)
|
||||
return await novel_service.get_section_data(project_id, current_user.id, section)
|
||||
|
||||
|
||||
@router.get("/{project_id}/chapters/{chapter_number}", response_model=ChapterSchema)
|
||||
async def get_chapter(
|
||||
project_id: str,
|
||||
chapter_number: int,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
current_user: UserInDB = Depends(get_current_user),
|
||||
) -> ChapterSchema:
|
||||
novel_service = NovelService(session)
|
||||
logger.info("用户 %s 获取项目 %s 第 %s 章", current_user.id, project_id, chapter_number)
|
||||
return await novel_service.get_chapter_schema(project_id, current_user.id, chapter_number)
|
||||
|
||||
|
||||
@router.delete("", status_code=status.HTTP_200_OK)
|
||||
async def delete_novels(
|
||||
project_ids: List[str] = Body(...),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
current_user: UserInDB = Depends(get_current_user),
|
||||
) -> Dict[str, str]:
|
||||
novel_service = NovelService(session)
|
||||
await novel_service.delete_projects(project_ids, current_user.id)
|
||||
logger.info("用户 %s 删除项目 %s", current_user.id, project_ids)
|
||||
return {"status": "success", "message": f"成功删除 {len(project_ids)} 个项目"}
|
||||
|
||||
|
||||
@router.post("/{project_id}/concept/converse", response_model=ConverseResponse)
|
||||
async def converse_with_concept(
|
||||
project_id: str,
|
||||
request: ConverseRequest,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
current_user: UserInDB = Depends(get_current_user),
|
||||
) -> ConverseResponse:
|
||||
"""与概念设计师(LLM)进行对话,引导蓝图筹备。"""
|
||||
novel_service = NovelService(session)
|
||||
prompt_service = PromptService(session)
|
||||
llm_service = LLMService(session)
|
||||
|
||||
project = await novel_service.ensure_project_owner(project_id, current_user.id)
|
||||
|
||||
history_records = await novel_service.list_conversations(project_id)
|
||||
logger.info(
|
||||
"项目 %s 概念对话请求,用户 %s,历史记录 %s 条",
|
||||
project_id,
|
||||
current_user.id,
|
||||
len(history_records),
|
||||
)
|
||||
conversation_history = [
|
||||
{"role": record.role, "content": record.content}
|
||||
for record in history_records
|
||||
]
|
||||
user_content = json.dumps(request.user_input, ensure_ascii=False)
|
||||
conversation_history.append({"role": "user", "content": user_content})
|
||||
|
||||
system_prompt = _ensure_prompt(await prompt_service.get_prompt("concept"), "concept")
|
||||
system_prompt = f"{system_prompt}\n{JSON_RESPONSE_INSTRUCTION}"
|
||||
|
||||
llm_response = await llm_service.get_llm_response(
|
||||
system_prompt=system_prompt,
|
||||
conversation_history=conversation_history,
|
||||
temperature=0.8,
|
||||
user_id=current_user.id,
|
||||
timeout=240.0,
|
||||
)
|
||||
llm_response = remove_think_tags(llm_response)
|
||||
|
||||
try:
|
||||
normalized = unwrap_markdown_json(llm_response)
|
||||
parsed = json.loads(normalized)
|
||||
except json.JSONDecodeError as exc:
|
||||
logger.exception(
|
||||
"Failed to parse concept converse response: project_id=%s user_id=%s normalized=%s",
|
||||
project_id,
|
||||
current_user.id,
|
||||
normalized,
|
||||
)
|
||||
raise HTTPException(status_code=500, detail="AI 返回内容不是有效的 JSON") from exc
|
||||
|
||||
await novel_service.append_conversation(project_id, "user", user_content)
|
||||
await novel_service.append_conversation(project_id, "assistant", normalized)
|
||||
|
||||
logger.info("项目 %s 概念对话完成,is_complete=%s", project_id, parsed.get("is_complete"))
|
||||
|
||||
if parsed.get("is_complete"):
|
||||
parsed["ready_for_blueprint"] = True
|
||||
|
||||
parsed.setdefault("conversation_state", parsed.get("conversation_state", {}))
|
||||
return ConverseResponse(**parsed)
|
||||
|
||||
|
||||
@router.post("/{project_id}/blueprint/generate", response_model=BlueprintGenerationResponse)
|
||||
async def generate_blueprint(
|
||||
project_id: str,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
current_user: UserInDB = Depends(get_current_user),
|
||||
) -> BlueprintGenerationResponse:
|
||||
"""根据完整对话生成可执行的小说蓝图。"""
|
||||
novel_service = NovelService(session)
|
||||
prompt_service = PromptService(session)
|
||||
llm_service = LLMService(session)
|
||||
|
||||
project = await novel_service.ensure_project_owner(project_id, current_user.id)
|
||||
logger.info("项目 %s 开始生成蓝图", project_id)
|
||||
|
||||
history_records = await novel_service.list_conversations(project_id)
|
||||
if not history_records:
|
||||
raise HTTPException(status_code=400, detail="缺少对话历史,无法生成蓝图")
|
||||
|
||||
formatted_history: List[Dict[str, str]] = []
|
||||
for record in history_records:
|
||||
role = record.role
|
||||
content = record.content
|
||||
if not role or not content:
|
||||
continue
|
||||
try:
|
||||
normalized = unwrap_markdown_json(content)
|
||||
data = json.loads(normalized)
|
||||
if role == "user":
|
||||
user_value = data.get("value", data)
|
||||
if isinstance(user_value, str):
|
||||
formatted_history.append({"role": "user", "content": user_value})
|
||||
elif role == "assistant":
|
||||
ai_message = data.get("ai_message") if isinstance(data, dict) else None
|
||||
if ai_message:
|
||||
formatted_history.append({"role": "assistant", "content": ai_message})
|
||||
except (json.JSONDecodeError, AttributeError):
|
||||
continue
|
||||
|
||||
if not formatted_history:
|
||||
raise HTTPException(status_code=400, detail="无法从历史对话中提取内容")
|
||||
|
||||
system_prompt = _ensure_prompt(await prompt_service.get_prompt("screenwriting"), "screenwriting")
|
||||
blueprint_raw = await llm_service.get_llm_response(
|
||||
system_prompt=system_prompt,
|
||||
conversation_history=formatted_history,
|
||||
temperature=0.3,
|
||||
user_id=current_user.id,
|
||||
timeout=480.0,
|
||||
)
|
||||
blueprint_raw = remove_think_tags(blueprint_raw)
|
||||
|
||||
blueprint_normalized = unwrap_markdown_json(blueprint_raw)
|
||||
try:
|
||||
blueprint_data = json.loads(blueprint_normalized)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise HTTPException(status_code=500, detail="蓝图生成失败,请稍后重试") from exc
|
||||
|
||||
blueprint = Blueprint(**blueprint_data)
|
||||
await novel_service.replace_blueprint(project_id, blueprint)
|
||||
if blueprint.title:
|
||||
project.title = blueprint.title
|
||||
project.status = "blueprint_ready"
|
||||
await session.commit()
|
||||
logger.info("项目 %s 更新标题为 %s,并标记为 blueprint_ready", project_id, blueprint.title)
|
||||
|
||||
ai_message = (
|
||||
"太棒了!我已经根据我们的对话整理出完整的小说蓝图。请确认是否进入写作阶段,或提出修改意见。"
|
||||
)
|
||||
return BlueprintGenerationResponse(blueprint=blueprint, ai_message=ai_message)
|
||||
|
||||
|
||||
@router.post("/{project_id}/blueprint/save", response_model=NovelProjectSchema)
|
||||
async def save_blueprint(
|
||||
project_id: str,
|
||||
blueprint_data: Blueprint | None = Body(None),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
current_user: UserInDB = Depends(get_current_user),
|
||||
) -> NovelProjectSchema:
|
||||
"""保存蓝图信息,可用于手动覆盖自动生成结果。"""
|
||||
novel_service = NovelService(session)
|
||||
project = await novel_service.ensure_project_owner(project_id, current_user.id)
|
||||
|
||||
if blueprint_data:
|
||||
await novel_service.replace_blueprint(project_id, blueprint_data)
|
||||
if blueprint_data.title:
|
||||
project.title = blueprint_data.title
|
||||
await session.commit()
|
||||
logger.info("项目 %s 手动保存蓝图", project_id)
|
||||
else:
|
||||
raise HTTPException(status_code=400, detail="缺少蓝图数据")
|
||||
|
||||
return await novel_service.get_project_schema(project_id, current_user.id)
|
||||
|
||||
|
||||
@router.patch("/{project_id}/blueprint", response_model=NovelProjectSchema)
|
||||
async def patch_blueprint(
|
||||
project_id: str,
|
||||
payload: BlueprintPatch,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
current_user: UserInDB = Depends(get_current_user),
|
||||
) -> NovelProjectSchema:
|
||||
"""局部更新蓝图字段,对世界观或角色做微调。"""
|
||||
novel_service = NovelService(session)
|
||||
project = await novel_service.ensure_project_owner(project_id, current_user.id)
|
||||
|
||||
update_data = payload.model_dump(exclude_unset=True)
|
||||
await novel_service.patch_blueprint(project_id, update_data)
|
||||
logger.info("项目 %s 局部更新蓝图字段:%s", project_id, list(update_data.keys()))
|
||||
return await novel_service.get_project_schema(project_id, current_user.id)
|
||||
Reference in New Issue
Block a user