138 lines
4.6 KiB
Python
138 lines
4.6 KiB
Python
"""Book deconstruction (拆书) service: novel architect that produces
|
|
concept, world, characters, outline, and foreshadowing sections.
|
|
|
|
Pure functions — no Flask imports. Each section is streamed via
|
|
:func:`llm.make_token_stream` with ``section_start`` / ``section_end``
|
|
SSE bookends.
|
|
"""
|
|
|
|
import logging
|
|
|
|
from services import llm
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
SECTIONS = ["concept", "world", "characters", "outline", "foreshadowing"]
|
|
|
|
SYSTEM_PROMPT = (
|
|
"你是一位资深小说架构师, 精通网文结构设计与节奏把控。"
|
|
"请根据用户的需求, 逐步构建完整的小说框架。"
|
|
"输出纯文本, 不要使用 Markdown 标题标记 (# / ## 等), 用换行分段即可。"
|
|
)
|
|
|
|
# ---- per-section Chinese user prompts -----------------------------------
|
|
|
|
_SECTION_PROMPTS = {
|
|
"concept": (
|
|
"请为小说《{title}》设计核心概念, 包括:\n"
|
|
"1. 主题 (这部小说想表达什么)\n"
|
|
"2. 核心冲突 (主角面临的主要矛盾)\n"
|
|
"3. 一句话卖点 (最能吸引读者的概括)\n"
|
|
"类型: {genre} | 风格: {style} | 目标字数: {target_words}"
|
|
),
|
|
"world": (
|
|
"请为小说《{title}》设计世界观设定, 包括:\n"
|
|
"1. 时代背景\n"
|
|
"2. 主要地点\n"
|
|
"3. 力量体系 (如有)\n"
|
|
"4. 社会结构\n"
|
|
"类型: {genre} | 风格: {style}"
|
|
),
|
|
"characters": (
|
|
"请为小说《{title}》设计主要角色表。\n"
|
|
"每个角色单独一段, 以【角色】名字 开头, 包含:\n"
|
|
"名字 / 定位 / 性格 / 动机 / 成长弧线。\n"
|
|
"类型: {genre} | 风格: {style}"
|
|
),
|
|
"outline": (
|
|
"请为小说《{title}》规划三幕结构与卷章大纲。\n"
|
|
"格式: 第N卷 / 第N章 标题 — 一句话概要\n"
|
|
"预估总章数按目标字数 {target_words} 除以每章 5000 字计算。\n"
|
|
"类型: {genre} | 风格: {style}"
|
|
),
|
|
"foreshadowing": (
|
|
"请为小说《{title}》设计伏笔计划。\n"
|
|
"每条伏笔单独一段, 以【伏笔】开头, 包含:\n"
|
|
"描述 / 埋设位置 (第几卷第几章) / 回收位置。\n"
|
|
"类型: {genre} | 风格: {style}"
|
|
),
|
|
}
|
|
|
|
|
|
def _format_inputs(inputs):
|
|
"""Return (title, genre, style, target_words, reference_novel, confirmed)."""
|
|
return (
|
|
inputs.get("title", "未命名"),
|
|
inputs.get("genre", "未指定"),
|
|
inputs.get("style", "未指定"),
|
|
inputs.get("target_words", "未指定"),
|
|
inputs.get("reference_novel"),
|
|
inputs.get("confirmed", {}),
|
|
)
|
|
|
|
|
|
def _confirmed_context(confirmed):
|
|
"""Render previously confirmed sections as context for the next prompt."""
|
|
if not confirmed:
|
|
return ""
|
|
parts = []
|
|
for sec in SECTIONS:
|
|
text = confirmed.get(sec)
|
|
if text:
|
|
parts.append(f"【已确认 - {sec}】\n{text}")
|
|
return "\n\n".join(parts)
|
|
|
|
|
|
def build_section_prompt(section, inputs):
|
|
"""Build the messages list for a given section.
|
|
|
|
``inputs`` is a dict with keys: title, genre, style, target_words,
|
|
reference_novel (optional), confirmed (dict of previous section texts).
|
|
Returns a list of ``{"role": ..., "content": ...}`` dicts ready for
|
|
the LLM chat API.
|
|
"""
|
|
title, genre, style, target_words, reference_novel, confirmed = _format_inputs(inputs)
|
|
|
|
template = _SECTION_PROMPTS.get(section)
|
|
if template is None:
|
|
raise ValueError(f"unknown section: {section}")
|
|
|
|
user_prompt = template.format(
|
|
title=title,
|
|
genre=genre,
|
|
style=style,
|
|
target_words=target_words,
|
|
)
|
|
|
|
if reference_novel:
|
|
user_prompt += f"\n\n参考小说: 《{reference_novel}》, 参考其节奏与结构但不抄袭。"
|
|
|
|
ctx = _confirmed_context(confirmed)
|
|
if ctx:
|
|
user_prompt = f"以下是已确认的前序设定:\n\n{ctx}\n\n---\n\n{user_prompt}"
|
|
|
|
return [
|
|
{"role": "system", "content": SYSTEM_PROMPT},
|
|
{"role": "user", "content": user_prompt},
|
|
]
|
|
|
|
|
|
def stream_section(section, inputs):
|
|
"""Generator yielding SSE frames for a section.
|
|
|
|
Emits ``section_start`` → token frames → ``section_end`` (with full
|
|
accumulated content). Uses :func:`llm.make_token_stream` under the hood.
|
|
"""
|
|
messages = build_section_prompt(section, inputs)
|
|
yield llm._sse("section_start", {"section": section})
|
|
|
|
parts = []
|
|
|
|
def _on_token(t):
|
|
parts.append(t)
|
|
|
|
yield from llm.make_token_stream(messages, _on_token, max_tokens=4096)
|
|
|
|
full_text = "".join(parts)
|
|
yield llm._sse("section_end", {"section": section, "content": full_text})
|