Files
SillyTavern_replica/backend/services/fiction_chapter_service.py

318 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
爽文章节写作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,
)