""" 爽文章节写作(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, )