Files
SillyTavern_replica/backend/services/fiction_coarse_service.py

179 lines
5.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
爽文粗纲生成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