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:
@@ -383,6 +383,73 @@ async def test_chapter_directive_uses_chapter_no() -> None:
|
||||
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 ----
|
||||
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ from __future__ import annotations
|
||||
import uuid
|
||||
|
||||
from fakes_orchestrator import FakeStreamGateway, RaisingGateway
|
||||
from ww_agents import load_craft_doctrine
|
||||
from ww_core.orchestrator import (
|
||||
EVENT_DONE,
|
||||
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.stream is True
|
||||
assert len(req.system) == 1
|
||||
assert req.system[0].text == STABLE
|
||||
assert req.system[0].cache is True # 不变量#9:稳定内核进缓存断点前
|
||||
# 灵感①:system = [craft 教条(cache), stable_core(cache)],craft 在最前。
|
||||
assert len(req.system) == 2
|
||||
assert all(b.cache is True for b in req.system) # 不变量#9:稳定内核进缓存断点前
|
||||
assert req.system[1].text == STABLE
|
||||
assert req.input == VOLATILE
|
||||
assert req.scope.user_id == USER
|
||||
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、构造正确请求 ----
|
||||
|
||||
|
||||
|
||||
@@ -99,6 +99,8 @@ class ProjectSpecView(BaseModel):
|
||||
logline: str | None = None
|
||||
premise: str | None = None
|
||||
theme: str | None = None
|
||||
# 题材(每书稳定项)——供 assemble 注入 genre-aware craft 片段(灵感 F1)。
|
||||
genre: str | None = None
|
||||
|
||||
|
||||
# ---- Repository 协议(async;统一按 project_id 过滤)----
|
||||
|
||||
@@ -11,6 +11,8 @@ import json
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from ww_config import genre_craft_fragment
|
||||
|
||||
from ww_core.domain.injection_repo import InjectionOverride
|
||||
from ww_core.domain.repositories import (
|
||||
CharacterView,
|
||||
@@ -31,6 +33,9 @@ from .types import AssembledContext, EntityKind
|
||||
# 四级规则合并优先级:global → genre → style → project(越具体越靠后/越优先)
|
||||
_LEVEL_RANK: dict[str, int] = {"global": 0, "genre": 1, "style": 2, "project": 3}
|
||||
|
||||
# 开篇黄金三章特判上限:前 N 章在 volatile 追加开篇提示(灵感①)。
|
||||
OPENING_CHAPTER_LIMIT = 3
|
||||
|
||||
|
||||
def _ser(value: Any) -> str:
|
||||
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))
|
||||
|
||||
|
||||
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(
|
||||
spec: ProjectSpecView | None,
|
||||
world_entities: list[WorldEntityView],
|
||||
@@ -89,6 +104,8 @@ def _build_stable(
|
||||
rule_lines = [f"[{r.level}] {r.content}" for r in merge_rules(rules)]
|
||||
|
||||
sections = [
|
||||
# 题材写法领衔——同题材作品共享此前缀,利于跨书隐式前缀缓存(不变量 #9)。
|
||||
_build_genre_craft_section(spec.genre if spec else None),
|
||||
_build_spec_section(spec),
|
||||
_section("世界观硬规则", "\n".join(world_lines)),
|
||||
_section("定型主角", "\n".join(char_lines)),
|
||||
@@ -98,6 +115,21 @@ def _build_stable(
|
||||
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(
|
||||
chapter_no: int,
|
||||
cards: str,
|
||||
@@ -118,6 +150,8 @@ def _build_volatile(
|
||||
_section("本章指令", directive.strip()) if directive and directive.strip() else None,
|
||||
# 写章指令始终在场——保证 volatile(断点后的 input)永不为空(修空 prompt 400)。
|
||||
_section("写作指令", f"请创作第 {chapter_no} 章的正文。"),
|
||||
# 开篇黄金三章标记(仅前 N 章)——每章易变,只入 volatile。
|
||||
_opening_marker(chapter_no),
|
||||
_section("本章注入卡片", cards),
|
||||
_section("相关伏笔窗口", "\n".join(fore_lines)),
|
||||
_section("近况摘要", "\n".join(digest_lines)),
|
||||
|
||||
@@ -215,6 +215,7 @@ class SqlProjectSpecRepo:
|
||||
logline=row.logline,
|
||||
premise=row.premise,
|
||||
theme=row.theme,
|
||||
genre=row.genre,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -17,10 +17,15 @@ import uuid
|
||||
from collections.abc import AsyncIterator
|
||||
from typing import Protocol
|
||||
|
||||
from ww_agents import load_craft_doctrine
|
||||
from ww_llm_gateway.types import Block, Delta, LlmRequest, Scope
|
||||
|
||||
from .state import ChapterState
|
||||
|
||||
# 写章 craft 教条(灵感①):品类无关内核,import 期读盘 → 缺失即 fail-fast(崩 import)。
|
||||
# 字节稳定的稳定内容,作为最前的 cache=True system 块(跨书前缀复用,不变量 #9)。
|
||||
_CRAFT_DOCTRINE = load_craft_doctrine()
|
||||
|
||||
|
||||
class GatewayStream(Protocol):
|
||||
"""write 节点对网关的最小依赖——只需 `stream`(注入真网关或 mock)。"""
|
||||
@@ -37,11 +42,15 @@ def build_write_request(
|
||||
) -> LlmRequest:
|
||||
"""据组装上下文构造写章请求(纯函数,决策 2026-06-18)。
|
||||
|
||||
`stable_core` → `system` 缓存断点前块(cache=True);`volatile` → `input`(断点后)。
|
||||
`system` = [craft 教条(cache), stable_core(cache)]——craft 品类无关内核在最前,
|
||||
最大化跨书前缀复用;两块均在缓存断点前(cache=True)。`volatile` → `input`(断点后)。
|
||||
"""
|
||||
return LlmRequest(
|
||||
tier="writer",
|
||||
system=[Block(text=stable_core, cache=True)],
|
||||
system=[
|
||||
Block(text=_CRAFT_DOCTRINE, cache=True),
|
||||
Block(text=stable_core, cache=True),
|
||||
],
|
||||
input=volatile,
|
||||
stream=True,
|
||||
scope=Scope(user_id=user_id, project_id=project_id),
|
||||
|
||||
Reference in New Issue
Block a user