feat(toolbox): T6 创作工具箱通用生成器框架 — 8 新生成器 + 声明驱动落地页 + P2 收尾

通用执行路径驱动全部生成器("加生成器=加一份声明"):
- @llm: ww_agents +7 输出 schema + 7 spec(book-title/blurb/name/golden-finger/
  glossary/opening/fine-outline,只声明 tier)+ build_outline_chapter_context
- @backend: ww_skills GeneratorTool 描述符 + TOOLBOX(11) + get_tool;3 通用端点
  GET /skills/toolbox · POST .../skills/{tool_key}/generate(预览不写库,仅记账) ·
  POST .../ingest(复用 continuity 409 + partition_writes 白名单);纯 context 派发
- @frontend: 工具箱落地页 RSC + 声明驱动 GeneratorRunner + lib/toolbox 纯函数
  + LeftNav「工具箱」+ ⌘K nav-toolbox/action-gen-*;legacy 3 跳现页
- @qa: tests/test_t6_toolbox_e2e.py 5 用例真 pg + mock 网关零 token,无端点 bug
- P2 收尾: 限流→decisions.md 记延后(单用户原型);noopener/Committable 早已修

守不变量 #2(只声明 tier)/#3(预览不写库,入库经验收 gate)/#9(缓存前缀)。无 DB 迁移。
门禁绿: 后端 ruff/format/mypy 195/alembic 无漂移/pytest 583;前端 lint/tsc/vitest 279/build。
spec 回写 PRODUCT_SPEC §7 + ARCHITECTURE §7.2 端点表。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Yaojia Wang
2026-06-22 20:37:55 +02:00
parent 1f1afa37b6
commit f43ccd293f
46 changed files with 4848 additions and 12 deletions

View File

@@ -0,0 +1,207 @@
"""T6 创作工具箱声明式描述符类型单测Wave-0 契约)。
仅校验描述符**类型**本身(无 TOOLBOX 注册表 seeding——那是 Wave A
- `ContextStrategy`4 种注入策略字面量;
- `InputField`声明式表单字段label/type/required/default/help
- `IngestSpec`可选入库目标table 必须在 KNOWN_TABLESNone=纯预览);
- `GeneratorTool`生成器声明legacy 带 legacy_route 且 spec 可为 None新工具带 spec+schema
全部 frozen不可变全局 immutability 约定)——构造后改字段须抛错。
"""
from __future__ import annotations
import dataclasses
import pytest
from pydantic import BaseModel, ValidationError
from ww_agents import AgentSpec
from ww_shared import AppError, ErrorCode
from ww_skills import (
ContextStrategy,
GeneratorTool,
IngestSpec,
InputField,
)
class _FakeOut(BaseModel):
"""构造测试用的 output_schema 占位Pydantic 模型类)。"""
value: str
def _spec() -> AgentSpec:
return AgentSpec(
name="brainstorm",
tier="light",
system_prompt="x",
output_schema=_FakeOut,
reads=["projects"],
writes=[],
)
# ---- ContextStrategy ----
def test_context_strategy_has_four_values() -> None:
# Arrange / Act
from typing import get_args
values = set(get_args(ContextStrategy))
# Assert
assert values == {
"brief_only",
"with_project",
"with_world",
"with_outline_chapter",
}
# ---- InputField ----
def test_input_field_required_fields_and_defaults() -> None:
# Arrange / Act
field = InputField(name="brief", label="一句话需求", type="textarea")
# Assert: required 默认 True其余可空
assert field.name == "brief"
assert field.label == "一句话需求"
assert field.type == "textarea"
assert field.required is True
assert field.default is None
assert field.help is None
def test_input_field_accepts_optional_metadata() -> None:
field = InputField(
name="count",
label="数量",
type="number",
required=False,
default="3",
help="一次生成几条",
)
assert field.required is False
assert field.default == "3"
assert field.help == "一次生成几条"
def test_input_field_is_frozen() -> None:
field = InputField(name="brief", label="需求", type="text")
with pytest.raises(ValidationError):
field.label = "改名"
# ---- IngestSpec ----
def test_ingest_spec_accepts_known_table() -> None:
spec = IngestSpec(table="world_entities")
assert spec.table == "world_entities"
def test_ingest_spec_rejects_unknown_table() -> None:
# 越权/不存在的入库表 → VALIDATION不可信声明的第一道闸
with pytest.raises(AppError) as exc:
IngestSpec(table="users")
assert exc.value.code is ErrorCode.VALIDATION
def test_ingest_spec_is_frozen() -> None:
spec = IngestSpec(table="outline")
with pytest.raises(ValidationError):
spec.table = "characters"
# ---- GeneratorTool ----
def test_generator_tool_new_tool_with_spec_and_schema() -> None:
# Arrange / Act新工具带 spec + output_schema无 legacy_route
tool = GeneratorTool(
key="brainstorm",
title="脑洞生成器",
subtitle="突破想象,脑洞大开",
spec=_spec(),
output_schema=_FakeOut,
context_strategy="brief_only",
input_fields=[InputField(name="brief", label="需求", type="textarea", required=False)],
)
# Assert
assert tool.key == "brainstorm"
assert tool.spec is not None
assert tool.output_schema is _FakeOut
assert tool.context_strategy == "brief_only"
assert tool.ingest is None
assert tool.legacy_route is None
assert tool.genre is None
assert len(tool.input_fields) == 1
def test_generator_tool_legacy_tool_without_spec() -> None:
# legacy 工具spec=None + 指向现有页面/端点
tool = GeneratorTool(
key="worldbuilding",
title="世界观生成器",
subtitle="构建自洽世界",
spec=None,
output_schema=None,
context_strategy="with_project",
input_fields=[],
legacy_route="/projects/{id}/world",
)
assert tool.spec is None
assert tool.output_schema is None
assert tool.legacy_route == "/projects/{id}/world"
def test_generator_tool_with_ingest_and_genre() -> None:
tool = GeneratorTool(
key="glossary",
title="词条生成器",
subtitle="批量造词条",
spec=_spec(),
output_schema=_FakeOut,
context_strategy="with_world",
input_fields=[],
ingest=IngestSpec(table="world_entities"),
genre="xuanhuan",
)
assert tool.ingest is not None
assert tool.ingest.table == "world_entities"
assert tool.genre == "xuanhuan"
def test_generator_tool_is_frozen() -> None:
tool = GeneratorTool(
key="brainstorm",
title="脑洞生成器",
subtitle="x",
spec=_spec(),
output_schema=_FakeOut,
context_strategy="brief_only",
input_fields=[],
)
with pytest.raises(ValidationError):
tool.title = "改名"
def test_generator_tool_input_fields_default_empty() -> None:
tool = GeneratorTool(
key="x",
title="t",
subtitle="s",
spec=None,
output_schema=None,
context_strategy="brief_only",
input_fields=[],
legacy_route="/x",
)
# input_fields 是显式入参frozen 后整体不可重绑
assert tool.input_fields == []
assert not dataclasses.is_dataclass(tool) # 用 Pydantic非 dataclass

View File

@@ -0,0 +1,85 @@
"""T6.2 创作工具箱注册表(`TOOLBOX`)单测。
校验11 条齐全legacy 3 + 新 8legacy 携 legacy_route 且 spec=None新工具携 spec +
output_schema与 spec.output_schema 一致ingest 工具的 table 在白名单内;`get_tool` 解析。
"""
from __future__ import annotations
from ww_skills import TOOLBOX, get_tool
_LEGACY_KEYS = {"worldbuilding", "character", "outline"}
_NEW_KEYS = {
"brainstorm",
"book-title",
"blurb",
"name",
"golden-finger",
"glossary",
"opening",
"fine-outline",
}
_INGEST_KEYS = {"golden-finger", "glossary", "fine-outline"}
def test_toolbox_has_all_eleven_tools() -> None:
# Arrange / Act
keys = set(TOOLBOX.keys())
# Assert
assert keys == _LEGACY_KEYS | _NEW_KEYS
assert len(TOOLBOX) == 11
def test_legacy_tools_have_route_and_no_spec() -> None:
for key in _LEGACY_KEYS:
tool = TOOLBOX[key]
assert tool.spec is None, key
assert tool.output_schema is None, key
assert tool.legacy_route is not None, key
assert "{id}" in tool.legacy_route, key
def test_new_tools_carry_spec_and_matching_schema() -> None:
for key in _NEW_KEYS:
tool = TOOLBOX[key]
assert tool.spec is not None, key
assert tool.output_schema is not None, key
# 描述符的 output_schema 必须与其 spec 的 output_schema 一致(同一真类)。
assert tool.output_schema is tool.spec.output_schema, key
assert tool.legacy_route is None, key
# 每个新工具的 key 与 spec.name 一致(路由稳定)。
assert tool.key == tool.spec.name, key
def test_ingest_tools_declare_known_table() -> None:
for key, tool in TOOLBOX.items():
if key in _INGEST_KEYS:
assert tool.ingest is not None, key
else:
assert tool.ingest is None, key
assert TOOLBOX["golden-finger"].ingest is not None
assert TOOLBOX["golden-finger"].ingest.table == "world_entities"
assert TOOLBOX["glossary"].ingest is not None
assert TOOLBOX["glossary"].ingest.table == "world_entities"
assert TOOLBOX["fine-outline"].ingest is not None
assert TOOLBOX["fine-outline"].ingest.table == "outline"
def test_chapter_tools_have_chapter_no_field() -> None:
for key in ("opening", "fine-outline"):
field_names = {f.name for f in TOOLBOX[key].input_fields}
assert "chapter_no" in field_names, key
def test_every_tool_has_brief_field_where_applicable() -> None:
# 新工具均带 brief textarea声明驱动表单的统一入口
for key in _NEW_KEYS:
field_names = {f.name for f in TOOLBOX[key].input_fields}
assert "brief" in field_names, key
def test_get_tool_resolves_known_and_unknown() -> None:
assert get_tool("brainstorm") is TOOLBOX["brainstorm"]
assert get_tool("worldbuilding") is TOOLBOX["worldbuilding"]
assert get_tool("does-not-exist") is None

View File

@@ -22,6 +22,13 @@ from ww_skills.skill_registry import (
SkillRepo,
SqlSkillRepo,
)
from ww_skills.toolbox import (
ContextStrategy,
GeneratorTool,
IngestSpec,
InputField,
)
from ww_skills.toolbox_registry import TOOLBOX, get_tool
__all__ = [
"KNOWN_TABLES",
@@ -32,4 +39,10 @@ __all__ = [
"SkillRegistry",
"SkillRepo",
"SqlSkillRepo",
"ContextStrategy",
"GeneratorTool",
"IngestSpec",
"InputField",
"TOOLBOX",
"get_tool",
]

View File

@@ -0,0 +1,103 @@
"""T6 创作工具箱 · 声明式生成器描述符类型(创作工具箱通用框架的契约)。
竞品把创作辅助做成一排「XX 生成器」卡片;本仓的 `AgentSpec`ARCH §5.1)本就是
「内置 Agent 与用户 Skill 同构声明」。这里再加一层**生成器描述符**:把「一个生成器」
声明成一条 `GeneratorTool`——复用 §5.1 的 `spec`tier/system_prompt/reads/writes
再补前端渲染所需的卡片文案 + 表单字段 + 注入策略 + 可选入库目标。
之后「加一个生成器」= 加一份 `GeneratorTool` 声明descriptor 在代码、不进 DB——
保 Pydantic schema 为真类避免迁移DB `skills` 表与只读注册表/权限故事不变)。
本模块只定义**类型**Wave-0 契约);`TOOLBOX: dict[str, GeneratorTool]` 注册表 seeding
属 Wave A需 @llm 落地的 8 个新 spec/output_schema
不可变:所有描述符 frozen——构造后不得改动全局 immutability 约定)。
"""
from __future__ import annotations
from typing import Literal
from pydantic import BaseModel, ConfigDict, model_validator
from ww_agents import AgentSpec
from ww_shared import AppError, ErrorCode
from ww_skills.skill_permissions import KNOWN_TABLES
# 注入策略:决定为该生成器组哪些材料喂给网关(覆盖全部生成器,~4 种)。
# - brief_only项目立意 + 用户一句话(脑洞/书名);
# - with_project+ 主线/卖点(简介);
# - with_world+ world_entities 卡(名字/金手指/词条——须契合世界观硬规则);
# - with_outline_chapter+ 指定章 outline beats细纲/黄金开篇)。
ContextStrategy = Literal[
"brief_only",
"with_project",
"with_world",
"with_outline_chapter",
]
class InputField(BaseModel):
"""声明式表单字段描述符前端据此渲染输入控件frozensnake_case
`type` 是前端控件类型("text"/"textarea"/"number"/"select" 等);`default` 统一以
字符串携带(声明层不区分数值/枚举的运行期类型,前端按 `type` 解释)。
"""
model_config = ConfigDict(frozen=True)
name: str # 表单字段名(提交时的 key
label: str # 展示标签
type: str # 控件类型text / textarea / number / select ...
required: bool = True
default: str | None = None
help: str | None = None # 字段说明/占位提示
class IngestSpec(BaseModel):
"""可选入库目标描述符None 表示纯预览不写库frozen
`table` 必须在 `KNOWN_TABLES` 创作表白名单内——驱动既有入库 gate
continuity 预检 + `partition_writes` 白名单 + schema→JSONB 形变,不变量 #3
越权/不存在的入库表 → AppError(VALIDATION)(不可信声明的第一道闸)。
KISS当前只需 `table` 即可驱动 gate更多形变字段属 Wave A 按需再加。
"""
model_config = ConfigDict(frozen=True)
table: str
@model_validator(mode="after")
def _check_table(self) -> IngestSpec:
if self.table not in KNOWN_TABLES:
raise AppError(
ErrorCode.VALIDATION,
f"IngestSpec 声明了未知入库表:{self.table}",
{"table": self.table},
)
return self
class GeneratorTool(BaseModel):
"""单个生成器的声明式描述符(创作工具箱卡片 + 执行路径的真源frozen
两类形态:
- 新工具:带 `spec`§5.1 声明)+ `output_schema`(结构化产出真类),走通用执行器;
- legacy 工具(已上架的 世界观/人设/大纲):`spec=None`,带 `legacy_route` 指向
现有更丰富的入库/冲突页面,不回归既测代码。
`ingest=None` 表示纯预览(不写库);`genre` 标注题材适用(前端徽标/过滤)。
"""
model_config = ConfigDict(frozen=True, arbitrary_types_allowed=True)
key: str # 路由用稳定标识("brainstorm" / "book-title" ...
title: str # 卡片标题("脑洞生成器"
subtitle: str # 卡片副标题("突破想象,脑洞大开"
spec: AgentSpec | None # §5.1 声明tier/system_prompt/reads/writeslegacy 为 None
output_schema: type[BaseModel] | None # 结构化产出真类legacy/纯文本可为 None
context_strategy: ContextStrategy # 注入策略(组哪些材料)
input_fields: list[InputField] # 声明式表单
ingest: IngestSpec | None = None # 可入库目标None=纯预览
legacy_route: str | None = None # legacy 工具指向的现有页面/端点
genre: str | None = None # 题材适用(卡片徽标/过滤)

View File

@@ -0,0 +1,187 @@
"""T6 创作工具箱 · 生成器注册表(`TOOLBOX`:把每个生成器声明成一条 `GeneratorTool`)。
「加一个生成器」= 在本表加一份声明descriptor 在代码、不进 DB——保 Pydantic schema
为真类避免迁移DB `skills` 表与只读注册表/权限故事不变)。
两类形态:
- **legacy**(已上架的 世界观/人设/大纲):`spec=None`,带 `legacy_route` 指向现有更丰富的
入库/冲突页面,前端据 `legacy_route` 直接跳页,不回归既测代码(通用 generate/ingest
端点对它们返 404/422
- **新工具**8 个):带 `spec`§5.1 声明)+ `output_schema`(结构化产物真类)+
`context_strategy`(注入策略)+ `input_fields`(声明式表单),走通用执行器;声明 `ingest`
者可经通用 ingest 端点入库(复用既有 continuity 预检 + 白名单 gate不变量 #3
不变量 #2只声明 tierspec 已守)/ #3AI 产出入库必经验收 gate预览不写库
"""
from __future__ import annotations
from ww_agents import (
blurb_spec,
book_title_spec,
brainstorm_spec,
fine_outline_spec,
glossary_spec,
golden_finger_spec,
name_spec,
opening_spec,
)
from ww_agents.schemas import (
BlurbResult,
DetailedOutlineResult,
GlossaryResult,
GoldenFingerResult,
IdeaListResult,
NameListResult,
OpeningResult,
TitleListResult,
)
from ww_skills.toolbox import GeneratorTool, IngestSpec, InputField
# 复用的常见输入字段DRY每个生成器都有「一句话需求」textarea
_BRIEF_FIELD = InputField(
name="brief",
label="一句话需求",
type="textarea",
required=False,
help="描述创作方向/约束;留空则按作品设定与题材自由发散",
)
def _chapter_no_field() -> InputField:
"""按章展开的生成器(开篇/细纲)所需的章号输入。"""
return InputField(
name="chapter_no",
label="章号",
type="number",
required=True,
default="1",
help="要生成/展开的章节号(读取该章大纲节拍)",
)
# 创作工具箱注册表key → 描述符。legacy 3 + 新 8 = 11 条。
TOOLBOX: dict[str, GeneratorTool] = {
# ---- legacyspec=None前端走 legacy_route 跳现有页面)----
"worldbuilding": GeneratorTool(
key="worldbuilding",
title="世界观生成器",
subtitle="构建内部自洽的世界观与硬规则",
spec=None,
output_schema=None,
context_strategy="with_project",
input_fields=[],
legacy_route="/projects/{id}/codex?gen=world",
),
"character": GeneratorTool(
key="character",
title="人设生成器",
subtitle="群像防雷同的结构化角色卡",
spec=None,
output_schema=None,
context_strategy="with_world",
input_fields=[],
legacy_route="/projects/{id}/codex?gen=character",
),
"outline": GeneratorTool(
key="outline",
title="大纲生成器",
subtitle="分卷分章 + 伏笔回收窗口",
spec=None,
output_schema=None,
context_strategy="with_project",
input_fields=[],
legacy_route="/projects/{id}/outline",
),
# ---- 新工具(走通用执行器)----
"brainstorm": GeneratorTool(
key="brainstorm",
title="脑洞生成器",
subtitle="突破想象,脑洞大开",
spec=brainstorm_spec,
output_schema=IdeaListResult,
context_strategy="brief_only",
input_fields=[_BRIEF_FIELD],
),
"book-title": GeneratorTool(
key="book-title",
title="书名生成器",
subtitle="一秒生成抓人书名",
spec=book_title_spec,
output_schema=TitleListResult,
context_strategy="brief_only",
input_fields=[_BRIEF_FIELD],
),
"blurb": GeneratorTool(
key="blurb",
title="简介生成器",
subtitle="多版差异化书页文案",
spec=blurb_spec,
output_schema=BlurbResult,
context_strategy="with_project",
input_fields=[_BRIEF_FIELD],
),
"name": GeneratorTool(
key="name",
title="名字生成器",
subtitle="契合世界观的人/物/地命名",
spec=name_spec,
output_schema=NameListResult,
context_strategy="with_world",
input_fields=[
_BRIEF_FIELD,
InputField(
name="kind",
label="命名对象",
type="text",
required=False,
help="人物 / 势力 / 地点 / 功法 / 物品 等",
),
],
),
"golden-finger": GeneratorTool(
key="golden-finger",
title="金手指生成器",
subtitle="自洽机制 + 成长 + 限制代价",
spec=golden_finger_spec,
output_schema=GoldenFingerResult,
context_strategy="with_world",
input_fields=[_BRIEF_FIELD],
ingest=IngestSpec(table="world_entities"),
),
"glossary": GeneratorTool(
key="glossary",
title="词条生成器",
subtitle="带硬规则的世界观术语表",
spec=glossary_spec,
output_schema=GlossaryResult,
context_strategy="with_world",
input_fields=[_BRIEF_FIELD],
ingest=IngestSpec(table="world_entities"),
),
"opening": GeneratorTool(
key="opening",
title="黄金开篇生成器",
subtitle="多版高代入感开篇正文",
spec=opening_spec,
output_schema=OpeningResult,
context_strategy="with_outline_chapter",
input_fields=[_chapter_no_field(), _BRIEF_FIELD],
),
"fine-outline": GeneratorTool(
key="fine-outline",
title="细纲生成器",
subtitle="把章节粗节拍展开为场景序列",
spec=fine_outline_spec,
output_schema=DetailedOutlineResult,
context_strategy="with_outline_chapter",
input_fields=[_chapter_no_field(), _BRIEF_FIELD],
ingest=IngestSpec(table="outline"),
),
}
def get_tool(key: str) -> GeneratorTool | None:
"""按 key 取生成器描述符;未知 key → None端点据此返 404"""
return TOOLBOX.get(key)