feat(agents,core,config): 写章 craft 教条块 + genre-aware 片段 + 开篇黄金三章特判
- 新增 write_craft.md 品类无关写章 craft 内核(网文语域/show-don't-tell/对话 voice/ 反注水/结构性文字质感,灵活原则非穷举清单)+ load_craft_doctrine()(import 期读盘, 不入 SPECS/金标准) - build_write_request 把 craft 教条作为最前的 cache=True system 块注入(craft→stable_core, 守缓存前缀 #9,tier=writer 不写 model) - 新增 packages/config GENRE_CRAFT 题材片段表,assemble 按 project.genre 注入 stable_core; ProjectSpecView + SqlProjectSpecRepo 透传 genre - 开篇前 3 章在 volatile 注入黄金三章提示(含章号→每章易变,不进缓存前缀) - 修正 toolbox_registry 注释:11 条 → 15 条(legacy 3 + 新 8 + Scope B 4)
This commit is contained in:
43
packages/agents/tests/test_craft_doctrine.py
Normal file
43
packages/agents/tests/test_craft_doctrine.py
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
"""写章 craft 教条加载器单测(灵感①)。
|
||||||
|
|
||||||
|
`load_craft_doctrine()` import 期读盘 `prompts/write_craft.md`(仿 load_prompt),
|
||||||
|
主写章不在 SPECS、不进 prompt_hashes 金标准。文件缺失应 fail-fast(崩 import)。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
|
||||||
|
from ww_agents import load_craft_doctrine
|
||||||
|
|
||||||
|
|
||||||
|
def _sha256(text: str) -> str:
|
||||||
|
return hashlib.sha256(text.encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_craft_doctrine_returns_nonempty_text() -> None:
|
||||||
|
# Act
|
||||||
|
doctrine = load_craft_doctrine()
|
||||||
|
# Assert
|
||||||
|
assert isinstance(doctrine, str)
|
||||||
|
assert doctrine.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_craft_doctrine_is_byte_stable() -> None:
|
||||||
|
# 稳定缓存前缀(不变量 #9):两次读盘字节完全一致。
|
||||||
|
assert _sha256(load_craft_doctrine()) == _sha256(load_craft_doctrine())
|
||||||
|
|
||||||
|
|
||||||
|
def test_craft_doctrine_covers_core_principles() -> None:
|
||||||
|
# 品类无关内核应覆盖:show-don't-tell / 对话个性化 / 反注水(按情节功能分配字数)。
|
||||||
|
doctrine = load_craft_doctrine()
|
||||||
|
assert "反注水" in doctrine or "注水" in doctrine
|
||||||
|
assert "对话" in doctrine
|
||||||
|
assert "情节功能" in doctrine
|
||||||
|
|
||||||
|
|
||||||
|
def test_craft_doctrine_is_genre_agnostic() -> None:
|
||||||
|
# 品类无关内核不应写死具体题材名(题材片段归 GENRE_CRAFT)。
|
||||||
|
doctrine = load_craft_doctrine()
|
||||||
|
for genre in ("玄幻", "仙侠", "都市"):
|
||||||
|
assert genre not in doctrine, f"品类无关内核不应含题材名 {genre!r}"
|
||||||
@@ -58,9 +58,15 @@ def test_specs_registry_len_is_21() -> None:
|
|||||||
assert all(key == spec.name for key, spec in SPECS.items())
|
assert all(key == spec.name for key, spec in SPECS.items())
|
||||||
|
|
||||||
|
|
||||||
|
# 非 SPEC 教条 prompt:主写章 craft 内核(灵感①)。它是散文教条、不是结构化产物 spec,
|
||||||
|
# 故不入 SPECS/SCHEMA_CATALOG、不进 prompt_hashes 金标准——但仍是 prompts/*.md,需在
|
||||||
|
# md↔spec↔catalog 一一对应校验里显式排除,否则集合等式误报。
|
||||||
|
_DOCTRINE_STEMS = {"write_craft"}
|
||||||
|
|
||||||
|
|
||||||
# ---- #2 md ↔ spec ↔ catalog 一一对应(覆盖 style.md / character-gen.md 连字符)----
|
# ---- #2 md ↔ spec ↔ catalog 一一对应(覆盖 style.md / character-gen.md 连字符)----
|
||||||
def test_md_spec_catalog_one_to_one() -> None:
|
def test_md_spec_catalog_one_to_one() -> None:
|
||||||
md_stems = {p.stem for p in PROMPTS_DIR.glob("*.md")}
|
md_stems = {p.stem for p in PROMPTS_DIR.glob("*.md")} - _DOCTRINE_STEMS
|
||||||
assert set(SPECS) == md_stems == set(SCHEMA_CATALOG)
|
assert set(SPECS) == md_stems == set(SCHEMA_CATALOG)
|
||||||
# 易错连字符 / 非变量名命名显式覆盖
|
# 易错连字符 / 非变量名命名显式覆盖
|
||||||
assert "style" in md_stems and "style_drift" not in md_stems
|
assert "style" in md_stems and "style_drift" not in md_stems
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from .prompt_loader import PromptNotFoundError, load_prompt
|
from .prompt_loader import PromptNotFoundError, load_craft_doctrine, load_prompt
|
||||||
from .schema_catalog import SCHEMA_CATALOG, output_schema_for
|
from .schema_catalog import SCHEMA_CATALOG, output_schema_for
|
||||||
from .schemas import (
|
from .schemas import (
|
||||||
Blurb,
|
Blurb,
|
||||||
@@ -132,6 +132,7 @@ __all__ = [
|
|||||||
"foreshadow_spec",
|
"foreshadow_spec",
|
||||||
"glossary_spec",
|
"glossary_spec",
|
||||||
"golden_finger_spec",
|
"golden_finger_spec",
|
||||||
|
"load_craft_doctrine",
|
||||||
"load_prompt",
|
"load_prompt",
|
||||||
"name_spec",
|
"name_spec",
|
||||||
"opening_spec",
|
"opening_spec",
|
||||||
|
|||||||
@@ -46,3 +46,18 @@ def load_prompt(name: str) -> str:
|
|||||||
|
|
||||||
_CACHE[name] = text
|
_CACHE[name] = text
|
||||||
return text
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
# 主写章 craft 教条文件名(灵感①)。它是散文教条、不是结构化产物 spec:
|
||||||
|
# 不入 SPECS、不进 prompt_hashes 金标准、不参与计数断言——但复用同一读盘/规整/缓存路径,
|
||||||
|
# 保证字节稳定(不变量 #9)。
|
||||||
|
CRAFT_DOCTRINE_NAME: Final = "write_craft"
|
||||||
|
|
||||||
|
|
||||||
|
def load_craft_doctrine() -> str:
|
||||||
|
"""读 `prompts/write_craft.md`(品类无关写章 craft 内核)→ 规整 → 缓存 → 返回。
|
||||||
|
|
||||||
|
与 `load_prompt` 同一读盘/规整/缓存逻辑(DRY),文件缺失同样 fail-fast
|
||||||
|
(`PromptNotFoundError`)。调用方在 import 期取常量即触发读盘。
|
||||||
|
"""
|
||||||
|
return load_prompt(CRAFT_DOCTRINE_NAME)
|
||||||
|
|||||||
32
packages/agents/ww_agents/prompts/write_craft.md
Normal file
32
packages/agents/ww_agents/prompts/write_craft.md
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
# 写章创作纪律(品类无关内核)
|
||||||
|
|
||||||
|
你是一位深耕中文网络小说的职业写手。下面是贯穿所有题材的创作纪律——它们是**灵活的原则**,不是逐条打勾的清单。按当下这一章真正要写的情节去取舍运用,不要机械套用、不要为了满足某条规则而牺牲叙事本身。
|
||||||
|
|
||||||
|
## 语域总纲
|
||||||
|
|
||||||
|
- 网文是**读起来快**的文体:信息密度偏低、句子偏短、段落偏碎。读者靠画面与动作往前滑,而不是靠密集的说明与铺陈。
|
||||||
|
- **重行动、轻独白**:优先用发生的事推进故事,少写大段静止的心理分析或情绪反刍。心理活动点到即止,让位于外部动作、对话与场景。
|
||||||
|
- **情节持续推进**:每个场景都要往前走一步——带来新信息、新冲突、新关系变化或新目标。避免原地打转的交代和没有推进的过场堆叠。
|
||||||
|
|
||||||
|
## show,别 tell
|
||||||
|
|
||||||
|
- 把情绪**外化**为可见可感的东西:动作、姿态、生理反应、环境细节、对话里的停顿与语气,而不是直接抛出「他很愤怒」「她很紧张」这类概括标签。
|
||||||
|
- 让读者从画面里**自己推断**人物的状态,而不是由叙述者代为总结。越关键的情绪,越要用细节演出来,而不是一句话说破。
|
||||||
|
|
||||||
|
## 对话的声音
|
||||||
|
|
||||||
|
- 每个角色的说话方式要**可区分**:依据角色档案里的性格、出身、处境,赋予各自的用词习惯、句子长短、口头禅与说话节奏。
|
||||||
|
- **忌所有人一个腔调**——反派、少年、长者、市井之徒不该共用同一套措辞与句式。对话要能让读者仅凭语气就认出是谁在说。
|
||||||
|
- 对话标签(「他说」「她道」)的**位置要变化**:时而前置、时而后置、时而用一个动作 beat 代替,避免每句都以同一模式收尾。
|
||||||
|
|
||||||
|
## 反注水:按情节功能分配篇幅
|
||||||
|
|
||||||
|
- 字数**跟着情节功能走**,不是往一个固定目标硬凑。关键的冲突、转折、情绪高点要写**厚**——给足动作分解、感官细节与张力;过场、赶路、次要交代要写**薄**——一两句带过。
|
||||||
|
- 密点厚、疏点薄。**绝不为凑字数注水**:删掉重复的心理复述、冗余的形容词堆叠、无推进的对话与说明。
|
||||||
|
- 篇幅是结果而非目标——先想清楚这一章每个部分承担什么功能,再决定它值多少笔墨。
|
||||||
|
|
||||||
|
## 文字质感
|
||||||
|
|
||||||
|
- **句长错落**:长短句交替,避免连续同长度、同结构的句子带来的机械节奏。
|
||||||
|
- **避免用情绪概括收尾**:段落或章节的结尾不要落在「他心中五味杂陈」「一切都不一样了」这类抽象总结上,用具体的动作、画面或悬念收束。
|
||||||
|
- **慎用连续排比与套路化修辞**:偶一为之可以蓄势,密集堆砌则显空洞、露出 AI 腔。宁可朴素准确,不要华而不实。
|
||||||
47
packages/config/tests/test_genre_craft.py
Normal file
47
packages/config/tests/test_genre_craft.py
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
"""GENRE_CRAFT 配置单测(F1 · 品类相关 craft 片段表)。
|
||||||
|
|
||||||
|
GENRE_CRAFT 是「题材 → 语域/节奏/钩子/字数区间/常见套路」的**文本片段**映射;
|
||||||
|
品类无关内核在 `write_craft.md`,品类相关片段在此表。lookup 对未知题材优雅返回 None。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from ww_config import GENRE_CRAFT, genre_craft_fragment
|
||||||
|
|
||||||
|
# 与前端 wizard.ts 的 GENRES 预设保持一致(玄幻/仙侠/都市/科幻/历史/悬疑)。
|
||||||
|
_FRONTEND_GENRES = ["玄幻", "仙侠", "都市", "科幻", "历史", "悬疑"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_genre_craft_covers_frontend_presets() -> None:
|
||||||
|
# Arrange / Act / Assert:前端每个预设题材都有对应片段。
|
||||||
|
for genre in _FRONTEND_GENRES:
|
||||||
|
assert genre in GENRE_CRAFT, f"题材 {genre!r} 缺 GENRE_CRAFT 片段"
|
||||||
|
|
||||||
|
|
||||||
|
def test_genre_craft_fragments_are_nonempty_text() -> None:
|
||||||
|
for genre, fragment in GENRE_CRAFT.items():
|
||||||
|
assert isinstance(fragment, str)
|
||||||
|
assert fragment.strip(), f"题材 {genre!r} 片段为空"
|
||||||
|
|
||||||
|
|
||||||
|
def test_genre_craft_fragment_returns_matching_text() -> None:
|
||||||
|
# Act
|
||||||
|
fragment = genre_craft_fragment("玄幻")
|
||||||
|
# Assert
|
||||||
|
assert fragment is not None
|
||||||
|
assert fragment == GENRE_CRAFT["玄幻"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_genre_craft_fragment_unknown_returns_none() -> None:
|
||||||
|
assert genre_craft_fragment("不存在的题材") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_genre_craft_fragment_none_returns_none() -> None:
|
||||||
|
assert genre_craft_fragment(None) is None
|
||||||
|
assert genre_craft_fragment("") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_genre_fragments_mention_wordcount_dimension() -> None:
|
||||||
|
# 每个片段应含字数/篇幅维度(灵活片段,非穷举清单)。
|
||||||
|
for genre, fragment in GENRE_CRAFT.items():
|
||||||
|
assert "字数" in fragment or "篇幅" in fragment, f"{genre!r} 片段未含字数区间"
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
from ww_config.genre_craft import GENRE_CRAFT, genre_craft_fragment
|
||||||
from ww_config.settings import Settings, get_settings
|
from ww_config.settings import Settings, get_settings
|
||||||
|
|
||||||
__all__ = ["Settings", "get_settings"]
|
__all__ = ["GENRE_CRAFT", "Settings", "genre_craft_fragment", "get_settings"]
|
||||||
|
|||||||
67
packages/config/ww_config/genre_craft.py
Normal file
67
packages/config/ww_config/genre_craft.py
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
"""品类相关 craft 配置(灵感 F1)。
|
||||||
|
|
||||||
|
`GENRE_CRAFT`:题材 → 「语域/节奏/钩子偏好/默认字数区间/常见套路」的**文本片段**。
|
||||||
|
品类无关内核(show-don't-tell / 对话个性化 / 反注水原则)在 `write_craft.md`;
|
||||||
|
本表只装**随题材而异**的片段,由 assemble 按 `project.genre` 选片段注入缓存前缀。
|
||||||
|
|
||||||
|
字数写成**区间参考**(软引导,非每章硬目标)——具体篇幅仍按情节功能分配(见内核)。
|
||||||
|
键与前端 wizard 的 GENRES 预设对齐(玄幻/仙侠/都市/科幻/历史/悬疑)+常见言情;
|
||||||
|
自由填写的题材命不中即优雅返回 None(不注入片段),不报错。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Final
|
||||||
|
|
||||||
|
# 每条片段是灵活提示,不是穷举清单——覆盖语域/节奏/钩子/字数/套路等维度,供模型自行取舍。
|
||||||
|
GENRE_CRAFT: Final[dict[str, str]] = {
|
||||||
|
"玄幻": (
|
||||||
|
"语域偏热血爽快、画面感强;节奏偏快,冲突密集,实力成长线要清晰可感。"
|
||||||
|
"钩子偏好等级压制、打脸反转、机缘奇遇与悬念前置。"
|
||||||
|
"单章篇幅参考约 2500–3500 字,按情节功能弹性调整。"
|
||||||
|
"常见套路:升级流、扮猪吃虎、系统/金手指、宗门家族争斗——用得要有新意,别照搬。"
|
||||||
|
),
|
||||||
|
"仙侠": (
|
||||||
|
"语域偏古雅大气,兼顾意境与热血;节奏中到快,以境界突破为张力节点。"
|
||||||
|
"钩子偏好机缘造化、道心考验、恩怨情仇与天骄争锋。"
|
||||||
|
"单章篇幅参考约 2500–3500 字,按情节功能弹性调整。"
|
||||||
|
"常见套路:修真等级、飞升渡劫、师门情义、逆境翻盘——古意要真、别堆砌辞藻。"
|
||||||
|
),
|
||||||
|
"都市": (
|
||||||
|
"语域偏现代口语、贴近生活、爽感直接;节奏快,即时反馈要强。"
|
||||||
|
"钩子偏好装逼打脸、身份反差、隐藏实力与逆袭翻身。"
|
||||||
|
"单章篇幅参考约 2000–3000 字(移动端碎片阅读),按情节功能弹性调整。"
|
||||||
|
"常见套路:扮猪吃虎、隐藏身份、都市异能、商战权斗——落点要接地气。"
|
||||||
|
),
|
||||||
|
"科幻": (
|
||||||
|
"语域偏冷静、有逻辑感、由设定驱动;节奏偏中,重悬念与推演,慎防设定倾泻。"
|
||||||
|
"钩子偏好谜题危机、科技奇观与命运抉择。"
|
||||||
|
"单章篇幅参考约 2500–3500 字,按情节功能弹性调整。"
|
||||||
|
"常见套路:末世、星际、赛博、时间循环——设定服务剧情,避免大段科普打断叙事。"
|
||||||
|
),
|
||||||
|
"历史": (
|
||||||
|
"语域偏厚重考究、代入感强;节奏偏中,以权谋博弈与人物弧线为主。"
|
||||||
|
"钩子偏好权谋算计、命运转折与家国大势。"
|
||||||
|
"单章篇幅参考约 3000–4000 字,按情节功能弹性调整。"
|
||||||
|
"常见套路:穿越架空、争霸天下、朝堂权谋、种田发展——细节要经得起推敲。"
|
||||||
|
),
|
||||||
|
"悬疑": (
|
||||||
|
"语域偏冷峻克制、讲究信息控制;节奏张弛有度,靠悬念与反转拉动。"
|
||||||
|
"钩子偏好谜题、危险迫近、误导与真相错位。"
|
||||||
|
"单章篇幅参考约 2500–3500 字,按情节功能弹性调整。"
|
||||||
|
"常见套路:多线叙事、伏笔回收、层层反转、不可靠叙述——保持信息差,慎过早揭底。"
|
||||||
|
),
|
||||||
|
"言情": (
|
||||||
|
"语域偏细腻、注重情绪张力与关系互动;节奏偏中,以情感推拉为主线。"
|
||||||
|
"钩子偏好暧昧拉扯、误会错过、情感转折与命运羁绊。"
|
||||||
|
"单章篇幅参考约 2500–3500 字,按情节功能弹性调整。"
|
||||||
|
"常见套路:先婚后爱、破镜重圆、双向奔赴、追妻火葬场——情绪外化于互动细节,别空喊。"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def genre_craft_fragment(genre: str | None) -> str | None:
|
||||||
|
"""按题材取 craft 片段;未指定或命不中预设题材时返回 None(不注入、不报错)。"""
|
||||||
|
if not genre:
|
||||||
|
return None
|
||||||
|
return GENRE_CRAFT.get(genre)
|
||||||
@@ -383,6 +383,73 @@ async def test_chapter_directive_uses_chapter_no() -> None:
|
|||||||
assert "请创作第 7 章的正文。" in ctx.volatile
|
assert "请创作第 7 章的正文。" in ctx.volatile
|
||||||
|
|
||||||
|
|
||||||
|
# ---- 灵感 F1:genre-aware craft 片段注入 ----
|
||||||
|
|
||||||
|
|
||||||
|
async def test_genre_fragment_injected_by_genre() -> None:
|
||||||
|
from ww_config import GENRE_CRAFT
|
||||||
|
|
||||||
|
repos = build_repos(spec=ProjectSpecView(title="书", genre="玄幻"))
|
||||||
|
ctx = await assemble(repos, PROJECT, 5)
|
||||||
|
# 玄幻题材片段进 stable_core(每书稳定,随书缓存),不进 volatile。
|
||||||
|
assert GENRE_CRAFT["玄幻"] in ctx.stable_core
|
||||||
|
assert GENRE_CRAFT["玄幻"] not in ctx.volatile
|
||||||
|
|
||||||
|
|
||||||
|
async def test_different_genre_yields_different_fragment() -> None:
|
||||||
|
from ww_config import GENRE_CRAFT
|
||||||
|
|
||||||
|
xuan = await assemble(build_repos(spec=ProjectSpecView(title="书", genre="玄幻")), PROJECT, 5)
|
||||||
|
city = await assemble(build_repos(spec=ProjectSpecView(title="书", genre="都市")), PROJECT, 5)
|
||||||
|
assert GENRE_CRAFT["玄幻"] in xuan.stable_core
|
||||||
|
assert GENRE_CRAFT["都市"] in city.stable_core
|
||||||
|
assert xuan.stable_core != city.stable_core
|
||||||
|
|
||||||
|
|
||||||
|
async def test_unknown_genre_injects_no_fragment() -> None:
|
||||||
|
repos = build_repos(spec=ProjectSpecView(title="书", genre="不存在的题材"))
|
||||||
|
ctx = await assemble(repos, PROJECT, 5)
|
||||||
|
assert "本作题材写法" not in ctx.stable_core
|
||||||
|
|
||||||
|
|
||||||
|
async def test_no_genre_injects_no_fragment() -> None:
|
||||||
|
repos = build_repos(spec=ProjectSpecView(title="书")) # genre 默认 None
|
||||||
|
ctx = await assemble(repos, PROJECT, 5)
|
||||||
|
assert "本作题材写法" not in ctx.stable_core
|
||||||
|
|
||||||
|
|
||||||
|
# ---- 灵感①/F1:开篇黄金三章特判(volatile,仅前 3 章) ----
|
||||||
|
|
||||||
|
|
||||||
|
async def test_opening_marker_injected_for_ch1to3_only() -> None:
|
||||||
|
repos = build_repos(spec=ProjectSpecView(title="书"))
|
||||||
|
for chapter_no in (1, 2, 3):
|
||||||
|
ctx = await assemble(repos, PROJECT, chapter_no)
|
||||||
|
assert "黄金三章" in ctx.volatile
|
||||||
|
assert f"开篇第 {chapter_no} 章" in ctx.volatile
|
||||||
|
# 第 4 章起不再注入开篇标记。
|
||||||
|
ctx4 = await assemble(repos, PROJECT, 4)
|
||||||
|
assert "黄金三章" not in ctx4.volatile
|
||||||
|
|
||||||
|
|
||||||
|
async def test_opening_marker_never_enters_stable_core() -> None:
|
||||||
|
# 不变量#9:开篇标记含章号(每章易变)→ 只入 volatile,绝不进缓存前缀。
|
||||||
|
repos = build_repos(spec=ProjectSpecView(title="书", genre="玄幻"))
|
||||||
|
ctx = await assemble(repos, PROJECT, 1)
|
||||||
|
assert "黄金三章" not in ctx.stable_core
|
||||||
|
|
||||||
|
|
||||||
|
async def test_no_per_chapter_varying_field_in_cached_blocks() -> None:
|
||||||
|
# 不变量#9:同书不同章,stable_core(含 genre 片段)必须字节稳定;
|
||||||
|
# 章号 / 开篇标记等每章易变项只在 volatile。
|
||||||
|
repos = build_repos(spec=ProjectSpecView(title="书", genre="玄幻"))
|
||||||
|
ch1 = await assemble(repos, PROJECT, 1)
|
||||||
|
ch5 = await assemble(repos, PROJECT, 5)
|
||||||
|
assert ch1.stable_core == ch5.stable_core # genre 片段随书稳定,跨章不变
|
||||||
|
assert "第 1 章" not in ch1.stable_core
|
||||||
|
assert "第 5 章" not in ch5.stable_core
|
||||||
|
|
||||||
|
|
||||||
# ---- helpers ----
|
# ---- helpers ----
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from __future__ import annotations
|
|||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from fakes_orchestrator import FakeStreamGateway, RaisingGateway
|
from fakes_orchestrator import FakeStreamGateway, RaisingGateway
|
||||||
|
from ww_agents import load_craft_doctrine
|
||||||
from ww_core.orchestrator import (
|
from ww_core.orchestrator import (
|
||||||
EVENT_DONE,
|
EVENT_DONE,
|
||||||
EVENT_ERROR,
|
EVENT_ERROR,
|
||||||
@@ -37,14 +38,58 @@ def test_build_write_request_puts_stable_core_in_cached_system_block() -> None:
|
|||||||
|
|
||||||
assert req.tier == "writer" # 不变量②:只传档位,不传 model
|
assert req.tier == "writer" # 不变量②:只传档位,不传 model
|
||||||
assert req.stream is True
|
assert req.stream is True
|
||||||
assert len(req.system) == 1
|
# 灵感①:system = [craft 教条(cache), stable_core(cache)],craft 在最前。
|
||||||
assert req.system[0].text == STABLE
|
assert len(req.system) == 2
|
||||||
assert req.system[0].cache is True # 不变量#9:稳定内核进缓存断点前
|
assert all(b.cache is True for b in req.system) # 不变量#9:稳定内核进缓存断点前
|
||||||
|
assert req.system[1].text == STABLE
|
||||||
assert req.input == VOLATILE
|
assert req.input == VOLATILE
|
||||||
assert req.scope.user_id == USER
|
assert req.scope.user_id == USER
|
||||||
assert req.scope.project_id == PROJECT
|
assert req.scope.project_id == PROJECT
|
||||||
|
|
||||||
|
|
||||||
|
# ---- 灵感①:写章 craft 教条块 ----
|
||||||
|
|
||||||
|
|
||||||
|
def test_write_request_includes_craft_block() -> None:
|
||||||
|
req = build_write_request(
|
||||||
|
stable_core=STABLE, volatile=VOLATILE, user_id=USER, project_id=PROJECT
|
||||||
|
)
|
||||||
|
# craft 教条作为独立的 cache=True system 块注入。
|
||||||
|
assert req.system[0].text == load_craft_doctrine()
|
||||||
|
assert req.system[0].cache is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_craft_precedes_stable_core() -> None:
|
||||||
|
req = build_write_request(
|
||||||
|
stable_core=STABLE, volatile=VOLATILE, user_id=USER, project_id=PROJECT
|
||||||
|
)
|
||||||
|
# craft 教条块必须在 stable_core 块之前(跨书前缀复用最大化)。
|
||||||
|
assert req.system[0].text == load_craft_doctrine()
|
||||||
|
assert req.system[1].text == STABLE
|
||||||
|
|
||||||
|
|
||||||
|
def test_craft_is_byte_stable() -> None:
|
||||||
|
# 不变量#9:craft 块字节稳定——两次构造完全一致,无每章易变项掺入。
|
||||||
|
req1 = build_write_request(
|
||||||
|
stable_core=STABLE, volatile="请创作第 1 章的正文。", user_id=USER, project_id=PROJECT
|
||||||
|
)
|
||||||
|
req2 = build_write_request(
|
||||||
|
stable_core=STABLE, volatile="请创作第 99 章的正文。", user_id=USER, project_id=PROJECT
|
||||||
|
)
|
||||||
|
assert req1.system[0].text == req2.system[0].text # craft 块与章号无关
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_per_chapter_field_in_cached_system_blocks() -> None:
|
||||||
|
# 不变量#9:每章易变项(章号)只在 input(断点后),绝不进任何 cache=True 的 system 块。
|
||||||
|
volatile = "## 写作指令\n请创作第 5 章的正文。"
|
||||||
|
req = build_write_request(
|
||||||
|
stable_core=STABLE, volatile=volatile, user_id=USER, project_id=PROJECT
|
||||||
|
)
|
||||||
|
for block in req.system:
|
||||||
|
assert "第 5 章" not in block.text
|
||||||
|
assert "第 5 章" in req.input
|
||||||
|
|
||||||
|
|
||||||
# ---- stream_chapter_draft:转发网关 Delta、构造正确请求 ----
|
# ---- stream_chapter_draft:转发网关 Delta、构造正确请求 ----
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -99,6 +99,8 @@ class ProjectSpecView(BaseModel):
|
|||||||
logline: str | None = None
|
logline: str | None = None
|
||||||
premise: str | None = None
|
premise: str | None = None
|
||||||
theme: str | None = None
|
theme: str | None = None
|
||||||
|
# 题材(每书稳定项)——供 assemble 注入 genre-aware craft 片段(灵感 F1)。
|
||||||
|
genre: str | None = None
|
||||||
|
|
||||||
|
|
||||||
# ---- Repository 协议(async;统一按 project_id 过滤)----
|
# ---- Repository 协议(async;统一按 project_id 过滤)----
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import json
|
|||||||
import uuid
|
import uuid
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
from ww_config import genre_craft_fragment
|
||||||
|
|
||||||
from ww_core.domain.injection_repo import InjectionOverride
|
from ww_core.domain.injection_repo import InjectionOverride
|
||||||
from ww_core.domain.repositories import (
|
from ww_core.domain.repositories import (
|
||||||
CharacterView,
|
CharacterView,
|
||||||
@@ -31,6 +33,9 @@ from .types import AssembledContext, EntityKind
|
|||||||
# 四级规则合并优先级:global → genre → style → project(越具体越靠后/越优先)
|
# 四级规则合并优先级:global → genre → style → project(越具体越靠后/越优先)
|
||||||
_LEVEL_RANK: dict[str, int] = {"global": 0, "genre": 1, "style": 2, "project": 3}
|
_LEVEL_RANK: dict[str, int] = {"global": 0, "genre": 1, "style": 2, "project": 3}
|
||||||
|
|
||||||
|
# 开篇黄金三章特判上限:前 N 章在 volatile 追加开篇提示(灵感①)。
|
||||||
|
OPENING_CHAPTER_LIMIT = 3
|
||||||
|
|
||||||
|
|
||||||
def _ser(value: Any) -> str:
|
def _ser(value: Any) -> str:
|
||||||
return json.dumps(value, ensure_ascii=False, sort_keys=True)
|
return json.dumps(value, ensure_ascii=False, sort_keys=True)
|
||||||
@@ -67,6 +72,16 @@ def _build_spec_section(spec: ProjectSpecView | None) -> str | None:
|
|||||||
return _section("作品蓝本", "\n".join(lines))
|
return _section("作品蓝本", "\n".join(lines))
|
||||||
|
|
||||||
|
|
||||||
|
def _build_genre_craft_section(genre: str | None) -> str | None:
|
||||||
|
"""按题材注入 craft 片段(语域/节奏/钩子/字数/套路,灵感 F1)。
|
||||||
|
|
||||||
|
genre 是每书稳定项(来自 spec)→ 入缓存前缀 stable_core,跨章字节稳定(不变量 #9)。
|
||||||
|
命不中预设题材(自由填写/未设)则不注入。品类无关内核在 write_craft.md,不在此处。
|
||||||
|
"""
|
||||||
|
fragment = genre_craft_fragment(genre)
|
||||||
|
return _section("本作题材写法", fragment) if fragment else None
|
||||||
|
|
||||||
|
|
||||||
def _build_stable(
|
def _build_stable(
|
||||||
spec: ProjectSpecView | None,
|
spec: ProjectSpecView | None,
|
||||||
world_entities: list[WorldEntityView],
|
world_entities: list[WorldEntityView],
|
||||||
@@ -89,6 +104,8 @@ def _build_stable(
|
|||||||
rule_lines = [f"[{r.level}] {r.content}" for r in merge_rules(rules)]
|
rule_lines = [f"[{r.level}] {r.content}" for r in merge_rules(rules)]
|
||||||
|
|
||||||
sections = [
|
sections = [
|
||||||
|
# 题材写法领衔——同题材作品共享此前缀,利于跨书隐式前缀缓存(不变量 #9)。
|
||||||
|
_build_genre_craft_section(spec.genre if spec else None),
|
||||||
_build_spec_section(spec),
|
_build_spec_section(spec),
|
||||||
_section("世界观硬规则", "\n".join(world_lines)),
|
_section("世界观硬规则", "\n".join(world_lines)),
|
||||||
_section("定型主角", "\n".join(char_lines)),
|
_section("定型主角", "\n".join(char_lines)),
|
||||||
@@ -98,6 +115,21 @@ def _build_stable(
|
|||||||
return "\n\n".join(s for s in sections if s)
|
return "\n\n".join(s for s in sections if s)
|
||||||
|
|
||||||
|
|
||||||
|
def _opening_marker(chapter_no: int) -> str | None:
|
||||||
|
"""开篇黄金三章特判(灵感①):前 N 章显式提示,不赌模型自读章号。
|
||||||
|
|
||||||
|
含章号 → 每章易变 → 只入 volatile(断点后),绝不进缓存前缀(不变量 #9)。
|
||||||
|
"""
|
||||||
|
if chapter_no > OPENING_CHAPTER_LIMIT:
|
||||||
|
return None
|
||||||
|
return _section(
|
||||||
|
"开篇黄金三章",
|
||||||
|
f"本章为开篇第 {chapter_no} 章,适用黄金三章原则:"
|
||||||
|
"开篇即抛出核心冲突或身份钩子;三章之内交付第一个小爽点或转折;"
|
||||||
|
"避免大段世界观倾泻,边推进情节边带出设定。",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _build_volatile(
|
def _build_volatile(
|
||||||
chapter_no: int,
|
chapter_no: int,
|
||||||
cards: str,
|
cards: str,
|
||||||
@@ -118,6 +150,8 @@ def _build_volatile(
|
|||||||
_section("本章指令", directive.strip()) if directive and directive.strip() else None,
|
_section("本章指令", directive.strip()) if directive and directive.strip() else None,
|
||||||
# 写章指令始终在场——保证 volatile(断点后的 input)永不为空(修空 prompt 400)。
|
# 写章指令始终在场——保证 volatile(断点后的 input)永不为空(修空 prompt 400)。
|
||||||
_section("写作指令", f"请创作第 {chapter_no} 章的正文。"),
|
_section("写作指令", f"请创作第 {chapter_no} 章的正文。"),
|
||||||
|
# 开篇黄金三章标记(仅前 N 章)——每章易变,只入 volatile。
|
||||||
|
_opening_marker(chapter_no),
|
||||||
_section("本章注入卡片", cards),
|
_section("本章注入卡片", cards),
|
||||||
_section("相关伏笔窗口", "\n".join(fore_lines)),
|
_section("相关伏笔窗口", "\n".join(fore_lines)),
|
||||||
_section("近况摘要", "\n".join(digest_lines)),
|
_section("近况摘要", "\n".join(digest_lines)),
|
||||||
|
|||||||
@@ -215,6 +215,7 @@ class SqlProjectSpecRepo:
|
|||||||
logline=row.logline,
|
logline=row.logline,
|
||||||
premise=row.premise,
|
premise=row.premise,
|
||||||
theme=row.theme,
|
theme=row.theme,
|
||||||
|
genre=row.genre,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -17,10 +17,15 @@ import uuid
|
|||||||
from collections.abc import AsyncIterator
|
from collections.abc import AsyncIterator
|
||||||
from typing import Protocol
|
from typing import Protocol
|
||||||
|
|
||||||
|
from ww_agents import load_craft_doctrine
|
||||||
from ww_llm_gateway.types import Block, Delta, LlmRequest, Scope
|
from ww_llm_gateway.types import Block, Delta, LlmRequest, Scope
|
||||||
|
|
||||||
from .state import ChapterState
|
from .state import ChapterState
|
||||||
|
|
||||||
|
# 写章 craft 教条(灵感①):品类无关内核,import 期读盘 → 缺失即 fail-fast(崩 import)。
|
||||||
|
# 字节稳定的稳定内容,作为最前的 cache=True system 块(跨书前缀复用,不变量 #9)。
|
||||||
|
_CRAFT_DOCTRINE = load_craft_doctrine()
|
||||||
|
|
||||||
|
|
||||||
class GatewayStream(Protocol):
|
class GatewayStream(Protocol):
|
||||||
"""write 节点对网关的最小依赖——只需 `stream`(注入真网关或 mock)。"""
|
"""write 节点对网关的最小依赖——只需 `stream`(注入真网关或 mock)。"""
|
||||||
@@ -37,11 +42,15 @@ def build_write_request(
|
|||||||
) -> LlmRequest:
|
) -> LlmRequest:
|
||||||
"""据组装上下文构造写章请求(纯函数,决策 2026-06-18)。
|
"""据组装上下文构造写章请求(纯函数,决策 2026-06-18)。
|
||||||
|
|
||||||
`stable_core` → `system` 缓存断点前块(cache=True);`volatile` → `input`(断点后)。
|
`system` = [craft 教条(cache), stable_core(cache)]——craft 品类无关内核在最前,
|
||||||
|
最大化跨书前缀复用;两块均在缓存断点前(cache=True)。`volatile` → `input`(断点后)。
|
||||||
"""
|
"""
|
||||||
return LlmRequest(
|
return LlmRequest(
|
||||||
tier="writer",
|
tier="writer",
|
||||||
system=[Block(text=stable_core, cache=True)],
|
system=[
|
||||||
|
Block(text=_CRAFT_DOCTRINE, cache=True),
|
||||||
|
Block(text=stable_core, cache=True),
|
||||||
|
],
|
||||||
input=volatile,
|
input=volatile,
|
||||||
stream=True,
|
stream=True,
|
||||||
scope=Scope(user_id=user_id, project_id=project_id),
|
scope=Scope(user_id=user_id, project_id=project_id),
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ _SOURCE_TEXT_FIELD = InputField(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# 创作工具箱注册表:key → 描述符。legacy 3 + 新 8 = 11 条。
|
# 创作工具箱注册表:key → 描述符。15 条(legacy 3 + 新 8 + Scope B 4:续写/扩写/降AI/拆书)。
|
||||||
TOOLBOX: dict[str, GeneratorTool] = {
|
TOOLBOX: dict[str, GeneratorTool] = {
|
||||||
# ---- legacy(spec=None,前端走 legacy_route 跳现有页面)----
|
# ---- legacy(spec=None,前端走 legacy_route 跳现有页面)----
|
||||||
"worldbuilding": GeneratorTool(
|
"worldbuilding": GeneratorTool(
|
||||||
|
|||||||
Reference in New Issue
Block a user