Files
SillyTavern_replica/backend/services/fiction_planning_service.py

541 lines
19 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.
"""
爽文新版规划服务:卷纲 → 情感链事件串 → 章纲。
原则:
- 硬编码提示词只保留规定性约束:输出结构、字数/章节数、必须遵循的上游内容。
- “如何写爽点/如何留钩子”等创作方法交给 book-local 世界书与全局指南。
- book-local 世界书按 volume/event/chapter 三层分别插入,不混用。
"""
from __future__ import annotations
import json
import logging
import random
import re
from typing import Any, Dict, List, Optional
from langchain_core.messages import HumanMessage, SystemMessage
from models.fiction_models import (
ChapterPlanItem,
EventChainItem,
FictionBookMetadata,
VolumeOutline,
)
from services.fiction_metadata_service import fiction_metadata_service
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)
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)
lines = [
f"主角人设:{guide.persona}",
f"核心爽点:{guide.highlight}",
f"用户体验:{guide.experience}",
f"创作禁区:{guide.forbiddenZones}",
]
text = "\n".join(line for line in lines if line.split("", 1)[1].strip()).strip()
return text or "(无本书 guide"
def _flow_catalog_text() -> str:
catalog = fiction_service.get_emotion_catalog()
lines: List[str] = []
for flow in catalog.flows:
steps = "".join([f"{s.key}:{s.text}" for s in flow.steps])
lines.append(f"- {flow.id}: {flow.intro} | steps={steps}")
return "\n".join(lines) if lines else "(无情感链目录)"
def _get_flow_by_id(flow_id: str):
catalog = fiction_service.get_emotion_catalog()
for flow in catalog.flows:
if flow.id == flow_id:
return flow
return None
def _choose_flow_id(book_id: str) -> str:
meta = fiction_service.get_book_meta(book_id)
allowed = list(meta.allowedFlowIds or [])
catalog = fiction_service.get_emotion_catalog()
catalog_ids = [flow.id for flow in catalog.flows]
candidates = [fid for fid in allowed if fid in catalog_ids] or allowed or catalog_ids
if not candidates:
return ""
return random.choice(candidates)
def _format_flow(flow_id: str) -> str:
flow = _get_flow_by_id(flow_id)
if not flow:
return f"情感链 id: {flow_id or '(未指定)'}"
lines = [f"情感链:{flow.intro} (id: {flow.id})"]
for step in flow.steps or []:
lines.append(f"- {step.key}: {step.text}")
return "\n".join(lines)
def _next_volume_id(metadata: FictionBookMetadata) -> str:
return f"vol_{len(metadata.volumes) + 1:03d}"
def _next_event_id(metadata: FictionBookMetadata, index: int) -> str:
all_events = [event for chain in metadata.eventChains.values() for event in chain]
return f"evt_{len(all_events) + index + 1:04d}"
def _build_volume_messages(book_id: str, metadata: FictionBookMetadata) -> List[Any]:
settings = fiction_service.get_book_settings(book_id)
user_prompt = (
settings.prompts.coarseOutline
or fiction_service.get_default_settings().prompts.coarseOutline
)
system_prompt = resolve_prompt("volumeOutline", user_prompt)
meta = fiction_service.get_book_meta(book_id)
guide_l1 = _format_guide_global_layers(["L1"])
book_guide = _format_book_guide(book_id)
existing = "\n".join([f"- {v.id} {v.title}: {v.goal}" for v in metadata.volumes]) or "(暂无)"
user_content = f"""## 全局创作指南L1
{guide_l1}
## 本书 guide具体人设/爽点/体验/禁区)
{book_guide}
## 书名
{meta.title}
## 已有卷纲
{existing}
## 可用情感链目录
{_flow_catalog_text()}
## 本次任务
生成下一卷卷纲。
## 绝对要求
- 只生成 1 卷。
- 本卷目标章节数 targetChapterCount 必须在 10 到 30 之间。
- primaryEmotionFlowId 必须来自可用情感链目录;如目录为空则留空。
- 不生成事件链、章纲或正文。
"""
return [SystemMessage(content=system_prompt), HumanMessage(content=user_content)]
def _normalize_volume(raw: Dict[str, Any], volume_id: str, order: int) -> VolumeOutline:
target = raw.get("targetChapterCount")
if not isinstance(target, int):
target = 20
return VolumeOutline(
id=str(raw.get("id") or volume_id),
order=order,
title=str(raw.get("title") or f"{order}"),
goal=str(raw.get("goal") or ""),
coreConflict=str(raw.get("coreConflict") or raw.get("core_conflict") or ""),
powerProgression=str(raw.get("powerProgression") or raw.get("power_progression") or ""),
emotionalPromise=str(raw.get("emotionalPromise") or raw.get("emotional_promise") or ""),
endingHook=str(raw.get("endingHook") or raw.get("ending_hook") or ""),
targetChapterCount=max(10, min(30, target)),
primaryEmotionFlowId=str(raw.get("primaryEmotionFlowId") or raw.get("primary_emotion_flow_id") or ""),
status=str(raw.get("status") or "active"),
)
def _build_event_chain_messages(book_id: str, volume: VolumeOutline, flow_id: str) -> List[Any]:
settings = fiction_service.get_book_settings(book_id)
user_prompt = (
settings.prompts.eventPlan
or fiction_service.get_default_settings().prompts.eventPlan
)
system_prompt = resolve_prompt("eventChain", user_prompt)
guide_l2 = _format_guide_global_layers(["L2"])
book_guide = _format_book_guide(book_id)
flow_text = _format_flow(flow_id)
user_content = f"""## 全局创作指南L2
{guide_l2}
## 本书 guide具体人设/爽点/体验/禁区)
{book_guide}
## 当前卷纲
- id: {volume.id}
- title: {volume.title}
- goal: {volume.goal}
- coreConflict: {volume.coreConflict}
- powerProgression: {volume.powerProgression}
- emotionalPromise: {volume.emotionalPromise}
- endingHook: {volume.endingHook}
- targetChapterCount: {volume.targetChapterCount}
## 必须遵循的情感链
{flow_text}
## 本次任务
生成当前卷的事件链。
## 绝对要求
- 事件链总章节数应接近卷纲 targetChapterCount。
- 每个事件 targetChapterCount 必须在 2 到 5 之间。
- 每个事件必须填写 emotionFlowId、emotionStepKey、emotionStepText。
- 事件顺序必须遵循情感链 steps 的顺序,不得倒置。
- 不生成章纲或正文。
"""
return [SystemMessage(content=system_prompt), HumanMessage(content=user_content)]
def _normalize_event_chain(
raw: Any,
metadata: FictionBookMetadata,
volume: VolumeOutline,
flow_id: str,
) -> List[EventChainItem]:
raw_events = raw if isinstance(raw, list) else []
events: List[EventChainItem] = []
flow = _get_flow_by_id(flow_id)
steps = flow.steps if flow else []
for idx, item in enumerate(raw_events):
if not isinstance(item, dict):
continue
target = item.get("targetChapterCount")
if not isinstance(target, int):
target = 3
step = steps[min(idx, len(steps) - 1)] if steps else None
events.append(
EventChainItem(
id=str(item.get("id") or _next_event_id(metadata, idx)),
volumeId=volume.id,
order=int(item.get("order")) if isinstance(item.get("order"), int) else idx + 1,
title=str(item.get("title") or f"事件 {idx + 1}"),
summary=str(item.get("summary") or ""),
purpose=str(item.get("purpose") or ""),
conflict=str(item.get("conflict") or ""),
turningPoint=str(item.get("turningPoint") or item.get("turning_point") or ""),
expectedPayoff=str(item.get("expectedPayoff") or item.get("expected_payoff") or ""),
targetChapterCount=max(2, min(5, target)),
emotionFlowId=str(item.get("emotionFlowId") or item.get("emotion_flow_id") or flow_id),
emotionStepKey=str(item.get("emotionStepKey") or item.get("emotion_step_key") or (step.key if step else "")),
emotionStepText=str(item.get("emotionStepText") or item.get("emotion_step_text") or (step.text if step else "")),
status=str(item.get("status") or "planned"),
)
)
events.sort(key=lambda e: e.order)
if not events:
raise ValueError("事件链为空")
return events
def _build_chapter_plan_messages(
book_id: str,
volume: VolumeOutline,
event: EventChainItem,
) -> List[Any]:
settings = fiction_service.get_book_settings(book_id)
user_prompt = (
settings.prompts.eventPlan
or fiction_service.get_default_settings().prompts.eventPlan
)
system_prompt = resolve_prompt("chapterPlan", user_prompt)
guide_l3 = _format_guide_global_layers(["L3"])
book_guide = _format_book_guide(book_id)
user_content = f"""## 全局创作指南L3
{guide_l3}
## 本书 guide具体人设/爽点/体验/禁区)
{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}
- targetChapterCount: {event.targetChapterCount}
## 当前事件绑定的情感链步骤
- emotionFlowId: {event.emotionFlowId}
- emotionStepKey: {event.emotionStepKey}
- emotionStepText: {event.emotionStepText}
## 本次任务
为当前事件生成章纲。
## 绝对要求
- 必须生成 {event.targetChapterCount} 章章纲。
- 每章 targetWords 必须为 2000。
- 每章必须继承当前事件 id。
- 每章必须填写 emotionStepKey 与 emotionGoal。
- 不生成正文。
"""
return [SystemMessage(content=system_prompt), HumanMessage(content=user_content)]
def _next_chapter_seq(metadata: FictionBookMetadata) -> int:
max_seq = 0
for plans in metadata.chapterPlans.values():
for item in plans:
max_seq = max(max_seq, item.seq)
return max_seq + 1
def _normalize_chapter_plans(
raw: Any,
metadata: FictionBookMetadata,
event: EventChainItem,
) -> List[ChapterPlanItem]:
raw_items = raw if isinstance(raw, list) else []
start_seq = _next_chapter_seq(metadata)
items: List[ChapterPlanItem] = []
for idx, item in enumerate(raw_items):
if not isinstance(item, dict):
continue
seq = start_seq + idx
title = str(item.get("title") or f"{seq}")
goal = str(item.get("goal") or item.get("brief") or "")
items.append(
ChapterPlanItem(
seq=seq,
phaseKey=str(item.get("phaseKey") or event.emotionStepKey),
phaseSlice=str(item.get("phaseSlice") or ""),
brief=str(item.get("brief") or goal),
eventId=event.id,
title=title,
goal=goal,
opening=str(item.get("opening") or ""),
mainConflict=str(item.get("mainConflict") or item.get("main_conflict") or event.conflict),
emotionalTurn=str(item.get("emotionalTurn") or item.get("emotional_turn") or ""),
emotionStepKey=str(item.get("emotionStepKey") or item.get("emotion_step_key") or event.emotionStepKey),
emotionGoal=str(item.get("emotionGoal") or item.get("emotion_goal") or event.emotionStepText),
payoff=str(item.get("payoff") or event.expectedPayoff),
endingHook=str(item.get("endingHook") or item.get("ending_hook") or ""),
forbidden=str(item.get("forbidden") or ""),
targetWords=2000,
status=str(item.get("status") or "planned"),
)
)
items.sort(key=lambda c: c.seq)
if not items:
raise ValueError("章纲为空")
return items
async def ensure_volume(
book_id: str,
*,
profile_id: Optional[str] = None,
api_config: Optional[Dict[str, str]] = None,
) -> FictionBookMetadata:
metadata = fiction_metadata_service.get_metadata(book_id)
if metadata.volumes:
return metadata
resolved = resolve_api_config(profile_id, api_config)
_validate_api_config(resolved)
fiction_metadata_service.set_pipeline_stage(
book_id, status="running", pipeline_stage="volume"
)
try:
messages = _build_volume_messages(book_id, metadata)
response = await _llm_client.chat_completion(
messages=messages,
api_url=resolved["api_url"],
api_key=resolved["api_key"],
model=resolved["model"],
temperature=0.7,
max_tokens=8000,
request_timeout=120,
stream=False,
)
data = _extract_json(response["choices"][0]["message"]["content"])
volume = _normalize_volume(
data.get("volume") if isinstance(data.get("volume"), dict) else data,
_next_volume_id(metadata),
len(metadata.volumes) + 1,
)
if not volume.primaryEmotionFlowId:
volume.primaryEmotionFlowId = _choose_flow_id(book_id)
metadata.volumes.append(volume)
saved = fiction_metadata_service.save_metadata(book_id, metadata)
fiction_metadata_service.set_pipeline_stage(
book_id, status="idle", pipeline_stage="volume_done"
)
return saved
except Exception:
fiction_metadata_service.set_pipeline_stage(
book_id, status="error", pipeline_stage="volume"
)
raise
async def ensure_event_chain(
book_id: str,
*,
volume_id: Optional[str] = None,
profile_id: Optional[str] = None,
api_config: Optional[Dict[str, str]] = None,
) -> FictionBookMetadata:
metadata = await ensure_volume(book_id, profile_id=profile_id, api_config=api_config)
volume = next((v for v in metadata.volumes if v.id == volume_id), metadata.volumes[-1])
if metadata.eventChains.get(volume.id):
return metadata
resolved = resolve_api_config(profile_id, api_config)
_validate_api_config(resolved)
flow_id = volume.primaryEmotionFlowId or _choose_flow_id(book_id)
volume.primaryEmotionFlowId = flow_id
fiction_metadata_service.set_pipeline_stage(
book_id, status="running", pipeline_stage="event_chain"
)
try:
messages = _build_event_chain_messages(book_id, volume, flow_id)
response = await _llm_client.chat_completion(
messages=messages,
api_url=resolved["api_url"],
api_key=resolved["api_key"],
model=resolved["model"],
temperature=0.7,
max_tokens=8000,
request_timeout=120,
stream=False,
)
data = _extract_json(response["choices"][0]["message"]["content"])
raw_events = data.get("events") or data.get("eventChain") or data.get("event_chain")
events = _normalize_event_chain(raw_events, metadata, volume, flow_id)
metadata.eventChains[volume.id] = events
saved = fiction_metadata_service.save_metadata(book_id, metadata)
fiction_metadata_service.set_pipeline_stage(
book_id, status="idle", pipeline_stage="event_chain_done"
)
return saved
except Exception:
fiction_metadata_service.set_pipeline_stage(
book_id, status="error", pipeline_stage="event_chain"
)
raise
async def ensure_chapter_plan(
book_id: str,
*,
event_id: Optional[str] = None,
profile_id: Optional[str] = None,
api_config: Optional[Dict[str, str]] = None,
) -> FictionBookMetadata:
metadata = await ensure_event_chain(book_id, profile_id=profile_id, api_config=api_config)
target_event: Optional[EventChainItem] = None
target_volume: Optional[VolumeOutline] = None
for volume in metadata.volumes:
for event in metadata.eventChains.get(volume.id, []):
if event_id and event.id != event_id:
continue
if metadata.chapterPlans.get(event.id):
if event_id:
return metadata
continue
target_event = event
target_volume = volume
break
if target_event:
break
if not target_event or not target_volume:
return metadata
resolved = resolve_api_config(profile_id, api_config)
_validate_api_config(resolved)
fiction_metadata_service.set_pipeline_stage(
book_id, status="running", pipeline_stage="chapter_plan"
)
try:
messages = _build_chapter_plan_messages(book_id, target_volume, target_event)
response = await _llm_client.chat_completion(
messages=messages,
api_url=resolved["api_url"],
api_key=resolved["api_key"],
model=resolved["model"],
temperature=0.7,
max_tokens=8000,
request_timeout=120,
stream=False,
)
data = _extract_json(response["choices"][0]["message"]["content"])
raw_items = data.get("chapterPlan") or data.get("chapters") or data.get("chapter_plan")
plans = _normalize_chapter_plans(raw_items, metadata, target_event)
metadata.chapterPlans[target_event.id] = plans
saved = fiction_metadata_service.save_metadata(book_id, metadata)
fiction_metadata_service.set_pipeline_stage(
book_id, status="idle", pipeline_stage="chapter_plan_done"
)
return saved
except Exception:
fiction_metadata_service.set_pipeline_stage(
book_id, status="error", pipeline_stage="chapter_plan"
)
raise