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