318 lines
10 KiB
Python
318 lines
10 KiB
Python
"""
|
||
爽文章节写作(fiction.chapter)— 新版 metadata v2 章纲驱动。
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import logging
|
||
import re
|
||
from datetime import datetime
|
||
from typing import Any, Dict, Iterator, List, Optional, Tuple
|
||
|
||
from langchain_core.messages import HumanMessage, SystemMessage
|
||
|
||
from models.fiction_models import (
|
||
ChapterPlanItem,
|
||
EventChainItem,
|
||
FictionBookMetadata,
|
||
FictionChapter,
|
||
VolumeOutline,
|
||
)
|
||
from services.fiction_metadata_service import fiction_metadata_service
|
||
from services.fiction_planning_service import ensure_chapter_plan
|
||
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)
|
||
|
||
PlannedChapter = Tuple[int, VolumeOutline, EventChainItem, ChapterPlanItem]
|
||
|
||
|
||
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")
|
||
if not api_config.get("model"):
|
||
raise ValueError("模型未配置,请先在 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)
|
||
return guide.chapter.content or "(无章节层世界书)"
|
||
|
||
|
||
def iter_planned_chapters(metadata: FictionBookMetadata) -> Iterator[PlannedChapter]:
|
||
"""按卷纲 → 事件链 → 章纲顺序展开章节计划。"""
|
||
volumes = sorted(metadata.volumes or [], key=lambda v: v.order)
|
||
for volume in volumes:
|
||
events = sorted(metadata.eventChains.get(volume.id, []), key=lambda e: e.order)
|
||
for event in events:
|
||
plans = sorted(metadata.chapterPlans.get(event.id, []), key=lambda c: c.seq)
|
||
for item in plans:
|
||
yield item.seq, volume, event, item
|
||
|
||
|
||
def find_next_unwritten_chapter(
|
||
book_id: str, metadata: Optional[FictionBookMetadata] = None
|
||
) -> Optional[PlannedChapter]:
|
||
metadata = metadata or fiction_metadata_service.get_metadata(book_id)
|
||
for seq, volume, event, item in iter_planned_chapters(metadata):
|
||
if fiction_service.chapter_exists(book_id, seq):
|
||
continue
|
||
if item.status == "written":
|
||
continue
|
||
return seq, volume, event, item
|
||
return None
|
||
|
||
|
||
def has_written_chapters(book_id: str) -> bool:
|
||
return len(fiction_service.list_written_chapter_seqs(book_id)) > 0
|
||
|
||
|
||
def _build_context_tail(book_id: str, before_seq: int, context_chars: int) -> str:
|
||
if before_seq <= 1 or context_chars <= 0:
|
||
return ""
|
||
parts: List[str] = []
|
||
for seq in range(1, before_seq):
|
||
if not fiction_service.chapter_exists(book_id, seq):
|
||
continue
|
||
ch = fiction_service.get_chapter(book_id, seq)
|
||
if ch.body:
|
||
parts.append(ch.body)
|
||
combined = "\n\n".join(parts)
|
||
if len(combined) <= context_chars:
|
||
return combined
|
||
return combined[-context_chars:]
|
||
|
||
|
||
def _build_chapter_messages(
|
||
book_id: str,
|
||
global_seq: int,
|
||
volume: VolumeOutline,
|
||
event: EventChainItem,
|
||
plan_item: ChapterPlanItem,
|
||
) -> List[Any]:
|
||
settings = fiction_service.get_book_settings(book_id)
|
||
user_prompt = settings.prompts.chapter or fiction_service.get_default_settings().prompts.chapter
|
||
system_prompt = resolve_prompt("chapter", user_prompt)
|
||
|
||
context_chars = settings.reader.contextWindowChars if settings.reader else 2000
|
||
context_tail = _build_context_tail(book_id, global_seq, context_chars)
|
||
guide_l3 = _format_guide_global_layers(["L3"])
|
||
book_guide = _format_book_guide(book_id)
|
||
|
||
context_block = context_tail if context_tail else "(本章为开篇,无上文)"
|
||
|
||
user_content = f"""## 全局创作指南(L3)
|
||
{guide_l3}
|
||
|
||
## 本书世界书(章节层)
|
||
{book_guide}
|
||
|
||
## 当前卷纲
|
||
- id: {volume.id}
|
||
- title: {volume.title}
|
||
- goal: {volume.goal}
|
||
- coreConflict: {volume.coreConflict}
|
||
- emotionalPromise: {volume.emotionalPromise}
|
||
|
||
## 当前事件
|
||
- id: {event.id}
|
||
- title: {event.title}
|
||
- summary: {event.summary}
|
||
- purpose: {event.purpose}
|
||
- conflict: {event.conflict}
|
||
- turningPoint: {event.turningPoint}
|
||
- expectedPayoff: {event.expectedPayoff}
|
||
|
||
## 本章章纲(第 {global_seq} 章)
|
||
- title: {plan_item.title}
|
||
- goal: {plan_item.goal}
|
||
- opening: {plan_item.opening}
|
||
- mainConflict: {plan_item.mainConflict}
|
||
- emotionalTurn: {plan_item.emotionalTurn}
|
||
- emotionStepKey: {plan_item.emotionStepKey}
|
||
- emotionGoal: {plan_item.emotionGoal}
|
||
- payoff: {plan_item.payoff}
|
||
- endingHook: {plan_item.endingHook}
|
||
- forbidden: {plan_item.forbidden}
|
||
- targetWords: {plan_item.targetWords}
|
||
|
||
## 已读上文末尾(最多 {context_chars} 字,供衔接)
|
||
{context_block}
|
||
|
||
## 绝对要求
|
||
- 只生成第 {global_seq} 章正文。
|
||
- 正文字数目标约 {plan_item.targetWords or 2000} 汉字。
|
||
- 必须遵循本章章纲、当前事件、当前卷纲与章节层世界书。
|
||
- 不生成下一章章纲,不生成解释说明。"""
|
||
|
||
return [
|
||
SystemMessage(content=system_prompt),
|
||
HumanMessage(content=user_content),
|
||
]
|
||
|
||
|
||
def _parse_chapter_response(
|
||
data: Dict[str, Any],
|
||
*,
|
||
global_seq: int,
|
||
event: EventChainItem,
|
||
plan_item: ChapterPlanItem,
|
||
) -> FictionChapter:
|
||
body = str(data.get("body") or "").strip()
|
||
if not body:
|
||
raise ValueError("章节正文为空")
|
||
title = str(data.get("title") or plan_item.title or f"第{global_seq}章").strip()
|
||
return FictionChapter(
|
||
seq=global_seq,
|
||
title=title,
|
||
body=body,
|
||
charCount=len(body),
|
||
status="written",
|
||
eventId=event.id,
|
||
phaseKey=plan_item.emotionStepKey or plan_item.phaseKey,
|
||
createdAt=datetime.now().isoformat(),
|
||
)
|
||
|
||
|
||
def _mark_chapter_written(
|
||
metadata: FictionBookMetadata, event_id: str, plan_seq: int
|
||
) -> FictionBookMetadata:
|
||
plans = metadata.chapterPlans.get(event_id, [])
|
||
updated_plan: List[ChapterPlanItem] = []
|
||
for item in plans:
|
||
if item.seq == plan_seq:
|
||
updated_plan.append(item.model_copy(update={"status": "written"}))
|
||
else:
|
||
updated_plan.append(item)
|
||
metadata.chapterPlans[event_id] = updated_plan
|
||
return metadata
|
||
|
||
|
||
async def run_chapter(
|
||
book_id: str,
|
||
*,
|
||
profile_id: Optional[str] = None,
|
||
api_config: Optional[Dict[str, str]] = None,
|
||
seq: Optional[int] = None,
|
||
) -> FictionChapter:
|
||
resolved = resolve_api_config(profile_id, api_config)
|
||
_validate_api_config(resolved)
|
||
|
||
metadata = await ensure_chapter_plan(
|
||
book_id,
|
||
profile_id=profile_id,
|
||
api_config=api_config,
|
||
)
|
||
|
||
target: Optional[PlannedChapter] = None
|
||
if seq is not None:
|
||
for global_seq, volume, event, item in iter_planned_chapters(metadata):
|
||
if global_seq == seq:
|
||
target = (global_seq, volume, event, item)
|
||
break
|
||
if not target:
|
||
raise ValueError(f"章节规划不存在: seq={seq}")
|
||
global_seq, volume, event, item = target
|
||
if fiction_service.chapter_exists(book_id, global_seq):
|
||
return fiction_service.get_chapter(book_id, global_seq)
|
||
else:
|
||
found = find_next_unwritten_chapter(book_id, metadata)
|
||
if not found:
|
||
raise ValueError("没有待撰写的章节")
|
||
global_seq, volume, event, item = found
|
||
if fiction_service.chapter_exists(book_id, global_seq):
|
||
return fiction_service.get_chapter(book_id, global_seq)
|
||
|
||
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="chapter"
|
||
)
|
||
try:
|
||
messages = _build_chapter_messages(book_id, global_seq, volume, event, item)
|
||
response = await _llm_client.chat_completion(
|
||
messages=messages,
|
||
api_url=resolved["api_url"],
|
||
api_key=resolved["api_key"],
|
||
model=resolved["model"],
|
||
temperature=0.8,
|
||
max_tokens=8000,
|
||
request_timeout=180,
|
||
stream=False,
|
||
)
|
||
content = response["choices"][0]["message"]["content"]
|
||
data = _extract_json(content)
|
||
chapter = _parse_chapter_response(
|
||
data, global_seq=global_seq, event=event, plan_item=item
|
||
)
|
||
fiction_service.save_chapter(book_id, chapter)
|
||
|
||
metadata = fiction_metadata_service.get_metadata(book_id)
|
||
metadata = _mark_chapter_written(metadata, event.id, item.seq)
|
||
progress = metadata.progress
|
||
if progress.currentChapterSeq <= 0:
|
||
progress.currentChapterSeq = global_seq
|
||
metadata.progress = progress
|
||
fiction_metadata_service.save_metadata(book_id, metadata)
|
||
|
||
fiction_metadata_service.set_pipeline_stage(
|
||
book_id, status="idle", pipeline_stage="ready"
|
||
)
|
||
return chapter
|
||
except Exception:
|
||
fiction_metadata_service.set_pipeline_stage(
|
||
book_id, status="error", pipeline_stage="chapter"
|
||
)
|
||
raise
|
||
|
||
|
||
async def ensure_chapter(
|
||
book_id: str,
|
||
*,
|
||
profile_id: Optional[str] = None,
|
||
api_config: Optional[Dict[str, str]] = None,
|
||
seq: Optional[int] = None,
|
||
) -> FictionChapter:
|
||
return await run_chapter(
|
||
book_id,
|
||
profile_id=profile_id,
|
||
api_config=api_config,
|
||
seq=seq,
|
||
)
|