"""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 # 题材适用(卡片徽标/过滤)