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