541 lines
19 KiB
Python
541 lines
19 KiB
Python
"""
|
||
爽文新版规划服务:卷纲 → 情感链事件串 → 章纲。
|
||
|
||
原则:
|
||
- 硬编码提示词只保留规定性约束:输出结构、字数/章节数、必须遵循的上游内容。
|
||
- “如何写爽点/如何留钩子”等创作方法交给 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
|