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,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

View File

@@ -0,0 +1,84 @@
"""T6 创作工具箱 · 7 个文本生成器的 spec 契约测试。
契约测试——校验每个 spec 的 name/tier/reads/writes/output_schema/scope/input_schema
对齐不变量 #2只声明 tier/ #3writes 经验收才生效)。不联网、无 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()

View File

@@ -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",

View File

@@ -336,3 +336,194 @@ class IdeaListResult(BaseModel):
default_factory=list,
description="生成的脑洞清单;无则空列表",
)
# ---- 创作工具箱 · 书名生成器book-titlelight纯预览 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="生成的书名候选清单;无则空列表",
)
# ---- 创作工具箱 · 简介生成器blurbanalyst纯预览 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="生成的简介变体清单;无则空列表",
)
# ---- 创作工具箱 · 取名生成器namelight纯预览 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-fingerwriter声明式 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="生成的金手指系统清单;无则空列表",
)
# ---- 创作工具箱 · 术语表生成器glossaryanalyst声明式 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="生成的术语清单;无则空列表",
)
# ---- 创作工具箱 · 开篇生成器openingwriter纯预览 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-outlineanalyst声明式 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="展开的场景清单;无则空列表",
)

View File

@@ -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_entitiesT6 创作工具箱)----
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_entitiesT6 创作工具箱)----
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=outlineT6 创作工具箱)----
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",
)

View 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

View File

@@ -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",

View File

@@ -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,
*,

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)