423 lines
13 KiB
Python
423 lines
13 KiB
Python
"""
|
||
爽文事件规划(fiction.event_plan)— LLM 调用逻辑。
|
||
按事件顺序生成,每完成一个事件即持久化并广播进度。
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import logging
|
||
import random
|
||
import re
|
||
from typing import Any, AsyncIterator, Dict, List, Optional
|
||
|
||
from langchain_core.messages import HumanMessage, SystemMessage
|
||
|
||
from models.fiction_models import (
|
||
ChapterPlanItem,
|
||
CoarseOutlineEvent,
|
||
EventPlanEntry,
|
||
FictionBookMetadata,
|
||
FlowStepsPlan,
|
||
)
|
||
from services.fiction_event_plan_progress import emit as emit_progress
|
||
from services.fiction_event_plan_progress import subscribe as subscribe_progress
|
||
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 _get_flow_by_id(flow_id: str):
|
||
catalog = fiction_service.get_emotion_catalog()
|
||
for flow in catalog.flows:
|
||
if flow.id == flow_id:
|
||
return flow
|
||
return None
|
||
|
||
|
||
def _format_flow_steps(flow) -> str:
|
||
lines = [f"情绪流:{flow.intro} (id: {flow.id})"]
|
||
for step in flow.steps or []:
|
||
lines.append(f" [{step.key}] {step.text}")
|
||
return "\n".join(lines)
|
||
|
||
|
||
def _build_event_plan_messages(
|
||
book_id: str,
|
||
event_id: str,
|
||
event_title: str,
|
||
event_summary: str,
|
||
emotion_flow_id: str,
|
||
) -> List[Any]:
|
||
settings = fiction_service.get_book_settings(book_id)
|
||
user_prompt = settings.prompts.eventPlan or fiction_service.get_default_settings().prompts.eventPlan
|
||
system_prompt = resolve_prompt("eventPlan", user_prompt)
|
||
|
||
guide_l2 = _format_guide_global_layers(["L2"])
|
||
book_guide = _format_book_guide(book_id)
|
||
flow = _get_flow_by_id(emotion_flow_id)
|
||
flow_text = _format_flow_steps(flow) if flow else f"情绪流 id: {emotion_flow_id}"
|
||
|
||
user_content = f"""## 全局创作指南(L2,仅用于事件规划)
|
||
{guide_l2}
|
||
|
||
## 本书 Guide 世界书
|
||
{book_guide}
|
||
|
||
## 当前粗纲事件
|
||
- id: {event_id}
|
||
- title: {event_title}
|
||
- summary: {event_summary}
|
||
|
||
## 为本事件随机选定的情绪流
|
||
{flow_text}
|
||
|
||
请输出 flowStepsPlan(起承转合)与 chapterPlan(章节级 brief)。
|
||
|
||
输出 JSON 示例:
|
||
{{
|
||
"flowStepsPlan": {{
|
||
"起": "本阶段规划…",
|
||
"承": "…",
|
||
"转": "…",
|
||
"合": "…"
|
||
}},
|
||
"chapterPlan": [
|
||
{{ "seq": 1, "phaseKey": "起", "phaseSlice": "全", "brief": "本章要点", "status": "planned" }}
|
||
]
|
||
}}"""
|
||
|
||
return [
|
||
SystemMessage(content=system_prompt),
|
||
HumanMessage(content=user_content),
|
||
]
|
||
|
||
|
||
def _normalize_flow_steps_plan(raw: Any) -> FlowStepsPlan:
|
||
data = raw if isinstance(raw, dict) else {}
|
||
return FlowStepsPlan(
|
||
起=str(data.get("起") or data.get("qi") or ""),
|
||
承=str(data.get("承") or data.get("cheng") or ""),
|
||
转=str(data.get("转") or data.get("zhuan") or ""),
|
||
合=str(data.get("合") or data.get("he") or ""),
|
||
)
|
||
|
||
|
||
def _normalize_chapter_plan(raw: Any) -> List[ChapterPlanItem]:
|
||
if not isinstance(raw, list):
|
||
return []
|
||
items: List[ChapterPlanItem] = []
|
||
for idx, item in enumerate(raw):
|
||
if not isinstance(item, dict):
|
||
continue
|
||
seq = item.get("seq")
|
||
if not isinstance(seq, int):
|
||
seq = idx + 1
|
||
status = str(item.get("status") or "planned")
|
||
items.append(
|
||
ChapterPlanItem(
|
||
seq=seq,
|
||
phaseKey=str(item.get("phaseKey") or item.get("phase_key") or "起"),
|
||
phaseSlice=str(item.get("phaseSlice") or item.get("phase_slice") or ""),
|
||
brief=str(item.get("brief") or ""),
|
||
status=status,
|
||
)
|
||
)
|
||
items.sort(key=lambda c: c.seq)
|
||
return items
|
||
|
||
|
||
def _parse_event_plan_response(data: Dict[str, Any], emotion_flow_id: str) -> EventPlanEntry:
|
||
flow_steps = _normalize_flow_steps_plan(data.get("flowStepsPlan"))
|
||
chapter_plan = _normalize_chapter_plan(data.get("chapterPlan"))
|
||
if not chapter_plan:
|
||
raise ValueError("chapterPlan 为空")
|
||
return EventPlanEntry(
|
||
emotionFlowId=emotion_flow_id,
|
||
flowStepsPlan=flow_steps,
|
||
chapterPlan=chapter_plan,
|
||
)
|
||
|
||
|
||
def _event_fully_planned(entry: EventPlanEntry) -> bool:
|
||
return bool(entry.chapterPlan)
|
||
|
||
|
||
def _resolve_targets(
|
||
metadata: FictionBookMetadata,
|
||
*,
|
||
event_id: Optional[str] = None,
|
||
) -> List[CoarseOutlineEvent]:
|
||
coarse_events = metadata.coarseOutline.events
|
||
if not coarse_events:
|
||
raise ValueError("请先生成粗纲")
|
||
|
||
events_map = dict(metadata.events or {})
|
||
if event_id:
|
||
targets = [e for e in coarse_events if e.id == event_id]
|
||
if not targets:
|
||
raise ValueError(f"粗纲中不存在事件: {event_id}")
|
||
return targets
|
||
|
||
return [
|
||
e
|
||
for e in coarse_events
|
||
if e.id not in events_map or not _event_fully_planned(events_map[e.id])
|
||
]
|
||
|
||
|
||
def event_planned_payload(evt: CoarseOutlineEvent, entry: EventPlanEntry) -> Dict[str, Any]:
|
||
phases: List[str] = []
|
||
fsp = entry.flowStepsPlan
|
||
for key in ("起", "承", "转", "合"):
|
||
if getattr(fsp, key, ""):
|
||
phases.append(key)
|
||
return {
|
||
"type": "event_planned",
|
||
"eventId": evt.id,
|
||
"title": evt.title,
|
||
"chapterCount": len(entry.chapterPlan),
|
||
"phases": phases,
|
||
}
|
||
|
||
|
||
def build_progress_snapshot(book_id: str) -> Dict[str, Any]:
|
||
"""已规划事件的目录快照(不含 brief 剧透)。"""
|
||
metadata = fiction_metadata_service.get_metadata(book_id)
|
||
coarse_events = metadata.coarseOutline.events
|
||
events_map = metadata.events or {}
|
||
items: List[Dict[str, Any]] = []
|
||
for evt in coarse_events:
|
||
entry = events_map.get(evt.id)
|
||
if entry and _event_fully_planned(entry):
|
||
items.append(event_planned_payload(evt, entry))
|
||
run = fiction_metadata_service.get_run(book_id)
|
||
progress = run.progress or {}
|
||
return {
|
||
"type": "snapshot",
|
||
"items": items,
|
||
"done": progress.get("done", len(items)),
|
||
"total": progress.get("total", len(coarse_events)),
|
||
}
|
||
|
||
|
||
async def _generate_one_event(
|
||
book_id: str,
|
||
evt: CoarseOutlineEvent,
|
||
*,
|
||
resolved: Dict[str, str],
|
||
allowed: List[str],
|
||
) -> EventPlanEntry:
|
||
emotion_flow_id = random.choice(allowed)
|
||
messages = _build_event_plan_messages(
|
||
book_id,
|
||
evt.id,
|
||
evt.title,
|
||
evt.summary,
|
||
emotion_flow_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)
|
||
return _parse_event_plan_response(data, emotion_flow_id)
|
||
|
||
|
||
async def iter_event_plan(
|
||
book_id: str,
|
||
*,
|
||
profile_id: Optional[str] = None,
|
||
api_config: Optional[Dict[str, str]] = None,
|
||
event_id: Optional[str] = None,
|
||
) -> AsyncIterator[Dict[str, Any]]:
|
||
"""按事件逐个生成,每完成一个即保存并 yield 进度事件。"""
|
||
resolved = resolve_api_config(profile_id, api_config)
|
||
_validate_api_config(resolved)
|
||
|
||
metadata = fiction_metadata_service.get_metadata(book_id)
|
||
meta = fiction_service.get_book_meta(book_id)
|
||
allowed = list(meta.allowedFlowIds or [])
|
||
if not allowed:
|
||
raise ValueError("本书未配置 allowedFlowIds")
|
||
|
||
targets = _resolve_targets(metadata, event_id=event_id)
|
||
coarse_events = metadata.coarseOutline.events
|
||
coarse_total = len(coarse_events)
|
||
|
||
if not targets:
|
||
logger.info("Event plans already exist for book %s, skipping generation", book_id)
|
||
fiction_metadata_service.set_pipeline_stage(
|
||
book_id, status="idle", pipeline_stage="event_plan_done"
|
||
)
|
||
fiction_metadata_service.clear_pipeline_progress(book_id)
|
||
done_evt: Dict[str, Any] = {"type": "done", "done": coarse_total, "total": coarse_total}
|
||
emit_progress(book_id, done_evt)
|
||
yield done_evt
|
||
return
|
||
|
||
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="event_plan"
|
||
)
|
||
|
||
events_map = dict(metadata.events or {})
|
||
|
||
def _planned_count() -> int:
|
||
return sum(
|
||
1
|
||
for e in coarse_events
|
||
if e.id in events_map and _event_fully_planned(events_map[e.id])
|
||
)
|
||
|
||
initial_done = _planned_count()
|
||
fiction_metadata_service.set_pipeline_progress(
|
||
book_id, done=initial_done, total=coarse_total
|
||
)
|
||
started: Dict[str, Any] = {
|
||
"type": "started",
|
||
"done": initial_done,
|
||
"total": coarse_total,
|
||
"pending": len(targets),
|
||
}
|
||
emit_progress(book_id, started)
|
||
yield started
|
||
|
||
try:
|
||
for evt in targets:
|
||
entry = await _generate_one_event(
|
||
book_id, evt, resolved=resolved, allowed=allowed
|
||
)
|
||
events_map[evt.id] = entry
|
||
metadata.events = events_map
|
||
fiction_metadata_service.save_metadata(book_id, metadata)
|
||
|
||
done_count = _planned_count()
|
||
fiction_metadata_service.set_pipeline_progress(
|
||
book_id, done=done_count, total=coarse_total
|
||
)
|
||
payload = event_planned_payload(evt, entry)
|
||
emit_progress(book_id, payload)
|
||
yield payload
|
||
|
||
fiction_metadata_service.set_pipeline_stage(
|
||
book_id, status="idle", pipeline_stage="event_plan_done"
|
||
)
|
||
fiction_metadata_service.clear_pipeline_progress(book_id)
|
||
done_evt = {"type": "done", "done": _planned_count(), "total": coarse_total}
|
||
emit_progress(book_id, done_evt)
|
||
yield done_evt
|
||
except Exception as exc:
|
||
completed = [
|
||
eid for eid, ent in events_map.items() if _event_fully_planned(ent)
|
||
]
|
||
err_evt: Dict[str, Any] = {
|
||
"type": "error",
|
||
"message": str(exc),
|
||
"completedEvents": completed,
|
||
"done": _planned_count(),
|
||
"total": coarse_total,
|
||
}
|
||
emit_progress(book_id, err_evt)
|
||
fiction_metadata_service.set_pipeline_stage(
|
||
book_id, status="error", pipeline_stage="event_plan"
|
||
)
|
||
yield err_evt
|
||
raise
|
||
|
||
|
||
async def run_event_plan(
|
||
book_id: str,
|
||
*,
|
||
profile_id: Optional[str] = None,
|
||
api_config: Optional[Dict[str, str]] = None,
|
||
event_id: Optional[str] = None,
|
||
) -> FictionBookMetadata:
|
||
async for _event in iter_event_plan(
|
||
book_id,
|
||
profile_id=profile_id,
|
||
api_config=api_config,
|
||
event_id=event_id,
|
||
):
|
||
pass
|
||
return fiction_metadata_service.get_metadata(book_id)
|
||
|
||
|
||
async def stream_event_plan_subscribe(book_id: str) -> AsyncIterator[Dict[str, Any]]:
|
||
"""订阅进行中的事件纲要进度(先快照,再实时)。"""
|
||
snapshot = build_progress_snapshot(book_id)
|
||
yield snapshot
|
||
|
||
run = fiction_metadata_service.get_run(book_id)
|
||
if run.status == "running" and run.pipelineStage == "event_plan":
|
||
async for event in subscribe_progress(book_id):
|
||
if event.get("type") == "snapshot":
|
||
continue
|
||
yield event
|
||
elif snapshot["items"] or snapshot.get("done", 0) > 0:
|
||
yield {
|
||
"type": "done",
|
||
"done": snapshot["done"],
|
||
"total": snapshot["total"],
|
||
}
|