initial: novel-app snapshot
This commit is contained in:
7
services/__init__.py
Normal file
7
services/__init__.py
Normal file
@@ -0,0 +1,7 @@
|
||||
"""Service layer for the novel app.
|
||||
|
||||
Pure functions — no Flask imports. LLM access, book deconstruction (拆书),
|
||||
and chapter writing with context assembly. All DB access goes through
|
||||
explicit sqlite3 connections (row_factory=sqlite3.Row) and repository
|
||||
functions. SSE framing helpers live here for use by routes/.
|
||||
"""
|
||||
194
services/chapter_writer.py
Normal file
194
services/chapter_writer.py
Normal file
@@ -0,0 +1,194 @@
|
||||
"""Chapter generation service: context assembly + streaming chapter writing.
|
||||
|
||||
Pure functions — no Flask imports. Context assembly follows the 拆书→写书
|
||||
pipeline: recent chapters full-text, earlier chapters as short summaries,
|
||||
relevant characters, open foreshadowing, novel meta. Total prompt size is
|
||||
bounded by ``config.CTX_BUDGET_MAX``.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
import config
|
||||
from repositories import chapter_repo, character_repo, foreshadowing_repo
|
||||
from services import llm
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SYSTEM_PROMPT = (
|
||||
"你是一位专业网文写手, 擅长按大纲续写章节, 文风贴合用户指定风格。"
|
||||
"只输出章节正文, 不要输出标题、解释或任何元信息。"
|
||||
)
|
||||
|
||||
_EARLIER_SUMMARY_CHARS = 200 # per earlier-chapter heuristic summary
|
||||
_PREFIX_TAIL_CHARS = 500 # how much of a resume-prefix to echo back
|
||||
|
||||
|
||||
def _truncate(text, limit):
|
||||
if not isinstance(text, str):
|
||||
return text
|
||||
if len(text) <= limit:
|
||||
return text
|
||||
return text[:limit] + "..."
|
||||
|
||||
|
||||
def build_chapter_context(db, novel, chapter, ctx_prev=None):
|
||||
"""Assemble the context dict for writing ``chapter`` of ``novel``.
|
||||
|
||||
Budget enforcement: drop earlier summaries first, then truncate prev
|
||||
chapter contents, to stay under ``config.CTX_BUDGET_MAX``.
|
||||
"""
|
||||
novel_id = novel["id"]
|
||||
prev_limit = ctx_prev or config.CTX_PREV_CHAPTERS
|
||||
|
||||
prev = chapter_repo.get_previous_chapters(
|
||||
db, novel_id, chapter.get("volume") or 1,
|
||||
chapter.get("chapter_number") or 1, limit=prev_limit,
|
||||
)
|
||||
prev_chapters = [
|
||||
{"title": c.get("title"), "volume": c.get("volume"),
|
||||
"chapter_number": c.get("chapter_number"), "content": c.get("content") or ""}
|
||||
for c in reversed(prev) # chronological order
|
||||
]
|
||||
|
||||
prev_ids = {c["id"] for c in prev}
|
||||
done = chapter_repo.list_done_chapters(
|
||||
db, novel_id, limit=config.CTX_SUMMARY_CHAPTERS
|
||||
)
|
||||
earlier_summaries = [
|
||||
{"title": c.get("title"), "volume": c.get("volume"),
|
||||
"chapter_number": c.get("chapter_number"),
|
||||
"summary": _truncate(c.get("content") or "", _EARLIER_SUMMARY_CHARS)}
|
||||
for c in done if c["id"] not in prev_ids
|
||||
]
|
||||
|
||||
outline = chapter.get("outline") or ""
|
||||
characters = character_repo.find_characters_in_text(db, novel_id, outline)
|
||||
if not characters:
|
||||
characters = character_repo.list_characters(db, novel_id)
|
||||
|
||||
context = {
|
||||
"novel_meta": {
|
||||
"title": novel.get("title"),
|
||||
"genre": novel.get("genre"),
|
||||
"style": novel.get("style"),
|
||||
},
|
||||
"prev_chapters": prev_chapters,
|
||||
"earlier_summaries": earlier_summaries,
|
||||
"characters": characters,
|
||||
"open_foreshadowing": foreshadowing_repo.list_open_foreshadowing(db, novel_id),
|
||||
}
|
||||
|
||||
serialized = json.dumps(context, ensure_ascii=False, default=str)
|
||||
# Budget pass 1: drop earlier summaries.
|
||||
if len(serialized) > config.CTX_BUDGET_MAX and context["earlier_summaries"]:
|
||||
logger.info("chapter context over budget, dropping earlier summaries")
|
||||
context["earlier_summaries"] = []
|
||||
serialized = json.dumps(context, ensure_ascii=False, default=str)
|
||||
# Budget pass 2: truncate prev chapter bodies proportionally.
|
||||
if len(serialized) > config.CTX_BUDGET_MAX and context["prev_chapters"]:
|
||||
per = max(1000, config.CTX_BUDGET_MAX // (2 * len(context["prev_chapters"])))
|
||||
for c in context["prev_chapters"]:
|
||||
c["content"] = _truncate(c["content"], per)
|
||||
serialized = json.dumps(context, ensure_ascii=False, default=str)
|
||||
logger.info("chapter context over budget, truncated prev chapters to %d chars each", per)
|
||||
|
||||
context["total_chars"] = len(serialized)
|
||||
if len(serialized) < config.CTX_BUDGET_MIN:
|
||||
logger.info(
|
||||
"chapter context small (%d chars < %d): early chapter or sparse data",
|
||||
len(serialized), config.CTX_BUDGET_MIN,
|
||||
)
|
||||
return context
|
||||
|
||||
|
||||
def build_chapter_prompts(context, chapter, prefix=None):
|
||||
"""Assemble system/user messages for chapter generation."""
|
||||
meta = context["novel_meta"]
|
||||
parts = [
|
||||
f"小说: 《{meta['title']}》 类型: {meta['genre']} 文风: {meta['style']}",
|
||||
"",
|
||||
]
|
||||
|
||||
if context["earlier_summaries"]:
|
||||
parts.append("【更早章节摘要】")
|
||||
for s in context["earlier_summaries"]:
|
||||
parts.append(
|
||||
f"第{s['volume']}卷 第{s['chapter_number']}章 {s.get('title') or ''}: {s['summary']}"
|
||||
)
|
||||
parts.append("")
|
||||
|
||||
if context["prev_chapters"]:
|
||||
parts.append("【最近章节正文】")
|
||||
for c in context["prev_chapters"]:
|
||||
parts.append(
|
||||
f"--- 第{c['volume']}卷 第{c['chapter_number']}章 {c.get('title') or ''} ---"
|
||||
)
|
||||
parts.append(c["content"])
|
||||
parts.append("")
|
||||
|
||||
if context["characters"]:
|
||||
parts.append("【相关角色】")
|
||||
for ch in context["characters"]:
|
||||
parts.append(
|
||||
f"{ch.get('name')} ({ch.get('role') or '角色'}, 状态: {ch.get('status')}): "
|
||||
f"{_truncate(ch.get('description') or '', 300)}"
|
||||
)
|
||||
parts.append("")
|
||||
|
||||
if context["open_foreshadowing"]:
|
||||
parts.append("【未回收伏笔】")
|
||||
for f in context["open_foreshadowing"]:
|
||||
parts.append(f"- {f.get('description')} (埋设: 第{f.get('planted_chapter') or '?'}章)")
|
||||
parts.append("")
|
||||
|
||||
parts.append("【当前章节大纲】")
|
||||
parts.append(
|
||||
f"第{chapter.get('volume') or 1}卷 第{chapter.get('chapter_number') or 1}章 "
|
||||
f"{chapter.get('title') or ''}"
|
||||
)
|
||||
parts.append(chapter.get("outline") or "(无大纲, 请自由发挥但承接前文)")
|
||||
parts.append("")
|
||||
|
||||
if prefix:
|
||||
parts.append("【续写指令】以下为本章已生成的开头, 请无缝续写, 不要重复已有内容:")
|
||||
parts.append(prefix[-_PREFIX_TAIL_CHARS:])
|
||||
parts.append("")
|
||||
|
||||
parts.append("要求: 写 3000-8000 字正文, 只输出正文, 不要标题不要解释。")
|
||||
|
||||
user_prompt = "\n".join(parts)
|
||||
return [
|
||||
{"role": "system", "content": SYSTEM_PROMPT},
|
||||
{"role": "user", "content": user_prompt},
|
||||
]
|
||||
|
||||
|
||||
def stream_chapter(db, novel, chapter, prefix=None):
|
||||
"""Generator yielding SSE frames: context → token… → done / error.
|
||||
|
||||
NOTE: ``db`` is only used here to build the context; callers must build
|
||||
everything inside the request view and may pass a connection that dies
|
||||
with the request — so we build context eagerly before streaming.
|
||||
"""
|
||||
context = build_chapter_context(db, novel, chapter)
|
||||
summary = (
|
||||
f"已注入上下文: 前 {len(context['prev_chapters'])} 章正文, "
|
||||
f"{len(context['earlier_summaries'])} 条更早摘要, "
|
||||
f"{len(context['characters'])} 个角色, "
|
||||
f"{len(context['open_foreshadowing'])} 条未回收伏笔, "
|
||||
f"共约 {context['total_chars']} 字符"
|
||||
+ (f"; 续写前缀 {len(prefix)} 字符" if prefix else "")
|
||||
)
|
||||
yield llm._sse("context", {"summary": summary})
|
||||
|
||||
messages = build_chapter_prompts(context, chapter, prefix=prefix)
|
||||
parts = []
|
||||
|
||||
def _on_token(t):
|
||||
parts.append(t)
|
||||
|
||||
yield from llm.make_token_stream(messages, _on_token, max_tokens=8192)
|
||||
|
||||
full_text = (prefix or "") + "".join(parts)
|
||||
yield llm._sse("done", {"content": full_text, "word_count": len(full_text)})
|
||||
137
services/deconstruct.py
Normal file
137
services/deconstruct.py
Normal file
@@ -0,0 +1,137 @@
|
||||
"""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})
|
||||
96
services/llm.py
Normal file
96
services/llm.py
Normal file
@@ -0,0 +1,96 @@
|
||||
"""LLM client: streaming and non-streaming chat completions, SSE framing.
|
||||
|
||||
Pure functions — no Flask imports. Uses ``requests`` for HTTP against an
|
||||
OpenAI-compatible endpoint (``config.LLM_URL``). The ``_sse`` helper is
|
||||
shared by deconstruct / chapter_writer so every SSE stream uses the same
|
||||
wire format.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
|
||||
import requests as http_requests
|
||||
|
||||
import config
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_KEEPALIVE_INTERVAL = 15 # seconds
|
||||
|
||||
|
||||
def _sse(event, payload):
|
||||
"""Return a single SSE frame: ``event: <event>\\ndata: <json>\\n\\n``."""
|
||||
return f"event: {event}\ndata: {json.dumps(payload, ensure_ascii=False)}\n\n"
|
||||
|
||||
|
||||
def stream_chat(messages, max_tokens=4096, model=None, timeout=None):
|
||||
"""Generator yielding raw token strings from a streaming chat completion.
|
||||
|
||||
POSTs to ``{config.LLM_URL}/chat/completions`` with ``stream=True``.
|
||||
Parses ``data:`` lines, handles ``[DONE]`` terminator, extracts
|
||||
``choices[0].delta.content``. Raises ``RuntimeError`` on HTTP failure.
|
||||
"""
|
||||
url = f"{config.LLM_URL}/chat/completions"
|
||||
body = {
|
||||
"model": model or config.LLM_MODEL,
|
||||
"messages": messages,
|
||||
"stream": True,
|
||||
"max_tokens": max_tokens,
|
||||
}
|
||||
resp = http_requests.post(url, json=body, stream=True, timeout=timeout or config.LLM_TIMEOUT)
|
||||
resp.raise_for_status()
|
||||
|
||||
for line in resp.iter_lines():
|
||||
if not line:
|
||||
continue
|
||||
line = line.decode("utf-8") if isinstance(line, bytes) else line
|
||||
if not line.startswith("data: "):
|
||||
continue
|
||||
chunk_data = line[6:]
|
||||
if chunk_data.strip() == "[DONE]":
|
||||
break
|
||||
try:
|
||||
chunk = json.loads(chunk_data)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
delta = chunk.get("choices", [{}])[0].get("delta", {})
|
||||
token_text = delta.get("content", "")
|
||||
if token_text:
|
||||
yield token_text
|
||||
|
||||
|
||||
def complete_chat(messages, max_tokens=2048, model=None, timeout=None):
|
||||
"""Non-streaming call returning the assistant content string."""
|
||||
url = f"{config.LLM_URL}/chat/completions"
|
||||
body = {
|
||||
"model": model or config.LLM_MODEL,
|
||||
"messages": messages,
|
||||
"stream": False,
|
||||
"max_tokens": max_tokens,
|
||||
}
|
||||
resp = http_requests.post(url, json=body, timeout=timeout or config.LLM_TIMEOUT)
|
||||
resp.raise_for_status()
|
||||
return resp.json().get("choices", [{}])[0].get("message", {}).get("content", "")
|
||||
|
||||
|
||||
def make_token_stream(messages, on_token, max_tokens=4096):
|
||||
"""Generator wrapping :func:`stream_chat` with SSE framing + keepalive.
|
||||
|
||||
Yields ``_sse('token', {'text': t})`` for each token. Calls
|
||||
``on_token(t)`` per token so callers can accumulate the full text.
|
||||
Emits ``: keepalive\\n\\n`` comments if >15 s silence between chunks.
|
||||
On exception yields ``_sse('error', {'error': ...})`` and stops.
|
||||
"""
|
||||
last_emit = time.time()
|
||||
try:
|
||||
for token in stream_chat(messages, max_tokens=max_tokens):
|
||||
now = time.time()
|
||||
if now - last_emit > _KEEPALIVE_INTERVAL:
|
||||
yield ": keepalive\n\n"
|
||||
yield _sse("token", {"text": token})
|
||||
on_token(token)
|
||||
last_emit = time.time()
|
||||
except Exception as exc:
|
||||
logger.exception("make_token_stream: LLM error")
|
||||
yield _sse("error", {"error": str(exc)})
|
||||
Reference in New Issue
Block a user