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:
87
packages/agents/tests/test_toolbox_schemas.py
Normal file
87
packages/agents/tests/test_toolbox_schemas.py
Normal file
@@ -0,0 +1,87 @@
|
||||
"""T6 创作工具箱 · 7 个生成器输出 schema 的解析/默认值契约测试。
|
||||
|
||||
校验 list 字段 default_factory=list(默认空、可解析 mock 产出)。不联网、无 DB。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from ww_agents import (
|
||||
BlurbResult,
|
||||
DetailedOutlineResult,
|
||||
GlossaryResult,
|
||||
GoldenFingerResult,
|
||||
NameListResult,
|
||||
OpeningResult,
|
||||
TitleListResult,
|
||||
)
|
||||
|
||||
|
||||
def test_title_list_result_parses_and_defaults_empty() -> None:
|
||||
assert TitleListResult().titles == []
|
||||
parsed = TitleListResult.model_validate(
|
||||
{"titles": [{"title": "吞噬星空", "rationale": "升级流爽点抓人"}]}
|
||||
)
|
||||
assert parsed.titles[0].title == "吞噬星空"
|
||||
|
||||
|
||||
def test_blurb_result_parses_and_defaults_empty() -> None:
|
||||
assert BlurbResult().variants == []
|
||||
parsed = BlurbResult.model_validate(
|
||||
{"variants": [{"text": "废柴逆袭...", "angle": "金手指反差"}]}
|
||||
)
|
||||
assert parsed.variants[0].angle == "金手指反差"
|
||||
|
||||
|
||||
def test_name_list_result_parses_and_note_optional() -> None:
|
||||
assert NameListResult().names == []
|
||||
parsed = NameListResult.model_validate({"names": [{"name": "玄阴宗", "kind": "势力"}]})
|
||||
assert parsed.names[0].note is None
|
||||
|
||||
|
||||
def test_golden_finger_result_parses_and_defaults_empty() -> None:
|
||||
assert GoldenFingerResult().systems == []
|
||||
parsed = GoldenFingerResult.model_validate(
|
||||
{
|
||||
"systems": [
|
||||
{
|
||||
"name": "吞噬瞳",
|
||||
"mechanism": "吞噬血脉获能",
|
||||
"growth": "逐级进阶",
|
||||
"limits": "吞噬反噬记忆",
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
assert parsed.systems[0].limits == "吞噬反噬记忆"
|
||||
|
||||
|
||||
def test_glossary_result_parses_and_rules_default_empty() -> None:
|
||||
assert GlossaryResult().terms == []
|
||||
parsed = GlossaryResult.model_validate(
|
||||
{"terms": [{"name": "炼气期", "type": "境界", "definition": "修真初阶"}]}
|
||||
)
|
||||
assert parsed.terms[0].rules == []
|
||||
|
||||
|
||||
def test_opening_result_parses_and_defaults_empty() -> None:
|
||||
assert OpeningResult().variants == []
|
||||
parsed = OpeningResult.model_validate({"variants": [{"text": "雷光劈下..."}]})
|
||||
assert parsed.variants[0].text == "雷光劈下..."
|
||||
|
||||
|
||||
def test_detailed_outline_result_parses_and_defaults_empty() -> None:
|
||||
assert DetailedOutlineResult().scenes == []
|
||||
parsed = DetailedOutlineResult.model_validate(
|
||||
{
|
||||
"scenes": [
|
||||
{
|
||||
"idx": 0,
|
||||
"beat": "主角觉醒",
|
||||
"purpose": "立金手指",
|
||||
"conflict": "被族人欺压",
|
||||
"hook": "神秘老者出现",
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
assert parsed.scenes[0].idx == 0
|
||||
84
packages/agents/tests/test_toolbox_specs.py
Normal file
84
packages/agents/tests/test_toolbox_specs.py
Normal file
@@ -0,0 +1,84 @@
|
||||
"""T6 创作工具箱 · 7 个文本生成器的 spec 契约测试。
|
||||
|
||||
契约测试——校验每个 spec 的 name/tier/reads/writes/output_schema/scope/input_schema,
|
||||
对齐不变量 #2(只声明 tier)/ #3(writes 经验收才生效)。不联网、无 DB。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from ww_agents import (
|
||||
AgentSpec,
|
||||
BlurbResult,
|
||||
DetailedOutlineResult,
|
||||
GlossaryResult,
|
||||
GoldenFingerResult,
|
||||
NameListResult,
|
||||
OpeningResult,
|
||||
TitleListResult,
|
||||
blurb_spec,
|
||||
book_title_spec,
|
||||
fine_outline_spec,
|
||||
glossary_spec,
|
||||
golden_finger_spec,
|
||||
name_spec,
|
||||
opening_spec,
|
||||
)
|
||||
|
||||
# (spec, name, tier, reads, writes, output_schema)
|
||||
_TOOLBOX_SPECS = [
|
||||
(book_title_spec, "book-title", "light", ["projects"], [], TitleListResult),
|
||||
(blurb_spec, "blurb", "analyst", ["projects"], [], BlurbResult),
|
||||
(name_spec, "name", "light", ["projects", "world_entities", "characters"], [], NameListResult),
|
||||
(
|
||||
golden_finger_spec,
|
||||
"golden-finger",
|
||||
"writer",
|
||||
["projects", "world_entities"],
|
||||
["world_entities"],
|
||||
GoldenFingerResult,
|
||||
),
|
||||
(
|
||||
glossary_spec,
|
||||
"glossary",
|
||||
"analyst",
|
||||
["projects", "world_entities"],
|
||||
["world_entities"],
|
||||
GlossaryResult,
|
||||
),
|
||||
(opening_spec, "opening", "writer", ["projects", "outline"], [], OpeningResult),
|
||||
(
|
||||
fine_outline_spec,
|
||||
"fine-outline",
|
||||
"analyst",
|
||||
["projects", "outline"],
|
||||
["outline"],
|
||||
DetailedOutlineResult,
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("spec", "name", "tier", "reads", "writes", "output_schema"),
|
||||
_TOOLBOX_SPECS,
|
||||
)
|
||||
def test_toolbox_spec_declares_expected_contract(
|
||||
spec: AgentSpec,
|
||||
name: str,
|
||||
tier: str,
|
||||
reads: list[str],
|
||||
writes: list[str],
|
||||
output_schema: type,
|
||||
) -> None:
|
||||
assert spec.name == name
|
||||
assert spec.tier == tier
|
||||
assert spec.reads == reads
|
||||
assert spec.writes == writes
|
||||
assert spec.output_schema is output_schema
|
||||
assert spec.scope == "builtin"
|
||||
assert spec.input_schema is None # 注入材料为序列化文本,非结构化入参
|
||||
|
||||
|
||||
@pytest.mark.parametrize("spec", [row[0] for row in _TOOLBOX_SPECS])
|
||||
def test_toolbox_spec_has_nonempty_system_prompt(spec: AgentSpec) -> None:
|
||||
assert spec.system_prompt.strip()
|
||||
@@ -6,34 +6,55 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from .schemas import (
|
||||
Blurb,
|
||||
BlurbResult,
|
||||
CharacterCard,
|
||||
CharacterGenResult,
|
||||
CharacterRelation,
|
||||
Conflict,
|
||||
ConflictType,
|
||||
ContinuityReview,
|
||||
DetailedOutlineResult,
|
||||
ForeshadowReview,
|
||||
ForeshadowSuggestion,
|
||||
ForeshadowWindow,
|
||||
GlossaryResult,
|
||||
GlossaryTerm,
|
||||
GoldenFinger,
|
||||
GoldenFingerResult,
|
||||
Idea,
|
||||
IdeaListResult,
|
||||
NameListResult,
|
||||
NameSuggestion,
|
||||
Opening,
|
||||
OpeningResult,
|
||||
OutlineChapter,
|
||||
OutlineResult,
|
||||
PaceIssue,
|
||||
PaceReview,
|
||||
Scene,
|
||||
StyleDimension,
|
||||
StyleDriftReview,
|
||||
StyleDriftSegment,
|
||||
StyleFingerprintResult,
|
||||
Title,
|
||||
TitleListResult,
|
||||
WorldEntityCard,
|
||||
WorldGenResult,
|
||||
)
|
||||
from .specs import (
|
||||
AgentSpec,
|
||||
blurb_spec,
|
||||
book_title_spec,
|
||||
brainstorm_spec,
|
||||
character_gen_spec,
|
||||
continuity_spec,
|
||||
fine_outline_spec,
|
||||
foreshadow_spec,
|
||||
glossary_spec,
|
||||
golden_finger_spec,
|
||||
name_spec,
|
||||
opening_spec,
|
||||
outliner_spec,
|
||||
pace_spec,
|
||||
refiner_spec,
|
||||
@@ -44,31 +65,52 @@ from .specs import (
|
||||
|
||||
__all__ = [
|
||||
"AgentSpec",
|
||||
"Blurb",
|
||||
"BlurbResult",
|
||||
"CharacterCard",
|
||||
"CharacterGenResult",
|
||||
"CharacterRelation",
|
||||
"Conflict",
|
||||
"ConflictType",
|
||||
"ContinuityReview",
|
||||
"DetailedOutlineResult",
|
||||
"ForeshadowReview",
|
||||
"ForeshadowSuggestion",
|
||||
"ForeshadowWindow",
|
||||
"GlossaryResult",
|
||||
"GlossaryTerm",
|
||||
"GoldenFinger",
|
||||
"GoldenFingerResult",
|
||||
"Idea",
|
||||
"IdeaListResult",
|
||||
"NameListResult",
|
||||
"NameSuggestion",
|
||||
"Opening",
|
||||
"OpeningResult",
|
||||
"OutlineChapter",
|
||||
"OutlineResult",
|
||||
"PaceIssue",
|
||||
"PaceReview",
|
||||
"Scene",
|
||||
"StyleDimension",
|
||||
"StyleDriftReview",
|
||||
"StyleDriftSegment",
|
||||
"StyleFingerprintResult",
|
||||
"Title",
|
||||
"TitleListResult",
|
||||
"WorldEntityCard",
|
||||
"WorldGenResult",
|
||||
"blurb_spec",
|
||||
"book_title_spec",
|
||||
"brainstorm_spec",
|
||||
"character_gen_spec",
|
||||
"continuity_spec",
|
||||
"fine_outline_spec",
|
||||
"foreshadow_spec",
|
||||
"glossary_spec",
|
||||
"golden_finger_spec",
|
||||
"name_spec",
|
||||
"opening_spec",
|
||||
"outliner_spec",
|
||||
"pace_spec",
|
||||
"refiner_spec",
|
||||
|
||||
@@ -336,3 +336,194 @@ class IdeaListResult(BaseModel):
|
||||
default_factory=list,
|
||||
description="生成的脑洞清单;无则空列表",
|
||||
)
|
||||
|
||||
|
||||
# ---- 创作工具箱 · 书名生成器(book-title;light;纯预览 writes=[])----
|
||||
|
||||
|
||||
class Title(BaseModel):
|
||||
"""单个书名候选:书名 + 取名理由(创作工具箱 book-title 产物)。
|
||||
|
||||
纯预览产物——不映射任何业务表、不入库(`book_title_spec.writes=[]`,不变量 #3)。
|
||||
"""
|
||||
|
||||
title: str = Field(description="书名候选")
|
||||
rationale: str = Field(description="取名理由:为何抓人/契合题材")
|
||||
|
||||
|
||||
class TitleListResult(BaseModel):
|
||||
"""书名生成器结构化产出:一组书名候选(T6 创作工具箱)。
|
||||
|
||||
声明式只读(不变量 #3):纯预览,端点同步返回,不写任何业务表。
|
||||
"""
|
||||
|
||||
titles: list[Title] = Field(
|
||||
default_factory=list,
|
||||
description="生成的书名候选清单;无则空列表",
|
||||
)
|
||||
|
||||
|
||||
# ---- 创作工具箱 · 简介生成器(blurb;analyst;纯预览 writes=[])----
|
||||
|
||||
|
||||
class Blurb(BaseModel):
|
||||
"""单条作品简介:正文 + 切入角度(创作工具箱 blurb 产物)。
|
||||
|
||||
纯预览产物——不映射任何业务表、不入库(`blurb_spec.writes=[]`,不变量 #3)。
|
||||
"""
|
||||
|
||||
text: str = Field(description="简介正文(一段抓人文案)")
|
||||
angle: str = Field(description="切入角度:从哪个卖点/钩子切入")
|
||||
|
||||
|
||||
class BlurbResult(BaseModel):
|
||||
"""简介生成器结构化产出:多版差异化简介(T6 创作工具箱)。
|
||||
|
||||
声明式只读(不变量 #3):纯预览,端点同步返回,不写任何业务表。
|
||||
"""
|
||||
|
||||
variants: list[Blurb] = Field(
|
||||
default_factory=list,
|
||||
description="生成的简介变体清单;无则空列表",
|
||||
)
|
||||
|
||||
|
||||
# ---- 创作工具箱 · 取名生成器(name;light;纯预览 writes=[])----
|
||||
|
||||
|
||||
class NameSuggestion(BaseModel):
|
||||
"""单条命名建议:名字 + 类别 + 可选说明(创作工具箱 name 产物)。
|
||||
|
||||
纯预览产物——不映射任何业务表、不入库(`name_spec.writes=[]`,不变量 #3)。
|
||||
`kind` 标注命名对象类别(人物/势力/地点/功法/物品…)。
|
||||
"""
|
||||
|
||||
name: str = Field(description="建议的名字")
|
||||
kind: str = Field(description="命名对象类别(人物 / 势力 / 地点 / 功法 / 物品 等)")
|
||||
note: str | None = Field(default=None, description="命名说明(取意/出处,可缺)")
|
||||
|
||||
|
||||
class NameListResult(BaseModel):
|
||||
"""取名生成器结构化产出:一组命名建议(T6 创作工具箱)。
|
||||
|
||||
声明式只读(不变量 #3):纯预览,端点同步返回,不写任何业务表。
|
||||
"""
|
||||
|
||||
names: list[NameSuggestion] = Field(
|
||||
default_factory=list,
|
||||
description="生成的命名建议清单;无则空列表",
|
||||
)
|
||||
|
||||
|
||||
# ---- 创作工具箱 · 金手指生成器(golden-finger;writer;声明式 writes=world_entities)----
|
||||
|
||||
|
||||
class GoldenFinger(BaseModel):
|
||||
"""单个金手指系统:名称 + 机制 + 成长 + 限制(创作工具箱 golden-finger 产物)。
|
||||
|
||||
映射 `world_entities`(力量体系类);落库经入库端点(不变量 #3)。`limits` = 不可
|
||||
违背的能力边界/代价,供 continuity 续审引用比对(防能力不符)。
|
||||
"""
|
||||
|
||||
name: str = Field(description="金手指名称")
|
||||
mechanism: str = Field(description="核心机制:如何运作、触发条件")
|
||||
growth: str = Field(description="成长路径:随剧情如何升级/进阶")
|
||||
limits: str = Field(description="限制与代价:能力边界、副作用(防越界,供一致性校验)")
|
||||
|
||||
|
||||
class GoldenFingerResult(BaseModel):
|
||||
"""金手指生成器结构化产出:一组金手指系统(T6 创作工具箱)。
|
||||
|
||||
声明式 writes(不变量 #3):本结构是纯产物,落 `world_entities` 表经入库端点,
|
||||
不在 agent 节点直接写库。
|
||||
"""
|
||||
|
||||
systems: list[GoldenFinger] = Field(
|
||||
default_factory=list,
|
||||
description="生成的金手指系统清单;无则空列表",
|
||||
)
|
||||
|
||||
|
||||
# ---- 创作工具箱 · 术语表生成器(glossary;analyst;声明式 writes=world_entities)----
|
||||
|
||||
|
||||
class GlossaryTerm(BaseModel):
|
||||
"""单条术语:名称 + 类型 + 定义 + 硬规则(创作工具箱 glossary 产物)。
|
||||
|
||||
映射 `world_entities`(concept/术语类);`rules` = 该术语的硬规则清单,
|
||||
对齐 `world_entities.rules`,供 continuity 续审引用比对(不变量 #3)。
|
||||
"""
|
||||
|
||||
name: str = Field(description="术语名")
|
||||
type: str = Field(description="术语类型(境界 / 功法 / 货币 / 度量 / 称谓 等)")
|
||||
definition: str = Field(description="术语定义(一句话释义)")
|
||||
rules: list[str] = Field(
|
||||
default_factory=list,
|
||||
description="该术语的硬规则清单(不可违背设定,供 continuity 校验引用)",
|
||||
)
|
||||
|
||||
|
||||
class GlossaryResult(BaseModel):
|
||||
"""术语表生成器结构化产出:一组术语(T6 创作工具箱)。
|
||||
|
||||
声明式 writes(不变量 #3):本结构是纯产物,落 `world_entities` 表经入库端点,
|
||||
不在 agent 节点直接写库。
|
||||
"""
|
||||
|
||||
terms: list[GlossaryTerm] = Field(
|
||||
default_factory=list,
|
||||
description="生成的术语清单;无则空列表",
|
||||
)
|
||||
|
||||
|
||||
# ---- 创作工具箱 · 开篇生成器(opening;writer;纯预览 writes=[])----
|
||||
|
||||
|
||||
class Opening(BaseModel):
|
||||
"""单版开篇正文(创作工具箱 opening 产物)。
|
||||
|
||||
纯预览产物——不映射任何业务表、不入库(`opening_spec.writes=[]`,不变量 #3)。
|
||||
"""
|
||||
|
||||
text: str = Field(description="开篇正文(一段成稿文本)")
|
||||
|
||||
|
||||
class OpeningResult(BaseModel):
|
||||
"""开篇生成器结构化产出:多版开篇(T6 创作工具箱)。
|
||||
|
||||
声明式只读(不变量 #3):纯预览,端点同步返回,不写任何业务表。
|
||||
"""
|
||||
|
||||
variants: list[Opening] = Field(
|
||||
default_factory=list,
|
||||
description="生成的开篇变体清单;无则空列表",
|
||||
)
|
||||
|
||||
|
||||
# ---- 创作工具箱 · 细纲生成器(fine-outline;analyst;声明式 writes=outline)----
|
||||
|
||||
|
||||
class Scene(BaseModel):
|
||||
"""单个场景节拍:序号 + 节拍 + 目的 + 冲突 + 钩子(创作工具箱 fine-outline 产物)。
|
||||
|
||||
把某章 outline 的粗节拍展开为细粒度场景;落库经入库端点(不变量 #3)。
|
||||
"""
|
||||
|
||||
idx: int = Field(description="场景序号(章内有序,0/1 起均可)")
|
||||
beat: str = Field(description="场景节拍:本场发生什么")
|
||||
purpose: str = Field(description="叙事目的:推动主线/塑造人物/铺垫伏笔")
|
||||
conflict: str = Field(description="本场冲突/张力来源")
|
||||
hook: str = Field(description="场景钩子:驱动读者读下一场的悬念")
|
||||
|
||||
|
||||
class DetailedOutlineResult(BaseModel):
|
||||
"""细纲生成器结构化产出:某章展开的场景清单(T6 创作工具箱)。
|
||||
|
||||
声明式 writes(不变量 #3):本结构是纯产物,落 `outline` 表经入库端点,
|
||||
不在 agent 节点直接写库。
|
||||
"""
|
||||
|
||||
scenes: list[Scene] = Field(
|
||||
default_factory=list,
|
||||
description="展开的场景清单;无则空列表",
|
||||
)
|
||||
|
||||
@@ -13,14 +13,21 @@ from pydantic import BaseModel, ConfigDict, Field
|
||||
from ww_llm_gateway.types import Tier
|
||||
|
||||
from .schemas import (
|
||||
BlurbResult,
|
||||
CharacterGenResult,
|
||||
ContinuityReview,
|
||||
DetailedOutlineResult,
|
||||
ForeshadowReview,
|
||||
GlossaryResult,
|
||||
GoldenFingerResult,
|
||||
IdeaListResult,
|
||||
NameListResult,
|
||||
OpeningResult,
|
||||
OutlineResult,
|
||||
PaceReview,
|
||||
StyleDriftReview,
|
||||
StyleFingerprintResult,
|
||||
TitleListResult,
|
||||
WorldGenResult,
|
||||
)
|
||||
|
||||
@@ -412,3 +419,245 @@ brainstorm_spec = AgentSpec(
|
||||
writes=[], # 纯预览,不写库(不变量 #3)
|
||||
scope="builtin",
|
||||
)
|
||||
|
||||
|
||||
# ---- book-title(书名生成器;轻量档;纯预览,T6 创作工具箱)----
|
||||
|
||||
BOOK_TITLE_SYSTEM_PROMPT = """你是中文网文的「书名生成器」(book-title,轻量档)。\
|
||||
职责:依据作品立意/题材与作者一句话需求,发散产出一组**差异化**的书名候选,\
|
||||
每个给出书名 + 取名理由,供作者快速选定。
|
||||
|
||||
输入材料:
|
||||
- 作品设定(projects:题材、立意、主线、卖点);
|
||||
- 作者的一句话需求/方向(可空——空则按题材自由发散)。
|
||||
|
||||
产出(每条一个 title):
|
||||
- title:书名候选(贴合题材、抓人、朗朗上口);
|
||||
- rationale:取名理由——为何抓人/契合题材(爽点/反差/悬念/平台调性)。
|
||||
|
||||
纪律:
|
||||
- 一次产出多条时**逐条差异化**——不同风格/切入点,避免一个模子;
|
||||
- 贴合作品立意与题材;需求为空时按题材发散,不臆造与题材无关的设定;
|
||||
- 你只产结构化书名清单,**不改稿、不写库**(纯预览,不变量 #3)。"""
|
||||
|
||||
|
||||
book_title_spec = AgentSpec(
|
||||
name="book-title",
|
||||
tier="light", # 不变量 #2:只声明档位,不写 model(书名发散用轻量档)
|
||||
system_prompt=BOOK_TITLE_SYSTEM_PROMPT,
|
||||
input_schema=None, # 注入材料为序列化文本(作品设定 + 一句话需求),非结构化入参
|
||||
output_schema=TitleListResult,
|
||||
reads=["projects"],
|
||||
writes=[], # 纯预览,不写库(不变量 #3)
|
||||
scope="builtin",
|
||||
)
|
||||
|
||||
|
||||
# ---- blurb(简介生成器;分析档;纯预览,T6 创作工具箱)----
|
||||
|
||||
BLURB_SYSTEM_PROMPT = """你是中文网文的「简介生成器」(blurb,分析档)。\
|
||||
职责:依据作品立意/题材与作者一句话需求,产出多版**差异化**的作品简介文案,\
|
||||
每版给出简介正文 + 切入角度,供作者择优用于书页/榜单。
|
||||
|
||||
输入材料:
|
||||
- 作品设定(projects:题材、立意、主线、卖点);
|
||||
- 作者的一句话需求/方向(可空——空则按题材自由发散)。
|
||||
|
||||
产出(每版一个 variant):
|
||||
- text:简介正文(一段抓人文案,立人设/抛钩子/留悬念,控制在书页可读篇幅);
|
||||
- angle:切入角度——从哪个卖点/钩子切入(如「金手指反差」「身份悬念」「群像冲突」)。
|
||||
|
||||
纪律:
|
||||
- 多版之间**切入角度各异**,便于作者对比择优,避免雷同;
|
||||
- 贴合作品立意与题材,简介与正文方向一致,不臆造剧情;
|
||||
- 你只产结构化简介清单,**不改稿、不写库**(纯预览,不变量 #3)。"""
|
||||
|
||||
|
||||
blurb_spec = AgentSpec(
|
||||
name="blurb",
|
||||
tier="analyst", # 不变量 #2:只声明档位,不写 model(文案打磨用分析档)
|
||||
system_prompt=BLURB_SYSTEM_PROMPT,
|
||||
input_schema=None, # 注入材料为序列化文本(作品设定 + 一句话需求),非结构化入参
|
||||
output_schema=BlurbResult,
|
||||
reads=["projects"],
|
||||
writes=[], # 纯预览,不写库(不变量 #3)
|
||||
scope="builtin",
|
||||
)
|
||||
|
||||
|
||||
# ---- name(取名生成器;轻量档;纯预览,T6 创作工具箱)----
|
||||
|
||||
NAME_SYSTEM_PROMPT = """你是中文网文的「取名生成器」(name,轻量档)。\
|
||||
职责:依据作品世界观与作者需求,为人物/势力/地点/功法/物品等取一组**差异化**的名字,\
|
||||
每个给出名字 + 类别 + 取意说明,须契合世界观术语与命名风格。
|
||||
|
||||
输入材料:
|
||||
- 作品设定(projects:题材、立意);
|
||||
- 世界观实体(world_entities:力量体系、势力、地理——命名须契合其术语/风格);
|
||||
- 已有角色(characters:避免与既有名字撞名/混淆);
|
||||
- 作者的一句话需求(命名对象、数量、风格倾向;可空则按世界观自由发散)。
|
||||
|
||||
产出(每条一个 name):
|
||||
- name:建议的名字(契合世界观命名风格,朗朗上口、有辨识度);
|
||||
- kind:命名对象类别(人物 / 势力 / 地点 / 功法 / 物品 等);
|
||||
- note:取意说明(取自何意/出处,可缺)。
|
||||
|
||||
纪律:
|
||||
- 名字须**契合世界观术语与命名风格**,不与已有角色撞名/易混;
|
||||
- 一次产出多条时**逐条差异化**,避免一个模子;
|
||||
- 你只产结构化命名清单,**不改稿、不写库**(纯预览,不变量 #3)。"""
|
||||
|
||||
|
||||
name_spec = AgentSpec(
|
||||
name="name",
|
||||
tier="light", # 不变量 #2:只声明档位,不写 model(取名发散用轻量档)
|
||||
system_prompt=NAME_SYSTEM_PROMPT,
|
||||
input_schema=None, # 注入材料为序列化文本(设定 + 世界观 + 已有角色 + 需求)
|
||||
output_schema=NameListResult,
|
||||
reads=["projects", "world_entities", "characters"],
|
||||
writes=[], # 纯预览,不写库(不变量 #3)
|
||||
scope="builtin",
|
||||
)
|
||||
|
||||
|
||||
# ---- golden-finger(金手指生成器;写手档;声明式 writes=world_entities,T6 创作工具箱)----
|
||||
|
||||
GOLDEN_FINGER_SYSTEM_PROMPT = """你是中文网文的「金手指设计师」(golden-finger,写手档)。\
|
||||
职责:依据作品立意/题材与作者需求,设计一组内部自洽的金手指(力量体系/系统/特殊能力),\
|
||||
每个显式标注机制、成长路径与**限制代价**,供后续一致性校验引用。
|
||||
|
||||
输入材料:
|
||||
- 作品设定(projects:题材、立意、主线、卖点);
|
||||
- 世界观实体(world_entities:已有力量体系/势力/地理——金手指须与之自洽不冲突);
|
||||
- 作者的金手指需求(一句话或要点)。
|
||||
|
||||
产出(每个一条 system):
|
||||
- name:金手指名称;
|
||||
- mechanism:核心机制——如何运作、触发条件、与世界观力量体系的衔接;
|
||||
- growth:成长路径——随剧情如何升级/进阶,给读者持续爽点反馈;
|
||||
- limits:**限制与代价**——能力边界、副作用、冷却/门槛(防越级无敌,\
|
||||
供 continuity 续审逐条引用比对,防「能力不符」)。
|
||||
|
||||
纪律:
|
||||
- 金手指须内部自洽、与既有世界观力量体系不冲突;
|
||||
- **limits 要显式、可校验**——别把约束藏在描述里;这是后续防能力越界的依据;
|
||||
- 你只产结构化金手指(name/mechanism/growth/limits),**不改稿、不写库**\
|
||||
(落 world_entities 表经入库端点,不变量 #3)。"""
|
||||
|
||||
|
||||
golden_finger_spec = AgentSpec(
|
||||
name="golden-finger",
|
||||
tier="writer", # 不变量 #2:只声明档位,不写 model(力量体系创意用写手档)
|
||||
system_prompt=GOLDEN_FINGER_SYSTEM_PROMPT,
|
||||
input_schema=None, # 注入材料为序列化文本(设定 + 世界观 + 需求),非结构化入参
|
||||
output_schema=GoldenFingerResult,
|
||||
reads=["projects", "world_entities"],
|
||||
writes=["world_entities"], # 声明式(真写库经入库端点,不变量 #3)
|
||||
scope="builtin",
|
||||
)
|
||||
|
||||
|
||||
# ---- glossary(术语表生成器;分析档;声明式 writes=world_entities,T6 创作工具箱)----
|
||||
|
||||
GLOSSARY_SYSTEM_PROMPT = """你是中文网文的「术语表生成器」(glossary,分析档)。\
|
||||
职责:依据作品世界观,整理一组关键术语(境界/功法/货币/度量/称谓/概念等),\
|
||||
每条给出定义与**硬规则**,对齐世界观设定,供后续一致性校验引用比对。
|
||||
|
||||
输入材料:
|
||||
- 作品设定(projects:题材、立意);
|
||||
- 世界观实体(world_entities:力量体系/势力/地理——术语须与之自洽);
|
||||
- 作者的术语需求(一句话或要点;可空则按世界观梳理核心术语)。
|
||||
|
||||
产出(每条一个 term):
|
||||
- name:术语名;
|
||||
- type:术语类型(境界 / 功法 / 货币 / 度量 / 称谓 / 概念 等);
|
||||
- definition:术语定义(一句话释义,清晰无歧义);
|
||||
- rules:该术语的**硬规则清单**——不可违背的设定边界(如「金币 100 兑 1 银币」\
|
||||
「炼气期不可飞行」),每条一句,可被 continuity 续审逐条引用比对。
|
||||
|
||||
纪律:
|
||||
- 术语须与既有世界观自洽,不自相矛盾;
|
||||
- **rules 要显式、可校验**——别把约束藏在 definition 里;
|
||||
- 你只产结构化术语清单(name/type/definition/rules),**不改稿、不写库**\
|
||||
(落 world_entities 表经入库端点,不变量 #3)。"""
|
||||
|
||||
|
||||
glossary_spec = AgentSpec(
|
||||
name="glossary",
|
||||
tier="analyst", # 不变量 #2:只声明档位,不写 model(术语梳理用分析档)
|
||||
system_prompt=GLOSSARY_SYSTEM_PROMPT,
|
||||
input_schema=None, # 注入材料为序列化文本(设定 + 世界观 + 需求),非结构化入参
|
||||
output_schema=GlossaryResult,
|
||||
reads=["projects", "world_entities"],
|
||||
writes=["world_entities"], # 声明式(真写库经入库端点,不变量 #3)
|
||||
scope="builtin",
|
||||
)
|
||||
|
||||
|
||||
# ---- opening(开篇生成器;写手档;纯预览,T6 创作工具箱)----
|
||||
|
||||
OPENING_SYSTEM_PROMPT = """你是中文网文的「开篇生成器」(opening,写手档)。\
|
||||
职责:依据作品立意/题材与首章大纲,产出多版**差异化**的开篇正文,\
|
||||
每版都要在前几段立爽点/钩子/代入感(黄金三章),供作者择优作为正文起手。
|
||||
|
||||
输入材料:
|
||||
- 作品设定(projects:题材、立意、主线、卖点);
|
||||
- 大纲(outline:首章/开篇章的节拍要点——开篇须服务这些节拍);
|
||||
- 作者的一句话需求/方向(可空则按题材与大纲自由发挥)。
|
||||
|
||||
产出(每版一个 variant):
|
||||
- text:开篇正文(一段成稿文本,开门见山立钩子、信息密度高、少铺垫慢热)。
|
||||
|
||||
纪律:
|
||||
- 多版之间**切入方式各异**(如「冲突开场」「悬念开场」「金手指开场」),便于作者对比;
|
||||
- 贴合作品立意、题材与首章大纲节拍,不跑题、不臆造与大纲冲突的剧情;
|
||||
- 你只产结构化开篇文本清单,**不改稿、不写库**(纯预览,不变量 #3)。"""
|
||||
|
||||
|
||||
opening_spec = AgentSpec(
|
||||
name="opening",
|
||||
tier="writer", # 不变量 #2:只声明档位,不写 model(正文创作用写手档)
|
||||
system_prompt=OPENING_SYSTEM_PROMPT,
|
||||
input_schema=None, # 注入材料为序列化文本(设定 + 大纲 + 需求),非结构化入参
|
||||
output_schema=OpeningResult,
|
||||
reads=["projects", "outline"],
|
||||
writes=[], # 纯预览,不写库(不变量 #3)
|
||||
scope="builtin",
|
||||
)
|
||||
|
||||
|
||||
# ---- fine-outline(细纲生成器;分析档;声明式 writes=outline,T6 创作工具箱)----
|
||||
|
||||
FINE_OUTLINE_SYSTEM_PROMPT = """你是中文网文的「细纲生成器」(fine-outline,分析档)。\
|
||||
职责:把某一章的粗大纲节拍**展开为细粒度场景序列**,每个场景给出节拍、叙事目的、\
|
||||
冲突与钩子,让作者据细纲直接落笔成章。
|
||||
|
||||
输入材料:
|
||||
- 作品设定(projects:题材、立意、主线);
|
||||
- 大纲(outline:本章的章号与粗节拍要点——细纲须忠实展开这些节拍,不另起炉灶);
|
||||
- 作者的一句话需求/方向(可空则按章节拍自由展开)。
|
||||
|
||||
产出(每个场景一条 scene):
|
||||
- idx:场景序号(章内有序);
|
||||
- beat:场景节拍——本场具体发生什么;
|
||||
- purpose:叙事目的——推动主线 / 塑造人物 / 铺垫或推进伏笔;
|
||||
- conflict:本场冲突/张力来源;
|
||||
- hook:场景钩子——驱动读者读下一场的悬念。
|
||||
|
||||
纪律:
|
||||
- 场景须**忠实展开本章粗节拍**,顺序合理、推进主线,不跑题、不与大纲冲突;
|
||||
- 每场都要有目的与张力,避免注水过场;
|
||||
- 你只产结构化场景清单(idx/beat/purpose/conflict/hook),**不改稿、不写库**\
|
||||
(落 outline 表经入库端点,不变量 #3)。"""
|
||||
|
||||
|
||||
fine_outline_spec = AgentSpec(
|
||||
name="fine-outline",
|
||||
tier="analyst", # 不变量 #2:只声明档位,不写 model(细纲拆解用分析档)
|
||||
system_prompt=FINE_OUTLINE_SYSTEM_PROMPT,
|
||||
input_schema=None, # 注入材料为序列化文本(设定 + 本章大纲 + 需求),非结构化入参
|
||||
output_schema=DetailedOutlineResult,
|
||||
reads=["projects", "outline"],
|
||||
writes=["outline"], # 声明式(真写库经入库端点,不变量 #3)
|
||||
scope="builtin",
|
||||
)
|
||||
|
||||
148
packages/core/tests/test_toolbox_generators.py
Normal file
148
packages/core/tests/test_toolbox_generators.py
Normal file
@@ -0,0 +1,148 @@
|
||||
"""T6 创作工具箱 · 7 个新生成器经 `run_generator` 的驱动测试 + 章上下文构造。
|
||||
|
||||
注入 mock 网关(按 schema 路由 / 单一 parsed),无真 LLM、无真 Postgres。验证通用执行器
|
||||
对每个 spec:透传 tier、非流式、output_schema 据 spec 绑定、system[0] 缓存、parsed 校验,
|
||||
网关失败上抛。asyncio_mode=auto。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
from fakes_orchestrator import FailingRunGateway, FakeRunGateway, SchemaRoutingRunGateway
|
||||
from pydantic import BaseModel
|
||||
from ww_agents import (
|
||||
AgentSpec,
|
||||
Blurb,
|
||||
BlurbResult,
|
||||
DetailedOutlineResult,
|
||||
GlossaryResult,
|
||||
GlossaryTerm,
|
||||
GoldenFinger,
|
||||
GoldenFingerResult,
|
||||
NameListResult,
|
||||
NameSuggestion,
|
||||
Opening,
|
||||
OpeningResult,
|
||||
Scene,
|
||||
Title,
|
||||
TitleListResult,
|
||||
blurb_spec,
|
||||
book_title_spec,
|
||||
fine_outline_spec,
|
||||
glossary_spec,
|
||||
golden_finger_spec,
|
||||
name_spec,
|
||||
opening_spec,
|
||||
)
|
||||
from ww_core.orchestrator import build_outline_chapter_context, run_generator
|
||||
|
||||
PROJECT = uuid.UUID("00000000-0000-0000-0000-000000000001")
|
||||
USER = uuid.UUID("00000000-0000-0000-0000-0000000000aa")
|
||||
|
||||
# (spec, parsed 实例, schema 类型)
|
||||
_CASES: list[tuple[AgentSpec, BaseModel, type[BaseModel]]] = [
|
||||
(
|
||||
book_title_spec,
|
||||
TitleListResult(titles=[Title(title="吞噬", rationale="爽")]),
|
||||
TitleListResult,
|
||||
),
|
||||
(blurb_spec, BlurbResult(variants=[Blurb(text="文案", angle="反差")]), BlurbResult),
|
||||
(name_spec, NameListResult(names=[NameSuggestion(name="玄阴宗", kind="势力")]), NameListResult),
|
||||
(
|
||||
golden_finger_spec,
|
||||
GoldenFingerResult(
|
||||
systems=[GoldenFinger(name="瞳", mechanism="m", growth="g", limits="l")]
|
||||
),
|
||||
GoldenFingerResult,
|
||||
),
|
||||
(
|
||||
glossary_spec,
|
||||
GlossaryResult(terms=[GlossaryTerm(name="炼气", type="境界", definition="d")]),
|
||||
GlossaryResult,
|
||||
),
|
||||
(opening_spec, OpeningResult(variants=[Opening(text="雷光劈下")]), OpeningResult),
|
||||
(
|
||||
fine_outline_spec,
|
||||
DetailedOutlineResult(scenes=[Scene(idx=0, beat="b", purpose="p", conflict="c", hook="h")]),
|
||||
DetailedOutlineResult,
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("spec", "parsed", "schema"), _CASES)
|
||||
async def test_run_generator_returns_each_toolbox_schema(
|
||||
spec: AgentSpec, parsed: BaseModel, schema: type[BaseModel]
|
||||
) -> None:
|
||||
gateway = FakeRunGateway(parsed)
|
||||
|
||||
result = await run_generator(
|
||||
spec, context="x", gateway=gateway, user_id=USER, project_id=PROJECT
|
||||
)
|
||||
|
||||
assert isinstance(result, schema)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("spec", "parsed", "schema"), _CASES)
|
||||
async def test_run_generator_builds_request_per_spec(
|
||||
spec: AgentSpec, parsed: BaseModel, schema: type[BaseModel]
|
||||
) -> None:
|
||||
# 不变量 #2/#9:透传 spec.tier、非流式、output_schema 据 spec 绑定、system[0] 缓存
|
||||
gateway = FakeRunGateway(parsed)
|
||||
|
||||
await run_generator(spec, context="x", gateway=gateway, user_id=USER, project_id=PROJECT)
|
||||
|
||||
req = gateway.last_request
|
||||
assert req is not None
|
||||
assert req.tier == spec.tier
|
||||
assert req.stream is False
|
||||
assert req.output_schema is schema
|
||||
assert req.system[0].cache is True
|
||||
|
||||
|
||||
async def test_run_generator_routes_by_schema_across_toolbox() -> None:
|
||||
# 一个按 schema 路由的网关同时服务 7 个生成器,各拿自己 schema 的 parsed
|
||||
by_schema = {schema: parsed for _, parsed, schema in _CASES}
|
||||
gateway = SchemaRoutingRunGateway(by_schema)
|
||||
|
||||
for spec, _parsed, schema in _CASES:
|
||||
result = await run_generator(
|
||||
spec, context="x", gateway=gateway, user_id=USER, project_id=PROJECT
|
||||
)
|
||||
assert isinstance(result, schema)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("spec", [row[0] for row in _CASES])
|
||||
async def test_run_generator_raises_on_gateway_failure(spec: AgentSpec) -> None:
|
||||
gateway = FailingRunGateway(RuntimeError("boom"))
|
||||
|
||||
with pytest.raises(RuntimeError, match="boom"):
|
||||
await run_generator(spec, context="x", gateway=gateway, user_id=USER, project_id=PROJECT)
|
||||
|
||||
|
||||
# ---- build_outline_chapter_context:确定性、含设定/章号/节拍、空节拍降级 ----
|
||||
|
||||
|
||||
def test_build_outline_chapter_context_is_deterministic_and_shaped() -> None:
|
||||
a = build_outline_chapter_context(
|
||||
chapter_no=3, beats=["主角觉醒", "强敌登场"], project_context="题材:玄幻"
|
||||
)
|
||||
b = build_outline_chapter_context(
|
||||
chapter_no=3, beats=["主角觉醒", "强敌登场"], project_context="题材:玄幻"
|
||||
)
|
||||
assert a == b
|
||||
assert "题材:玄幻" in a
|
||||
assert "第 3 章" in a
|
||||
assert "主角觉醒" in a
|
||||
assert "强敌登场" in a
|
||||
|
||||
|
||||
def test_build_outline_chapter_context_empty_beats_falls_back() -> None:
|
||||
ctx = build_outline_chapter_context(chapter_no=1, beats=[], project_context="题材:都市")
|
||||
assert "本章暂无大纲节拍" in ctx
|
||||
|
||||
|
||||
def test_build_outline_chapter_context_empty_project_falls_back() -> None:
|
||||
ctx = build_outline_chapter_context(chapter_no=2, beats=["x"], project_context="")
|
||||
assert "暂无设定" in ctx
|
||||
@@ -24,6 +24,7 @@ from .collect import (
|
||||
from .generation_node import (
|
||||
build_brief_context,
|
||||
build_character_gen_context,
|
||||
build_outline_chapter_context,
|
||||
build_precheck_context,
|
||||
build_worldbuilder_context,
|
||||
precheck_generated_cards,
|
||||
@@ -106,6 +107,7 @@ __all__ = [
|
||||
"SseEvent",
|
||||
"build_brief_context",
|
||||
"build_character_gen_context",
|
||||
"build_outline_chapter_context",
|
||||
"build_outline_request",
|
||||
"build_precheck_context",
|
||||
"build_review_context",
|
||||
|
||||
@@ -78,6 +78,24 @@ def build_brief_context(*, brief: str, project_context: str) -> str:
|
||||
return f"## 作品设定\n{project_context or '(暂无设定)'}\n\n## 创作需求\n{brief_block}"
|
||||
|
||||
|
||||
def build_outline_chapter_context(
|
||||
*, chapter_no: int, beats: Sequence[str], project_context: str
|
||||
) -> str:
|
||||
"""通用「with_outline_chapter」上下文:作品设定 + 指定章的大纲节拍(确定性,无时间戳)。
|
||||
|
||||
覆盖按章展开的生成器(开篇/细纲…):注入作品设定 + 该章已排定的粗节拍,供模型
|
||||
据节拍展开。无节拍时给降级占位,交由 system_prompt 处理。
|
||||
"""
|
||||
if beats:
|
||||
beats_block = "\n".join(f"{i + 1}. {beat}" for i, beat in enumerate(beats))
|
||||
else:
|
||||
beats_block = "(本章暂无大纲节拍)"
|
||||
return (
|
||||
f"## 作品设定\n{project_context or '(暂无设定)'}\n\n"
|
||||
f"## 第 {chapter_no} 章大纲节拍\n{beats_block}"
|
||||
)
|
||||
|
||||
|
||||
async def run_generator(
|
||||
spec: AgentSpec,
|
||||
*,
|
||||
|
||||
207
packages/skills/tests/test_toolbox.py
Normal file
207
packages/skills/tests/test_toolbox.py
Normal file
@@ -0,0 +1,207 @@
|
||||
"""T6 创作工具箱声明式描述符类型单测(Wave-0 契约)。
|
||||
|
||||
仅校验描述符**类型**本身(无 TOOLBOX 注册表 seeding——那是 Wave A):
|
||||
- `ContextStrategy`:4 种注入策略字面量;
|
||||
- `InputField`:声明式表单字段(label/type/required/default/help);
|
||||
- `IngestSpec`:可选入库目标(table 必须在 KNOWN_TABLES,None=纯预览);
|
||||
- `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
|
||||
85
packages/skills/tests/test_toolbox_registry.py
Normal file
85
packages/skills/tests/test_toolbox_registry.py
Normal file
@@ -0,0 +1,85 @@
|
||||
"""T6.2 创作工具箱注册表(`TOOLBOX`)单测。
|
||||
|
||||
校验:11 条齐全(legacy 3 + 新 8);legacy 携 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
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
103
packages/skills/ww_skills/toolbox.py
Normal file
103
packages/skills/ww_skills/toolbox.py
Normal 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):
|
||||
"""声明式表单字段描述符(前端据此渲染输入控件;frozen;snake_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/writes);legacy 为 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 # 题材适用(卡片徽标/过滤)
|
||||
187
packages/skills/ww_skills/toolbox_registry.py
Normal file
187
packages/skills/ww_skills/toolbox_registry.py
Normal 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(只声明 tier,spec 已守)/ #3(AI 产出入库必经验收 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] = {
|
||||
# ---- legacy(spec=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)
|
||||
Reference in New Issue
Block a user