Files
arboris-novel/backend/app/services/novel_service.py
2025-10-21 09:51:27 +08:00

701 lines
28 KiB
Python

from __future__ import annotations
import json
import uuid
from datetime import datetime, timezone
from typing import Any, Dict, Iterable, List, Optional
_PREFERRED_CONTENT_KEYS: tuple[str, ...] = (
"content",
"chapter_content",
"chapter_text",
"full_content",
"text",
"body",
"story",
"chapter",
"real_summary",
"summary",
)
def _normalize_version_content(raw_content: Any, metadata: Any) -> str:
text = _coerce_text(metadata)
if not text:
text = _coerce_text(raw_content)
return text or ""
def _coerce_text(value: Any) -> Optional[str]:
if value is None:
return None
if isinstance(value, str):
return _clean_string(value)
if isinstance(value, (int, float)):
return str(value)
if isinstance(value, dict):
for key in _PREFERRED_CONTENT_KEYS:
if key in value and value[key]:
nested = _coerce_text(value[key])
if nested:
return nested
return _clean_string(json.dumps(value, ensure_ascii=False))
if isinstance(value, (list, tuple, set)):
parts = [text for text in (_coerce_text(item) for item in value) if text]
if parts:
return "\n".join(parts)
return None
return _clean_string(str(value))
def _clean_string(text: str) -> str:
stripped = text.strip()
if not stripped:
return stripped
if stripped.startswith("{") and stripped.endswith("}"):
try:
parsed = json.loads(stripped)
coerced = _coerce_text(parsed)
if coerced:
return coerced
except json.JSONDecodeError:
pass
if stripped.startswith('"') and stripped.endswith('"') and len(stripped) >= 2:
stripped = stripped[1:-1]
return (
stripped.replace("\\n", "\n")
.replace("\\t", "\t")
.replace('\\"', '"')
.replace("\\\\", "\\")
)
from fastapi import HTTPException, status
from sqlalchemy import delete, func, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from ..models import (
BlueprintCharacter,
BlueprintRelationship,
Chapter,
ChapterEvaluation,
ChapterOutline,
ChapterVersion,
NovelBlueprint,
NovelConversation,
NovelProject,
)
from ..repositories.novel_repository import NovelRepository
from ..schemas.admin import AdminNovelSummary
from ..schemas.novel import (
Blueprint,
Chapter as ChapterSchema,
ChapterGenerationStatus,
ChapterOutline as ChapterOutlineSchema,
NovelProject as NovelProjectSchema,
NovelProjectSummary,
NovelSectionResponse,
NovelSectionType,
)
class NovelService:
"""小说项目服务,基于拆表后的结构提供聚合与业务操作。"""
def __init__(self, session: AsyncSession):
self.session = session
self.repo = NovelRepository(session)
# ------------------------------------------------------------------
# 项目与摘要
# ------------------------------------------------------------------
async def create_project(self, user_id: int, title: str, initial_prompt: str) -> NovelProject:
project = NovelProject(
id=str(uuid.uuid4()),
user_id=user_id,
title=title,
initial_prompt=initial_prompt,
)
blueprint = NovelBlueprint(project=project)
self.session.add_all([project, blueprint])
await self.session.commit()
await self.session.refresh(project)
return project
async def ensure_project_owner(self, project_id: str, user_id: int) -> NovelProject:
project = await self.repo.get_by_id(project_id)
if not project:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="项目不存在")
if project.user_id != user_id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="无权访问该项目")
return project
async def get_project_schema(self, project_id: str, user_id: int) -> NovelProjectSchema:
project = await self.ensure_project_owner(project_id, user_id)
return await self._serialize_project(project)
async def get_section_data(
self,
project_id: str,
user_id: int,
section: NovelSectionType,
) -> NovelSectionResponse:
project = await self.ensure_project_owner(project_id, user_id)
return self._build_section_response(project, section)
async def get_chapter_schema(
self,
project_id: str,
user_id: int,
chapter_number: int,
) -> ChapterSchema:
project = await self.ensure_project_owner(project_id, user_id)
return self._build_chapter_schema(project, chapter_number)
async def list_projects_for_user(self, user_id: int) -> List[NovelProjectSummary]:
projects = await self.repo.list_by_user(user_id)
summaries: List[NovelProjectSummary] = []
for project in projects:
blueprint = project.blueprint
genre = blueprint.genre if blueprint and blueprint.genre else "未知"
outlines = project.outlines
chapters = project.chapters
total = len(outlines) or len(chapters)
completed = sum(1 for chapter in chapters if chapter.selected_version_id)
summaries.append(
NovelProjectSummary(
id=project.id,
title=project.title,
genre=genre,
last_edited=project.updated_at.isoformat() if project.updated_at else "未知",
completed_chapters=completed,
total_chapters=total,
)
)
return summaries
async def list_projects_for_admin(self) -> List[AdminNovelSummary]:
projects = await self.repo.list_all()
summaries: List[AdminNovelSummary] = []
for project in projects:
blueprint = project.blueprint
genre = blueprint.genre if blueprint and blueprint.genre else "未知"
outlines = project.outlines
chapters = project.chapters
total = len(outlines) or len(chapters)
completed = sum(1 for chapter in chapters if chapter.selected_version_id)
owner = project.owner
summaries.append(
AdminNovelSummary(
id=project.id,
title=project.title,
owner_id=owner.id if owner else 0,
owner_username=owner.username if owner else "未知",
genre=genre,
last_edited=project.updated_at.isoformat() if project.updated_at else "",
completed_chapters=completed,
total_chapters=total,
)
)
return summaries
async def delete_projects(self, project_ids: List[str], user_id: int) -> None:
for pid in project_ids:
project = await self.ensure_project_owner(pid, user_id)
await self.repo.delete(project)
await self.session.commit()
async def count_projects(self) -> int:
result = await self.session.execute(select(func.count(NovelProject.id)))
return result.scalar_one()
# ------------------------------------------------------------------
# 对话管理
# ------------------------------------------------------------------
async def list_conversations(self, project_id: str) -> List[NovelConversation]:
stmt = (
select(NovelConversation)
.where(NovelConversation.project_id == project_id)
.order_by(NovelConversation.seq.asc())
)
result = await self.session.execute(stmt)
return list(result.scalars())
async def append_conversation(self, project_id: str, role: str, content: str, metadata: Optional[Dict] = None) -> None:
result = await self.session.execute(
select(func.max(NovelConversation.seq)).where(NovelConversation.project_id == project_id)
)
current_max = result.scalar()
next_seq = (current_max or 0) + 1
convo = NovelConversation(
project_id=project_id,
seq=next_seq,
role=role,
content=content,
metadata=metadata,
)
self.session.add(convo)
await self.session.commit()
await self._touch_project(project_id)
# ------------------------------------------------------------------
# 蓝图管理
# ------------------------------------------------------------------
async def replace_blueprint(self, project_id: str, blueprint: Blueprint) -> None:
record = await self.session.get(NovelBlueprint, project_id)
if not record:
record = NovelBlueprint(project_id=project_id)
self.session.add(record)
record.title = blueprint.title
record.target_audience = blueprint.target_audience
record.genre = blueprint.genre
record.style = blueprint.style
record.tone = blueprint.tone
record.one_sentence_summary = blueprint.one_sentence_summary
record.full_synopsis = blueprint.full_synopsis
record.world_setting = blueprint.world_setting
await self.session.execute(delete(BlueprintCharacter).where(BlueprintCharacter.project_id == project_id))
for index, data in enumerate(blueprint.characters):
self.session.add(
BlueprintCharacter(
project_id=project_id,
name=data.get("name", ""),
identity=data.get("identity"),
personality=data.get("personality"),
goals=data.get("goals"),
abilities=data.get("abilities"),
relationship_to_protagonist=data.get("relationship_to_protagonist"),
extra={k: v for k, v in data.items() if k not in {
"name",
"identity",
"personality",
"goals",
"abilities",
"relationship_to_protagonist",
}},
position=index,
)
)
await self.session.execute(delete(BlueprintRelationship).where(BlueprintRelationship.project_id == project_id))
for index, relation in enumerate(blueprint.relationships):
self.session.add(
BlueprintRelationship(
project_id=project_id,
character_from=relation.character_from,
character_to=relation.character_to,
description=relation.description,
position=index,
)
)
await self.session.execute(delete(ChapterOutline).where(ChapterOutline.project_id == project_id))
for outline in blueprint.chapter_outline:
self.session.add(
ChapterOutline(
project_id=project_id,
chapter_number=outline.chapter_number,
title=outline.title,
summary=outline.summary,
)
)
await self.session.commit()
await self._touch_project(project_id)
async def patch_blueprint(self, project_id: str, patch: Dict) -> None:
blueprint = await self.session.get(NovelBlueprint, project_id)
if not blueprint:
blueprint = NovelBlueprint(project_id=project_id)
self.session.add(blueprint)
if "one_sentence_summary" in patch:
blueprint.one_sentence_summary = patch["one_sentence_summary"]
if "full_synopsis" in patch:
blueprint.full_synopsis = patch["full_synopsis"]
if "world_setting" in patch and patch["world_setting"] is not None:
existing = blueprint.world_setting or {}
existing.update(patch["world_setting"])
blueprint.world_setting = existing
if "characters" in patch and patch["characters"] is not None:
await self.session.execute(delete(BlueprintCharacter).where(BlueprintCharacter.project_id == project_id))
for index, data in enumerate(patch["characters"]):
self.session.add(
BlueprintCharacter(
project_id=project_id,
name=data.get("name", ""),
identity=data.get("identity"),
personality=data.get("personality"),
goals=data.get("goals"),
abilities=data.get("abilities"),
relationship_to_protagonist=data.get("relationship_to_protagonist"),
extra={k: v for k, v in data.items() if k not in {
"name",
"identity",
"personality",
"goals",
"abilities",
"relationship_to_protagonist",
}},
position=index,
)
)
if "relationships" in patch and patch["relationships"] is not None:
await self.session.execute(delete(BlueprintRelationship).where(BlueprintRelationship.project_id == project_id))
for index, relation in enumerate(patch["relationships"]):
self.session.add(
BlueprintRelationship(
project_id=project_id,
character_from=relation.get("character_from"),
character_to=relation.get("character_to"),
description=relation.get("description"),
position=index,
)
)
if "chapter_outline" in patch and patch["chapter_outline"] is not None:
await self.session.execute(delete(ChapterOutline).where(ChapterOutline.project_id == project_id))
for outline in patch["chapter_outline"]:
self.session.add(
ChapterOutline(
project_id=project_id,
chapter_number=outline.get("chapter_number"),
title=outline.get("title", ""),
summary=outline.get("summary"),
)
)
await self.session.commit()
await self._touch_project(project_id)
# ------------------------------------------------------------------
# 章节与版本
# ------------------------------------------------------------------
async def get_outline(self, project_id: str, chapter_number: int) -> Optional[ChapterOutline]:
stmt = (
select(ChapterOutline)
.where(
ChapterOutline.project_id == project_id,
ChapterOutline.chapter_number == chapter_number,
)
)
result = await self.session.execute(stmt)
return result.scalars().first()
async def get_or_create_chapter(self, project_id: str, chapter_number: int) -> Chapter:
stmt = (
select(Chapter)
.where(
Chapter.project_id == project_id,
Chapter.chapter_number == chapter_number,
)
)
result = await self.session.execute(stmt)
chapter = result.scalars().first()
if chapter:
return chapter
chapter = Chapter(project_id=project_id, chapter_number=chapter_number)
self.session.add(chapter)
await self.session.commit()
await self.session.refresh(chapter)
return chapter
async def replace_chapter_versions(self, chapter: Chapter, contents: List[str], metadata: Optional[List[Dict]] = None) -> List[ChapterVersion]:
await self.session.execute(delete(ChapterVersion).where(ChapterVersion.chapter_id == chapter.id))
versions: List[ChapterVersion] = []
for index, content in enumerate(contents):
extra = metadata[index] if metadata and index < len(metadata) else None
text_content = _normalize_version_content(content, extra)
version = ChapterVersion(
chapter_id=chapter.id,
content=text_content,
metadata=None,
version_label=f"v{index+1}",
)
self.session.add(version)
versions.append(version)
chapter.status = ChapterGenerationStatus.WAITING_FOR_CONFIRM.value
await self.session.commit()
await self.session.refresh(chapter)
await self._touch_project(chapter.project_id)
return versions
async def select_chapter_version(self, chapter: Chapter, version_index: int) -> ChapterVersion:
versions = sorted(chapter.versions, key=lambda item: item.created_at)
if not versions or version_index < 0 or version_index >= len(versions):
raise HTTPException(status_code=400, detail="版本索引无效")
selected = versions[version_index]
chapter.selected_version_id = selected.id
chapter.status = ChapterGenerationStatus.SUCCESSFUL.value
chapter.word_count = len(selected.content or "")
await self.session.commit()
await self.session.refresh(chapter)
await self._touch_project(chapter.project_id)
return selected
async def add_chapter_evaluation(self, chapter: Chapter, version: Optional[ChapterVersion], feedback: str, decision: Optional[str] = None) -> None:
evaluation = ChapterEvaluation(
chapter_id=chapter.id,
version_id=version.id if version else None,
feedback=feedback,
decision=decision,
)
self.session.add(evaluation)
chapter.status = ChapterGenerationStatus.WAITING_FOR_CONFIRM.value
await self.session.commit()
await self.session.refresh(chapter)
await self._touch_project(chapter.project_id)
async def delete_chapters(self, project_id: str, chapter_numbers: Iterable[int]) -> None:
await self.session.execute(
delete(Chapter).where(
Chapter.project_id == project_id,
Chapter.chapter_number.in_(list(chapter_numbers)),
)
)
await self.session.execute(
delete(ChapterOutline).where(
ChapterOutline.project_id == project_id,
ChapterOutline.chapter_number.in_(list(chapter_numbers)),
)
)
await self.session.commit()
await self._touch_project(project_id)
# ------------------------------------------------------------------
# 序列化辅助
# ------------------------------------------------------------------
async def get_project_schema_for_admin(self, project_id: str) -> NovelProjectSchema:
project = await self.repo.get_by_id(project_id)
if not project:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="项目不存在")
return await self._serialize_project(project)
async def get_section_data_for_admin(
self,
project_id: str,
section: NovelSectionType,
) -> NovelSectionResponse:
project = await self.repo.get_by_id(project_id)
if not project:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="项目不存在")
return self._build_section_response(project, section)
async def get_chapter_schema_for_admin(
self,
project_id: str,
chapter_number: int,
) -> ChapterSchema:
project = await self.repo.get_by_id(project_id)
if not project:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="项目不存在")
return self._build_chapter_schema(project, chapter_number)
async def _serialize_project(self, project: NovelProject) -> NovelProjectSchema:
conversations = [
{"role": convo.role, "content": convo.content}
for convo in sorted(project.conversations, key=lambda c: c.seq)
]
blueprint_schema = self._build_blueprint_schema(project)
outlines_map = {outline.chapter_number: outline for outline in project.outlines}
chapters_map = {chapter.chapter_number: chapter for chapter in project.chapters}
chapter_numbers = sorted(set(outlines_map.keys()) | set(chapters_map.keys()))
chapters_schema: List[ChapterSchema] = [
self._build_chapter_schema(
project,
number,
outlines_map=outlines_map,
chapters_map=chapters_map,
)
for number in chapter_numbers
]
return NovelProjectSchema(
id=project.id,
user_id=project.user_id,
title=project.title,
initial_prompt=project.initial_prompt or "",
conversation_history=conversations,
blueprint=blueprint_schema,
chapters=chapters_schema,
)
async def _touch_project(self, project_id: str) -> None:
await self.session.execute(
update(NovelProject)
.where(NovelProject.id == project_id)
.values(updated_at=datetime.now(timezone.utc))
)
await self.session.commit()
def _build_blueprint_schema(self, project: NovelProject) -> Blueprint:
blueprint_obj = project.blueprint
if blueprint_obj:
return Blueprint(
title=blueprint_obj.title or "",
target_audience=blueprint_obj.target_audience or "",
genre=blueprint_obj.genre or "",
style=blueprint_obj.style or "",
tone=blueprint_obj.tone or "",
one_sentence_summary=blueprint_obj.one_sentence_summary or "",
full_synopsis=blueprint_obj.full_synopsis or "",
world_setting=blueprint_obj.world_setting or {},
characters=[
{
"name": character.name,
"identity": character.identity,
"personality": character.personality,
"goals": character.goals,
"abilities": character.abilities,
"relationship_to_protagonist": character.relationship_to_protagonist,
**(character.extra or {}),
}
for character in sorted(project.characters, key=lambda c: c.position)
],
relationships=[
{
"character_from": relation.character_from,
"character_to": relation.character_to,
"description": relation.description or "",
"relationship_type": getattr(relation, "relationship_type", None),
}
for relation in sorted(project.relationships_, key=lambda r: r.position)
],
chapter_outline=[
ChapterOutlineSchema(
chapter_number=outline.chapter_number,
title=outline.title,
summary=outline.summary or "",
)
for outline in sorted(project.outlines, key=lambda o: o.chapter_number)
],
)
return Blueprint(
title="",
target_audience="",
genre="",
style="",
tone="",
one_sentence_summary="",
full_synopsis="",
world_setting={},
characters=[],
relationships=[],
chapter_outline=[],
)
def _build_section_response(
self,
project: NovelProject,
section: NovelSectionType,
) -> NovelSectionResponse:
blueprint = self._build_blueprint_schema(project)
if section == NovelSectionType.OVERVIEW:
data = {
"title": project.title,
"initial_prompt": project.initial_prompt or "",
"status": project.status,
"one_sentence_summary": blueprint.one_sentence_summary,
"target_audience": blueprint.target_audience,
"genre": blueprint.genre,
"style": blueprint.style,
"tone": blueprint.tone,
"full_synopsis": blueprint.full_synopsis,
"updated_at": project.updated_at.isoformat() if project.updated_at else None,
}
elif section == NovelSectionType.WORLD_SETTING:
data = {
"world_setting": blueprint.world_setting or {},
}
elif section == NovelSectionType.CHARACTERS:
data = {
"characters": blueprint.characters,
}
elif section == NovelSectionType.RELATIONSHIPS:
data = {
"relationships": blueprint.relationships,
}
elif section == NovelSectionType.CHAPTER_OUTLINE:
data = {
"chapter_outline": [outline.model_dump() for outline in blueprint.chapter_outline],
}
elif section == NovelSectionType.CHAPTERS:
outlines_map = {outline.chapter_number: outline for outline in project.outlines}
chapters_map = {chapter.chapter_number: chapter for chapter in project.chapters}
chapter_numbers = sorted(set(outlines_map.keys()) | set(chapters_map.keys()))
# 章节列表只返回元数据,不包含完整内容
chapters = [
self._build_chapter_schema(
project,
number,
outlines_map=outlines_map,
chapters_map=chapters_map,
include_content=False,
).model_dump()
for number in chapter_numbers
]
data = {
"chapters": chapters,
"total": len(chapters),
}
else:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="未知的章节类型")
return NovelSectionResponse(section=section, data=data)
def _build_chapter_schema(
self,
project: NovelProject,
chapter_number: int,
*,
outlines_map: Optional[Dict[int, ChapterOutline]] = None,
chapters_map: Optional[Dict[int, Chapter]] = None,
include_content: bool = True,
) -> ChapterSchema:
outlines = outlines_map or {outline.chapter_number: outline for outline in project.outlines}
chapters = chapters_map or {chapter.chapter_number: chapter for chapter in project.chapters}
outline = outlines.get(chapter_number)
chapter = chapters.get(chapter_number)
if not outline and not chapter:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="章节不存在")
title = outline.title if outline else f"{chapter_number}"
summary = outline.summary if outline else ""
real_summary = chapter.real_summary if chapter else None
content = None
versions: Optional[List[str]] = None
evaluation_text: Optional[str] = None
status_value = ChapterGenerationStatus.NOT_GENERATED.value
word_count = 0
if chapter:
status_value = chapter.status or ChapterGenerationStatus.NOT_GENERATED.value
word_count = chapter.word_count or 0
# 只有在 include_content=True 时才包含完整内容
if include_content:
if chapter.selected_version:
content = chapter.selected_version.content
if chapter.versions:
versions = [
v.content
for v in sorted(chapter.versions, key=lambda item: item.created_at)
]
if chapter.evaluations:
latest = sorted(chapter.evaluations, key=lambda item: item.created_at)[-1]
evaluation_text = latest.feedback or latest.decision
return ChapterSchema(
chapter_number=chapter_number,
title=title,
summary=summary,
real_summary=real_summary,
content=content,
versions=versions,
evaluation=evaluation_text,
generation_status=ChapterGenerationStatus(status_value),
word_count=word_count,
)