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,12 @@
from fastapi import APIRouter
from . import admin, auth, llm_config, novels, updates, writer
api_router = APIRouter()
api_router.include_router(auth.router)
api_router.include_router(novels.router)
api_router.include_router(writer.router)
api_router.include_router(admin.router)
api_router.include_router(updates.router)
api_router.include_router(llm_config.router)

View File

@@ -0,0 +1,340 @@
import logging
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from ...core.dependencies import get_current_admin
from ...db.session import get_session
from ...models import NovelProject, UsageMetric, User
from ...schemas.admin import (
AdminNovelSummary,
DailyRequestLimit,
Statistics,
UpdateLogCreate,
UpdateLogRead,
UpdateLogUpdate,
)
from ...schemas.config import SystemConfigCreate, SystemConfigRead, SystemConfigUpdate
from ...schemas.prompt import PromptCreate, PromptRead, PromptUpdate
from ...schemas.novel import (
Chapter as ChapterSchema,
NovelProject as NovelProjectSchema,
NovelSectionResponse,
NovelSectionType,
)
from ...schemas.user import PasswordChangeRequest, User as UserSchema
from ...services.auth_service import AuthService
from ...services.admin_setting_service import AdminSettingService
from ...services.config_service import ConfigService
from ...services.novel_service import NovelService
from ...services.prompt_service import PromptService
from ...services.update_log_service import UpdateLogService
from ...services.user_service import UserService
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/admin", tags=["Admin"])
def get_prompt_service(session: AsyncSession = Depends(get_session)) -> PromptService:
return PromptService(session)
def get_update_log_service(session: AsyncSession = Depends(get_session)) -> UpdateLogService:
return UpdateLogService(session)
def get_admin_setting_service(session: AsyncSession = Depends(get_session)) -> AdminSettingService:
return AdminSettingService(session)
def get_config_service(session: AsyncSession = Depends(get_session)) -> ConfigService:
return ConfigService(session)
def get_novel_service(session: AsyncSession = Depends(get_session)) -> NovelService:
return NovelService(session)
def get_user_service(session: AsyncSession = Depends(get_session)) -> UserService:
return UserService(session)
def get_auth_service(session: AsyncSession = Depends(get_session)) -> AuthService:
return AuthService(session)
@router.get("/stats", response_model=Statistics)
async def read_statistics(
session: AsyncSession = Depends(get_session),
_: None = Depends(get_current_admin),
) -> Statistics:
novel_count = await session.scalar(select(func.count(NovelProject.id))) or 0
user_count = await session.scalar(select(func.count(User.id))) or 0
usage = await session.get(UsageMetric, "api_request_count")
api_request_count = usage.value if usage else 0
logger.info("管理员获取统计数据:小说=%s,用户=%s,请求=%s", novel_count, user_count, api_request_count)
return Statistics(novel_count=novel_count, user_count=user_count, api_request_count=api_request_count)
@router.get("/users", response_model=List[UserSchema])
async def list_users(
service: UserService = Depends(get_user_service),
_: None = Depends(get_current_admin),
) -> List[UserSchema]:
users = await service.list_users()
logger.info("管理员请求用户列表,共 %s", len(users))
return [UserSchema.model_validate(user) for user in users]
@router.get("/novel-projects", response_model=List[AdminNovelSummary])
async def list_novel_projects(
service: NovelService = Depends(get_novel_service),
_: None = Depends(get_current_admin),
) -> List[AdminNovelSummary]:
projects = await service.list_projects_for_admin()
logger.info("管理员查看项目列表,共 %s", len(projects))
return projects
@router.get("/novel-projects/{project_id}", response_model=NovelProjectSchema)
async def get_novel_project(
project_id: str,
service: NovelService = Depends(get_novel_service),
_: None = Depends(get_current_admin),
) -> NovelProjectSchema:
logger.info("管理员查看项目详情:%s", project_id)
return await service.get_project_schema_for_admin(project_id)
@router.get("/novel-projects/{project_id}/sections/{section}", response_model=NovelSectionResponse)
async def get_novel_project_section(
project_id: str,
section: NovelSectionType,
service: NovelService = Depends(get_novel_service),
_: None = Depends(get_current_admin),
) -> NovelSectionResponse:
logger.info("管理员查看项目 %s%s 区段", project_id, section)
return await service.get_section_data_for_admin(project_id, section)
@router.get("/novel-projects/{project_id}/chapters/{chapter_number}", response_model=ChapterSchema)
async def get_novel_project_chapter(
project_id: str,
chapter_number: int,
service: NovelService = Depends(get_novel_service),
_: None = Depends(get_current_admin),
) -> ChapterSchema:
logger.info("管理员查看项目 %s%s 章详情", project_id, chapter_number)
return await service.get_chapter_schema_for_admin(project_id, chapter_number)
@router.get("/prompts", response_model=List[PromptRead])
async def list_prompts(
service: PromptService = Depends(get_prompt_service),
_: None = Depends(get_current_admin),
) -> List[PromptRead]:
prompts = await service.list_prompts()
logger.info("管理员请求提示词列表,共 %s", len(prompts))
return prompts
@router.post("/prompts", response_model=PromptRead, status_code=status.HTTP_201_CREATED)
async def create_prompt(
payload: PromptCreate,
service: PromptService = Depends(get_prompt_service),
_: None = Depends(get_current_admin),
) -> PromptRead:
prompt = await service.create_prompt(payload)
logger.info("管理员创建提示词:%s", prompt.id)
return prompt
@router.get("/prompts/{prompt_id}", response_model=PromptRead)
async def get_prompt(
prompt_id: int,
service: PromptService = Depends(get_prompt_service),
_: None = Depends(get_current_admin),
) -> PromptRead:
prompt = await service.get_prompt_by_id(prompt_id)
if not prompt:
logger.warning("提示词 %s 不存在", prompt_id)
raise HTTPException(status_code=404, detail="提示词不存在")
logger.info("管理员获取提示词:%s", prompt_id)
return prompt
@router.patch("/prompts/{prompt_id}", response_model=PromptRead)
async def update_prompt(
prompt_id: int,
payload: PromptUpdate,
service: PromptService = Depends(get_prompt_service),
_: None = Depends(get_current_admin),
) -> PromptRead:
result = await service.update_prompt(prompt_id, payload)
if not result:
logger.warning("提示词 %s 不存在,无法更新", prompt_id)
raise HTTPException(status_code=404, detail="提示词不存在")
logger.info("管理员更新提示词:%s", prompt_id)
return result
@router.delete("/prompts/{prompt_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_prompt(
prompt_id: int,
service: PromptService = Depends(get_prompt_service),
_: None = Depends(get_current_admin),
) -> None:
deleted = await service.delete_prompt(prompt_id)
if not deleted:
logger.warning("提示词 %s 不存在,无法删除", prompt_id)
raise HTTPException(status_code=404, detail="提示词不存在")
logger.info("管理员删除提示词:%s", prompt_id)
@router.get("/update-logs", response_model=List[UpdateLogRead])
async def list_update_logs(
service: UpdateLogService = Depends(get_update_log_service),
_: None = Depends(get_current_admin),
) -> List[UpdateLogRead]:
logs = await service.list_logs()
logger.info("管理员查看更新日志列表,共 %s", len(logs))
return [UpdateLogRead.model_validate(log) for log in logs]
@router.post("/update-logs", response_model=UpdateLogRead, status_code=status.HTTP_201_CREATED)
async def create_update_log(
payload: UpdateLogCreate,
service: UpdateLogService = Depends(get_update_log_service),
current_admin=Depends(get_current_admin),
) -> UpdateLogRead:
log = await service.create_log(
payload.content,
creator=current_admin.username,
is_pinned=payload.is_pinned or False,
)
logger.info("管理员 %s 创建更新日志:%s", current_admin.username, log.id)
return UpdateLogRead.model_validate(log)
@router.delete("/update-logs/{log_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_update_log(
log_id: int,
service: UpdateLogService = Depends(get_update_log_service),
_: None = Depends(get_current_admin),
) -> None:
await service.delete_log(log_id)
logger.info("管理员删除更新日志:%s", log_id)
@router.patch("/update-logs/{log_id}", response_model=UpdateLogRead)
async def update_update_log(
log_id: int,
payload: UpdateLogUpdate,
service: UpdateLogService = Depends(get_update_log_service),
_: None = Depends(get_current_admin),
) -> UpdateLogRead:
log = await service.update_log(
log_id,
content=payload.content,
is_pinned=payload.is_pinned,
)
logger.info("管理员更新日志 %s", log_id)
return UpdateLogRead.model_validate(log)
@router.get("/settings/daily-request-limit", response_model=DailyRequestLimit)
async def get_daily_limit(
service: AdminSettingService = Depends(get_admin_setting_service),
_: None = Depends(get_current_admin),
) -> DailyRequestLimit:
value = await service.get("daily_request_limit", "100")
logger.info("管理员查询每日请求上限:%s", value)
return DailyRequestLimit(limit=int(value or 100))
@router.put("/settings/daily-request-limit", response_model=DailyRequestLimit)
async def update_daily_limit(
payload: DailyRequestLimit,
service: AdminSettingService = Depends(get_admin_setting_service),
_: None = Depends(get_current_admin),
) -> DailyRequestLimit:
await service.set("daily_request_limit", str(payload.limit))
logger.info("管理员设置每日请求上限为 %s", payload.limit)
return payload
@router.get("/system-configs", response_model=List[SystemConfigRead])
async def list_system_configs(
service: ConfigService = Depends(get_config_service),
_: None = Depends(get_current_admin),
) -> List[SystemConfigRead]:
configs = await service.list_configs()
logger.info("管理员获取系统配置,共 %s", len(configs))
return configs
@router.get("/system-configs/{key}", response_model=SystemConfigRead)
async def get_system_config(
key: str,
service: ConfigService = Depends(get_config_service),
_: None = Depends(get_current_admin),
) -> SystemConfigRead:
config = await service.get_config(key)
if not config:
logger.warning("系统配置 %s 不存在", key)
raise HTTPException(status_code=404, detail="配置项不存在")
logger.info("管理员查询系统配置:%s", key)
return config
@router.put("/system-configs/{key}", response_model=SystemConfigRead)
async def upsert_system_config(
key: str,
payload: SystemConfigCreate,
service: ConfigService = Depends(get_config_service),
_: None = Depends(get_current_admin),
) -> SystemConfigRead:
logger.info("管理员写入系统配置:%s", key)
return await service.upsert_config(
SystemConfigCreate(key=key, value=payload.value, description=payload.description)
)
@router.patch("/system-configs/{key}", response_model=SystemConfigRead)
async def patch_system_config(
key: str,
payload: SystemConfigUpdate,
service: ConfigService = Depends(get_config_service),
_: None = Depends(get_current_admin),
) -> SystemConfigRead:
config = await service.patch_config(key, payload)
if not config:
logger.warning("系统配置 %s 不存在,无法更新", key)
raise HTTPException(status_code=404, detail="配置项不存在")
logger.info("管理员部分更新系统配置:%s", key)
return config
@router.delete("/system-configs/{key}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_system_config(
key: str,
service: ConfigService = Depends(get_config_service),
_: None = Depends(get_current_admin),
) -> None:
deleted = await service.remove_config(key)
if not deleted:
logger.warning("系统配置 %s 不存在,无法删除", key)
raise HTTPException(status_code=404, detail="配置项不存在")
logger.info("管理员删除系统配置:%s", key)
@router.post("/password", status_code=status.HTTP_204_NO_CONTENT)
async def change_password(
payload: PasswordChangeRequest,
current_admin=Depends(get_current_admin),
service: AuthService = Depends(get_auth_service),
) -> None:
await service.change_password(current_admin.username, payload.old_password, payload.new_password)
logger.info("管理员 %s 修改密码", current_admin.username)

View File

@@ -0,0 +1,106 @@
import logging
from datetime import timedelta
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.security import OAuth2PasswordRequestForm
from sqlalchemy.ext.asyncio import AsyncSession
from ...core.config import settings
from ...core.dependencies import get_current_user
from ...db.session import get_session
from ...schemas.user import AuthOptions, Token, User, UserInDB, UserRegistration
from ...services.auth_service import AuthService
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/auth", tags=["Authentication"])
def get_auth_service(session: AsyncSession = Depends(get_session)) -> AuthService:
return AuthService(session)
@router.post("/send-code", status_code=204)
async def send_verification_code(email: str, service: AuthService = Depends(get_auth_service)):
await service.send_verification_code(email)
logger.info("%s 发送验证码", email)
@router.get("/options", response_model=AuthOptions)
async def read_auth_options(service: AuthService = Depends(get_auth_service)):
"""读取认证功能开关,供前端动态渲染。"""
options = await service.get_auth_options()
return options
@router.post("/users", response_model=User, status_code=status.HTTP_201_CREATED)
async def register_user(payload: UserRegistration, service: AuthService = Depends(get_auth_service)):
user = await service.register_user(payload)
logger.info("注册新用户:%s", user.username)
return User.model_validate(user)
@router.post("/token", response_model=Token)
async def login(form_data: OAuth2PasswordRequestForm = Depends(), service: AuthService = Depends(get_auth_service)):
user = await service.authenticate_user(form_data.username, form_data.password)
must_change_password = service.requires_password_reset(user)
token = await service.create_access_token(user, must_change_password=must_change_password)
logger.info("用户 %s 登录成功,需改密=%s", form_data.username, must_change_password)
return token
@router.get("/users/me", response_model=User)
async def read_current_user(current_user: UserInDB = Depends(get_current_user)):
logger.debug("读取当前用户:%s", current_user.username)
return current_user
@router.get("/linuxdo/login")
async def login_with_linuxdo(service: AuthService = Depends(get_auth_service)):
if not await service.is_linuxdo_login_enabled():
logger.warning("Linux.do 登录未启用")
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="未启用 Linux.do 登录")
client_id = await service.get_config_value("linuxdo.client_id")
redirect_uri = await service.get_config_value("linuxdo.redirect_uri")
auth_url = await service.get_config_value("linuxdo.auth_url")
if not all([client_id, redirect_uri, auth_url]):
logger.error("Linux.do OAuth 参数未配置完整")
raise HTTPException(status_code=500, detail="未配置 Linux.do OAuth 参数")
params = {
"client_id": client_id,
"redirect_uri": redirect_uri,
"response_type": "code",
"scope": "user",
}
query = "&".join(f"{k}={v}" for k, v in params.items())
logger.info("跳转 Linux.do 授权client_id=%s", client_id)
return RedirectResponse(url=f"{auth_url}?{query}")
@router.get("/linuxdo/register", response_class=HTMLResponse)
async def register_with_linuxdo(code: str, service: AuthService = Depends(get_auth_service)):
token = await service.handle_linuxdo_callback(code)
logger.info("Linux.do 授权回调成功")
token_json = token.model_dump_json()
html_content = f"""<!DOCTYPE html>
<html lang=\"zh-CN\">
<head><meta charset=\"UTF-8\"><title>正在跳转</title></head>
<body>
<p>正在跳转,请稍候...</p>
<script>
(function() {{
const token = JSON.parse('{token_json}');
try {{
window.localStorage.setItem('token', token.access_token);
}} catch (err) {{
console.error('无法写入本地存储', err);
}}
window.location.replace('/');
}})();
</script>
</body>
</html>"""
return HTMLResponse(content=html_content)

View File

@@ -0,0 +1,54 @@
import logging
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from ...core.dependencies import get_current_user
from ...db.session import get_session
from ...schemas.llm_config import LLMConfigCreate, LLMConfigRead
from ...schemas.user import UserInDB
from ...services.llm_config_service import LLMConfigService
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/llm-config", tags=["LLM Configuration"])
def get_llm_config_service(session: AsyncSession = Depends(get_session)) -> LLMConfigService:
return LLMConfigService(session)
@router.get("", response_model=LLMConfigRead)
async def read_llm_config(
service: LLMConfigService = Depends(get_llm_config_service),
current_user: UserInDB = Depends(get_current_user),
) -> LLMConfigRead:
config = await service.get_config(current_user.id)
if not config:
logger.warning("用户 %s 尚未设置 LLM 配置", current_user.id)
raise HTTPException(status_code=404, detail="尚未设置自定义配置")
logger.info("用户 %s 获取 LLM 配置", current_user.id)
return config
@router.put("", response_model=LLMConfigRead)
async def upsert_llm_config(
payload: LLMConfigCreate,
service: LLMConfigService = Depends(get_llm_config_service),
current_user: UserInDB = Depends(get_current_user),
) -> LLMConfigRead:
logger.info("用户 %s 更新 LLM 配置", current_user.id)
return await service.upsert_config(current_user.id, payload)
@router.delete("", status_code=status.HTTP_204_NO_CONTENT)
async def delete_llm_config(
service: LLMConfigService = Depends(get_llm_config_service),
current_user: UserInDB = Depends(get_current_user),
) -> None:
deleted = await service.delete_config(current_user.id)
if not deleted:
logger.warning("用户 %s 删除 LLM 配置失败,未找到记录", current_user.id)
raise HTTPException(status_code=404, detail="未找到配置")
logger.info("用户 %s 删除 LLM 配置", current_user.id)

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)

View File

@@ -0,0 +1,22 @@
from typing import List
from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from ...db.session import get_session
from ...schemas.admin import UpdateLogRead
from ...services.update_log_service import UpdateLogService
router = APIRouter(prefix="/api/updates", tags=["Updates"])
def get_update_log_service(session: AsyncSession = Depends(get_session)) -> UpdateLogService:
return UpdateLogService(session)
@router.get("/latest", response_model=List[UpdateLogRead])
async def read_latest_updates(
service: UpdateLogService = Depends(get_update_log_service),
) -> List[UpdateLogRead]:
logs = await service.list_logs(limit=5)
return [UpdateLogRead.model_validate(log) for log in logs]

View File

@@ -0,0 +1,613 @@
import json
import logging
import os
from typing import Dict, List, Optional
from fastapi import APIRouter, Body, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from ...core.config import settings
from ...core.dependencies import get_current_user
from ...db.session import get_session
from ...models.novel import Chapter, ChapterOutline
from ...schemas.novel import (
DeleteChapterRequest,
EditChapterRequest,
EvaluateChapterRequest,
GenerateChapterRequest,
GenerateOutlineRequest,
NovelProject as NovelProjectSchema,
SelectVersionRequest,
UpdateChapterOutlineRequest,
)
from ...schemas.user import UserInDB
from ...services.chapter_context_service import ChapterContextService
from ...services.chapter_ingest_service import ChapterIngestionService
from ...services.llm_service import LLMService
from ...services.novel_service import NovelService
from ...services.prompt_service import PromptService
from ...services.vector_store_service import VectorStoreService
from ...utils.json_utils import remove_think_tags, unwrap_markdown_json
from ...repositories.system_config_repository import SystemConfigRepository
router = APIRouter(prefix="/api/writer", tags=["Writer"])
logger = logging.getLogger(__name__)
async def _load_project_schema(service: NovelService, project_id: str, user_id: int) -> NovelProjectSchema:
return await service.get_project_schema(project_id, user_id)
def _extract_tail_excerpt(text: Optional[str], limit: int = 500) -> str:
"""截取章节结尾文本,默认保留 500 字。"""
if not text:
return ""
stripped = text.strip()
if len(stripped) <= limit:
return stripped
return stripped[-limit:]
@router.post("/novels/{project_id}/chapters/generate", response_model=NovelProjectSchema)
async def generate_chapter(
project_id: str,
request: GenerateChapterRequest,
session: AsyncSession = Depends(get_session),
current_user: UserInDB = Depends(get_current_user),
) -> NovelProjectSchema:
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 开始为项目 %s 生成第 %s", current_user.id, project_id, request.chapter_number)
outline = await novel_service.get_outline(project_id, request.chapter_number)
if not outline:
logger.warning("项目 %s 未找到第 %s 章纲要,生成流程终止", project_id, request.chapter_number)
raise HTTPException(status_code=404, detail="蓝图中未找到对应章节纲要")
chapter = await novel_service.get_or_create_chapter(project_id, request.chapter_number)
chapter.real_summary = None
chapter.selected_version_id = None
chapter.status = "generating"
await session.commit()
outlines_map = {item.chapter_number: item for item in project.outlines}
# 收集所有可用的历史章节摘要,便于在 Prompt 中提供前情背景
completed_chapters = []
latest_prev_number = -1
previous_summary_text = ""
previous_tail_excerpt = ""
for existing in project.chapters:
if existing.chapter_number >= request.chapter_number:
continue
if existing.selected_version is None or not existing.selected_version.content:
continue
if not existing.real_summary:
summary = await llm_service.get_summary(
existing.selected_version.content,
temperature=0.15,
user_id=current_user.id,
timeout=180.0,
)
existing.real_summary = remove_think_tags(summary)
await session.commit()
completed_chapters.append(
{
"chapter_number": existing.chapter_number,
"title": outlines_map.get(existing.chapter_number).title if outlines_map.get(existing.chapter_number) else f"{existing.chapter_number}",
"summary": existing.real_summary,
}
)
if existing.chapter_number > latest_prev_number:
latest_prev_number = existing.chapter_number
previous_summary_text = existing.real_summary or ""
previous_tail_excerpt = _extract_tail_excerpt(existing.selected_version.content)
project_schema = await novel_service._serialize_project(project)
blueprint_dict = project_schema.blueprint.model_dump()
if "relationships" in blueprint_dict and blueprint_dict["relationships"]:
for relation in blueprint_dict["relationships"]:
if "character_from" in relation:
relation["from"] = relation.pop("character_from")
if "character_to" in relation:
relation["to"] = relation.pop("character_to")
# 蓝图中禁止携带章节级别的细节信息,避免重复传输大段场景或对话内容
banned_blueprint_keys = {
"chapter_outline",
"chapter_summaries",
"chapter_details",
"chapter_dialogues",
"chapter_events",
"conversation_history",
"character_timelines",
}
for key in banned_blueprint_keys:
if key in blueprint_dict:
blueprint_dict.pop(key, None)
writer_prompt = await prompt_service.get_prompt("writing")
if not writer_prompt:
raise HTTPException(status_code=500, detail="缺少写作提示词")
# 初始化向量检索服务,若未配置则自动降级为纯提示词生成
vector_store: Optional[VectorStoreService]
if not settings.vector_store_enabled:
vector_store = None
else:
try:
vector_store = VectorStoreService()
except RuntimeError as exc:
logger.warning("向量库初始化失败RAG 检索被禁用: %s", exc)
vector_store = None
context_service = ChapterContextService(llm_service=llm_service, vector_store=vector_store)
outline_title = outline.title or f"{outline.chapter_number}"
outline_summary = outline.summary or "暂无摘要"
query_parts = [outline_title, outline_summary]
if request.writing_notes:
query_parts.append(request.writing_notes)
rag_query = "\n".join(part for part in query_parts if part)
rag_context = await context_service.retrieve_for_generation(
project_id=project_id,
query_text=rag_query or outline.title or outline.summary or "",
user_id=current_user.id,
)
chunk_count = len(rag_context.chunks) if rag_context and rag_context.chunks else 0
summary_count = len(rag_context.summaries) if rag_context and rag_context.summaries else 0
logger.info(
"项目 %s%s 章检索到 %s 个剧情片段和 %s 条摘要",
project_id,
request.chapter_number,
chunk_count,
summary_count,
)
# print("rag_context:",rag_context)
# 将蓝图、前情、RAG 检索结果拼装成结构化段落,供模型理解
blueprint_text = json.dumps(blueprint_dict, ensure_ascii=False, indent=2)
completed_lines = [
f"- 第{item['chapter_number']}章 - {item['title']}:{item['summary']}"
for item in completed_chapters
]
previous_summary_text = previous_summary_text or "暂无可用摘要"
previous_tail_excerpt = previous_tail_excerpt or "暂无上一章结尾内容"
completed_section = "\n".join(completed_lines) if completed_lines else "暂无前情摘要"
rag_chunks_text = "\n\n".join(rag_context.chunk_texts()) if rag_context.chunks else "未检索到章节片段"
rag_summaries_text = "\n".join(rag_context.summary_lines()) if rag_context.summaries else "未检索到章节摘要"
writing_notes = request.writing_notes or "无额外写作指令"
prompt_sections = [
("[世界蓝图](JSON)", blueprint_text),
# ("[前情摘要]", completed_section),
("[上一章摘要]", previous_summary_text),
("[上一章结尾]", previous_tail_excerpt),
("[检索到的剧情上下文](Markdown)", rag_chunks_text),
("[检索到的章节摘要]", rag_summaries_text),
(
"[当前章节目标]",
f"标题:{outline_title}\n摘要:{outline_summary}\n写作要求:{writing_notes}",
),
]
prompt_input = "\n\n".join(f"{title}\n{content}" for title, content in prompt_sections if content)
logger.debug("章节写作提示词:%s\n%s", writer_prompt, prompt_input)
async def _generate_single_version(idx: int) -> Dict:
try:
response = await llm_service.get_llm_response(
system_prompt=writer_prompt,
conversation_history=[{"role": "user", "content": prompt_input}],
temperature=0.9,
user_id=current_user.id,
timeout=600.0,
)
cleaned = remove_think_tags(response)
normalized = unwrap_markdown_json(cleaned)
try:
return json.loads(normalized)
except json.JSONDecodeError:
return {"content": normalized}
except Exception as exc:
logger.exception(
"项目 %s 生成第 %s 章第 %s 个版本时发生异常: %s",
project_id,
request.chapter_number,
idx + 1,
exc,
)
return {"content": f"生成失败: {exc}"}
version_count = await _resolve_version_count(session)
logger.info(
"项目 %s%s 章计划生成 %s 个版本",
project_id,
request.chapter_number,
version_count,
)
raw_versions = []
for idx in range(version_count):
raw_versions.append(await _generate_single_version(idx))
contents: List[str] = []
metadata: List[Dict] = []
for variant in raw_versions:
if isinstance(variant, dict):
if "content" in variant and isinstance(variant["content"], str):
contents.append(variant["content"])
elif "chapter_content" in variant:
contents.append(str(variant["chapter_content"]))
else:
contents.append(json.dumps(variant, ensure_ascii=False))
metadata.append(variant)
else:
contents.append(str(variant))
metadata.append({"raw": variant})
await novel_service.replace_chapter_versions(chapter, contents, metadata)
logger.info(
"项目 %s%s 章生成完成,已写入 %s 个版本",
project_id,
request.chapter_number,
len(contents),
)
return await _load_project_schema(novel_service, project_id, current_user.id)
async def _resolve_version_count(session: AsyncSession) -> int:
repo = SystemConfigRepository(session)
record = await repo.get_by_key("writer.chapter_versions")
if record:
try:
value = int(record.value)
if value > 0:
return value
except (TypeError, ValueError):
pass
env_value = os.getenv("WRITER_CHAPTER_VERSION_COUNT")
if env_value:
try:
value = int(env_value)
if value > 0:
return value
except ValueError:
pass
return 3
@router.post("/novels/{project_id}/chapters/select", response_model=NovelProjectSchema)
async def select_chapter_version(
project_id: str,
request: SelectVersionRequest,
session: AsyncSession = Depends(get_session),
current_user: UserInDB = Depends(get_current_user),
) -> NovelProjectSchema:
novel_service = NovelService(session)
llm_service = LLMService(session)
project = await novel_service.ensure_project_owner(project_id, current_user.id)
chapter = next((ch for ch in project.chapters if ch.chapter_number == request.chapter_number), None)
if not chapter:
logger.warning("项目 %s 未找到第 %s 章,无法选择版本", project_id, request.chapter_number)
raise HTTPException(status_code=404, detail="章节不存在")
selected = await novel_service.select_chapter_version(chapter, request.version_index)
logger.info(
"用户 %s 选择了项目 %s%s 章的第 %s 个版本",
current_user.id,
project_id,
request.chapter_number,
request.version_index,
)
if selected and selected.content:
summary = await llm_service.get_summary(
selected.content,
temperature=0.15,
user_id=current_user.id,
timeout=180.0,
)
chapter.real_summary = remove_think_tags(summary)
await session.commit()
# 选定版本后同步向量库,确保后续章节可检索到最新内容
vector_store: Optional[VectorStoreService]
if not settings.vector_store_enabled:
vector_store = None
else:
try:
vector_store = VectorStoreService()
except RuntimeError as exc:
logger.warning("向量库初始化失败,跳过章节向量同步: %s", exc)
vector_store = None
if vector_store:
ingestion_service = ChapterIngestionService(llm_service=llm_service, vector_store=vector_store)
outline = next((item for item in project.outlines if item.chapter_number == chapter.chapter_number), None)
chapter_title = outline.title if outline and outline.title else f"{chapter.chapter_number}"
await ingestion_service.ingest_chapter(
project_id=project_id,
chapter_number=chapter.chapter_number,
title=chapter_title,
content=selected.content,
summary=chapter.real_summary,
user_id=current_user.id,
)
logger.info(
"项目 %s%s 章已同步至向量库",
project_id,
chapter.chapter_number,
)
return await _load_project_schema(novel_service, project_id, current_user.id)
@router.post("/novels/{project_id}/chapters/evaluate", response_model=NovelProjectSchema)
async def evaluate_chapter(
project_id: str,
request: EvaluateChapterRequest,
session: AsyncSession = Depends(get_session),
current_user: UserInDB = Depends(get_current_user),
) -> NovelProjectSchema:
novel_service = NovelService(session)
prompt_service = PromptService(session)
llm_service = LLMService(session)
project = await novel_service.ensure_project_owner(project_id, current_user.id)
chapter = next((ch for ch in project.chapters if ch.chapter_number == request.chapter_number), None)
if not chapter:
logger.warning("项目 %s 未找到第 %s 章,无法执行评估", project_id, request.chapter_number)
raise HTTPException(status_code=404, detail="章节不存在")
if not chapter.versions:
logger.warning("项目 %s%s 章无可评估版本", project_id, request.chapter_number)
raise HTTPException(status_code=400, detail="无可评估的章节版本")
evaluator_prompt = await prompt_service.get_prompt("evaluation")
if not evaluator_prompt:
logger.error("缺少评估提示词,项目 %s%s 章评估失败", project_id, request.chapter_number)
raise HTTPException(status_code=500, detail="缺少评估提示词")
project_schema = await novel_service._serialize_project(project)
blueprint_dict = project_schema.blueprint.model_dump()
versions_to_evaluate = [
{"version_id": idx + 1, "content": version.content}
for idx, version in enumerate(sorted(chapter.versions, key=lambda item: item.created_at))
]
# print("blueprint_dict:",blueprint_dict)
evaluator_payload = {
"novel_blueprint": blueprint_dict,
"content_to_evaluate": {
"chapter_number": chapter.chapter_number,
"versions": versions_to_evaluate,
},
}
evaluation_raw = await llm_service.get_llm_response(
system_prompt=evaluator_prompt,
conversation_history=[{"role": "user", "content": json.dumps(evaluator_payload, ensure_ascii=False)}],
temperature=0.3,
user_id=current_user.id,
timeout=360.0,
)
evaluation_clean = remove_think_tags(evaluation_raw)
await novel_service.add_chapter_evaluation(chapter, None, evaluation_clean)
logger.info("项目 %s%s 章评估完成", project_id, request.chapter_number)
return await _load_project_schema(novel_service, project_id, current_user.id)
@router.post("/novels/{project_id}/chapters/outline", response_model=NovelProjectSchema)
async def generate_chapter_outline(
project_id: str,
request: GenerateOutlineRequest,
session: AsyncSession = Depends(get_session),
current_user: UserInDB = Depends(get_current_user),
) -> NovelProjectSchema:
novel_service = NovelService(session)
prompt_service = PromptService(session)
llm_service = LLMService(session)
await novel_service.ensure_project_owner(project_id, current_user.id)
logger.info(
"用户 %s 请求生成项目 %s 的章节大纲,起始章节 %s,数量 %s",
current_user.id,
project_id,
request.start_chapter,
request.num_chapters,
)
outline_prompt = await prompt_service.get_prompt("outline")
if not outline_prompt:
logger.error("缺少大纲提示词,项目 %s 大纲生成失败", project_id)
raise HTTPException(status_code=500, detail="缺少大纲提示词")
project_schema = await novel_service.get_project_schema(project_id, current_user.id)
blueprint_dict = project_schema.blueprint.model_dump()
payload = {
"novel_blueprint": blueprint_dict,
"wait_to_generate": {
"start_chapter": request.start_chapter,
"num_chapters": request.num_chapters,
},
}
response = await llm_service.get_llm_response(
system_prompt=outline_prompt,
conversation_history=[{"role": "user", "content": json.dumps(payload, ensure_ascii=False)}],
temperature=0.7,
user_id=current_user.id,
timeout=360.0,
)
normalized = unwrap_markdown_json(remove_think_tags(response))
try:
data = json.loads(normalized)
except json.JSONDecodeError as exc:
raise HTTPException(status_code=500, detail="章节大纲生成失败") from exc
new_outlines = data.get("chapters", [])
for item in new_outlines:
stmt = (
select(ChapterOutline)
.where(
ChapterOutline.project_id == project_id,
ChapterOutline.chapter_number == item.get("chapter_number"),
)
)
result = await session.execute(stmt)
record = result.scalars().first()
if record:
record.title = item.get("title", record.title)
record.summary = item.get("summary", record.summary)
else:
session.add(
ChapterOutline(
project_id=project_id,
chapter_number=item.get("chapter_number"),
title=item.get("title", ""),
summary=item.get("summary"),
)
)
await session.commit()
logger.info("项目 %s 章节大纲生成完成", project_id)
return await novel_service.get_project_schema(project_id, current_user.id)
@router.post("/novels/{project_id}/chapters/update-outline", response_model=NovelProjectSchema)
async def update_chapter_outline(
project_id: str,
request: UpdateChapterOutlineRequest,
session: AsyncSession = Depends(get_session),
current_user: UserInDB = Depends(get_current_user),
) -> NovelProjectSchema:
novel_service = NovelService(session)
await novel_service.ensure_project_owner(project_id, current_user.id)
logger.info(
"用户 %s 更新项目 %s%s 章大纲",
current_user.id,
project_id,
request.chapter_number,
)
stmt = (
select(ChapterOutline)
.where(
ChapterOutline.project_id == project_id,
ChapterOutline.chapter_number == request.chapter_number,
)
)
result = await session.execute(stmt)
outline = result.scalars().first()
if not outline:
outline = ChapterOutline(
project_id=project_id,
chapter_number=request.chapter_number,
)
session.add(outline)
outline.title = request.title
outline.summary = request.summary
await session.commit()
logger.info("项目 %s%s 章大纲已更新", project_id, request.chapter_number)
return await novel_service.get_project_schema(project_id, current_user.id)
@router.post("/novels/{project_id}/chapters/delete", response_model=NovelProjectSchema)
async def delete_chapters(
project_id: str,
request: DeleteChapterRequest,
session: AsyncSession = Depends(get_session),
current_user: UserInDB = Depends(get_current_user),
) -> NovelProjectSchema:
if not request.chapter_numbers:
logger.warning("项目 %s 未提供要删除的章节号", project_id)
raise HTTPException(status_code=400, detail="请提供要删除的章节号")
novel_service = NovelService(session)
llm_service = LLMService(session)
await novel_service.ensure_project_owner(project_id, current_user.id)
logger.info(
"用户 %s 删除项目 %s 的章节 %s",
current_user.id,
project_id,
request.chapter_numbers,
)
await novel_service.delete_chapters(project_id, request.chapter_numbers)
# 删除章节时同步清理向量库,避免过时内容被检索
vector_store: Optional[VectorStoreService]
if not settings.vector_store_enabled:
vector_store = None
else:
try:
vector_store = VectorStoreService()
except RuntimeError as exc:
logger.warning("向量库初始化失败,跳过章节向量删除: %s", exc)
vector_store = None
if vector_store:
ingestion_service = ChapterIngestionService(llm_service=llm_service, vector_store=vector_store)
await ingestion_service.delete_chapters(project_id, request.chapter_numbers)
logger.info(
"项目 %s 已从向量库移除章节 %s",
project_id,
request.chapter_numbers,
)
return await novel_service.get_project_schema(project_id, current_user.id)
@router.post("/novels/{project_id}/chapters/edit", response_model=NovelProjectSchema)
async def edit_chapter(
project_id: str,
request: EditChapterRequest,
session: AsyncSession = Depends(get_session),
current_user: UserInDB = Depends(get_current_user),
) -> NovelProjectSchema:
novel_service = NovelService(session)
llm_service = LLMService(session)
project = await novel_service.ensure_project_owner(project_id, current_user.id)
chapter = next((ch for ch in project.chapters if ch.chapter_number == request.chapter_number), None)
if not chapter or chapter.selected_version is None:
logger.warning("项目 %s%s 章尚未生成或未选择版本,无法编辑", project_id, request.chapter_number)
raise HTTPException(status_code=404, detail="章节尚未生成或未选择版本")
chapter.selected_version.content = request.content
chapter.word_count = len(request.content)
logger.info("用户 %s 更新了项目 %s%s 章内容", current_user.id, project_id, request.chapter_number)
if request.content.strip():
summary = await llm_service.get_summary(
request.content,
temperature=0.15,
user_id=current_user.id,
timeout=180.0,
)
chapter.real_summary = remove_think_tags(summary)
await session.commit()
vector_store: Optional[VectorStoreService]
if not settings.vector_store_enabled:
vector_store = None
else:
try:
vector_store = VectorStoreService()
except RuntimeError as exc:
logger.warning("向量库初始化失败,跳过章节向量更新: %s", exc)
vector_store = None
if vector_store and chapter.selected_version and chapter.selected_version.content:
ingestion_service = ChapterIngestionService(llm_service=llm_service, vector_store=vector_store)
outline = next((item for item in project.outlines if item.chapter_number == chapter.chapter_number), None)
chapter_title = outline.title if outline and outline.title else f"{chapter.chapter_number}"
await ingestion_service.ingest_chapter(
project_id=project_id,
chapter_number=chapter.chapter_number,
title=chapter_title,
content=chapter.selected_version.content,
summary=chapter.real_summary,
user_id=current_user.id,
)
logger.info("项目 %s%s 章更新内容已同步至向量库", project_id, chapter.chapter_number)
return await novel_service.get_project_schema(project_id, current_user.id)