""" 爽文粗纲生成(fiction.coarse)— LLM 调用逻辑。 """ from __future__ import annotations import json import logging import re from typing import Any, Dict, List, Optional from langchain_core.messages import HumanMessage, SystemMessage from models.fiction_models import CoarseOutline, CoarseOutlineEvent, FictionBookMetadata 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") 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) parts = [ f"主角人设:{guide.persona}", f"核心爽点:{guide.highlight}", f"用户体验:{guide.experience}", f"创作禁区:{guide.forbiddenZones}", ] return "\n".join(parts) def _build_coarse_messages(book_id: str) -> 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("coarseOutline", user_prompt) guide_l1 = _format_guide_global_layers(["L1"]) book_guide = _format_book_guide(book_id) meta = fiction_service.get_book_meta(book_id) user_content = f"""## 全局创作指南(L1,仅用于粗纲) {guide_l1} ## 本书 Guide 世界书 {book_guide} ## 书名 {meta.title} ## 已选情绪流 ID {", ".join(meta.allowedFlowIds or []) or "(未配置)"} 请生成本书的粗纲事件链。""" return [ SystemMessage(content=system_prompt), HumanMessage(content=user_content), ] def _normalize_coarse_events(raw_events: Any) -> List[CoarseOutlineEvent]: if not isinstance(raw_events, list): return [] events: List[CoarseOutlineEvent] = [] for idx, item in enumerate(raw_events): if not isinstance(item, dict): continue evt_id = str(item.get("id") or f"evt-{idx + 1}").strip() title = str(item.get("title") or f"事件 {idx + 1}").strip() summary = str(item.get("summary") or "").strip() order = item.get("order") if not isinstance(order, int): order = idx + 1 events.append( CoarseOutlineEvent(id=evt_id, title=title, summary=summary, order=order) ) events.sort(key=lambda e: e.order) return events def _parse_coarse_response(data: Dict[str, Any]) -> CoarseOutline: events = _normalize_coarse_events(data.get("events")) if not events: raise ValueError("粗纲事件列表为空") version = data.get("version") if not isinstance(version, int): version = 1 return CoarseOutline(events=events, version=version) async def run_coarse_outline( book_id: str, *, profile_id: Optional[str] = None, api_config: Optional[Dict[str, str]] = None, ) -> FictionBookMetadata: existing = fiction_metadata_service.get_metadata(book_id) if existing.coarseOutline.events: logger.info("Coarse outline already exists for book %s, skipping generation", book_id) return existing resolved = resolve_api_config(profile_id, api_config) _validate_api_config(resolved) 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="coarse" ) try: messages = _build_coarse_messages(book_id) response = await _llm_client.chat_completion( messages=messages, api_url=resolved["api_url"], api_key=resolved["api_key"], model=resolved.get("model", "gpt-4o-mini"), temperature=0.7, max_tokens=8000, request_timeout=120, stream=False, ) content = response["choices"][0]["message"]["content"] data = _extract_json(content) coarse = _parse_coarse_response(data) current = fiction_metadata_service.get_metadata(book_id) current.coarseOutline = coarse saved = fiction_metadata_service.save_metadata(book_id, current) fiction_metadata_service.set_pipeline_stage( book_id, status="idle", pipeline_stage="coarse_done" ) return saved except Exception: fiction_metadata_service.set_pipeline_stage( book_id, status="error", pipeline_stage="coarse" ) raise