""" 爽文开书优化(fiction.open_book)— 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 ( EmotionFlow, FictionGuideWorldbook, OpenBookResult, ) 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_catalog_for_prompt(flows: List[EmotionFlow]) -> str: lines: List[str] = [] for flow in flows: tags = "、".join(flow.tags or []) lines.append(f"- id: {flow.id}\n intro: {flow.intro}\n tags: {tags}") return "\n".join(lines) if lines else "(无可用情绪流)" def _format_guide_global_for_prompt() -> str: entries = fiction_service.get_guide_global_entries().entries lines: List[str] = [] for entry in entries: lines.append(f"[{entry.layer}] {entry.title}\n{entry.content}") return "\n\n".join(lines) if lines else "(无全局指南)" def _build_open_book_messages(inspiration: str) -> List[Any]: default_settings = fiction_service.get_default_settings() system_prompt = resolve_prompt("openBook", default_settings.prompts.openBook) catalog = fiction_service.get_emotion_catalog() catalog_text = _format_catalog_for_prompt(catalog.flows) guide_global_text = _format_guide_global_for_prompt() user_content = f"""## 全局创作指南(L0–L3) {guide_global_text} ## 可选情绪流 catalog {catalog_text} ## 用户创作灵感 {inspiration.strip()} 请根据以上信息优化开书方案。""" return [ SystemMessage(content=system_prompt), HumanMessage(content=user_content), ] def _normalize_flow_ids(raw_ids: Any, valid_ids: set[str]) -> List[str]: if not isinstance(raw_ids, list): return [] result: List[str] = [] for item in raw_ids: fid = str(item).strip() if fid in valid_ids and fid not in result: result.append(fid) return result def _parse_open_book_response( data: Dict[str, Any], valid_flow_ids: set[str] ) -> OpenBookResult: guide_raw = data.get("guide") or {} guide = FictionGuideWorldbook( persona=str(guide_raw.get("persona") or "").strip(), highlight=str(guide_raw.get("highlight") or "").strip(), experience=str(guide_raw.get("experience") or "").strip(), forbiddenZones=str(guide_raw.get("forbiddenZones") or "").strip(), ) allowed = _normalize_flow_ids(data.get("allowedFlowIds"), valid_flow_ids) if not allowed and valid_flow_ids: allowed = [next(iter(valid_flow_ids))] title = str(data.get("title") or "未命名作品").strip() or "未命名作品" optimized_intro = str(data.get("optimizedIntro") or "").strip() return OpenBookResult( title=title, optimizedIntro=optimized_intro, guide=guide, allowedFlowIds=allowed, ) async def run_open_book( inspiration: str, *, profile_id: Optional[str] = None, api_config: Optional[Dict[str, str]] = None, ) -> OpenBookResult: inspiration = (inspiration or "").strip() if not inspiration: raise ValueError("创作灵感不能为空") resolved = resolve_api_config(profile_id, api_config) _validate_api_config(resolved) catalog = fiction_service.get_emotion_catalog() valid_flow_ids = {f.id for f in catalog.flows} messages = _build_open_book_messages(inspiration) 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) return _parse_open_book_response(data, valid_flow_ids)