179 lines
5.9 KiB
Python
179 lines
5.9 KiB
Python
"""
|
||
爽文粗纲生成(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
|