feat: 初始提交

This commit is contained in:
anonymous
2025-10-21 09:38:26 +08:00
committed by t59688
parent 2965b8e28f
commit c9fc816fab
175 changed files with 23968 additions and 87 deletions

View 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)