feat: add NovelPage component with bookshelf, open book, and reading views
This commit is contained in:
317
backend/services/fiction_chapter_service.py
Normal file
317
backend/services/fiction_chapter_service.py
Normal file
@@ -0,0 +1,317 @@
|
||||
"""
|
||||
爽文章节写作(fiction.chapter)— 新版 metadata v2 章纲驱动。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, Iterator, List, Optional, Tuple
|
||||
|
||||
from langchain_core.messages import HumanMessage, SystemMessage
|
||||
|
||||
from models.fiction_models import (
|
||||
ChapterPlanItem,
|
||||
EventChainItem,
|
||||
FictionBookMetadata,
|
||||
FictionChapter,
|
||||
VolumeOutline,
|
||||
)
|
||||
from services.fiction_metadata_service import fiction_metadata_service
|
||||
from services.fiction_planning_service import ensure_chapter_plan
|
||||
from services.fiction_prompt_utils import resolve_prompt
|
||||
from services.fiction_service import fiction_service
|
||||
from services.studio_step_respond import resolve_api_config
|
||||
from utils.llm_client import LLMClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_llm_client = LLMClient()
|
||||
_JSON_FENCE = re.compile(r"```(?:json)?\s*([\s\S]*?)```", re.IGNORECASE)
|
||||
|
||||
PlannedChapter = Tuple[int, VolumeOutline, EventChainItem, ChapterPlanItem]
|
||||
|
||||
|
||||
def _extract_json(raw: str) -> Dict[str, Any]:
|
||||
text = (raw or "").strip()
|
||||
if not text:
|
||||
raise ValueError("模型返回为空")
|
||||
fence = _JSON_FENCE.search(text)
|
||||
if fence:
|
||||
text = fence.group(1).strip()
|
||||
try:
|
||||
return json.loads(text)
|
||||
except json.JSONDecodeError:
|
||||
start = text.find("{")
|
||||
end = text.rfind("}")
|
||||
if start >= 0 and end > start:
|
||||
return json.loads(text[start : end + 1])
|
||||
raise ValueError("无法解析模型返回的 JSON")
|
||||
|
||||
|
||||
def _validate_api_config(api_config: Dict[str, str]) -> None:
|
||||
if not api_config.get("api_key"):
|
||||
raise ValueError("API Key 未配置,请先在 API 配置页面保存密钥")
|
||||
if not api_config.get("api_url"):
|
||||
raise ValueError("API 地址未配置,请先在 API 配置页面保存 mainLLM")
|
||||
if not api_config.get("model"):
|
||||
raise ValueError("模型未配置,请先在 API 配置页面保存 mainLLM 模型")
|
||||
|
||||
|
||||
def _format_guide_global_layers(layers: List[str]) -> str:
|
||||
entries = fiction_service.get_guide_global_entries().entries
|
||||
filtered = [e for e in entries if e.layer in layers]
|
||||
lines: List[str] = []
|
||||
for entry in filtered:
|
||||
lines.append(f"[{entry.layer}] {entry.title}\n{entry.content}")
|
||||
return "\n\n".join(lines) if lines else "(无全局指南)"
|
||||
|
||||
|
||||
def _format_book_guide(book_id: str) -> str:
|
||||
guide = fiction_service.get_book_guide(book_id)
|
||||
return guide.chapter.content or "(无章节层世界书)"
|
||||
|
||||
|
||||
def iter_planned_chapters(metadata: FictionBookMetadata) -> Iterator[PlannedChapter]:
|
||||
"""按卷纲 → 事件链 → 章纲顺序展开章节计划。"""
|
||||
volumes = sorted(metadata.volumes or [], key=lambda v: v.order)
|
||||
for volume in volumes:
|
||||
events = sorted(metadata.eventChains.get(volume.id, []), key=lambda e: e.order)
|
||||
for event in events:
|
||||
plans = sorted(metadata.chapterPlans.get(event.id, []), key=lambda c: c.seq)
|
||||
for item in plans:
|
||||
yield item.seq, volume, event, item
|
||||
|
||||
|
||||
def find_next_unwritten_chapter(
|
||||
book_id: str, metadata: Optional[FictionBookMetadata] = None
|
||||
) -> Optional[PlannedChapter]:
|
||||
metadata = metadata or fiction_metadata_service.get_metadata(book_id)
|
||||
for seq, volume, event, item in iter_planned_chapters(metadata):
|
||||
if fiction_service.chapter_exists(book_id, seq):
|
||||
continue
|
||||
if item.status == "written":
|
||||
continue
|
||||
return seq, volume, event, item
|
||||
return None
|
||||
|
||||
|
||||
def has_written_chapters(book_id: str) -> bool:
|
||||
return len(fiction_service.list_written_chapter_seqs(book_id)) > 0
|
||||
|
||||
|
||||
def _build_context_tail(book_id: str, before_seq: int, context_chars: int) -> str:
|
||||
if before_seq <= 1 or context_chars <= 0:
|
||||
return ""
|
||||
parts: List[str] = []
|
||||
for seq in range(1, before_seq):
|
||||
if not fiction_service.chapter_exists(book_id, seq):
|
||||
continue
|
||||
ch = fiction_service.get_chapter(book_id, seq)
|
||||
if ch.body:
|
||||
parts.append(ch.body)
|
||||
combined = "\n\n".join(parts)
|
||||
if len(combined) <= context_chars:
|
||||
return combined
|
||||
return combined[-context_chars:]
|
||||
|
||||
|
||||
def _build_chapter_messages(
|
||||
book_id: str,
|
||||
global_seq: int,
|
||||
volume: VolumeOutline,
|
||||
event: EventChainItem,
|
||||
plan_item: ChapterPlanItem,
|
||||
) -> List[Any]:
|
||||
settings = fiction_service.get_book_settings(book_id)
|
||||
user_prompt = settings.prompts.chapter or fiction_service.get_default_settings().prompts.chapter
|
||||
system_prompt = resolve_prompt("chapter", user_prompt)
|
||||
|
||||
context_chars = settings.reader.contextWindowChars if settings.reader else 2000
|
||||
context_tail = _build_context_tail(book_id, global_seq, context_chars)
|
||||
guide_l3 = _format_guide_global_layers(["L3"])
|
||||
book_guide = _format_book_guide(book_id)
|
||||
|
||||
context_block = context_tail if context_tail else "(本章为开篇,无上文)"
|
||||
|
||||
user_content = f"""## 全局创作指南(L3)
|
||||
{guide_l3}
|
||||
|
||||
## 本书世界书(章节层)
|
||||
{book_guide}
|
||||
|
||||
## 当前卷纲
|
||||
- id: {volume.id}
|
||||
- title: {volume.title}
|
||||
- goal: {volume.goal}
|
||||
- coreConflict: {volume.coreConflict}
|
||||
- emotionalPromise: {volume.emotionalPromise}
|
||||
|
||||
## 当前事件
|
||||
- id: {event.id}
|
||||
- title: {event.title}
|
||||
- summary: {event.summary}
|
||||
- purpose: {event.purpose}
|
||||
- conflict: {event.conflict}
|
||||
- turningPoint: {event.turningPoint}
|
||||
- expectedPayoff: {event.expectedPayoff}
|
||||
|
||||
## 本章章纲(第 {global_seq} 章)
|
||||
- title: {plan_item.title}
|
||||
- goal: {plan_item.goal}
|
||||
- opening: {plan_item.opening}
|
||||
- mainConflict: {plan_item.mainConflict}
|
||||
- emotionalTurn: {plan_item.emotionalTurn}
|
||||
- emotionStepKey: {plan_item.emotionStepKey}
|
||||
- emotionGoal: {plan_item.emotionGoal}
|
||||
- payoff: {plan_item.payoff}
|
||||
- endingHook: {plan_item.endingHook}
|
||||
- forbidden: {plan_item.forbidden}
|
||||
- targetWords: {plan_item.targetWords}
|
||||
|
||||
## 已读上文末尾(最多 {context_chars} 字,供衔接)
|
||||
{context_block}
|
||||
|
||||
## 绝对要求
|
||||
- 只生成第 {global_seq} 章正文。
|
||||
- 正文字数目标约 {plan_item.targetWords or 2000} 汉字。
|
||||
- 必须遵循本章章纲、当前事件、当前卷纲与章节层世界书。
|
||||
- 不生成下一章章纲,不生成解释说明。"""
|
||||
|
||||
return [
|
||||
SystemMessage(content=system_prompt),
|
||||
HumanMessage(content=user_content),
|
||||
]
|
||||
|
||||
|
||||
def _parse_chapter_response(
|
||||
data: Dict[str, Any],
|
||||
*,
|
||||
global_seq: int,
|
||||
event: EventChainItem,
|
||||
plan_item: ChapterPlanItem,
|
||||
) -> FictionChapter:
|
||||
body = str(data.get("body") or "").strip()
|
||||
if not body:
|
||||
raise ValueError("章节正文为空")
|
||||
title = str(data.get("title") or plan_item.title or f"第{global_seq}章").strip()
|
||||
return FictionChapter(
|
||||
seq=global_seq,
|
||||
title=title,
|
||||
body=body,
|
||||
charCount=len(body),
|
||||
status="written",
|
||||
eventId=event.id,
|
||||
phaseKey=plan_item.emotionStepKey or plan_item.phaseKey,
|
||||
createdAt=datetime.now().isoformat(),
|
||||
)
|
||||
|
||||
|
||||
def _mark_chapter_written(
|
||||
metadata: FictionBookMetadata, event_id: str, plan_seq: int
|
||||
) -> FictionBookMetadata:
|
||||
plans = metadata.chapterPlans.get(event_id, [])
|
||||
updated_plan: List[ChapterPlanItem] = []
|
||||
for item in plans:
|
||||
if item.seq == plan_seq:
|
||||
updated_plan.append(item.model_copy(update={"status": "written"}))
|
||||
else:
|
||||
updated_plan.append(item)
|
||||
metadata.chapterPlans[event_id] = updated_plan
|
||||
return metadata
|
||||
|
||||
|
||||
async def run_chapter(
|
||||
book_id: str,
|
||||
*,
|
||||
profile_id: Optional[str] = None,
|
||||
api_config: Optional[Dict[str, str]] = None,
|
||||
seq: Optional[int] = None,
|
||||
) -> FictionChapter:
|
||||
resolved = resolve_api_config(profile_id, api_config)
|
||||
_validate_api_config(resolved)
|
||||
|
||||
metadata = await ensure_chapter_plan(
|
||||
book_id,
|
||||
profile_id=profile_id,
|
||||
api_config=api_config,
|
||||
)
|
||||
|
||||
target: Optional[PlannedChapter] = None
|
||||
if seq is not None:
|
||||
for global_seq, volume, event, item in iter_planned_chapters(metadata):
|
||||
if global_seq == seq:
|
||||
target = (global_seq, volume, event, item)
|
||||
break
|
||||
if not target:
|
||||
raise ValueError(f"章节规划不存在: seq={seq}")
|
||||
global_seq, volume, event, item = target
|
||||
if fiction_service.chapter_exists(book_id, global_seq):
|
||||
return fiction_service.get_chapter(book_id, global_seq)
|
||||
else:
|
||||
found = find_next_unwritten_chapter(book_id, metadata)
|
||||
if not found:
|
||||
raise ValueError("没有待撰写的章节")
|
||||
global_seq, volume, event, item = found
|
||||
if fiction_service.chapter_exists(book_id, global_seq):
|
||||
return fiction_service.get_chapter(book_id, global_seq)
|
||||
|
||||
run = fiction_metadata_service.get_run(book_id)
|
||||
if run.status == "error":
|
||||
fiction_metadata_service.clear_pipeline_error(book_id)
|
||||
|
||||
fiction_metadata_service.set_pipeline_stage(
|
||||
book_id, status="running", pipeline_stage="chapter"
|
||||
)
|
||||
try:
|
||||
messages = _build_chapter_messages(book_id, global_seq, volume, event, item)
|
||||
response = await _llm_client.chat_completion(
|
||||
messages=messages,
|
||||
api_url=resolved["api_url"],
|
||||
api_key=resolved["api_key"],
|
||||
model=resolved["model"],
|
||||
temperature=0.8,
|
||||
max_tokens=8000,
|
||||
request_timeout=180,
|
||||
stream=False,
|
||||
)
|
||||
content = response["choices"][0]["message"]["content"]
|
||||
data = _extract_json(content)
|
||||
chapter = _parse_chapter_response(
|
||||
data, global_seq=global_seq, event=event, plan_item=item
|
||||
)
|
||||
fiction_service.save_chapter(book_id, chapter)
|
||||
|
||||
metadata = fiction_metadata_service.get_metadata(book_id)
|
||||
metadata = _mark_chapter_written(metadata, event.id, item.seq)
|
||||
progress = metadata.progress
|
||||
if progress.currentChapterSeq <= 0:
|
||||
progress.currentChapterSeq = global_seq
|
||||
metadata.progress = progress
|
||||
fiction_metadata_service.save_metadata(book_id, metadata)
|
||||
|
||||
fiction_metadata_service.set_pipeline_stage(
|
||||
book_id, status="idle", pipeline_stage="ready"
|
||||
)
|
||||
return chapter
|
||||
except Exception:
|
||||
fiction_metadata_service.set_pipeline_stage(
|
||||
book_id, status="error", pipeline_stage="chapter"
|
||||
)
|
||||
raise
|
||||
|
||||
|
||||
async def ensure_chapter(
|
||||
book_id: str,
|
||||
*,
|
||||
profile_id: Optional[str] = None,
|
||||
api_config: Optional[Dict[str, str]] = None,
|
||||
seq: Optional[int] = None,
|
||||
) -> FictionChapter:
|
||||
return await run_chapter(
|
||||
book_id,
|
||||
profile_id=profile_id,
|
||||
api_config=api_config,
|
||||
seq=seq,
|
||||
)
|
||||
Reference in New Issue
Block a user