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:
@@ -785,6 +785,9 @@ def assemble(project_id, chapter_no):
|
||||
| POST | `/projects/:id/chapters/:no/refine` | `{段落, 指令}` | 回炉重写段(diff) | 200 |
|
||||
| GET | `/projects/:id/foreshadow` | `?status=` | 伏笔看板数据 | 200 |
|
||||
| POST | `/projects/:id/rules` | `{level, content}` | rule | 201 |
|
||||
| GET | `/skills/toolbox` | — | 生成器描述符列表(key/标题/input_fields/ingestable/legacy_route) | 200 |
|
||||
| POST | `/projects/:id/skills/:tool_key/generate` | `{brief, chapter_no?, count?, kind?}` | 结构化预览(不写库,仅记账) | 200 (未知 key 404/无 provider 503) |
|
||||
| POST | `/projects/:id/skills/:tool_key/ingest` | `{world_entities?\|scenes?, chapter_no?, acknowledge_conflicts}` | 入库结果 | 201 (continuity 冲突 409) |
|
||||
| GET/PUT | `/settings/providers` | 凭据/档位配置 | 配置(Key 脱敏) | 200 |
|
||||
| POST | `/settings/providers/test` | `{provider}` | 连接+能力矩阵 | 200 |
|
||||
|
||||
|
||||
@@ -513,6 +513,9 @@ skills(id, scope, name, description, model, system_prompt,
|
||||
| `POST /projects/:id/chapters/:no/accept` | 验收 | 验收 | 作者裁决后更新记忆表、伏笔账本、章节摘要 |
|
||||
| `POST /projects/:id/chapters/:no/refine` | 回炉 | 写作 | 仅重写指定段落(文风漂移/选中段),返回新旧对比 |
|
||||
| `POST /projects/:id/rules` | 加规则 | 任意 | 审稿发现的问题/亮点随手沉淀为项目规则 |
|
||||
| `GET /skills/toolbox` | 进创作工具箱 | 任意 | 列出生成器描述符(key/标题/输入表单/是否可入库/legacy 路由),前端声明式渲染卡片栅格 |
|
||||
| `POST /projects/:id/skills/:tool_key/generate` | 用某生成器 | 任意 | 通用执行器:按工具的 context 策略组材料 → 按 `tier` 建网关 → 结构化预览(不写库,仅记账)。未知 key 404 / 无 provider 503 |
|
||||
| `POST /projects/:id/skills/:tool_key/ingest` | 入库生成结果 | 任意 | 仅可入库工具(金手指/词条→world_entities、细纲→outline):复用 continuity 预检 409 + 权限白名单后写库 |
|
||||
|
||||
**典型循环**:新建作品 → 学文风 → 排大纲 →(写本章 → 审稿 → 验收)× N
|
||||
|
||||
|
||||
13
PROGRESS.md
13
PROGRESS.md
@@ -18,11 +18,14 @@
|
||||
| 任务 | 状态 | 负责 | 依赖 | 契约 | 备注 |
|
||||
|---|---|---|---|---|---|
|
||||
| T6.1 脑洞 spec + IdeaListResult schema + brief_only context + 通用 run 路径 | ✅ | @llm | — | 新 schema/spec | 完成:`schemas.py` 加 `Idea`/`IdeaListResult`;`specs.py` 加 `brainstorm_spec`(light, reads=[projects], writes=[]);`generation_node.py` 加 `build_brief_context` + **通用 `run_generator`**(据 `spec.output_schema` 校验 parsed,不绑定具体类型,任一声明式生成器共用);两包 ruff/format/mypy 干净、新增 13 测 + 全包 252 passed 无回归。**@backend T6.2 可起:从 `ww_agents` 取 `brainstorm_spec`/`IdeaListResult`、从 `ww_core.orchestrator` 取 `run_generator`/`build_brief_context`** |
|
||||
| T6.2 GeneratorTool 描述符 + TOOLBOX 注册表 | ⬜ | @backend | T6.1 | **C扩(新)** | `GeneratorTool{key,title,subtitle,spec,output_schema,context_strategy,input_fields,ingest,legacy_route}` + `TOOLBOX` dict(seed: brainstorm + 已有3个 legacy 条目)。放 `packages/skills` |
|
||||
| T6.3 通用端点 toolbox/generate | ⬜ | @backend | T6.2 | **C扩(新)** | `GET /skills/toolbox`→descriptor 列表;`POST /projects/{id}/skills/{tool_key}/generate`→按 context_strategy 组材料→`build_gateway_for_tier(spec.tier)`→preview(不写库,仅 commit ledger)。未知 key 404 / 无 provider 503 |
|
||||
| T6.4 P1 E2E(脑洞 generate) | ⬜ | @qa | T6.3 | — | 真 pg + mock 网关零 token:toolbox 列表含 brainstorm → generate 预览 IdeaListResult → 断言仅 ledger 记账、零业务表写、unknown key 404、无 provider 503 |
|
||||
| T6.2 GeneratorTool 描述符 + TOOLBOX 注册表 | ✅ | @backend | T6.1 | C3扩稳定 | `packages/skills/ww_skills/toolbox.py`(描述符类型,frozen) + `toolbox_registry.py`(`TOOLBOX` 11 条 + `get_tool`)。25+7 测 |
|
||||
| T6.3 通用端点 toolbox/generate(+ingest) | ✅ | @backend | T6.2 | C3扩稳定 | `routers/toolbox.py` 3 端点 + `schemas/toolbox.py` + `services/toolbox_context.py`(纯 dispatch) + `get_tier_gateway_builder` 注入缝。generate 预览仅 commit ledger;ingest 复用 continuity 409 + partition_writes 白名单。未知 key 404 / 无 provider 503 |
|
||||
| T6.4 E2E(toolbox 全路径) | ✅ | @qa | T6.3 | — | `tests/test_t6_toolbox_e2e.py` 5 用例真 pg + mock 网关零 token:列表 11 key、brainstorm 预览仅 ledger 零业务写(#3)、glossary ingest 409→ack 放行真落 world_entities、unknown 404、无 provider 503、token 不泄漏。无端点 bug |
|
||||
| T6.5 7 新生成器 spec+schema + with_outline_chapter | ✅ | @llm | — | 新 schema/spec | `schemas.py` +7 输出 schema、`specs.py` +7 spec(book-title/blurb/name/golden-finger/glossary/opening/fine-outline,只声明 tier #2)、`generation_node.py` +`build_outline_chapter_context`。309 passed |
|
||||
| T6.6 工具箱落地页 + 声明驱动 Runner | ✅ | @frontend | T6.3 | — | `app/projects/[id]/toolbox/page.tsx`(RSC) + `components/toolbox/{ToolboxPage,ToolCard,GeneratorRunner}` + `lib/toolbox/*`(纯函数) + LeftNav「工具箱」+ ⌘K `nav-toolbox`/`action-gen-*`。legacy 3 跳现页。vitest 155→279/build OK |
|
||||
| P2 收尾(限流/noopener/Committable) | ✅ | @backend+@frontend | — | — | 限流→`decisions.md` 记延后(单用户原型);noopener 与 Committable 经查本分支早已修;`provider_deps.py` 残留 `type:ignore` 为 SDK 私有属性探活,load-bearing 保留 |
|
||||
|
||||
**P1 出口(DoD)**:通用执行器跑通脑洞生成器;后端门禁绿(ruff/format/mypy/alembic 无漂移/pytest);E2E mock 网关零 token 走通。达成后进入 P2(工具箱落地页 @frontend)。
|
||||
**T6 出口(DoD)✅ 达成**:通用执行器驱动全部 8 个新生成器 + 工具箱落地页声明驱动;后端门禁绿(ruff/format/mypy **195**/alembic **无漂移**/pytest **583**);前端门禁绿(gen:api/lint/tsc/vitest **279**/build);E2E mock 网关零 token 全路径走通。**实景 browse 复验(工具箱卡片栅格→脑洞预览→词条入库)待做。** 后续可选 P5(封面图像轨道 / 自定义 skill 运行期 schema / 收敛已有 3 竖井)。
|
||||
|
||||
---
|
||||
|
||||
@@ -108,6 +111,7 @@ T0.1 monorepo 骨架 ✅ @devops · T0.2 16 MVP 表迁移(无漂移,users st
|
||||
|
||||
> 格式:`- [YYYY-MM-DD] @skill 完成/进展 Txx — 一句话结果 + 影响的契约/文件`
|
||||
|
||||
- [2026-06-22] @orchestrator — **🎉 T6 创作工具箱(通用生成器框架)全工具箱交付 + 独立复核(分支 `feat/t6-creation-toolbox`)**。多 agent 并行波次:Wave 0(@llm T6.5 ‖ @backend 描述符类型 ‖ P2 收尾)→ Wave A(@backend T6.2+T6.3 TOOLBOX+端点合龙,契约稳定点)→ Wave B(@frontend T6.6 ‖ @qa T6.4)→ Wave C 收口,目录单写者零写冲突、契约先行、零返工。**@llm T6.5**:`ww_agents` +7 输出 schema + 7 spec(book-title/blurb/name/golden-finger/glossary/opening/fine-outline,只声明 tier #2)+ `build_outline_chapter_context`(with_outline_chapter 策略)。**@frontend T6.6**:`gen:api` 纳入 3 端点;`app/projects/[id]/toolbox/page.tsx`(RSC) + `components/toolbox/{ToolboxPage,ToolCard,GeneratorRunner}` + `lib/toolbox/*`(声明驱动纯函数,按 `input_fields` 渲染表单、按 `output_kind` 渲染预览、可入库者复用 `ConflictAdjudication` 走 409→ack)+ LeftNav「工具箱」+ ⌘K `nav-toolbox`/`action-gen-*`;legacy 3 跳现页不回归。**@qa T6.4**:`tests/test_t6_toolbox_e2e.py` 5 用例真 pg + mock 网关零 token,**未发现端点 bug**。**P2 收尾**:限流→`decisions.md` 记延后(单用户原型);noopener/Committable 经查本分支早已修;`provider_deps.py` 残留 `type:ignore` 为 SDK 私有属性探活、load-bearing 保留。**最终独立复跑全仓门禁绿**:后端 ruff/format 干净、mypy **195 Success**、alembic **无漂移**(descriptor 在代码、复用既有表,零建表迁移)、pytest **583 passed**;前端 gen:api/lint/tsc/vitest **279**/build OK。守不变量 #2(只声明 tier)/#3(预览不写库、入库经验收 gate)/#9(system_prompt 进缓存前块)。**"加生成器=加一份声明" 经实证(8 个新生成器复用同一通用执行器 + 同一前端组件)。** **实景 browse 复验(工具箱卡片栅格→脑洞预览→词条入库)待做。** 后续可选 P5:封面图像轨道 / 自定义 skill 运行期 schema / 收敛已有 3 竖井。
|
||||
- [2026-06-20] UX计划 B0(可控版)+F1(控件) — **✅ 本章注入透明 可控版(分支 `feat/ux-b0-controllable-injection`,提交 `0d473e7` 后端 + `6449c31` 前端,未合 develop)**。在 B0 读端点之上加作者微调,守不变量 #6「确定性选择」的作者兜底。**后端**(@backend/@db):`PUT /projects/{id}/chapters/{no}/injection` ← `InjectionOverrideRequest{pinned,excluded,recent_n(1..20→422)}` → `InjectionResponse`(新增回显 `pinned`/`excluded`,`selected[].reasons` 可含新值 **`author_pin`**);`select_relevant_entities` 加 `pinned/excluded` frozenset 入参(pin 强制纳入加 author_pin / excluded 强制剔除且**优先于 pin**);`assemble` 加 `override` 关键字参(recent_n 覆盖回看章数);**GET injection + PUT + draft 流式端点同读同一覆盖**(看到的=写章用的)。持久化:**新表 `chapter_injection`**(迁移 `ad2c4c663daf`,唯一 `(project_id,chapter_no)`;复用 outline 行污染 beats 已否决)+ 新 `domain/injection_repo.py`(`InjectionOverride`/`EntityRef`/`SqlInjectionOverrideRepo`,upsert 只 flush 端点 commit)。**前端**(@frontend):`gen:api` 纳入 PUT + 新 schema;`lib/workbench/injection.ts` override 纯函数(`currentOverride`/`withPinToggled`/`withExcluded`/`withRestored`/`withRecentN`/`isPinned`,钳 1..20);`useInjection` 加 `saving` 态 + `togglePin/exclude/restore/setRecentN`(PUT 后以服务端确定结果为准、写失败可读文案不改本地态);`ChapterAssistant` 每实体 📌置顶/✕排除 + 「近 N 章」步进器 + 「已排除」恢复区。**全仓门禁绿**:后端 ruff/format/mypy **163**/alembic 无漂移/pytest **476**;前端 lint/tsc/vitest **183**/build。记 contracts C3 再扩。**实景 browse 复验待做。** UX 计划余项:R1/R2/R3 审稿人体学 + Tier4。
|
||||
- [2026-06-20] UX计划 M1+M2 — **✅ 移动端可用(纯前端,分支 `feat/ux-m1-m2-mobile` 提交 9b893a6)**。救活手机端(390px)写→审主路径。**M1 导航响应式抽屉**:抽出纯逻辑 `lib/nav/items.ts`(`GLOBAL_NAV_ITEMS`/`projectNavItems`/`isNavItemActive` + `ActiveNav` 类型,6 vitest),桌面静态侧栏与移动抽屉共用避免漂移;新增通用 `Drawer`(遮罩点击/Esc 关闭/打开聚焦面板,复用 `CommandPalette` a11y 模式,`lg:hidden`)+ `NavDrawer`(汉堡 `aria-label=打开导航` + 左抽屉)+ `NavItems`;`LeftNav` 改 `hidden lg:block`,`AppShell` 顶栏加汉堡。**M2 工具条不溢出 + 移动可达**:`Workbench` 工具条改 `flex-wrap`(390px 不再横切「尚未保存」);`ChapterList`/`ChapterAssistant` 抽出 `ChapterListContent`/`AssistantContent`,`Workbench` 经左/右 `Drawer` 在 `<lg` 触达目录/助手。**实景 390px 复验(browse)**:汉堡显示 + 静态导航 `display:none`、抽屉开合(聚焦 + Esc 关闭)、工具条两行无横向溢出、助手抽屉真注入面板。前端门禁绿:lint/typecheck 干净 · vitest **177**(171→+6) · build OK。无后端/契约变更。文件:`lib/nav/{items,items.test}.ts`(新) · `components/{Drawer,NavDrawer,NavItems}.tsx`(新) · `components/{AppShell,LeftNav}.tsx` · `components/workbench/{Workbench,ChapterList,ChapterAssistant}.tsx`。**UX 计划余项**:B0 可控版(PUT override) + F1 控件 + R1/R2/R3 审稿人体学 + Tier4。
|
||||
- [2026-06-20] UX计划 F3+F4 — **✅ 流式进度感 + 友好错误文案(纯前端)**。F4:新 `apps/web/lib/errors/messages.ts`(`friendlyError(code,fallback)`→中文文案 + 可选「去设置提供商」动作;覆盖后端 `ErrorCode` + 前端 `NETWORK/STREAM_FAILED`;未知码回退原 message;+4 vitest);写作页 `Workbench` 与审稿页 `ReviewReport` 的错误块改用之,不再直接暴露 raw error code。F3:`Workbench` 工具条字数计数器流式时变「生成中… N 字」+ 朱砂 `motion-safe:animate-pulse`(尊重 prefers-reduced-motion),停后回「字数 N」;纯 `text.length`,无后端改动。前端门禁绿:lint/typecheck/vitest **171**/build。文件:`lib/errors/{messages,messages.test}.ts` · `components/workbench/Workbench.tsx` · `components/review/ReviewReport.tsx`。无契约变更。
|
||||
@@ -178,4 +182,5 @@ T0.1 monorepo 骨架 ✅ @devops · T0.2 16 MVP 表迁移(无漂移,users st
|
||||
- [2026-06-19] @backend — **bugfix 空写章 prompt**:`assemble`(C5)从不含项目 premise/logline/theme/title 且无写章指令 → 全新项目(仅 premise)产空 `stable_core`+`volatile`,写章触发 LLM `400 message must not be empty`。修复:`MemoryRepos` 加第 8 repo `project:ProjectSpecRepo`(`SqlProjectSpecRepo` 按 project_id 读 `projects`);`assemble` 把「作品蓝本」放入 `stable_core` 首段(书级 spec→缓存前缀,守 #9)+ `volatile` 始终含「请创作第 N 章的正文。」。改 `domain/repositories.py`(+ProjectSpecView/Repo) · `memory/sql_repositories.py`(+SqlProjectSpecRepo, sql_memory_repos 装配) · `memory/assemble.py`(_build_spec_section + 注入);测试 `test_memory.py`(+bare-project/chapter-directive 两测+fake) · `test_projects.py`/`test_generation.py`(补 project= fake)。无 DDL/迁移(只读既有列)。门禁全绿:ruff/format/mypy(154 文件)/alembic check(无漂移)/pytest **438 passed**。更 contracts C5 + gotcha。消费方注意:手搓 `MemoryRepos(...)` 须补 `project=`。
|
||||
- [2026-06-20] @frontend — **UX R1+R2 完成**(审稿裁决人体学,纯前端无后端改)。R1:冲突卡内联命中段预览——新纯函数 `lib/review/snippet.ts`(从 `where`「第 N 段」解析 1-based 段号 → 取终稿对应段截断预览,无段号/越界/空段→null 回退「跳转」),`ConflictCard` 加 `snippet` prop 渲染朱砂引用块。R2:冲突按 type 分组 + 严重度排序(设定违例>能力不符>时间线倒错>地理矛盾>性格漂移,未知殿后)+ 组计数徽标 + 「跳到下一条未裁决」(按显示顺序环回)+ 主次裁决按钮(采纳改法=朱砂主、忽略/手改=次级)——新纯函数 `lib/review/grouping.ts`(`groupConflicts`/`displayOrder`/`nextUnresolvedInOrder`,**分组只改显示顺序、每条携原始 index,守 conflict_index 覆盖 range 不变量**),`ReviewReport` 改分组渲染。TDD:snippet 8 测 + grouping 10 测。前端门禁绿:lint/tsc/**vitest 201**(+18)/build。分支 `feat/ux-r1-r2-review-ergonomics`。下一步(按 plan):R3 验收前清单 → Tier4。
|
||||
- [2026-06-20] @frontend — **UX R3 完成**(验收前清单 HITL gate,UX §7.4,纯前端无后端改,续 `feat/ux-r1-r2-review-ergonomics`)。新纯函数 `lib/review/accept-preview.ts`(`buildAcceptPreview`:据现有 client state 列「本次验收将更新」——晋升版次 + 提炼章节摘要 + 写回裁决(有冲突时) + 验收后伏笔到期扫描(可能置 OVERDUE) + 伏笔建议待登记提醒(有建议时))。**只列后端 accept 事务确实做的事**(核对 `projects.py` accept 端点:promote+digest+decisions+overdue 扫描;**不**声称改人物 latest_state/自动落库伏笔建议);带「以上为预期,最终以验收回执为准」免责。`AcceptPanel` 加 `foreshadowCount` prop + 未阻断时渲染清单,`ReviewReport` 传 `foreshadow.length`。TDD:4 测。前端门禁绿:lint/tsc/**vitest 205**(+4)/build。下一步(按 plan):Tier4(T4-a 顶部 AI 工具条 / T4-b 本章指令框+风格预设)。
|
||||
- [2026-06-22] @backend — **✅ T6.2+T6.3 创作工具箱通用端点稳定**(通用生成器框架后端落地,分支 `feat/t6-creation-toolbox`)。新 `ww_skills.TOOLBOX`(11 条:legacy 3 + 新 8) + `get_tool`(`toolbox_registry.py`,descriptor 在代码无迁移)。3 新端点(`routers/toolbox.py`,已注册):`GET /skills/toolbox`(描述符列表,legacy 携 `legacy_route`:`codex?gen=world|character` / `/outline`)、`POST .../skills/{tool_key}/generate`(按 `spec.tier` 经新注入缝 `get_tier_gateway_builder` 建网关 → `run_generator` 结构化预览,**不写库**仅落 ledger;未知/legacy→404、项目不存在→404、无凭据→503)、`POST .../skills/{tool_key}/ingest`(仅 `ingest!=None` 工具;world_entities 走 **continuity 预检 409 gate**(`acknowledge_conflicts` 放行) + `partition_writes` 白名单 + schema→JSONB 写库,outline 细纲按 idx 拼 beats upsert;不可入库→422)。PURE context 派发 `services/toolbox_context.build_toolbox_context`(4 策略,缺章/大纲→空节拍不报错)。response/request schema `schemas/toolbox.py`(concrete 子模型,非裸 dict)。守不变量 #2/#3/#9。TDD:注册表 7 + context 8 + 端点 11 = **+26 测**。**全仓门禁绿**:ruff/format 干净、mypy **183 Success**(隔离 cache)、alembic **无漂移**(无新迁移)、pytest **578 passed**。OpenAPI 新增 3 端点 → **@frontend 须 `cd apps/web && pnpm gen:api`**(T6 前端前置)。记 contracts C3 扩(T6.2/T6.3)。
|
||||
- [2026-06-20] @frontend — **Tier4 完成**(T4-a 全局 AI 工具条 + T4-b 本章指令直通生成,分支 `feat/ux-tier4-ai-toolbar`,未并 develop)。T4-a:新 `lib/nav/ai-tools.ts`(aiToolItems) + `components/AiToolbar.tsx` 服务端组件,AppShell 顶栏下仅项目页渲染(写本章/审稿/大纲/设定库/工具箱常驻一排,写本章=朱砂主动作),用 `--chrome` CSS 变量统一 chrome 高度(项目页 7rem=header4+toolbar3,余 4rem),6 个固定高度页改用 `calc(100vh-var(--chrome,4rem))` 适配。T4-b(全链):后端 `assemble/_build_volatile` 加 `directive` 形参(「本章指令」段领衔 volatile,**绝不入 stable_core**,守缓存前缀不变量 #9),`stream_draft` 加可选 body `DraftStreamRequest{directive}` 透传、`directive_len` 入 log 不记原文、不持久化无迁移,重生成 TS 客户端;前端新 `lib/workbench/directive.ts`(STYLE_PRESETS + composeDirective 纯函数)、`useDraftStream.start(p,no,directive?)` 非空时发 JSON body、Workbench 加可折叠「本章指令」textarea + 风格预设 chips。TDD:ai-tools 4 测 + directive 4 测 + assemble directive 测 + draft 端点 directive/向后兼容 2 测。门禁全绿:后端 ruff/format/mypy(163 文件)/**pytest 479 passed**;前端 lint/tsc/**vitest 213**/build。契约记 contracts C3 扩(T4-b)。
|
||||
|
||||
84
apps/api/tests/test_toolbox_context.py
Normal file
84
apps/api/tests/test_toolbox_context.py
Normal file
@@ -0,0 +1,84 @@
|
||||
"""T6.3 创作工具箱 context 派发纯函数单测(LLM-free,确定性)。
|
||||
|
||||
每种策略的注入文本形状 + with_outline_chapter 缺节拍降级(空 beats 不报错)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from ww_api.services.toolbox_context import build_toolbox_context
|
||||
|
||||
|
||||
def test_brief_only_includes_setting_and_brief() -> None:
|
||||
text = build_toolbox_context(
|
||||
"brief_only", brief="爽文脑洞", project_context="标题:测试\n题材:玄幻"
|
||||
)
|
||||
assert "## 作品设定" in text
|
||||
assert "标题:测试" in text
|
||||
assert "## 创作需求" in text
|
||||
assert "爽文脑洞" in text
|
||||
|
||||
|
||||
def test_brief_only_empty_brief_degrades() -> None:
|
||||
text = build_toolbox_context("brief_only", brief="", project_context="标题:测试")
|
||||
# 空 brief → 降级占位(由 system_prompt 的「自由发散」纪律处理)。
|
||||
assert "自由发散" in text
|
||||
|
||||
|
||||
def test_with_project_uses_brief_context() -> None:
|
||||
text = build_toolbox_context(
|
||||
"with_project", brief="多版简介", project_context="标题:测试\n前提:废柴逆袭"
|
||||
)
|
||||
assert "前提:废柴逆袭" in text
|
||||
assert "多版简介" in text
|
||||
|
||||
|
||||
def test_with_world_injects_world_block() -> None:
|
||||
text = build_toolbox_context(
|
||||
"with_world",
|
||||
brief="取个名字",
|
||||
project_context="标题:测试",
|
||||
world_context="- [力量体系] 灵脉:灵力守恒",
|
||||
)
|
||||
assert "## 世界观" in text
|
||||
assert "灵脉" in text
|
||||
assert "取个名字" in text
|
||||
|
||||
|
||||
def test_with_world_empty_world_degrades() -> None:
|
||||
text = build_toolbox_context(
|
||||
"with_world", brief="x", project_context="标题:测试", world_context=""
|
||||
)
|
||||
assert "暂无世界观设定" in text
|
||||
|
||||
|
||||
def test_with_outline_chapter_injects_beats() -> None:
|
||||
text = build_toolbox_context(
|
||||
"with_outline_chapter",
|
||||
brief="展开细纲",
|
||||
project_context="标题:测试",
|
||||
chapter_no=3,
|
||||
beats=["主角觉醒", "初遇反派"],
|
||||
)
|
||||
assert "第 3 章大纲节拍" in text
|
||||
assert "主角觉醒" in text
|
||||
assert "初遇反派" in text
|
||||
assert "展开细纲" in text
|
||||
|
||||
|
||||
def test_with_outline_chapter_missing_beats_does_not_error() -> None:
|
||||
# 缺章/缺大纲 → 空节拍占位,不报错(预览降级)。
|
||||
text = build_toolbox_context(
|
||||
"with_outline_chapter",
|
||||
brief="",
|
||||
project_context="标题:测试",
|
||||
chapter_no=99,
|
||||
beats=[],
|
||||
)
|
||||
assert "第 99 章大纲节拍" in text
|
||||
assert "暂无大纲节拍" in text
|
||||
|
||||
|
||||
def test_unknown_strategy_raises() -> None:
|
||||
with pytest.raises(ValueError):
|
||||
build_toolbox_context("nope", brief="x", project_context="y") # type: ignore[arg-type]
|
||||
377
apps/api/tests/test_toolbox_endpoints.py
Normal file
377
apps/api/tests/test_toolbox_endpoints.py
Normal file
@@ -0,0 +1,377 @@
|
||||
"""T6.2/T6.3 创作工具箱通用端点测试(内存替身,无 DB/无网络)。
|
||||
|
||||
覆盖:
|
||||
- GET /skills/toolbox:列出全部工具(legacy + 新)+ ingestable/legacy_route 形。
|
||||
- POST .../generate:按 key 解析 spec → 结构化预览;未知 key 404;legacy key 404;无凭据 503。
|
||||
- POST .../ingest:world_entities continuity 409 gate + acknowledge 放行 + partition_writes 丢越权;
|
||||
不可入库工具 422;outline 细纲入库 upsert。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from cryptography.fernet import Fernet
|
||||
from fakes_projects import FakeProjectRepo, FakeSession
|
||||
from test_projects import _empty_memory_repos
|
||||
from ww_agents import Conflict, ContinuityReview
|
||||
from ww_agents.schemas import Idea, IdeaListResult
|
||||
from ww_core.domain.outline_write_repo import OutlineWriteView
|
||||
from ww_core.domain.project_repo import ProjectCreate
|
||||
from ww_core.domain.world_entity_repo import WorldEntityWriteView
|
||||
from ww_llm_gateway.types import LlmRequest, LlmResponse, ServedBy, Usage
|
||||
from ww_shared import AppError, ErrorCode
|
||||
|
||||
STUB_OWNER = uuid.UUID(int=1)
|
||||
|
||||
|
||||
class _SchemaRoutingGateway:
|
||||
"""按 `req.output_schema` 返对应 parsed(generate / precheck 各拿自己的产物)。"""
|
||||
|
||||
def __init__(self, by_schema: dict[type[Any] | None, Any]) -> None:
|
||||
self._by_schema = by_schema
|
||||
self.calls: list[type[Any] | None] = []
|
||||
|
||||
async def run(self, req: LlmRequest) -> LlmResponse:
|
||||
schema = req.output_schema
|
||||
self.calls.append(schema)
|
||||
parsed = self._by_schema.get(schema)
|
||||
return LlmResponse(
|
||||
text=parsed.model_dump_json() if parsed is not None else "{}",
|
||||
parsed=parsed,
|
||||
usage=Usage(
|
||||
provider="fake",
|
||||
model="fake",
|
||||
input_tokens=1,
|
||||
output_tokens=1,
|
||||
cost_minor=0,
|
||||
currency="USD",
|
||||
),
|
||||
served_by=ServedBy(provider="fake", model="fake"),
|
||||
)
|
||||
|
||||
|
||||
class _FakeWorldWriteRepo:
|
||||
def __init__(self) -> None:
|
||||
self.rows: list[dict[str, Any]] = []
|
||||
|
||||
async def create(
|
||||
self, project_id: uuid.UUID, *, type: str, name: str, rules: list[str]
|
||||
) -> WorldEntityWriteView:
|
||||
self.rows.append({"type": type, "name": name, "rules": list(rules)})
|
||||
return WorldEntityWriteView(id=uuid.uuid4(), type=type, name=name)
|
||||
|
||||
|
||||
class _FakeOutlineWriteRepo:
|
||||
def __init__(self) -> None:
|
||||
self.rows: list[dict[str, Any]] = []
|
||||
|
||||
async def upsert_chapter(
|
||||
self,
|
||||
project_id: uuid.UUID,
|
||||
*,
|
||||
volume: int,
|
||||
chapter_no: int,
|
||||
beats: list[str],
|
||||
foreshadow_windows: list[dict[str, Any]],
|
||||
) -> OutlineWriteView:
|
||||
self.rows.append({"chapter_no": chapter_no, "beats": list(beats)})
|
||||
return OutlineWriteView(
|
||||
chapter_no=chapter_no, volume=volume, beats=list(beats), foreshadow_windows=[]
|
||||
)
|
||||
|
||||
|
||||
def _ideas() -> IdeaListResult:
|
||||
return IdeaListResult(ideas=[Idea(premise="废柴觉醒系统", hook="开局即巅峰", genre_fit="玄幻")])
|
||||
|
||||
|
||||
def _no_conflicts() -> ContinuityReview:
|
||||
return ContinuityReview(conflicts=[])
|
||||
|
||||
|
||||
def _with_conflicts() -> ContinuityReview:
|
||||
return ContinuityReview(
|
||||
conflicts=[Conflict(type="设定违例", where="吞噬系统", refs=["灵脉"], suggestion="改设定")]
|
||||
)
|
||||
|
||||
|
||||
def _make_app(
|
||||
*,
|
||||
project_repo: FakeProjectRepo,
|
||||
gateway: Any,
|
||||
session: FakeSession | None = None,
|
||||
world_repo: _FakeWorldWriteRepo | None = None,
|
||||
outline_write_repo: _FakeOutlineWriteRepo | None = None,
|
||||
memory: Any = None,
|
||||
no_creds: bool = False,
|
||||
) -> tuple[Any, FakeSession, _FakeWorldWriteRepo, _FakeOutlineWriteRepo]:
|
||||
os.environ.setdefault("CREDENTIAL_ENC_KEY", Fernet.generate_key().decode())
|
||||
from ww_api.main import create_app
|
||||
from ww_api.services.project_deps import (
|
||||
get_memory_repos,
|
||||
get_outline_write_repo,
|
||||
get_project_repo,
|
||||
get_tier_gateway_builder,
|
||||
get_world_entity_write_repo,
|
||||
)
|
||||
from ww_db import get_session
|
||||
|
||||
session = session or FakeSession()
|
||||
world_repo = world_repo or _FakeWorldWriteRepo()
|
||||
outline_write_repo = outline_write_repo or _FakeOutlineWriteRepo()
|
||||
|
||||
async def _build_ok(_tier: str) -> Any:
|
||||
return gateway
|
||||
|
||||
async def _build_no_creds(_tier: str) -> Any:
|
||||
raise AppError(ErrorCode.LLM_UNAVAILABLE, "未配置凭据", {"provider": "deepseek"})
|
||||
|
||||
app = create_app()
|
||||
app.dependency_overrides[get_project_repo] = lambda: project_repo
|
||||
app.dependency_overrides[get_memory_repos] = (
|
||||
(lambda: memory) if memory is not None else _empty_memory_repos
|
||||
)
|
||||
app.dependency_overrides[get_world_entity_write_repo] = lambda: world_repo
|
||||
app.dependency_overrides[get_outline_write_repo] = lambda: outline_write_repo
|
||||
app.dependency_overrides[get_session] = lambda: session
|
||||
app.dependency_overrides[get_tier_gateway_builder] = lambda: (
|
||||
_build_no_creds if no_creds else _build_ok
|
||||
)
|
||||
return app, session, world_repo, outline_write_repo
|
||||
|
||||
|
||||
async def _seed_project(repo: FakeProjectRepo) -> uuid.UUID:
|
||||
view = await repo.create(STUB_OWNER, ProjectCreate(title="测试作品", genre="玄幻"))
|
||||
return uuid.UUID(str(view.id))
|
||||
|
||||
|
||||
def _client(app: Any) -> httpx.AsyncClient:
|
||||
transport = httpx.ASGITransport(app=app, raise_app_exceptions=False)
|
||||
return httpx.AsyncClient(transport=transport, base_url="http://test")
|
||||
|
||||
|
||||
# ---- GET /skills/toolbox ----
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_toolbox_contains_all_keys() -> None:
|
||||
repo = FakeProjectRepo()
|
||||
app, *_ = _make_app(project_repo=repo, gateway=_SchemaRoutingGateway({}))
|
||||
|
||||
async with _client(app) as client:
|
||||
resp = await client.get("/skills/toolbox")
|
||||
|
||||
assert resp.status_code == 200
|
||||
tools = resp.json()["tools"]
|
||||
keys = {t["key"] for t in tools}
|
||||
assert keys >= {
|
||||
"worldbuilding",
|
||||
"character",
|
||||
"outline",
|
||||
"brainstorm",
|
||||
"book-title",
|
||||
"blurb",
|
||||
"name",
|
||||
"golden-finger",
|
||||
"glossary",
|
||||
"opening",
|
||||
"fine-outline",
|
||||
}
|
||||
by_key = {t["key"]: t for t in tools}
|
||||
# legacy 携 legacy_route + is_legacy;新工具 ingestable 标记。
|
||||
assert by_key["worldbuilding"]["is_legacy"] is True
|
||||
assert by_key["worldbuilding"]["legacy_route"]
|
||||
assert by_key["brainstorm"]["is_legacy"] is False
|
||||
assert by_key["golden-finger"]["ingestable"] is True
|
||||
assert by_key["brainstorm"]["ingestable"] is False
|
||||
# input_fields 形(brief textarea)。
|
||||
assert any(f["name"] == "brief" for f in by_key["brainstorm"]["input_fields"])
|
||||
|
||||
|
||||
# ---- POST .../generate ----
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_generate_resolves_spec_and_returns_preview() -> None:
|
||||
repo = FakeProjectRepo()
|
||||
pid = await _seed_project(repo)
|
||||
gateway = _SchemaRoutingGateway({IdeaListResult: _ideas()})
|
||||
app, session, *_ = _make_app(project_repo=repo, gateway=gateway)
|
||||
|
||||
async with _client(app) as client:
|
||||
resp = await client.post(
|
||||
f"/projects/{pid}/skills/brainstorm/generate", json={"brief": "废柴流"}
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["tool_key"] == "brainstorm"
|
||||
assert body["output_kind"] == "IdeaListResult"
|
||||
assert body["preview"]["ideas"][0]["premise"] == "废柴觉醒系统"
|
||||
assert session.commits == 1 # 预览不写业务表,但落 ledger
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_generate_unknown_tool_404() -> None:
|
||||
repo = FakeProjectRepo()
|
||||
pid = await _seed_project(repo)
|
||||
app, *_ = _make_app(project_repo=repo, gateway=_SchemaRoutingGateway({}))
|
||||
|
||||
async with _client(app) as client:
|
||||
resp = await client.post(f"/projects/{pid}/skills/nope/generate", json={"brief": "x"})
|
||||
|
||||
assert resp.status_code == 404
|
||||
assert resp.json()["error"]["code"] == ErrorCode.NOT_FOUND
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_generate_legacy_tool_404() -> None:
|
||||
# legacy 工具无通用执行路径(前端走 legacy_route)。
|
||||
repo = FakeProjectRepo()
|
||||
pid = await _seed_project(repo)
|
||||
app, *_ = _make_app(project_repo=repo, gateway=_SchemaRoutingGateway({}))
|
||||
|
||||
async with _client(app) as client:
|
||||
resp = await client.post(
|
||||
f"/projects/{pid}/skills/worldbuilding/generate", json={"brief": "x"}
|
||||
)
|
||||
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_generate_unknown_project_404() -> None:
|
||||
repo = FakeProjectRepo()
|
||||
app, *_ = _make_app(
|
||||
project_repo=repo, gateway=_SchemaRoutingGateway({IdeaListResult: _ideas()})
|
||||
)
|
||||
|
||||
async with _client(app) as client:
|
||||
resp = await client.post(
|
||||
f"/projects/{uuid.uuid4()}/skills/brainstorm/generate", json={"brief": "x"}
|
||||
)
|
||||
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_generate_no_credentials_503() -> None:
|
||||
repo = FakeProjectRepo()
|
||||
pid = await _seed_project(repo)
|
||||
app, session, *_ = _make_app(project_repo=repo, gateway=object(), no_creds=True)
|
||||
|
||||
async with _client(app) as client:
|
||||
resp = await client.post(f"/projects/{pid}/skills/brainstorm/generate", json={"brief": "x"})
|
||||
|
||||
assert resp.status_code == 503
|
||||
assert resp.json()["error"]["code"] == ErrorCode.LLM_UNAVAILABLE
|
||||
assert session.commits == 0
|
||||
|
||||
|
||||
# ---- POST .../ingest(world_entities gate)----
|
||||
|
||||
|
||||
def _gf_payload() -> dict[str, Any]:
|
||||
return {
|
||||
"world_entities": [
|
||||
{"type": "力量体系", "name": "吞噬系统", "rules": ["每日上限", "不可越级"]}
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ingest_world_entities_no_conflict_writes_201() -> None:
|
||||
repo = FakeProjectRepo()
|
||||
pid = await _seed_project(repo)
|
||||
gateway = _SchemaRoutingGateway({ContinuityReview: _no_conflicts()})
|
||||
app, session, world_repo, _ = _make_app(project_repo=repo, gateway=gateway)
|
||||
|
||||
async with _client(app) as client:
|
||||
resp = await client.post(f"/projects/{pid}/skills/golden-finger/ingest", json=_gf_payload())
|
||||
|
||||
assert resp.status_code == 201
|
||||
body = resp.json()
|
||||
assert body["table"] == "world_entities"
|
||||
assert body["created"] == ["吞噬系统"]
|
||||
assert body["rejected_tables"] == []
|
||||
assert len(world_repo.rows) == 1
|
||||
assert world_repo.rows[0]["rules"] == ["每日上限", "不可越级"]
|
||||
assert session.commits == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ingest_world_entities_conflict_blocks_409() -> None:
|
||||
repo = FakeProjectRepo()
|
||||
pid = await _seed_project(repo)
|
||||
gateway = _SchemaRoutingGateway({ContinuityReview: _with_conflicts()})
|
||||
app, session, world_repo, _ = _make_app(project_repo=repo, gateway=gateway)
|
||||
|
||||
async with _client(app) as client:
|
||||
resp = await client.post(f"/projects/{pid}/skills/golden-finger/ingest", json=_gf_payload())
|
||||
|
||||
assert resp.status_code == 409
|
||||
err = resp.json()["error"]
|
||||
assert err["code"] == ErrorCode.CONFLICT_UNRESOLVED
|
||||
assert err["details"]["conflict_count"] == 1
|
||||
assert len(world_repo.rows) == 0
|
||||
assert session.commits == 1 # 落 precheck ledger,不写业务表
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ingest_world_entities_acknowledged_writes() -> None:
|
||||
repo = FakeProjectRepo()
|
||||
pid = await _seed_project(repo)
|
||||
gateway = _SchemaRoutingGateway({ContinuityReview: _with_conflicts()})
|
||||
app, _, world_repo, _ = _make_app(project_repo=repo, gateway=gateway)
|
||||
|
||||
payload = {**_gf_payload(), "acknowledge_conflicts": True}
|
||||
async with _client(app) as client:
|
||||
resp = await client.post(f"/projects/{pid}/skills/golden-finger/ingest", json=payload)
|
||||
|
||||
assert resp.status_code == 201
|
||||
assert len(world_repo.rows) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ingest_non_ingestable_tool_422() -> None:
|
||||
# brainstorm 纯预览 → 不可入库。
|
||||
repo = FakeProjectRepo()
|
||||
pid = await _seed_project(repo)
|
||||
app, *_ = _make_app(project_repo=repo, gateway=_SchemaRoutingGateway({}))
|
||||
|
||||
async with _client(app) as client:
|
||||
resp = await client.post(f"/projects/{pid}/skills/brainstorm/ingest", json={})
|
||||
|
||||
assert resp.status_code == 422
|
||||
assert resp.json()["error"]["code"] == ErrorCode.VALIDATION
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ingest_outline_upserts_scenes() -> None:
|
||||
repo = FakeProjectRepo()
|
||||
pid = await _seed_project(repo)
|
||||
gateway = _SchemaRoutingGateway({})
|
||||
app, session, _, outline_repo = _make_app(project_repo=repo, gateway=gateway)
|
||||
|
||||
payload = {
|
||||
"chapter_no": 3,
|
||||
"scenes": [
|
||||
{"idx": 1, "beat": "初遇反派", "purpose": "推进", "conflict": "对峙", "hook": "悬念"},
|
||||
{"idx": 0, "beat": "主角觉醒", "purpose": "塑造", "conflict": "内心", "hook": "钩子"},
|
||||
],
|
||||
}
|
||||
async with _client(app) as client:
|
||||
resp = await client.post(f"/projects/{pid}/skills/fine-outline/ingest", json=payload)
|
||||
|
||||
assert resp.status_code == 201
|
||||
body = resp.json()
|
||||
assert body["table"] == "outline"
|
||||
assert len(outline_repo.rows) == 1
|
||||
# 场景按 idx 升序拼装成 beats(确定性)。
|
||||
assert outline_repo.rows[0]["beats"] == ["主角觉醒", "初遇反派"]
|
||||
assert outline_repo.rows[0]["chapter_no"] == 3
|
||||
assert session.commits == 1
|
||||
@@ -26,6 +26,7 @@ from ww_api.routers import (
|
||||
rules,
|
||||
settings_providers,
|
||||
style,
|
||||
toolbox,
|
||||
)
|
||||
from ww_api.security.credentials import _fernet
|
||||
from ww_api.services.project_deps import seed_stub_user
|
||||
@@ -115,6 +116,7 @@ def create_app() -> FastAPI:
|
||||
app.include_router(style.router)
|
||||
app.include_router(generation.router)
|
||||
app.include_router(generation.skills_router)
|
||||
app.include_router(toolbox.router)
|
||||
app.include_router(settings_providers.router)
|
||||
app.include_router(kimi_oauth.router)
|
||||
return app
|
||||
|
||||
453
apps/api/ww_api/routers/toolbox.py
Normal file
453
apps/api/ww_api/routers/toolbox.py
Normal file
@@ -0,0 +1,453 @@
|
||||
"""创作工具箱通用端点(C3 扩 T6 / 通用生成器框架;不变量 #1/#2/#3/#9)。
|
||||
|
||||
声明驱动、一条执行路径——「加一个生成器」= 在 `ww_skills.TOOLBOX` 加一份声明,无需新端点:
|
||||
- `GET /skills/toolbox` 列出全部生成器描述符(legacy + 新工具)。
|
||||
- `POST /projects/:id/skills/:tool_key/generate` 按 context_strategy 组材料 → 网关结构化预览。
|
||||
- `POST /projects/:id/skills/:tool_key/ingest` 仅可入库工具:过 continuity 预检 + 白名单 → 写库。
|
||||
|
||||
legacy 工具(spec=None)无通用执行路径——generate/ingest 对其返 404(前端走 legacy_route 跳现页)。
|
||||
未知 tool_key→404;项目不存在→404;无凭据→503 LLM_UNAVAILABLE;缺章/大纲→空节拍不报错(预览)。
|
||||
|
||||
提交边界:网关 ledger + 写侧均只 flush → 端点末尾一次 `commit()`(含冲突 409 路径也 commit
|
||||
落 precheck usage,同 characters 入库纪律,不变量 #3)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from typing import Annotated, Any
|
||||
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from ww_agents import AgentSpec, Conflict, ContinuityReview, continuity_spec
|
||||
from ww_core.domain.outline_write_repo import OutlineWriteRepo
|
||||
from ww_core.domain.project_repo import ProjectRepo
|
||||
from ww_core.domain.repositories import MemoryRepos, OutlineRepo
|
||||
from ww_core.domain.world_entity_repo import WorldEntityWriteRepo
|
||||
from ww_core.orchestrator import run_generator
|
||||
from ww_db import get_session
|
||||
from ww_llm_gateway import Gateway
|
||||
from ww_llm_gateway.types import Block, LlmRequest, Scope
|
||||
from ww_shared import AppError, ErrorCode, ErrorEnvelope
|
||||
from ww_skills import GeneratorTool, get_tool, partition_writes
|
||||
|
||||
from ww_api.logging_config import get_logger
|
||||
from ww_api.routers.generation import _project_context, _world_context
|
||||
from ww_api.schemas.generation import IngestConflictView, WorldEntityCardView
|
||||
from ww_api.schemas.toolbox import (
|
||||
OutlineSceneIngestView,
|
||||
ToolboxListResponse,
|
||||
ToolDescriptorView,
|
||||
ToolGeneratePreviewResponse,
|
||||
ToolGenerateRequest,
|
||||
ToolIngestRequest,
|
||||
ToolIngestResponse,
|
||||
ToolInputFieldView,
|
||||
)
|
||||
from ww_api.services.credentials import STUB_OWNER_ID
|
||||
from ww_api.services.project_deps import (
|
||||
TierGatewayBuilder,
|
||||
get_memory_repos,
|
||||
get_outline_read_repo,
|
||||
get_outline_write_repo,
|
||||
get_project_repo,
|
||||
get_tier_gateway_builder,
|
||||
get_world_entity_write_repo,
|
||||
)
|
||||
from ww_api.services.toolbox_context import build_toolbox_context
|
||||
|
||||
log = get_logger("ww.api.toolbox")
|
||||
|
||||
router = APIRouter(tags=["toolbox"])
|
||||
|
||||
# OpenAPI 错误响应声明(类型化错误形,§7.1)。
|
||||
_TOOL_ERRORS: dict[int | str, dict[str, Any]] = {
|
||||
404: {"model": ErrorEnvelope, "description": "工具或项目不存在 / legacy 工具无通用执行"},
|
||||
422: {"model": ErrorEnvelope, "description": "工具不支持该操作(如不可入库)"},
|
||||
503: {"model": ErrorEnvelope, "description": "LLM 不可用"},
|
||||
}
|
||||
_INGEST_ERRORS: dict[int | str, dict[str, Any]] = {
|
||||
**_TOOL_ERRORS,
|
||||
409: {"model": ErrorEnvelope, "description": "存在未裁决 continuity 冲突"},
|
||||
}
|
||||
|
||||
ProjectRepoDep = Annotated[ProjectRepo, Depends(get_project_repo)]
|
||||
MemoryReposDep = Annotated[MemoryRepos, Depends(get_memory_repos)]
|
||||
OutlineReadRepoDep = Annotated[OutlineRepo, Depends(get_outline_read_repo)]
|
||||
WorldWriteRepoDep = Annotated[WorldEntityWriteRepo, Depends(get_world_entity_write_repo)]
|
||||
OutlineWriteRepoDep = Annotated[OutlineWriteRepo, Depends(get_outline_write_repo)]
|
||||
GatewayBuilderDep = Annotated[TierGatewayBuilder, Depends(get_tier_gateway_builder)]
|
||||
SessionDep = Annotated[AsyncSession, Depends(get_session)]
|
||||
|
||||
|
||||
def _descriptor_view(tool: GeneratorTool) -> ToolDescriptorView:
|
||||
return ToolDescriptorView(
|
||||
key=tool.key,
|
||||
title=tool.title,
|
||||
subtitle=tool.subtitle,
|
||||
genre=tool.genre,
|
||||
is_legacy=tool.spec is None,
|
||||
ingestable=tool.ingest is not None,
|
||||
input_fields=[
|
||||
ToolInputFieldView(
|
||||
name=f.name,
|
||||
label=f.label,
|
||||
type=f.type,
|
||||
required=f.required,
|
||||
default=f.default,
|
||||
help=f.help,
|
||||
)
|
||||
for f in tool.input_fields
|
||||
],
|
||||
legacy_route=tool.legacy_route,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/skills/toolbox")
|
||||
async def list_toolbox() -> ToolboxListResponse:
|
||||
"""创作工具箱全量描述符(legacy + 新工具;前端据此渲染卡片栅格 + 表单 + 路由)。"""
|
||||
from ww_skills import TOOLBOX
|
||||
|
||||
# 确定性顺序(按 key 升序),便于前端稳定渲染与测试断言。
|
||||
tools = [_descriptor_view(TOOLBOX[key]) for key in sorted(TOOLBOX)]
|
||||
return ToolboxListResponse(tools=tools)
|
||||
|
||||
|
||||
def _require_real_tool(tool_key: str) -> GeneratorTool:
|
||||
"""解析工具:未知 → 404;legacy(无 spec)→ 404(前端走 legacy_route)。"""
|
||||
tool = get_tool(tool_key)
|
||||
if tool is None:
|
||||
raise AppError(ErrorCode.NOT_FOUND, f"未知工具:{tool_key}", {"tool_key": tool_key})
|
||||
if tool.spec is None:
|
||||
raise AppError(
|
||||
ErrorCode.NOT_FOUND,
|
||||
f"工具 {tool_key} 无通用执行路径,请使用其专属页面(legacy_route)",
|
||||
{"tool_key": tool_key, "legacy_route": tool.legacy_route},
|
||||
)
|
||||
return tool
|
||||
|
||||
|
||||
async def _chapter_beats(
|
||||
outline_repo: OutlineRepo, project_id: uuid.UUID, chapter_no: int
|
||||
) -> list[str]:
|
||||
"""读指定章的大纲粗节拍(缺章/缺大纲 → 空 list,不报错;DB JSONB dict→list 解包)。"""
|
||||
view = await outline_repo.get(project_id, chapter_no)
|
||||
if view is None:
|
||||
return []
|
||||
raw = view.beats or {}
|
||||
return list(raw.get("beats", [])) if isinstance(raw, dict) else []
|
||||
|
||||
|
||||
@router.post("/projects/{project_id}/skills/{tool_key}/generate", responses=_TOOL_ERRORS)
|
||||
async def generate_with_tool(
|
||||
project_id: uuid.UUID,
|
||||
tool_key: str,
|
||||
body: ToolGenerateRequest,
|
||||
request: Request,
|
||||
project_repo: ProjectRepoDep,
|
||||
memory: MemoryReposDep,
|
||||
outline_repo: OutlineReadRepoDep,
|
||||
build_gateway: GatewayBuilderDep,
|
||||
session: SessionDep,
|
||||
) -> ToolGeneratePreviewResponse:
|
||||
"""通用生成(结构化预览,不入库)。未知/ legacy 工具→404;项目不存在→404;无凭据→503。"""
|
||||
request_id = getattr(request.state, "request_id", None)
|
||||
tool = _require_real_tool(tool_key)
|
||||
spec = tool.spec
|
||||
assert spec is not None # _require_real_tool 已保证(mypy 收窄)
|
||||
|
||||
project = await project_repo.get(STUB_OWNER_ID, project_id)
|
||||
if project is None:
|
||||
raise AppError(ErrorCode.NOT_FOUND, f"project not found: {project_id}")
|
||||
|
||||
# brief_only 给精简设定、其余给完整设定(确定性序列化,无时间戳/UUID)。
|
||||
if tool.context_strategy == "brief_only":
|
||||
project_context = _project_context(project.title, project.genre, None, None)
|
||||
else:
|
||||
project_context = _project_context(
|
||||
project.title, project.genre, project.premise, project.theme
|
||||
)
|
||||
|
||||
world_context = ""
|
||||
beats: list[str] = []
|
||||
if tool.context_strategy == "with_world":
|
||||
world_context = await _world_context(memory, project_id)
|
||||
elif tool.context_strategy == "with_outline_chapter":
|
||||
chapter_no = body.chapter_no or 1
|
||||
beats = await _chapter_beats(outline_repo, project_id, chapter_no)
|
||||
|
||||
context = build_toolbox_context(
|
||||
tool.context_strategy,
|
||||
brief=body.brief,
|
||||
project_context=project_context,
|
||||
world_context=world_context,
|
||||
chapter_no=body.chapter_no,
|
||||
beats=beats,
|
||||
)
|
||||
|
||||
gateway = await build_gateway(spec.tier)
|
||||
parsed = await run_generator(
|
||||
spec,
|
||||
context=context,
|
||||
gateway=gateway,
|
||||
user_id=STUB_OWNER_ID,
|
||||
project_id=project_id,
|
||||
)
|
||||
# 提交边界:预览不写业务表,但网关 ledger 只 flush → 末尾 commit 落 usage(否则丢失)。
|
||||
await session.commit()
|
||||
log.info(
|
||||
"toolbox_generate_done",
|
||||
tool_key=tool_key,
|
||||
project_id=str(project_id),
|
||||
request_id=request_id,
|
||||
output_kind=type(parsed).__name__,
|
||||
)
|
||||
return ToolGeneratePreviewResponse(
|
||||
tool_key=tool_key,
|
||||
output_kind=type(parsed).__name__,
|
||||
preview=parsed.model_dump(),
|
||||
)
|
||||
|
||||
|
||||
async def _precheck_world_entities(
|
||||
spec: AgentSpec,
|
||||
*,
|
||||
items: list[WorldEntityCardView],
|
||||
world_context: str,
|
||||
gateway: Gateway,
|
||||
project_id: uuid.UUID,
|
||||
) -> list[Conflict]:
|
||||
"""入库前 continuity 预检(world_entities):把待入库实体 vs 既有世界观真相源比对。
|
||||
|
||||
仿 `precheck_generated_cards` 但服务于世界观实体(金手指/词条)。复用 `continuity_spec`
|
||||
(analyst 档,只读,不变量 #3)——直接构 LlmRequest 走网关,节点逻辑无 LLM 非确定性。
|
||||
"""
|
||||
lines = [
|
||||
f"- [{e.type}] {e.name}:{(';'.join(e.rules) if e.rules else '(无硬规则)')}"
|
||||
for e in items
|
||||
]
|
||||
context = (
|
||||
"## 待校验:本次生成的世界观实体(尚未入库)\n"
|
||||
f"{chr(10).join(lines) or '(无)'}\n\n"
|
||||
"## 真相源:既有世界观硬规则\n"
|
||||
f"{world_context or '(暂无世界观设定)'}"
|
||||
)
|
||||
req = LlmRequest(
|
||||
tier=spec.tier,
|
||||
system=[Block(text=spec.system_prompt, cache=True)],
|
||||
input=context,
|
||||
output_schema=spec.output_schema,
|
||||
scope=Scope(user_id=STUB_OWNER_ID, project_id=project_id),
|
||||
)
|
||||
resp = await gateway.run(req)
|
||||
parsed = resp.parsed
|
||||
if not isinstance(parsed, ContinuityReview):
|
||||
raise ValueError("gateway returned no parsed ContinuityReview for toolbox precheck")
|
||||
return list(parsed.conflicts)
|
||||
|
||||
|
||||
def _conflict_409(conflicts: list[Conflict]) -> AppError:
|
||||
return AppError(
|
||||
ErrorCode.CONFLICT_UNRESOLVED,
|
||||
"生成内容与现有设定存在 continuity 冲突,请裁决后确认入库",
|
||||
{
|
||||
"conflicts": [
|
||||
IngestConflictView(
|
||||
type=c.type, where=c.where, refs=list(c.refs), suggestion=c.suggestion
|
||||
).model_dump()
|
||||
for c in conflicts
|
||||
],
|
||||
"conflict_count": len(conflicts),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/projects/{project_id}/skills/{tool_key}/ingest", status_code=201, responses=_INGEST_ERRORS
|
||||
)
|
||||
async def ingest_with_tool(
|
||||
project_id: uuid.UUID,
|
||||
tool_key: str,
|
||||
body: ToolIngestRequest,
|
||||
request: Request,
|
||||
project_repo: ProjectRepoDep,
|
||||
memory: MemoryReposDep,
|
||||
world_write_repo: WorldWriteRepoDep,
|
||||
outline_write_repo: OutlineWriteRepoDep,
|
||||
build_gateway: GatewayBuilderDep,
|
||||
session: SessionDep,
|
||||
) -> ToolIngestResponse:
|
||||
"""通用入库(仅可入库工具):过 continuity 预检 + 白名单 → 写 ingest.table。
|
||||
|
||||
未知/legacy 工具→404;不可入库工具→422;有冲突且未确认→409;越权写表丢弃+审计。
|
||||
"""
|
||||
request_id = getattr(request.state, "request_id", None)
|
||||
tool = _require_real_tool(tool_key)
|
||||
spec = tool.spec
|
||||
assert spec is not None
|
||||
if tool.ingest is None:
|
||||
raise AppError(
|
||||
ErrorCode.VALIDATION,
|
||||
f"工具 {tool_key} 不支持入库(纯预览)",
|
||||
{"tool_key": tool_key},
|
||||
)
|
||||
|
||||
project = await project_repo.get(STUB_OWNER_ID, project_id)
|
||||
if project is None:
|
||||
raise AppError(ErrorCode.NOT_FOUND, f"project not found: {project_id}")
|
||||
|
||||
table = tool.ingest.table
|
||||
|
||||
if table == "world_entities":
|
||||
return await _ingest_world_entities(
|
||||
spec,
|
||||
body=body,
|
||||
project_id=project_id,
|
||||
memory=memory,
|
||||
world_write_repo=world_write_repo,
|
||||
session=session,
|
||||
build_gateway=build_gateway,
|
||||
request_id=request_id,
|
||||
tool_key=tool_key,
|
||||
)
|
||||
if table == "outline":
|
||||
return await _ingest_outline(
|
||||
spec,
|
||||
body=body,
|
||||
project_id=project_id,
|
||||
outline_write_repo=outline_write_repo,
|
||||
session=session,
|
||||
request_id=request_id,
|
||||
tool_key=tool_key,
|
||||
)
|
||||
# 描述符的 IngestSpec.table 已限白名单;其余表本期通用入库未实现。
|
||||
raise AppError(ErrorCode.VALIDATION, f"入库表 {table} 暂未支持通用入库", {"table": table})
|
||||
|
||||
|
||||
async def _ingest_world_entities(
|
||||
spec: AgentSpec,
|
||||
*,
|
||||
body: ToolIngestRequest,
|
||||
project_id: uuid.UUID,
|
||||
memory: MemoryRepos,
|
||||
world_write_repo: WorldEntityWriteRepo,
|
||||
session: AsyncSession,
|
||||
build_gateway: TierGatewayBuilder,
|
||||
request_id: str | None,
|
||||
tool_key: str,
|
||||
) -> ToolIngestResponse:
|
||||
"""world_entities 入库(金手指/词条):continuity 预检 + 白名单 → 写 world_entities。"""
|
||||
items = body.world_entities
|
||||
# gate 1:入库前 continuity 预检(analyst 网关;编排器追加的检查,守不变量 #1/#3)。
|
||||
world_context = await _world_context(memory, project_id)
|
||||
precheck_gateway = await build_gateway("analyst")
|
||||
conflicts = await _precheck_world_entities(
|
||||
continuity_spec,
|
||||
items=items,
|
||||
world_context=world_context,
|
||||
gateway=precheck_gateway,
|
||||
project_id=project_id,
|
||||
)
|
||||
if conflicts and not body.acknowledge_conflicts:
|
||||
await session.commit() # 落 precheck 网关 usage;不写业务表。
|
||||
log.info(
|
||||
"toolbox_ingest_blocked_by_conflicts",
|
||||
tool_key=tool_key,
|
||||
project_id=str(project_id),
|
||||
request_id=request_id,
|
||||
conflict_count=len(conflicts),
|
||||
)
|
||||
raise _conflict_409(conflicts)
|
||||
|
||||
# gate 2:权限白名单——按 spec 声明的 writes 过滤(越权表丢弃 + 审计)。
|
||||
allowed, rejected = partition_writes(spec, {"world_entities": items})
|
||||
if rejected:
|
||||
log.warning(
|
||||
"toolbox_ingest_rejected_over_permission",
|
||||
tool_key=tool_key,
|
||||
project_id=str(project_id),
|
||||
request_id=request_id,
|
||||
rejected_tables=rejected,
|
||||
)
|
||||
|
||||
created: list[str] = []
|
||||
for entity in allowed.get("world_entities", []):
|
||||
view = await world_write_repo.create(
|
||||
project_id, type=entity.type, name=entity.name, rules=list(entity.rules)
|
||||
)
|
||||
created.append(view.name)
|
||||
|
||||
await session.commit()
|
||||
log.info(
|
||||
"toolbox_ingest_done",
|
||||
tool_key=tool_key,
|
||||
project_id=str(project_id),
|
||||
request_id=request_id,
|
||||
table="world_entities",
|
||||
created_count=len(created),
|
||||
)
|
||||
return ToolIngestResponse(table="world_entities", created=created, rejected_tables=rejected)
|
||||
|
||||
|
||||
async def _ingest_outline(
|
||||
spec: AgentSpec,
|
||||
*,
|
||||
body: ToolIngestRequest,
|
||||
project_id: uuid.UUID,
|
||||
outline_write_repo: OutlineWriteRepo,
|
||||
session: AsyncSession,
|
||||
request_id: str | None,
|
||||
tool_key: str,
|
||||
) -> ToolIngestResponse:
|
||||
"""outline 入库(细纲):白名单 → 把场景节拍 upsert 到目标章的 outline 行。
|
||||
|
||||
细纲不做 continuity 预检(场景是粗节拍的展开,非与世界观真相源比对的设定/角色);
|
||||
仍过 partition_writes 白名单。无章号 → VALIDATION。
|
||||
"""
|
||||
if body.chapter_no is None:
|
||||
raise AppError(ErrorCode.VALIDATION, "细纲入库需指定 chapter_no", {"tool_key": tool_key})
|
||||
|
||||
# gate:权限白名单(细纲 spec 声明 writes=["outline"])。
|
||||
scenes: list[OutlineSceneIngestView] = body.scenes
|
||||
allowed, rejected = partition_writes(spec, {"outline": scenes})
|
||||
if rejected:
|
||||
log.warning(
|
||||
"toolbox_ingest_rejected_over_permission",
|
||||
tool_key=tool_key,
|
||||
project_id=str(project_id),
|
||||
request_id=request_id,
|
||||
rejected_tables=rejected,
|
||||
)
|
||||
|
||||
allowed_scenes: list[OutlineSceneIngestView] = allowed.get("outline", [])
|
||||
# 场景节拍序列化为该章 beats(按 idx 升序拼装,确定性)。
|
||||
ordered = sorted(allowed_scenes, key=lambda s: s.idx)
|
||||
beats = [s.beat for s in ordered]
|
||||
created: list[str] = []
|
||||
if "outline" in allowed:
|
||||
view = await outline_write_repo.upsert_chapter(
|
||||
project_id,
|
||||
volume=1,
|
||||
chapter_no=body.chapter_no,
|
||||
beats=beats,
|
||||
foreshadow_windows=[],
|
||||
)
|
||||
created = [str(s.idx) for s in ordered]
|
||||
log.info(
|
||||
"toolbox_outline_upserted",
|
||||
tool_key=tool_key,
|
||||
project_id=str(project_id),
|
||||
chapter_no=view.chapter_no,
|
||||
scene_count=len(ordered),
|
||||
)
|
||||
|
||||
await session.commit()
|
||||
log.info(
|
||||
"toolbox_ingest_done",
|
||||
tool_key=tool_key,
|
||||
project_id=str(project_id),
|
||||
request_id=request_id,
|
||||
table="outline",
|
||||
created_count=len(created),
|
||||
)
|
||||
return ToolIngestResponse(table="outline", created=created, rejected_tables=rejected)
|
||||
141
apps/api/ww_api/schemas/toolbox.py
Normal file
141
apps/api/ww_api/schemas/toolbox.py
Normal file
@@ -0,0 +1,141 @@
|
||||
"""创作工具箱通用端点的请求/响应 schema(C3 扩 T6 / 通用生成器框架)。
|
||||
|
||||
snake_case;前端经 OpenAPI 生成 TS 类型消费。改字段 → 前端必须 `pnpm gen:api`。
|
||||
|
||||
- `GET /skills/toolbox` → `ToolboxListResponse`(声明式描述符视图,含 legacy_route 供前端路由)。
|
||||
- `POST .../skills/{tool_key}/generate` → `ToolGeneratePreviewResponse`(结构化预览,不入库)。
|
||||
- `POST .../skills/{tool_key}/ingest` → `ToolIngestResponse`(过 continuity gate + 白名单后入库)。
|
||||
|
||||
预览/入库共用既有 schema:world_entities 入库走 `WorldEntityCardView`(贴 worldbuilder 形),
|
||||
outline 入库走 `OutlineSceneIngestView`(贴 fine-outline 场景行)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from ww_api.schemas.generation import IngestConflictView, WorldEntityCardView
|
||||
|
||||
# 一次性生成数量上限沿用 generation 的保守值(避免巨量调用);章号下限。
|
||||
MIN_CHAPTER_NO = 1
|
||||
|
||||
|
||||
# ---- GET /skills/toolbox(描述符列表)----
|
||||
|
||||
|
||||
class ToolInputFieldView(BaseModel):
|
||||
"""单个声明式表单字段视图(贴 ww_skills.InputField)。"""
|
||||
|
||||
name: str
|
||||
label: str
|
||||
type: str = Field(description="控件类型:text / textarea / number / select 等")
|
||||
required: bool = True
|
||||
default: str | None = None
|
||||
help: str | None = None
|
||||
|
||||
|
||||
class ToolDescriptorView(BaseModel):
|
||||
"""单个生成器卡片描述符视图(前端据此渲染卡片栅格 + 输入表单)。"""
|
||||
|
||||
key: str = Field(description="路由用稳定标识(brainstorm / book-title ...)")
|
||||
title: str
|
||||
subtitle: str
|
||||
genre: str | None = None
|
||||
is_legacy: bool = Field(description="legacy 工具(spec=None)→ 前端走 legacy_route 跳现有页面")
|
||||
ingestable: bool = Field(description="是否可入库(声明了 ingest 目标)")
|
||||
input_fields: list[ToolInputFieldView] = Field(default_factory=list)
|
||||
legacy_route: str | None = Field(
|
||||
default=None, description="legacy 工具指向的现有页面路径(含 {id} 占位)"
|
||||
)
|
||||
|
||||
|
||||
class ToolboxListResponse(BaseModel):
|
||||
"""GET /skills/toolbox:创作工具箱全量描述符(legacy + 新工具)。"""
|
||||
|
||||
tools: list[ToolDescriptorView] = Field(default_factory=list)
|
||||
|
||||
|
||||
# ---- POST .../skills/{tool_key}/generate(结构化预览)----
|
||||
|
||||
|
||||
class ToolGenerateRequest(BaseModel):
|
||||
"""通用生成请求(覆盖全部工具的可选入参;按工具 input_fields 取用,snake_case)。"""
|
||||
|
||||
brief: str = Field(default="", description="一句话需求/方向(可空,空则按题材发散)")
|
||||
chapter_no: int | None = Field(
|
||||
default=None, ge=MIN_CHAPTER_NO, description="按章展开类工具的章号(开篇/细纲)"
|
||||
)
|
||||
count: int | None = Field(default=None, ge=1, le=12, description="生成数量(部分工具可用)")
|
||||
kind: str | None = Field(default=None, description="命名对象类别等(部分工具可用)")
|
||||
|
||||
|
||||
class ToolGeneratePreviewResponse(BaseModel):
|
||||
"""通用生成预览:结构化产物(不持久化;仅记 ledger)。
|
||||
|
||||
`output_kind` = 该工具产物的 schema 名(如 IdeaListResult),供前端按类型选预览渲染器;
|
||||
`preview` = 解析后的结构化产物(按各工具 output_schema 的形,键名见 ww_agents.schemas)。
|
||||
"""
|
||||
|
||||
tool_key: str
|
||||
output_kind: str = Field(description="产物 schema 名(前端按此选预览渲染器)")
|
||||
preview: dict[str, Any] = Field(
|
||||
default_factory=dict,
|
||||
description="结构化产物(各工具 output_schema 的 model_dump;不入库)",
|
||||
)
|
||||
|
||||
|
||||
# ---- POST .../skills/{tool_key}/ingest(入库;过 continuity gate + 白名单)----
|
||||
|
||||
|
||||
class OutlineSceneIngestView(BaseModel):
|
||||
"""细纲入库的单章场景行(贴 ww_agents.Scene;落 outline 表)。"""
|
||||
|
||||
idx: int
|
||||
beat: str
|
||||
purpose: str = ""
|
||||
conflict: str = ""
|
||||
hook: str = ""
|
||||
|
||||
|
||||
class ToolIngestRequest(BaseModel):
|
||||
"""通用入库请求:待持久化的产物 + 冲突确认。
|
||||
|
||||
仅声明了 `ingest` 的工具可用。按入库表取用对应字段:
|
||||
- world_entities(金手指/词条)→ `world_entities`(贴 WorldEntityCardView:type/name/rules);
|
||||
- outline(细纲)→ `chapter_no` + `scenes`(贴 OutlineSceneIngestView)。
|
||||
`acknowledge_conflicts`:作者已查看并接受 continuity 预检冲突时置 true 放行(仿 accept gate)。
|
||||
"""
|
||||
|
||||
world_entities: list[WorldEntityCardView] = Field(
|
||||
default_factory=list, description="world_entities 入库项(金手指/词条)"
|
||||
)
|
||||
chapter_no: int | None = Field(
|
||||
default=None, ge=MIN_CHAPTER_NO, description="outline 入库的目标章号(细纲)"
|
||||
)
|
||||
scenes: list[OutlineSceneIngestView] = Field(
|
||||
default_factory=list, description="outline 入库的场景行(细纲)"
|
||||
)
|
||||
acknowledge_conflicts: bool = Field(
|
||||
default=False, description="作者已知悉并接受 continuity 冲突 → 放行入库"
|
||||
)
|
||||
|
||||
|
||||
class ToolIngestResponse(BaseModel):
|
||||
"""通用入库结果(201):写入项标识 + 被白名单丢弃的越权表(审计)。"""
|
||||
|
||||
table: str = Field(description="入库目标表")
|
||||
created: list[str] = Field(
|
||||
default_factory=list, description="写入项的标识(实体名 / 场景索引),按入参顺序"
|
||||
)
|
||||
rejected_tables: list[str] = Field(
|
||||
default_factory=list, description="被权限白名单丢弃的越权写表名(审计;正常为空)"
|
||||
)
|
||||
|
||||
|
||||
class ToolIngestConflictDetails(BaseModel):
|
||||
"""409 冲突详情(贴 generation 的 IngestConflictView)。"""
|
||||
|
||||
conflicts: list[IngestConflictView] = Field(default_factory=list)
|
||||
conflict_count: int = 0
|
||||
@@ -9,6 +9,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Annotated
|
||||
|
||||
import httpx
|
||||
@@ -211,6 +212,27 @@ def get_outline_read_repo(
|
||||
return SqlOutlineRepo(session)
|
||||
|
||||
|
||||
# 创作工具箱通用端点的网关来源:按 tier 动态建网关(工具的 tier 在运行时才知)。
|
||||
# 测试经 `app.dependency_overrides[get_tier_gateway_builder]` 注入返回 mock 网关的 builder。
|
||||
TierGatewayBuilder = Callable[[Tier], Awaitable[Gateway]]
|
||||
|
||||
|
||||
def get_tier_gateway_builder(
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
) -> TierGatewayBuilder:
|
||||
"""返回「按 tier 建网关」的可注入缝(创作工具箱按工具 spec.tier 动态路由)。
|
||||
|
||||
无凭据时 builder 调用内 `build_gateway_for_tier` 抛 `LLM_UNAVAILABLE`(503)——
|
||||
与固定档位网关缝语义一致。测试 override 返回固定 mock 网关的 builder(绝不联网)。
|
||||
"""
|
||||
store = SqlCredentialStore(session)
|
||||
|
||||
async def _build(tier: Tier) -> Gateway:
|
||||
return await build_gateway_for_tier(session, store, tier)
|
||||
|
||||
return _build
|
||||
|
||||
|
||||
async def get_worldbuilder_gateway(
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
) -> Gateway:
|
||||
|
||||
60
apps/api/ww_api/services/toolbox_context.py
Normal file
60
apps/api/ww_api/services/toolbox_context.py
Normal file
@@ -0,0 +1,60 @@
|
||||
"""创作工具箱 · context 派发(PURE,LLM-free,确定性)。
|
||||
|
||||
把 `ContextStrategy` 映射成喂给网关的注入文本。**纯函数**:给定已组装好的材料
|
||||
(项目设定 / 世界观文本 / 本章节拍),同输入同输出、无 IO、无时间戳——便于单测与缓存。
|
||||
|
||||
IO(读 projects/world_entities/outline)发生在端点;本模块只负责「材料 → 注入文本」的
|
||||
确定性拼装,复用 `ww_core.orchestrator` 的既有 context builders(brief / outline-chapter)
|
||||
与 `routers/generation` 的 `_project_context` / `_world_context` 序列化口径。
|
||||
|
||||
策略 → 材料:
|
||||
- `brief_only` :作品设定(仅标题/题材级)+ 一句话需求;
|
||||
- `with_project` :作品设定(含前提/主题)+ 一句话需求;
|
||||
- `with_world` :作品设定 + world_entities 硬规则卡 + 一句话需求;
|
||||
- `with_outline_chapter`:作品设定 + 指定章大纲节拍(缺章/缺大纲 → 空节拍,不报错)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from ww_core.orchestrator import build_brief_context, build_outline_chapter_context
|
||||
from ww_skills import ContextStrategy
|
||||
|
||||
|
||||
def build_toolbox_context(
|
||||
strategy: ContextStrategy,
|
||||
*,
|
||||
brief: str,
|
||||
project_context: str,
|
||||
world_context: str = "",
|
||||
chapter_no: int | None = None,
|
||||
beats: Sequence[str] = (),
|
||||
) -> str:
|
||||
"""据注入策略组装喂网关的文本(PURE,确定性)。
|
||||
|
||||
`project_context` 由调用方按策略序列化(brief_only 给精简设定、with_project 给完整设定);
|
||||
`world_context` 仅 with_world 用(已序列化的硬规则卡);`chapter_no`/`beats` 仅
|
||||
with_outline_chapter 用(缺章/缺大纲时 beats 为空,降级到空节拍而非报错)。
|
||||
"""
|
||||
if strategy in ("brief_only", "with_project"):
|
||||
return build_brief_context(brief=brief, project_context=project_context)
|
||||
|
||||
if strategy == "with_world":
|
||||
base = build_brief_context(brief=brief, project_context=project_context)
|
||||
world_block = world_context.strip() or "(暂无世界观设定)"
|
||||
return f"{base}\n\n## 世界观(硬规则供契合/校验)\n{world_block}"
|
||||
|
||||
if strategy == "with_outline_chapter":
|
||||
# 缺章号时退回首章占位(端点对需要章号的工具会先校验,这里只做确定性兜底)。
|
||||
no = chapter_no if chapter_no is not None else 1
|
||||
outline_block = build_outline_chapter_context(
|
||||
chapter_no=no, beats=beats, project_context=project_context
|
||||
)
|
||||
brief_block = brief.strip()
|
||||
if brief_block:
|
||||
return f"{outline_block}\n\n## 创作需求\n{brief_block}"
|
||||
return outline_block
|
||||
|
||||
# 受限枚举不应到达此分支;保守抛错而非静默错配策略。
|
||||
raise ValueError(f"unknown context strategy: {strategy!r}")
|
||||
34
apps/web/app/projects/[id]/toolbox/page.tsx
Normal file
34
apps/web/app/projects/[id]/toolbox/page.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import { notFound } from "next/navigation";
|
||||
|
||||
import { ToolboxPage } from "@/components/toolbox/ToolboxPage";
|
||||
import { fetchProject, fetchToolbox } from "@/lib/api/server";
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
searchParams: Promise<{ open?: string }>;
|
||||
}
|
||||
|
||||
// 创作工具箱页。Server Component 取项目 + 工具描述符(GET /skills/toolbox,错误降级为空)。
|
||||
// ?open=<tool_key> 由命令面板 action-gen-<key> 跳转,进页直开对应生成器(仅新工具)。
|
||||
export default async function ToolboxRoutePage({
|
||||
params,
|
||||
searchParams,
|
||||
}: PageProps) {
|
||||
const { id } = await params;
|
||||
const { open } = await searchParams;
|
||||
try {
|
||||
const [project, toolbox] = await Promise.all([
|
||||
fetchProject(id),
|
||||
fetchToolbox(),
|
||||
]);
|
||||
return (
|
||||
<ToolboxPage
|
||||
project={project}
|
||||
tools={toolbox.tools ?? []}
|
||||
initialOpenKey={open}
|
||||
/>
|
||||
);
|
||||
} catch {
|
||||
notFound();
|
||||
}
|
||||
}
|
||||
@@ -9,8 +9,10 @@ import {
|
||||
moveHighlight,
|
||||
projectCommands,
|
||||
type Command,
|
||||
type ToolCommandInfo,
|
||||
} from "@/lib/command/palette";
|
||||
import { handleTabTrap } from "@/lib/a11y/focusTrap";
|
||||
import { api } from "@/lib/api/client";
|
||||
|
||||
// 从 pathname 抽取当前 projectId(/projects/<id>/...)。
|
||||
function projectIdFromPath(pathname: string): string | null {
|
||||
@@ -31,14 +33,17 @@ export function CommandPalette({ pathname }: CommandPaletteProps) {
|
||||
const [highlight, setHighlight] = useState(0);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const dialogRef = useRef<HTMLDivElement>(null);
|
||||
// 工具箱生成器(命令面板发现性):首次打开惰性拉取一次,失败静默降级为空。
|
||||
const [tools, setTools] = useState<ToolCommandInfo[]>([]);
|
||||
const toolsLoadedRef = useRef(false);
|
||||
|
||||
const projectId = projectIdFromPath(pathname);
|
||||
const commands = useMemo<Command[]>(
|
||||
() =>
|
||||
projectId
|
||||
? [...projectCommands(projectId), ...globalCommands()]
|
||||
? [...projectCommands(projectId, tools), ...globalCommands()]
|
||||
: globalCommands(),
|
||||
[projectId],
|
||||
[projectId, tools],
|
||||
);
|
||||
const results = useMemo(
|
||||
() => filterCommands(commands, query),
|
||||
@@ -76,6 +81,29 @@ export function CommandPalette({ pathname }: CommandPaletteProps) {
|
||||
useEffect(() => {
|
||||
if (open) inputRef.current?.focus();
|
||||
}, [open]);
|
||||
|
||||
// 首次打开(项目内)惰性拉工具箱生成器列表,供生成 action-gen-<key> 命令。
|
||||
useEffect(() => {
|
||||
if (!open || !projectId || toolsLoadedRef.current) return;
|
||||
toolsLoadedRef.current = true;
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
try {
|
||||
const { data } = await api.GET("/skills/toolbox");
|
||||
if (cancelled || !data?.tools) return;
|
||||
setTools(
|
||||
data.tools
|
||||
.filter((t) => !t.is_legacy)
|
||||
.map((t) => ({ key: t.key, title: t.title, genre: t.genre })),
|
||||
);
|
||||
} catch {
|
||||
// 静默降级:命令面板照常工作,仅缺生成器动作。
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [open, projectId]);
|
||||
useEffect(() => {
|
||||
setHighlight(0);
|
||||
}, [query]);
|
||||
|
||||
271
apps/web/components/toolbox/GeneratorRunner.tsx
Normal file
271
apps/web/components/toolbox/GeneratorRunner.tsx
Normal file
@@ -0,0 +1,271 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
|
||||
import { useToast } from "@/components/Toast";
|
||||
import { ConflictAdjudication } from "@/components/generation/ConflictAdjudication";
|
||||
import type { ToolDescriptorView, ToolInputFieldView } from "@/lib/api/types";
|
||||
import {
|
||||
buildGenerateRequest,
|
||||
initialFieldValues,
|
||||
missingRequiredFields,
|
||||
previewRows,
|
||||
type FieldValues,
|
||||
type PreviewItem,
|
||||
} from "@/lib/toolbox/toolbox";
|
||||
import { ingestTable } from "@/lib/toolbox/ingest";
|
||||
import { useGenerator } from "@/lib/toolbox/useGenerator";
|
||||
|
||||
interface GeneratorRunnerProps {
|
||||
projectId: string;
|
||||
tool: ToolDescriptorView;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
// 声明驱动的通用生成器(核心):按 descriptor.input_fields 渲染表单 → 调通用 generate →
|
||||
// 按 output_kind 渲染结构化预览 → 可入库者勾选 + 入库(复用 ConflictAdjudication 过 409)。
|
||||
// 不硬编码任何工具的表单/预览/入库形状——全部由声明 + lib/toolbox 纯逻辑驱动。
|
||||
export function GeneratorRunner({
|
||||
projectId,
|
||||
tool,
|
||||
onClose,
|
||||
}: GeneratorRunnerProps) {
|
||||
const gen = useGenerator();
|
||||
const toast = useToast();
|
||||
const [values, setValues] = useState<FieldValues>(() =>
|
||||
initialFieldValues(tool.input_fields),
|
||||
);
|
||||
// 入库勾选:预览行下标集合(与 gen.rawPreview 行对齐)。
|
||||
const [selected, setSelected] = useState<Set<number>>(new Set());
|
||||
|
||||
const setField = useCallback((name: string, value: string): void => {
|
||||
setValues((prev) => ({ ...prev, [name]: value }));
|
||||
}, []);
|
||||
|
||||
const onGenerate = useCallback(async (): Promise<void> => {
|
||||
const missing = missingRequiredFields(tool.input_fields, values);
|
||||
if (missing.length > 0) {
|
||||
toast(`请填写:${missing.join("、")}`, "error");
|
||||
return;
|
||||
}
|
||||
setSelected(new Set());
|
||||
await gen.generate(projectId, tool.key, buildGenerateRequest(values));
|
||||
}, [gen, projectId, tool, values, toast]);
|
||||
|
||||
const rows = useMemo(
|
||||
() => previewRows(gen.rawPreview),
|
||||
[gen.rawPreview],
|
||||
);
|
||||
|
||||
const table = gen.outputKind ? ingestTable(gen.outputKind) : null;
|
||||
const canIngest = tool.ingestable && table !== null && rows.length > 0;
|
||||
|
||||
const toggle = useCallback((idx: number): void => {
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(idx)) {
|
||||
next.delete(idx);
|
||||
} else {
|
||||
next.add(idx);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const selectedRows = useMemo(
|
||||
() => rows.filter((_, i) => selected.has(i)),
|
||||
[rows, selected],
|
||||
);
|
||||
|
||||
const runIngest = useCallback(
|
||||
async (acknowledge: boolean): Promise<void> => {
|
||||
if (!gen.outputKind) return;
|
||||
const chapterNo = Number(values["chapter_no"]?.trim() || "") || null;
|
||||
await gen.ingest(projectId, tool.key, {
|
||||
outputKind: gen.outputKind,
|
||||
rows: selectedRows,
|
||||
chapterNo,
|
||||
acknowledgeConflicts: acknowledge,
|
||||
});
|
||||
},
|
||||
[gen, projectId, tool.key, selectedRows, values],
|
||||
);
|
||||
|
||||
const generating = gen.genStatus === "generating";
|
||||
const ingesting = gen.ingestStatus === "ingesting";
|
||||
|
||||
return (
|
||||
<section
|
||||
className="flex flex-col gap-4 rounded border border-line bg-panel p-5"
|
||||
aria-label={tool.title}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="font-serif text-lg text-ink">{tool.title}</h2>
|
||||
<p className="mt-1 text-sm text-ink-soft">{tool.subtitle}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded border border-line px-3 py-1 text-sm text-ink-soft hover:text-ink"
|
||||
>
|
||||
返回
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form
|
||||
className="flex flex-col gap-3"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
void onGenerate();
|
||||
}}
|
||||
>
|
||||
{(tool.input_fields ?? []).map((field) => (
|
||||
<FormField
|
||||
key={field.name}
|
||||
field={field}
|
||||
value={values[field.name] ?? ""}
|
||||
onChange={(v) => setField(field.name, v)}
|
||||
/>
|
||||
))}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={generating}
|
||||
className="self-start rounded bg-cinnabar px-4 py-2 text-sm text-white disabled:opacity-50"
|
||||
>
|
||||
{generating ? "生成中…" : "生成"}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{gen.genStatus === "preview" && gen.preview ? (
|
||||
<div className="flex flex-col gap-3">
|
||||
<h3 className="font-serif text-sm text-ink">
|
||||
预览({gen.preview.items.length})
|
||||
{canIngest ? "(勾选后可入库)" : null}
|
||||
</h3>
|
||||
<ul className="flex flex-col gap-2">
|
||||
{gen.preview.items.map((item, i) => (
|
||||
<PreviewRow
|
||||
key={i}
|
||||
item={item}
|
||||
index={i}
|
||||
selectable={canIngest}
|
||||
checked={selected.has(i)}
|
||||
onToggle={toggle}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{canIngest && gen.ingestStatus !== "conflict" ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void runIngest(false)}
|
||||
disabled={ingesting || selected.size === 0}
|
||||
className="self-start rounded border border-cinnabar px-4 py-2 text-sm text-cinnabar disabled:opacity-50"
|
||||
>
|
||||
{ingesting ? "入库中…" : `入库选中(${selected.size})至 ${table}`}
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
{gen.ingestStatus === "conflict" && gen.conflicts ? (
|
||||
<ConflictAdjudication
|
||||
conflicts={gen.conflicts}
|
||||
busy={ingesting}
|
||||
onAcknowledge={() => void runIngest(true)}
|
||||
onCancel={() => gen.reset()}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{gen.ingestStatus === "done" ? (
|
||||
<p className="text-sm text-ink-soft">
|
||||
已入库 {gen.created.length} 项。
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
interface FormFieldProps {
|
||||
field: ToolInputFieldView;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
}
|
||||
|
||||
// 声明式控件映射:type → text / textarea / number(select 暂同 text,后端未给 options)。
|
||||
function FormField({ field, value, onChange }: FormFieldProps) {
|
||||
const labelText = field.required ? `${field.label} *` : field.label;
|
||||
const common =
|
||||
"mt-1 w-full rounded border border-line bg-bg px-3 py-2 text-sm text-ink";
|
||||
return (
|
||||
<label className="block text-sm text-ink-soft">
|
||||
{labelText}
|
||||
{field.type === "textarea" ? (
|
||||
<textarea
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
rows={3}
|
||||
className={`${common} resize-y`}
|
||||
aria-label={field.label}
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
type={field.type === "number" ? "number" : "text"}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className={common}
|
||||
aria-label={field.label}
|
||||
/>
|
||||
)}
|
||||
{field.help ? (
|
||||
<span className="mt-1 block text-xs text-ink-soft/80">{field.help}</span>
|
||||
) : null}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
interface PreviewRowProps {
|
||||
item: PreviewItem;
|
||||
index: number;
|
||||
selectable: boolean;
|
||||
checked: boolean;
|
||||
onToggle: (index: number) => void;
|
||||
}
|
||||
|
||||
// 统一预览行渲染(heading + 次要字段 + 正文段),由 mapPreview 归一,跨工具复用。
|
||||
function PreviewRow({
|
||||
item,
|
||||
index,
|
||||
selectable,
|
||||
checked,
|
||||
onToggle,
|
||||
}: PreviewRowProps) {
|
||||
return (
|
||||
<li className="flex gap-2 rounded border border-line bg-bg p-3 text-sm">
|
||||
{selectable ? (
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={() => onToggle(index)}
|
||||
className="mt-1"
|
||||
aria-label={`选择第 ${index + 1} 项入库`}
|
||||
/>
|
||||
) : null}
|
||||
<div className="min-w-0 flex-1">
|
||||
{item.heading ? (
|
||||
<p className="mb-1 font-serif text-base text-ink">{item.heading}</p>
|
||||
) : null}
|
||||
{item.fields.map((f, j) => (
|
||||
<p key={j} className="text-ink-soft">
|
||||
<span className="text-ink-soft/80">{f.label}:</span>
|
||||
{f.value}
|
||||
</p>
|
||||
))}
|
||||
{item.body ? (
|
||||
<p className="whitespace-pre-wrap text-ink">{item.body}</p>
|
||||
) : null}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
41
apps/web/components/toolbox/ToolCard.tsx
Normal file
41
apps/web/components/toolbox/ToolCard.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
"use client";
|
||||
|
||||
import type { ToolDescriptorView } from "@/lib/api/types";
|
||||
|
||||
interface ToolCardProps {
|
||||
tool: ToolDescriptorView;
|
||||
// 点击:legacy 工具导航到现有页面;新工具开 GeneratorRunner。
|
||||
onOpen: (tool: ToolDescriptorView) => void;
|
||||
}
|
||||
|
||||
// 工具箱卡片(对齐 ProjectCard 纸感视觉):标题 + 副标题 + 题材徽标 + NEW/可入库标。
|
||||
// 纯展示;点击交由 ToolboxPage 分派(legacy 跳转 / 新工具开 runner)。
|
||||
export function ToolCard({ tool, onOpen }: ToolCardProps) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpen(tool)}
|
||||
className="flex h-full min-h-[140px] flex-col rounded border border-line bg-panel p-5 text-left shadow-paper transition-colors hover:border-cinnabar focus-visible:border-cinnabar focus-visible:outline-none"
|
||||
>
|
||||
<div className="mb-2 flex flex-wrap items-center gap-2">
|
||||
<h2 className="font-serif text-xl text-ink">{tool.title}</h2>
|
||||
{tool.genre ? (
|
||||
<span className="rounded bg-bg px-2 py-0.5 text-xs text-ink-soft">
|
||||
{tool.genre}
|
||||
</span>
|
||||
) : null}
|
||||
{tool.is_legacy ? null : (
|
||||
<span className="rounded bg-[var(--color-cinnabar-wash)] px-2 py-0.5 text-xs text-cinnabar">
|
||||
NEW
|
||||
</span>
|
||||
)}
|
||||
{tool.ingestable ? (
|
||||
<span className="rounded bg-bg px-2 py-0.5 text-xs text-ink-soft">
|
||||
可入库
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="line-clamp-2 text-sm text-ink-soft">{tool.subtitle}</p>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
92
apps/web/components/toolbox/ToolboxPage.tsx
Normal file
92
apps/web/components/toolbox/ToolboxPage.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import { AppShell } from "@/components/AppShell";
|
||||
import type { ProjectResponse, ToolDescriptorView } from "@/lib/api/types";
|
||||
import { isLegacyTool, resolveLegacyRoute } from "@/lib/toolbox/toolbox";
|
||||
import { ToolCard } from "./ToolCard";
|
||||
import { GeneratorRunner } from "./GeneratorRunner";
|
||||
|
||||
interface ToolboxPageProps {
|
||||
project: ProjectResponse;
|
||||
tools: ToolDescriptorView[];
|
||||
// 命令面板 action-gen-<key> 深链:进页直接打开该工具的 runner(仅新工具)。
|
||||
initialOpenKey?: string;
|
||||
}
|
||||
|
||||
// 创作工具箱(通用生成器框架):声明驱动的卡片栅格。
|
||||
// legacy 工具(世界观/人设/大纲)跳现有页面保其更丰富的入库流;新工具开 GeneratorRunner。
|
||||
export function ToolboxPage({
|
||||
project,
|
||||
tools,
|
||||
initialOpenKey,
|
||||
}: ToolboxPageProps) {
|
||||
const router = useRouter();
|
||||
|
||||
const findOpenable = useCallback(
|
||||
(key: string | undefined): ToolDescriptorView | null => {
|
||||
if (!key) return null;
|
||||
const tool = tools.find((t) => t.key === key);
|
||||
return tool && !isLegacyTool(tool) ? tool : null;
|
||||
},
|
||||
[tools],
|
||||
);
|
||||
|
||||
const [active, setActive] = useState<ToolDescriptorView | null>(() =>
|
||||
findOpenable(initialOpenKey),
|
||||
);
|
||||
|
||||
const onOpen = useCallback(
|
||||
(tool: ToolDescriptorView): void => {
|
||||
if (isLegacyTool(tool) && tool.legacy_route) {
|
||||
router.push(resolveLegacyRoute(tool.legacy_route, project.id));
|
||||
return;
|
||||
}
|
||||
setActive(tool);
|
||||
},
|
||||
[router, project.id],
|
||||
);
|
||||
|
||||
const sorted = useMemo(
|
||||
// 新工具靠前(更想被发现),legacy 收后;同组保持后端顺序。
|
||||
() => [...tools].sort((a, b) => Number(a.is_legacy) - Number(b.is_legacy)),
|
||||
[tools],
|
||||
);
|
||||
|
||||
return (
|
||||
<AppShell
|
||||
title={`〈${project.title}〉`}
|
||||
subtitle="工具箱"
|
||||
projectId={project.id}
|
||||
activeNav="toolbox"
|
||||
>
|
||||
<div className="mx-auto flex max-w-5xl flex-col gap-6 p-6">
|
||||
<header>
|
||||
<h1 className="font-serif text-lg text-ink">创作工具箱</h1>
|
||||
<p className="mt-1 text-xs text-ink-soft">
|
||||
声明驱动的生成器集合:脑洞 / 书名 / 简介 / 名字 / 金手指 / 词条 / 黄金开篇 / 细纲…
|
||||
选一个开始,结构化产物可一键入库(经一致性预检)。
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{active ? (
|
||||
<GeneratorRunner
|
||||
projectId={project.id}
|
||||
tool={active}
|
||||
onClose={() => setActive(null)}
|
||||
/>
|
||||
) : sorted.length === 0 ? (
|
||||
<p className="text-sm text-ink-soft">(暂无可用生成器)</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{sorted.map((tool) => (
|
||||
<ToolCard key={tool.key} tool={tool} onOpen={onOpen} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
393
apps/web/lib/api/schema.d.ts
vendored
393
apps/web/lib/api/schema.d.ts
vendored
@@ -487,6 +487,68 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/skills/toolbox": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/**
|
||||
* List Toolbox
|
||||
* @description 创作工具箱全量描述符(legacy + 新工具;前端据此渲染卡片栅格 + 表单 + 路由)。
|
||||
*/
|
||||
get: operations["list_toolbox_skills_toolbox_get"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/projects/{project_id}/skills/{tool_key}/generate": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/**
|
||||
* Generate With Tool
|
||||
* @description 通用生成(结构化预览,不入库)。未知/ legacy 工具→404;项目不存在→404;无凭据→503。
|
||||
*/
|
||||
post: operations["generate_with_tool_projects__project_id__skills__tool_key__generate_post"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/projects/{project_id}/skills/{tool_key}/ingest": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/**
|
||||
* Ingest With Tool
|
||||
* @description 通用入库(仅可入库工具):过 continuity 预检 + 白名单 → 写 ingest.table。
|
||||
*
|
||||
* 未知/legacy 工具→404;不可入库工具→422;有冲突且未确认→409;越权写表丢弃+审计。
|
||||
*/
|
||||
post: operations["ingest_with_tool_projects__project_id__skills__tool_key__ingest_post"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/settings/providers": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -1216,6 +1278,31 @@ export interface components {
|
||||
/** Chapters */
|
||||
chapters?: components["schemas"]["OutlineChapterView"][];
|
||||
};
|
||||
/**
|
||||
* OutlineSceneIngestView
|
||||
* @description 细纲入库的单章场景行(贴 ww_agents.Scene;落 outline 表)。
|
||||
*/
|
||||
OutlineSceneIngestView: {
|
||||
/** Idx */
|
||||
idx: number;
|
||||
/** Beat */
|
||||
beat: string;
|
||||
/**
|
||||
* Purpose
|
||||
* @default
|
||||
*/
|
||||
purpose: string;
|
||||
/**
|
||||
* Conflict
|
||||
* @default
|
||||
*/
|
||||
conflict: string;
|
||||
/**
|
||||
* Hook
|
||||
* @default
|
||||
*/
|
||||
hook: string;
|
||||
};
|
||||
/**
|
||||
* ProjectCreateRequest
|
||||
* @description POST /projects:立项向导字段(owner_id 由后端补 stub,不入参)。
|
||||
@@ -1568,6 +1655,175 @@ export interface components {
|
||||
/** Fallback */
|
||||
fallback?: string[];
|
||||
};
|
||||
/**
|
||||
* ToolDescriptorView
|
||||
* @description 单个生成器卡片描述符视图(前端据此渲染卡片栅格 + 输入表单)。
|
||||
*/
|
||||
ToolDescriptorView: {
|
||||
/**
|
||||
* Key
|
||||
* @description 路由用稳定标识(brainstorm / book-title ...)
|
||||
*/
|
||||
key: string;
|
||||
/** Title */
|
||||
title: string;
|
||||
/** Subtitle */
|
||||
subtitle: string;
|
||||
/** Genre */
|
||||
genre?: string | null;
|
||||
/**
|
||||
* Is Legacy
|
||||
* @description legacy 工具(spec=None)→ 前端走 legacy_route 跳现有页面
|
||||
*/
|
||||
is_legacy: boolean;
|
||||
/**
|
||||
* Ingestable
|
||||
* @description 是否可入库(声明了 ingest 目标)
|
||||
*/
|
||||
ingestable: boolean;
|
||||
/** Input Fields */
|
||||
input_fields?: components["schemas"]["ToolInputFieldView"][];
|
||||
/**
|
||||
* Legacy Route
|
||||
* @description legacy 工具指向的现有页面路径(含 {id} 占位)
|
||||
*/
|
||||
legacy_route?: string | null;
|
||||
};
|
||||
/**
|
||||
* ToolGeneratePreviewResponse
|
||||
* @description 通用生成预览:结构化产物(不持久化;仅记 ledger)。
|
||||
*
|
||||
* `output_kind` = 该工具产物的 schema 名(如 IdeaListResult),供前端按类型选预览渲染器;
|
||||
* `preview` = 解析后的结构化产物(按各工具 output_schema 的形,键名见 ww_agents.schemas)。
|
||||
*/
|
||||
ToolGeneratePreviewResponse: {
|
||||
/** Tool Key */
|
||||
tool_key: string;
|
||||
/**
|
||||
* Output Kind
|
||||
* @description 产物 schema 名(前端按此选预览渲染器)
|
||||
*/
|
||||
output_kind: string;
|
||||
/**
|
||||
* Preview
|
||||
* @description 结构化产物(各工具 output_schema 的 model_dump;不入库)
|
||||
*/
|
||||
preview?: {
|
||||
[key: string]: unknown;
|
||||
};
|
||||
};
|
||||
/**
|
||||
* ToolGenerateRequest
|
||||
* @description 通用生成请求(覆盖全部工具的可选入参;按工具 input_fields 取用,snake_case)。
|
||||
*/
|
||||
ToolGenerateRequest: {
|
||||
/**
|
||||
* Brief
|
||||
* @description 一句话需求/方向(可空,空则按题材发散)
|
||||
* @default
|
||||
*/
|
||||
brief: string;
|
||||
/**
|
||||
* Chapter No
|
||||
* @description 按章展开类工具的章号(开篇/细纲)
|
||||
*/
|
||||
chapter_no?: number | null;
|
||||
/**
|
||||
* Count
|
||||
* @description 生成数量(部分工具可用)
|
||||
*/
|
||||
count?: number | null;
|
||||
/**
|
||||
* Kind
|
||||
* @description 命名对象类别等(部分工具可用)
|
||||
*/
|
||||
kind?: string | null;
|
||||
};
|
||||
/**
|
||||
* ToolIngestRequest
|
||||
* @description 通用入库请求:待持久化的产物 + 冲突确认。
|
||||
*
|
||||
* 仅声明了 `ingest` 的工具可用。按入库表取用对应字段:
|
||||
* - world_entities(金手指/词条)→ `world_entities`(贴 WorldEntityCardView:type/name/rules);
|
||||
* - outline(细纲)→ `chapter_no` + `scenes`(贴 OutlineSceneIngestView)。
|
||||
* `acknowledge_conflicts`:作者已查看并接受 continuity 预检冲突时置 true 放行(仿 accept gate)。
|
||||
*/
|
||||
ToolIngestRequest: {
|
||||
/**
|
||||
* World Entities
|
||||
* @description world_entities 入库项(金手指/词条)
|
||||
*/
|
||||
world_entities?: components["schemas"]["WorldEntityCardView"][];
|
||||
/**
|
||||
* Chapter No
|
||||
* @description outline 入库的目标章号(细纲)
|
||||
*/
|
||||
chapter_no?: number | null;
|
||||
/**
|
||||
* Scenes
|
||||
* @description outline 入库的场景行(细纲)
|
||||
*/
|
||||
scenes?: components["schemas"]["OutlineSceneIngestView"][];
|
||||
/**
|
||||
* Acknowledge Conflicts
|
||||
* @description 作者已知悉并接受 continuity 冲突 → 放行入库
|
||||
* @default false
|
||||
*/
|
||||
acknowledge_conflicts: boolean;
|
||||
};
|
||||
/**
|
||||
* ToolIngestResponse
|
||||
* @description 通用入库结果(201):写入项标识 + 被白名单丢弃的越权表(审计)。
|
||||
*/
|
||||
ToolIngestResponse: {
|
||||
/**
|
||||
* Table
|
||||
* @description 入库目标表
|
||||
*/
|
||||
table: string;
|
||||
/**
|
||||
* Created
|
||||
* @description 写入项的标识(实体名 / 场景索引),按入参顺序
|
||||
*/
|
||||
created?: string[];
|
||||
/**
|
||||
* Rejected Tables
|
||||
* @description 被权限白名单丢弃的越权写表名(审计;正常为空)
|
||||
*/
|
||||
rejected_tables?: string[];
|
||||
};
|
||||
/**
|
||||
* ToolInputFieldView
|
||||
* @description 单个声明式表单字段视图(贴 ww_skills.InputField)。
|
||||
*/
|
||||
ToolInputFieldView: {
|
||||
/** Name */
|
||||
name: string;
|
||||
/** Label */
|
||||
label: string;
|
||||
/**
|
||||
* Type
|
||||
* @description 控件类型:text / textarea / number / select 等
|
||||
*/
|
||||
type: string;
|
||||
/**
|
||||
* Required
|
||||
* @default true
|
||||
*/
|
||||
required: boolean;
|
||||
/** Default */
|
||||
default?: string | null;
|
||||
/** Help */
|
||||
help?: string | null;
|
||||
};
|
||||
/**
|
||||
* ToolboxListResponse
|
||||
* @description GET /skills/toolbox:创作工具箱全量描述符(legacy + 新工具)。
|
||||
*/
|
||||
ToolboxListResponse: {
|
||||
/** Tools */
|
||||
tools?: components["schemas"]["ToolDescriptorView"][];
|
||||
};
|
||||
/** ValidationError */
|
||||
ValidationError: {
|
||||
/** Location */
|
||||
@@ -2681,6 +2937,143 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
list_toolbox_skills_toolbox_get: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ToolboxListResponse"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
generate_with_tool_projects__project_id__skills__tool_key__generate_post: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
project_id: string;
|
||||
tool_key: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["ToolGenerateRequest"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ToolGeneratePreviewResponse"];
|
||||
};
|
||||
};
|
||||
/** @description 工具或项目不存在 / legacy 工具无通用执行 */
|
||||
404: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description 工具不支持该操作(如不可入库) */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description LLM 不可用 */
|
||||
503: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
ingest_with_tool_projects__project_id__skills__tool_key__ingest_post: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
project_id: string;
|
||||
tool_key: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["ToolIngestRequest"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
201: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ToolIngestResponse"];
|
||||
};
|
||||
};
|
||||
/** @description 工具或项目不存在 / legacy 工具无通用执行 */
|
||||
404: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description 存在未裁决 continuity 冲突 */
|
||||
409: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description 工具不支持该操作(如不可入库) */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description LLM 不可用 */
|
||||
503: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
list_providers_settings_providers_get: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
||||
@@ -13,6 +13,7 @@ import type {
|
||||
RuleListResponse,
|
||||
SkillListResponse,
|
||||
StyleFingerprintResponse,
|
||||
ToolboxListResponse,
|
||||
WorldEntityListResponse,
|
||||
} from "./types";
|
||||
|
||||
@@ -107,6 +108,16 @@ export async function fetchSkills(): Promise<SkillListResponse> {
|
||||
return getJson<SkillListResponse>("/skills");
|
||||
}
|
||||
|
||||
// 创作工具箱描述符(GET /skills/toolbox,全局只读)。
|
||||
// 任何错误(含后端未上线)降级为空列表,工具箱页照常渲染(不阻塞进页)。
|
||||
export async function fetchToolbox(): Promise<ToolboxListResponse> {
|
||||
try {
|
||||
return await getJson<ToolboxListResponse>("/skills/toolbox");
|
||||
} catch {
|
||||
return { tools: [] };
|
||||
}
|
||||
}
|
||||
|
||||
// 设定库 Codex:跨会话全量已入库角色(GET .../characters;无行→空列表,非 404)。
|
||||
export async function fetchCharacters(
|
||||
projectId: string,
|
||||
|
||||
@@ -91,3 +91,19 @@ export type RuleView = components["schemas"]["RuleView"];
|
||||
export type RuleListResponse = components["schemas"]["RuleListResponse"];
|
||||
export type SkillView = components["schemas"]["SkillView"];
|
||||
export type SkillListResponse = components["schemas"]["SkillListResponse"];
|
||||
|
||||
// 创作工具箱:声明驱动的通用生成器(GET /skills/toolbox + 通用 generate/ingest)。
|
||||
export type ToolboxListResponse =
|
||||
components["schemas"]["ToolboxListResponse"];
|
||||
export type ToolDescriptorView =
|
||||
components["schemas"]["ToolDescriptorView"];
|
||||
export type ToolInputFieldView =
|
||||
components["schemas"]["ToolInputFieldView"];
|
||||
export type ToolGenerateRequest =
|
||||
components["schemas"]["ToolGenerateRequest"];
|
||||
export type ToolGeneratePreviewResponse =
|
||||
components["schemas"]["ToolGeneratePreviewResponse"];
|
||||
export type ToolIngestRequest = components["schemas"]["ToolIngestRequest"];
|
||||
export type ToolIngestResponse = components["schemas"]["ToolIngestResponse"];
|
||||
export type OutlineSceneIngestView =
|
||||
components["schemas"]["OutlineSceneIngestView"];
|
||||
|
||||
@@ -17,6 +17,32 @@ describe("projectCommands", () => {
|
||||
expect(genChar?.kind).toBe("action");
|
||||
expect(genChar?.href).toBe("/projects/p1/codex?gen=character");
|
||||
});
|
||||
|
||||
it("always offers a 工具箱 nav command", () => {
|
||||
const toolbox = projectCommands("p1").find((c) => c.id === "nav-toolbox");
|
||||
expect(toolbox?.kind).toBe("navigate");
|
||||
expect(toolbox?.href).toBe("/projects/p1/toolbox");
|
||||
});
|
||||
|
||||
it("generates one action-gen-<key> per toolbox tool deep-linking with ?open", () => {
|
||||
const cmds = projectCommands("p1", [
|
||||
{ key: "brainstorm", title: "脑洞生成器", genre: "玄幻" },
|
||||
{ key: "book-title", title: "书名生成器" },
|
||||
]);
|
||||
const brainstorm = cmds.find((c) => c.id === "action-gen-brainstorm");
|
||||
expect(brainstorm?.kind).toBe("action");
|
||||
expect(brainstorm?.href).toBe("/projects/p1/toolbox?open=brainstorm");
|
||||
expect(brainstorm?.title).toBe("脑洞生成器");
|
||||
expect(brainstorm?.keywords).toContain("玄幻");
|
||||
expect(cmds.find((c) => c.id === "action-gen-book-title")?.href).toBe(
|
||||
"/projects/p1/toolbox?open=book-title",
|
||||
);
|
||||
});
|
||||
|
||||
it("emits no tool commands when none provided", () => {
|
||||
const ids = projectCommands("p1").map((c) => c.id);
|
||||
expect(ids.some((id) => id === "action-gen-brainstorm")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("globalCommands", () => {
|
||||
|
||||
@@ -15,9 +15,30 @@ export interface Command {
|
||||
group: string;
|
||||
}
|
||||
|
||||
// 工具箱生成器的最小描述(命令面板按此生成 action-gen-<key>,无需整份 descriptor)。
|
||||
export interface ToolCommandInfo {
|
||||
key: string;
|
||||
title: string;
|
||||
genre?: string | null;
|
||||
}
|
||||
|
||||
// 项目内可用命令(projectId 已知)。导航项给 href;动作项(生成角色等)给 id。
|
||||
export function projectCommands(projectId: string): Command[] {
|
||||
// tools:工具箱生成器列表(可空)→ 每个生成一条 action-gen-<key> 深链命令(命令面板发现性)。
|
||||
export function projectCommands(
|
||||
projectId: string,
|
||||
tools: readonly ToolCommandInfo[] = [],
|
||||
): Command[] {
|
||||
const p = `/projects/${projectId}`;
|
||||
const toolCommands: Command[] = tools.map((tool) => ({
|
||||
id: `action-gen-${tool.key}`,
|
||||
title: tool.title,
|
||||
keywords: ["生成器", "工具箱", "generator", tool.key, tool.genre ?? ""].filter(
|
||||
(k) => k.length > 0,
|
||||
),
|
||||
kind: "action",
|
||||
href: `${p}/toolbox?open=${tool.key}`,
|
||||
group: "动作",
|
||||
}));
|
||||
return [
|
||||
{
|
||||
id: "nav-write",
|
||||
@@ -67,6 +88,14 @@ export function projectCommands(projectId: string): Command[] {
|
||||
href: `${p}/codex`,
|
||||
group: "导航",
|
||||
},
|
||||
{
|
||||
id: "nav-toolbox",
|
||||
title: "工具箱",
|
||||
keywords: ["toolbox", "工具箱", "生成器", "脑洞", "书名", "金手指"],
|
||||
kind: "navigate",
|
||||
href: `${p}/toolbox`,
|
||||
group: "导航",
|
||||
},
|
||||
{
|
||||
id: "nav-rules",
|
||||
title: "规则",
|
||||
@@ -99,6 +128,7 @@ export function projectCommands(projectId: string): Command[] {
|
||||
href: `${p}/codex?gen=world`,
|
||||
group: "动作",
|
||||
},
|
||||
...toolCommands,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -10,10 +10,10 @@ import {
|
||||
const PID = "p1";
|
||||
|
||||
describe("projectNavItems", () => {
|
||||
it("builds the eight project entries scoped to the project id", () => {
|
||||
it("builds the nine project entries scoped to the project id", () => {
|
||||
const items = projectNavItems(PID);
|
||||
|
||||
expect(items).toHaveLength(8);
|
||||
expect(items).toHaveLength(9);
|
||||
expect(items.map((i) => i.key)).toEqual([
|
||||
"write",
|
||||
"outline",
|
||||
@@ -21,6 +21,7 @@ describe("projectNavItems", () => {
|
||||
"review",
|
||||
"style",
|
||||
"codex",
|
||||
"toolbox",
|
||||
"rules",
|
||||
"skills",
|
||||
]);
|
||||
|
||||
@@ -9,7 +9,8 @@ export type ActiveNav =
|
||||
| "style"
|
||||
| "codex"
|
||||
| "rules"
|
||||
| "skills";
|
||||
| "skills"
|
||||
| "toolbox";
|
||||
|
||||
export interface NavItem {
|
||||
href: string;
|
||||
@@ -36,6 +37,7 @@ export function projectNavItems(projectId: string): NavItem[] {
|
||||
{ href: `/projects/${projectId}/review`, label: "审稿", key: "review" },
|
||||
{ href: `/projects/${projectId}/style`, label: "文风", key: "style" },
|
||||
{ href: `/projects/${projectId}/codex`, label: "设定库", key: "codex" },
|
||||
{ href: `/projects/${projectId}/toolbox`, label: "工具箱", key: "toolbox" },
|
||||
{ href: `/projects/${projectId}/rules`, label: "规则", key: "rules" },
|
||||
{ href: `/projects/${projectId}/skills`, label: "技能库", key: "skills" },
|
||||
];
|
||||
|
||||
89
apps/web/lib/toolbox/ingest.test.ts
Normal file
89
apps/web/lib/toolbox/ingest.test.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { buildIngestRequest, ingestTable } from "./ingest";
|
||||
|
||||
describe("ingestTable", () => {
|
||||
it("maps ingestable kinds to their target table", () => {
|
||||
expect(ingestTable("GoldenFingerResult")).toBe("world_entities");
|
||||
expect(ingestTable("GlossaryResult")).toBe("world_entities");
|
||||
expect(ingestTable("DetailedOutlineResult")).toBe("outline");
|
||||
});
|
||||
|
||||
it("returns null for non-ingestable kinds", () => {
|
||||
expect(ingestTable("IdeaListResult")).toBeNull();
|
||||
expect(ingestTable("unknown")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildIngestRequest", () => {
|
||||
it("maps golden finger rows into world_entities with merged rules", () => {
|
||||
const body = buildIngestRequest({
|
||||
outputKind: "GoldenFingerResult",
|
||||
rows: [
|
||||
{ name: "吞噬系统", mechanism: "吞噬获取", growth: "等级提升", limits: "需进食" },
|
||||
],
|
||||
acknowledgeConflicts: true,
|
||||
});
|
||||
expect(body).toEqual({
|
||||
world_entities: [
|
||||
{
|
||||
type: "力量体系",
|
||||
name: "吞噬系统",
|
||||
rules: ["吞噬获取", "等级提升", "需进食"],
|
||||
},
|
||||
],
|
||||
acknowledge_conflicts: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("drops blank rule parts for golden finger", () => {
|
||||
const body = buildIngestRequest({
|
||||
outputKind: "GoldenFingerResult",
|
||||
rows: [{ name: "x", mechanism: "m", growth: "", limits: "" }],
|
||||
});
|
||||
expect(body?.world_entities?.[0]?.rules).toEqual(["m"]);
|
||||
});
|
||||
|
||||
it("maps glossary rows preserving type and rules array", () => {
|
||||
const body = buildIngestRequest({
|
||||
outputKind: "GlossaryResult",
|
||||
rows: [{ name: "灵气", type: "概念", rules: ["不可逆"] }],
|
||||
});
|
||||
expect(body?.world_entities?.[0]).toEqual({
|
||||
type: "概念",
|
||||
name: "灵气",
|
||||
rules: ["不可逆"],
|
||||
});
|
||||
});
|
||||
|
||||
it("defaults glossary type to 概念 when missing", () => {
|
||||
const body = buildIngestRequest({
|
||||
outputKind: "GlossaryResult",
|
||||
rows: [{ name: "灵气" }],
|
||||
});
|
||||
expect(body?.world_entities?.[0]?.type).toBe("概念");
|
||||
});
|
||||
|
||||
it("maps detailed outline rows to scenes with chapter_no", () => {
|
||||
const body = buildIngestRequest({
|
||||
outputKind: "DetailedOutlineResult",
|
||||
rows: [
|
||||
{ idx: 2, beat: "对峙", purpose: "立威", conflict: "正反", hook: "悬念" },
|
||||
{ beat: "收束" },
|
||||
],
|
||||
chapterNo: 7,
|
||||
});
|
||||
expect(body?.chapter_no).toBe(7);
|
||||
expect(body?.scenes).toEqual([
|
||||
{ idx: 2, beat: "对峙", purpose: "立威", conflict: "正反", hook: "悬念" },
|
||||
{ idx: 1, beat: "收束", purpose: "", conflict: "", hook: "" },
|
||||
]);
|
||||
expect(body?.acknowledge_conflicts).toBe(false);
|
||||
});
|
||||
|
||||
it("returns null for non-ingestable output kind", () => {
|
||||
expect(
|
||||
buildIngestRequest({ outputKind: "IdeaListResult", rows: [] }),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
113
apps/web/lib/toolbox/ingest.ts
Normal file
113
apps/web/lib/toolbox/ingest.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
// 工具箱入库纯逻辑:把结构化预览产物按 output_kind 形变为 ToolIngestRequest 的入库项。
|
||||
// 复用既有入库双 gate(continuity 预检 409 + partition_writes 白名单),故仅负责 body 组装。
|
||||
// 对齐契约:world_entities(金手指/词条)贴 WorldEntityCardView;outline(细纲)贴 OutlineSceneIngestView。
|
||||
|
||||
import type {
|
||||
OutlineSceneIngestView,
|
||||
ToolIngestRequest,
|
||||
WorldEntityCardView,
|
||||
} from "@/lib/api/types";
|
||||
|
||||
function asString(v: unknown): string {
|
||||
if (typeof v === "string") return v;
|
||||
if (typeof v === "number" || typeof v === "boolean") return String(v);
|
||||
return "";
|
||||
}
|
||||
|
||||
function asStringArray(v: unknown): string[] {
|
||||
if (!Array.isArray(v)) return [];
|
||||
return v.filter((x): x is string => typeof x === "string");
|
||||
}
|
||||
|
||||
function asInt(v: unknown, fallback: number): number {
|
||||
if (typeof v === "number" && Number.isFinite(v)) return Math.round(v);
|
||||
if (typeof v === "string") {
|
||||
const n = Number(v.trim());
|
||||
if (Number.isFinite(n)) return Math.round(n);
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
// 金手指 → world_entities:type 固定「力量体系」,mechanism/growth/limits 合并入 rules(硬规则供续审比对)。
|
||||
function goldenFingerToEntity(
|
||||
row: Record<string, unknown>,
|
||||
): WorldEntityCardView {
|
||||
const rules = [
|
||||
asString(row["mechanism"]),
|
||||
asString(row["growth"]),
|
||||
asString(row["limits"]),
|
||||
].filter((r) => r.length > 0);
|
||||
return { type: "力量体系", name: asString(row["name"]), rules };
|
||||
}
|
||||
|
||||
// 词条 → world_entities:保留 type/name,rules 取 rules(已是硬规则清单)。
|
||||
function glossaryToEntity(row: Record<string, unknown>): WorldEntityCardView {
|
||||
return {
|
||||
type: asString(row["type"]) || "概念",
|
||||
name: asString(row["name"]),
|
||||
rules: asStringArray(row["rules"]),
|
||||
};
|
||||
}
|
||||
|
||||
// 细纲场景 → outline scenes:贴 OutlineSceneIngestView(idx/beat/purpose/conflict/hook)。
|
||||
function rowToScene(
|
||||
row: Record<string, unknown>,
|
||||
fallbackIdx: number,
|
||||
): OutlineSceneIngestView {
|
||||
return {
|
||||
idx: asInt(row["idx"], fallbackIdx),
|
||||
beat: asString(row["beat"]),
|
||||
purpose: asString(row["purpose"]),
|
||||
conflict: asString(row["conflict"]),
|
||||
hook: asString(row["hook"]),
|
||||
};
|
||||
}
|
||||
|
||||
export interface IngestBuildInput {
|
||||
outputKind: string;
|
||||
// 作者勾选要入库的预览行(结构化产物原始记录)。
|
||||
rows: readonly Record<string, unknown>[];
|
||||
// 细纲入库的目标章号(outputKind=DetailedOutlineResult 时必填)。
|
||||
chapterNo?: number | null;
|
||||
acknowledgeConflicts?: boolean;
|
||||
}
|
||||
|
||||
// 入库目标表(供 UI 文案 / 判定)。未知 → null(不可入库)。
|
||||
export function ingestTable(outputKind: string): string | null {
|
||||
switch (outputKind) {
|
||||
case "GoldenFingerResult":
|
||||
case "GlossaryResult":
|
||||
return "world_entities";
|
||||
case "DetailedOutlineResult":
|
||||
return "outline";
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// 组装通用入库请求;不可入库的 output_kind → null(调用方据此隐藏入库 UI)。
|
||||
export function buildIngestRequest(
|
||||
input: IngestBuildInput,
|
||||
): ToolIngestRequest | null {
|
||||
const acknowledge_conflicts = input.acknowledgeConflicts ?? false;
|
||||
switch (input.outputKind) {
|
||||
case "GoldenFingerResult":
|
||||
return {
|
||||
world_entities: input.rows.map(goldenFingerToEntity),
|
||||
acknowledge_conflicts,
|
||||
};
|
||||
case "GlossaryResult":
|
||||
return {
|
||||
world_entities: input.rows.map(glossaryToEntity),
|
||||
acknowledge_conflicts,
|
||||
};
|
||||
case "DetailedOutlineResult":
|
||||
return {
|
||||
chapter_no: input.chapterNo ?? null,
|
||||
scenes: input.rows.map((row, i) => rowToScene(row, i)),
|
||||
acknowledge_conflicts,
|
||||
};
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
169
apps/web/lib/toolbox/toolbox.test.ts
Normal file
169
apps/web/lib/toolbox/toolbox.test.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { ToolDescriptorView, ToolInputFieldView } from "@/lib/api/types";
|
||||
import {
|
||||
buildGenerateRequest,
|
||||
initialFieldValues,
|
||||
isLegacyTool,
|
||||
mapPreview,
|
||||
missingRequiredFields,
|
||||
previewRows,
|
||||
resolveLegacyRoute,
|
||||
} from "./toolbox";
|
||||
|
||||
const field = (over: Partial<ToolInputFieldView> = {}): ToolInputFieldView => ({
|
||||
name: "brief",
|
||||
label: "需求",
|
||||
type: "textarea",
|
||||
required: true,
|
||||
...over,
|
||||
});
|
||||
|
||||
describe("initialFieldValues", () => {
|
||||
it("seeds defaults and empty strings", () => {
|
||||
const values = initialFieldValues([
|
||||
field({ name: "brief", default: undefined }),
|
||||
field({ name: "count", type: "number", default: "3", required: false }),
|
||||
]);
|
||||
expect(values).toEqual({ brief: "", count: "3" });
|
||||
});
|
||||
|
||||
it("handles undefined fields", () => {
|
||||
expect(initialFieldValues(undefined)).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe("missingRequiredFields", () => {
|
||||
it("reports labels of blank required fields only", () => {
|
||||
const fields = [
|
||||
field({ name: "brief", label: "需求", required: true }),
|
||||
field({ name: "count", label: "数量", required: false }),
|
||||
];
|
||||
expect(missingRequiredFields(fields, { brief: " ", count: "" })).toEqual([
|
||||
"需求",
|
||||
]);
|
||||
});
|
||||
|
||||
it("passes when all required present", () => {
|
||||
expect(
|
||||
missingRequiredFields([field()], { brief: "一个脑洞" }),
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildGenerateRequest", () => {
|
||||
it("trims brief and maps known number fields", () => {
|
||||
expect(
|
||||
buildGenerateRequest({ brief: " 修真 ", count: "5", chapter_no: "3" }),
|
||||
).toEqual({ brief: "修真", count: 5, chapter_no: 3 });
|
||||
});
|
||||
|
||||
it("omits blank/invalid numbers and empty kind", () => {
|
||||
expect(
|
||||
buildGenerateRequest({ brief: "x", count: "", chapter_no: "abc", kind: " " }),
|
||||
).toEqual({ brief: "x" });
|
||||
});
|
||||
|
||||
it("includes kind when present", () => {
|
||||
expect(buildGenerateRequest({ brief: "x", kind: "门派" })).toEqual({
|
||||
brief: "x",
|
||||
kind: "门派",
|
||||
});
|
||||
});
|
||||
|
||||
it("defaults brief to empty string", () => {
|
||||
expect(buildGenerateRequest({})).toEqual({ brief: "" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("previewRows", () => {
|
||||
it("picks the first array field as records", () => {
|
||||
const rows = previewRows({ ideas: [{ premise: "a" }, { premise: "b" }] });
|
||||
expect(rows).toHaveLength(2);
|
||||
expect(rows[0]).toEqual({ premise: "a" });
|
||||
});
|
||||
|
||||
it("returns empty for missing/non-array", () => {
|
||||
expect(previewRows(undefined)).toEqual([]);
|
||||
expect(previewRows({ note: "x" })).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("mapPreview", () => {
|
||||
it("maps IdeaListResult to heading + fields", () => {
|
||||
const model = mapPreview("IdeaListResult", {
|
||||
ideas: [{ premise: "废柴逆袭", hook: "系统觉醒", genre_fit: "玄幻" }],
|
||||
});
|
||||
expect(model.kind).toBe("IdeaListResult");
|
||||
expect(model.items[0]?.heading).toBe("废柴逆袭");
|
||||
expect(model.items[0]?.fields).toEqual([
|
||||
{ label: "爽点", value: "系统觉醒" },
|
||||
{ label: "题材契合", value: "玄幻" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("maps BlurbResult text into body and angle into heading", () => {
|
||||
const model = mapPreview("BlurbResult", {
|
||||
variants: [{ angle: "悬念向", text: "一段简介" }],
|
||||
});
|
||||
expect(model.items[0]?.heading).toBe("悬念向");
|
||||
expect(model.items[0]?.body).toBe("一段简介");
|
||||
});
|
||||
|
||||
it("maps GlossaryResult rules into a joined field", () => {
|
||||
const model = mapPreview("GlossaryResult", {
|
||||
terms: [{ name: "灵气", type: "概念", rules: ["不可逆", "有上限"] }],
|
||||
});
|
||||
const ruleField = model.items[0]?.fields.find((f) => f.label === "硬规则");
|
||||
expect(ruleField?.value).toBe("不可逆;有上限");
|
||||
});
|
||||
|
||||
it("maps OpeningResult as pure body", () => {
|
||||
const model = mapPreview("OpeningResult", {
|
||||
variants: [{ text: "开篇第一段" }],
|
||||
});
|
||||
expect(model.items[0]?.heading).toBeNull();
|
||||
expect(model.items[0]?.body).toBe("开篇第一段");
|
||||
});
|
||||
|
||||
it("falls back gracefully for unknown output_kind", () => {
|
||||
const model = mapPreview("MysteryResult", {
|
||||
things: [{ a: "first", b: "second" }],
|
||||
});
|
||||
expect(model.items[0]?.heading).toBe("first");
|
||||
expect(model.items[0]?.fields).toEqual([{ label: "b", value: "second" }]);
|
||||
});
|
||||
|
||||
it("yields no items for empty preview", () => {
|
||||
expect(mapPreview("IdeaListResult", undefined).items).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveLegacyRoute", () => {
|
||||
it("substitutes the {id} placeholder", () => {
|
||||
expect(resolveLegacyRoute("/projects/{id}/codex?gen=world", "p9")).toBe(
|
||||
"/projects/p9/codex?gen=world",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isLegacyTool", () => {
|
||||
const tool = (over: Partial<ToolDescriptorView>): ToolDescriptorView => ({
|
||||
key: "k",
|
||||
title: "t",
|
||||
subtitle: "s",
|
||||
is_legacy: false,
|
||||
ingestable: false,
|
||||
...over,
|
||||
});
|
||||
|
||||
it("is true only with legacy flag and a route", () => {
|
||||
expect(
|
||||
isLegacyTool(tool({ is_legacy: true, legacy_route: "/x/{id}" })),
|
||||
).toBe(true);
|
||||
expect(isLegacyTool(tool({ is_legacy: true }))).toBe(false);
|
||||
expect(
|
||||
isLegacyTool(tool({ is_legacy: false, legacy_route: "/x/{id}" })),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
259
apps/web/lib/toolbox/toolbox.ts
Normal file
259
apps/web/lib/toolbox/toolbox.ts
Normal file
@@ -0,0 +1,259 @@
|
||||
// 创作工具箱纯逻辑:声明式表单值 → 请求体组装、字段校验、output_kind → 预览渲染模型、legacy 路由解析。
|
||||
// 对齐通用 toolbox 契约(GET /skills/toolbox + generate/ingest)。纯逻辑,便于 node 环境单测。
|
||||
// 关键边界:UI 不硬编码每个工具的表单/预览,全部由 descriptor + 此处映射驱动("加生成器=加声明")。
|
||||
|
||||
import type {
|
||||
ToolDescriptorView,
|
||||
ToolGenerateRequest,
|
||||
ToolInputFieldView,
|
||||
} from "@/lib/api/types";
|
||||
|
||||
// 声明式表单字段值(控件原始值;提交前经此处归一为 ToolGenerateRequest)。
|
||||
export type FieldValues = Record<string, string>;
|
||||
|
||||
// —— 表单初值 ——
|
||||
// 从 descriptor 的 input_fields 取默认值组装初始表单状态(缺省值用空串,保证受控输入)。
|
||||
export function initialFieldValues(
|
||||
fields: readonly ToolInputFieldView[] | undefined,
|
||||
): FieldValues {
|
||||
const values: FieldValues = {};
|
||||
for (const field of fields ?? []) {
|
||||
values[field.name] = field.default ?? "";
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
// —— 校验 ——
|
||||
// 必填字段缺失 → 返回字段名列表(空数组=通过)。number 类型仅校验非空(值合法性交后端)。
|
||||
export function missingRequiredFields(
|
||||
fields: readonly ToolInputFieldView[] | undefined,
|
||||
values: FieldValues,
|
||||
): string[] {
|
||||
const missing: string[] = [];
|
||||
for (const field of fields ?? []) {
|
||||
if (!field.required) continue;
|
||||
if ((values[field.name] ?? "").trim().length === 0) {
|
||||
missing.push(field.label);
|
||||
}
|
||||
}
|
||||
return missing;
|
||||
}
|
||||
|
||||
// —— 请求体组装 ——
|
||||
// 已知入参字段(brief/chapter_no/count/kind)按 ToolGenerateRequest snake_case 取用;
|
||||
// 其余字段名忽略(descriptor 的 input_fields 命名须落在该联合内)。number 安全解析,非法→省略。
|
||||
const KNOWN_NUMBER_FIELDS = ["chapter_no", "count"] as const;
|
||||
|
||||
function parseOptionalNumber(raw: string | undefined): number | null {
|
||||
if (raw === undefined) return null;
|
||||
const trimmed = raw.trim();
|
||||
if (trimmed.length === 0) return null;
|
||||
const n = Number(trimmed);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
export function buildGenerateRequest(values: FieldValues): ToolGenerateRequest {
|
||||
const body: ToolGenerateRequest = { brief: (values["brief"] ?? "").trim() };
|
||||
for (const key of KNOWN_NUMBER_FIELDS) {
|
||||
const n = parseOptionalNumber(values[key]);
|
||||
if (n !== null) body[key] = n;
|
||||
}
|
||||
const kind = values["kind"]?.trim();
|
||||
if (kind) body.kind = kind;
|
||||
return body;
|
||||
}
|
||||
|
||||
// —— output_kind → 预览渲染模型 ——
|
||||
// 把后端结构化产物(preview: 任意 schema 的 model_dump)归一为「卡片列表」渲染模型,
|
||||
// 组件据此渲染(小而稳的 switch + 兜底),无需为每个工具写专属组件。
|
||||
export interface PreviewField {
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface PreviewItem {
|
||||
// 标题行(如脑洞 premise / 书名 title / 实体 name);可空(纯文本变体)。
|
||||
heading: string | null;
|
||||
// 次要字段(angle/rationale/mechanism…),逐行展示。
|
||||
fields: PreviewField[];
|
||||
// 大段正文(开篇/简介变体的 text),独占一段。
|
||||
body: string | null;
|
||||
}
|
||||
|
||||
export interface PreviewModel {
|
||||
// 用于「入库」时贴对应 schema(world_entities / outline scenes)。
|
||||
kind: string;
|
||||
items: PreviewItem[];
|
||||
}
|
||||
|
||||
function asString(v: unknown): string {
|
||||
if (typeof v === "string") return v;
|
||||
if (typeof v === "number" || typeof v === "boolean") return String(v);
|
||||
return "";
|
||||
}
|
||||
|
||||
function asStringArray(v: unknown): string[] {
|
||||
if (!Array.isArray(v)) return [];
|
||||
return v.filter((x): x is string => typeof x === "string");
|
||||
}
|
||||
|
||||
function asRecordArray(v: unknown): Record<string, unknown>[] {
|
||||
if (!Array.isArray(v)) return [];
|
||||
return v.filter(
|
||||
(x): x is Record<string, unknown> => typeof x === "object" && x !== null,
|
||||
);
|
||||
}
|
||||
|
||||
// 取 preview 对象里第一个数组型字段(各工具的列表键名不同:ideas/titles/variants/names/...)。
|
||||
function firstArrayEntry(
|
||||
preview: Record<string, unknown> | undefined,
|
||||
): unknown[] {
|
||||
if (!preview) return [];
|
||||
for (const value of Object.values(preview)) {
|
||||
if (Array.isArray(value)) return value;
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
// 把一条记录的若干「次要字段」按 (key,label) 映射收集为 PreviewField(缺省字段跳过)。
|
||||
function collectFields(
|
||||
row: Record<string, unknown>,
|
||||
specs: readonly { key: string; label: string }[],
|
||||
): PreviewField[] {
|
||||
const fields: PreviewField[] = [];
|
||||
for (const spec of specs) {
|
||||
const value = asString(row[spec.key]);
|
||||
if (value) fields.push({ label: spec.label, value });
|
||||
}
|
||||
return fields;
|
||||
}
|
||||
|
||||
function rulesToField(row: Record<string, unknown>): PreviewField[] {
|
||||
const rules = asStringArray(row["rules"]);
|
||||
return rules.length > 0
|
||||
? [{ label: "硬规则", value: rules.join(";") }]
|
||||
: [];
|
||||
}
|
||||
|
||||
// 取 preview 里的原始记录数组(供入库形变;与 mapPreview 同源,保证选中下标对齐)。
|
||||
export function previewRows(
|
||||
preview: Record<string, unknown> | undefined,
|
||||
): Record<string, unknown>[] {
|
||||
return asRecordArray(firstArrayEntry(preview));
|
||||
}
|
||||
|
||||
// output_kind → 行渲染策略。未知 kind 走兜底(heading=首个字符串字段,其余键值列出)。
|
||||
export function mapPreview(
|
||||
outputKind: string,
|
||||
preview: Record<string, unknown> | undefined,
|
||||
): PreviewModel {
|
||||
const rows = asRecordArray(firstArrayEntry(preview));
|
||||
const items = rows.map((row) => previewItem(outputKind, row));
|
||||
return { kind: outputKind, items };
|
||||
}
|
||||
|
||||
function previewItem(
|
||||
outputKind: string,
|
||||
row: Record<string, unknown>,
|
||||
): PreviewItem {
|
||||
switch (outputKind) {
|
||||
case "IdeaListResult":
|
||||
return {
|
||||
heading: asString(row["premise"]) || null,
|
||||
fields: collectFields(row, [
|
||||
{ key: "hook", label: "爽点" },
|
||||
{ key: "genre_fit", label: "题材契合" },
|
||||
]),
|
||||
body: null,
|
||||
};
|
||||
case "TitleListResult":
|
||||
return {
|
||||
heading: asString(row["title"]) || null,
|
||||
fields: collectFields(row, [{ key: "rationale", label: "立意" }]),
|
||||
body: null,
|
||||
};
|
||||
case "BlurbResult":
|
||||
return {
|
||||
heading: asString(row["angle"]) || null,
|
||||
fields: [],
|
||||
body: asString(row["text"]) || null,
|
||||
};
|
||||
case "NameListResult":
|
||||
return {
|
||||
heading: asString(row["name"]) || null,
|
||||
fields: collectFields(row, [
|
||||
{ key: "kind", label: "类别" },
|
||||
{ key: "note", label: "说明" },
|
||||
]),
|
||||
body: null,
|
||||
};
|
||||
case "GoldenFingerResult":
|
||||
return {
|
||||
heading: asString(row["name"]) || null,
|
||||
fields: collectFields(row, [
|
||||
{ key: "mechanism", label: "机制" },
|
||||
{ key: "growth", label: "成长" },
|
||||
{ key: "limits", label: "限制" },
|
||||
]),
|
||||
body: null,
|
||||
};
|
||||
case "GlossaryResult":
|
||||
return {
|
||||
heading: asString(row["name"]) || null,
|
||||
fields: [
|
||||
...collectFields(row, [
|
||||
{ key: "type", label: "类型" },
|
||||
{ key: "definition", label: "释义" },
|
||||
]),
|
||||
...rulesToField(row),
|
||||
],
|
||||
body: null,
|
||||
};
|
||||
case "OpeningResult":
|
||||
return { heading: null, fields: [], body: asString(row["text"]) || null };
|
||||
case "DetailedOutlineResult":
|
||||
return {
|
||||
heading: asString(row["beat"]) || null,
|
||||
fields: collectFields(row, [
|
||||
{ key: "idx", label: "序" },
|
||||
{ key: "purpose", label: "作用" },
|
||||
{ key: "conflict", label: "冲突" },
|
||||
{ key: "hook", label: "钩子" },
|
||||
]),
|
||||
body: null,
|
||||
};
|
||||
default:
|
||||
return fallbackItem(row);
|
||||
}
|
||||
}
|
||||
|
||||
// 兜底:首个字符串字段当标题,其余键值平铺(未知 output_kind 也能展示,不报错)。
|
||||
function fallbackItem(row: Record<string, unknown>): PreviewItem {
|
||||
const entries = Object.entries(row);
|
||||
let heading: string | null = null;
|
||||
const fields: PreviewField[] = [];
|
||||
for (const [key, raw] of entries) {
|
||||
const value = Array.isArray(raw) ? asStringArray(raw).join(";") : asString(raw);
|
||||
if (!value) continue;
|
||||
if (heading === null) {
|
||||
heading = value;
|
||||
} else {
|
||||
fields.push({ label: key, value });
|
||||
}
|
||||
}
|
||||
return { heading, fields, body: null };
|
||||
}
|
||||
|
||||
// —— legacy 路由解析 ——
|
||||
// legacy 工具的 legacy_route 含 {id} 占位(如 /projects/{id}/codex?gen=world)→ 替换为真实 projectId。
|
||||
export function resolveLegacyRoute(
|
||||
route: string,
|
||||
projectId: string,
|
||||
): string {
|
||||
return route.replace(/\{id\}/g, projectId);
|
||||
}
|
||||
|
||||
// 判定工具点击应导航(legacy)还是开 runner(新工具)。
|
||||
export function isLegacyTool(tool: Readonly<ToolDescriptorView>): boolean {
|
||||
return tool.is_legacy && !!tool.legacy_route;
|
||||
}
|
||||
162
apps/web/lib/toolbox/useGenerator.ts
Normal file
162
apps/web/lib/toolbox/useGenerator.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
|
||||
import { api } from "@/lib/api/client";
|
||||
import { useToast } from "@/components/Toast";
|
||||
import {
|
||||
extractIngestConflicts,
|
||||
generationErrorMessage,
|
||||
type IngestConflicts,
|
||||
} from "@/lib/generation/cards";
|
||||
import { mapPreview, type PreviewModel } from "./toolbox";
|
||||
import { buildIngestRequest, type IngestBuildInput } from "./ingest";
|
||||
import type { ToolGenerateRequest } from "@/lib/api/types";
|
||||
|
||||
export type GenStatus = "idle" | "generating" | "preview" | "error";
|
||||
export type IngestStatus = "idle" | "ingesting" | "conflict" | "done" | "error";
|
||||
|
||||
export interface UseGenerator {
|
||||
genStatus: GenStatus;
|
||||
preview: PreviewModel | null;
|
||||
// 原始结构化产物(供入库按选中下标形变;与 preview.items 同源对齐)。
|
||||
rawPreview: Record<string, unknown> | undefined;
|
||||
outputKind: string | null;
|
||||
ingestStatus: IngestStatus;
|
||||
// 409 冲突详情(待作者裁决);裁决后带 acknowledge 重发。
|
||||
conflicts: IngestConflicts | null;
|
||||
created: string[];
|
||||
generate: (
|
||||
projectId: string,
|
||||
toolKey: string,
|
||||
body: ToolGenerateRequest,
|
||||
) => Promise<void>;
|
||||
// 入库选中行;acknowledge 用于过 409(已查看冲突后重发)。
|
||||
ingest: (
|
||||
projectId: string,
|
||||
toolKey: string,
|
||||
input: IngestBuildInput,
|
||||
) => Promise<boolean>;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
// 通用生成器状态机(idle→generating→preview)+ 入库 409 裁决流,仿 useCharacterGen。
|
||||
// 声明驱动:generate/ingest 不感知具体工具,按 tool_key + descriptor 形变后调通用端点。
|
||||
export function useGenerator(): UseGenerator {
|
||||
const [genStatus, setGenStatus] = useState<GenStatus>("idle");
|
||||
const [preview, setPreview] = useState<PreviewModel | null>(null);
|
||||
const [rawPreview, setRawPreview] = useState<
|
||||
Record<string, unknown> | undefined
|
||||
>(undefined);
|
||||
const [outputKind, setOutputKind] = useState<string | null>(null);
|
||||
const [ingestStatus, setIngestStatus] = useState<IngestStatus>("idle");
|
||||
const [conflicts, setConflicts] = useState<IngestConflicts | null>(null);
|
||||
const [created, setCreated] = useState<string[]>([]);
|
||||
const toast = useToast();
|
||||
|
||||
const generate = useCallback<UseGenerator["generate"]>(
|
||||
async (projectId, toolKey, body) => {
|
||||
setGenStatus("generating");
|
||||
setPreview(null);
|
||||
setRawPreview(undefined);
|
||||
setOutputKind(null);
|
||||
setConflicts(null);
|
||||
setIngestStatus("idle");
|
||||
setCreated([]);
|
||||
try {
|
||||
const { data, error } = await api.POST(
|
||||
"/projects/{project_id}/skills/{tool_key}/generate",
|
||||
{
|
||||
params: { path: { project_id: projectId, tool_key: toolKey } },
|
||||
body,
|
||||
},
|
||||
);
|
||||
if (error || !data) {
|
||||
setGenStatus("error");
|
||||
toast(generationErrorMessage(error), "error");
|
||||
return;
|
||||
}
|
||||
setPreview(mapPreview(data.output_kind, data.preview));
|
||||
setRawPreview(data.preview);
|
||||
setOutputKind(data.output_kind);
|
||||
setGenStatus("preview");
|
||||
} catch {
|
||||
setGenStatus("error");
|
||||
toast("生成请求异常,请检查网络。", "error");
|
||||
}
|
||||
},
|
||||
[toast],
|
||||
);
|
||||
|
||||
const ingest = useCallback<UseGenerator["ingest"]>(
|
||||
async (projectId, toolKey, input) => {
|
||||
const body = buildIngestRequest(input);
|
||||
if (!body) {
|
||||
toast("该生成器不支持入库。", "error");
|
||||
return false;
|
||||
}
|
||||
if (input.rows.length === 0) {
|
||||
toast("请至少选择一项入库。", "error");
|
||||
return false;
|
||||
}
|
||||
setIngestStatus("ingesting");
|
||||
try {
|
||||
const { data, error } = await api.POST(
|
||||
"/projects/{project_id}/skills/{tool_key}/ingest",
|
||||
{
|
||||
params: { path: { project_id: projectId, tool_key: toolKey } },
|
||||
body,
|
||||
},
|
||||
);
|
||||
if (error || !data) {
|
||||
const conflict = extractIngestConflicts(error);
|
||||
if (conflict) {
|
||||
// 409 CONFLICT_UNRESOLVED:展示冲突让作者裁决,再带 acknowledge 重发。
|
||||
setConflicts(conflict);
|
||||
setIngestStatus("conflict");
|
||||
return false;
|
||||
}
|
||||
setIngestStatus("error");
|
||||
toast(generationErrorMessage(error), "error");
|
||||
return false;
|
||||
}
|
||||
setCreated(data.created ?? []);
|
||||
setConflicts(null);
|
||||
setIngestStatus("done");
|
||||
toast(`已入库 ${data.created?.length ?? 0} 项至 ${data.table}`, "success");
|
||||
if (data.rejected_tables && data.rejected_tables.length > 0) {
|
||||
toast(`越权写表被丢弃:${data.rejected_tables.join("、")}`, "info");
|
||||
}
|
||||
return true;
|
||||
} catch {
|
||||
setIngestStatus("error");
|
||||
toast("入库请求异常,请检查网络。", "error");
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[toast],
|
||||
);
|
||||
|
||||
const reset = useCallback((): void => {
|
||||
setGenStatus("idle");
|
||||
setPreview(null);
|
||||
setRawPreview(undefined);
|
||||
setOutputKind(null);
|
||||
setIngestStatus("idle");
|
||||
setConflicts(null);
|
||||
setCreated([]);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
genStatus,
|
||||
preview,
|
||||
rawPreview,
|
||||
outputKind,
|
||||
ingestStatus,
|
||||
conflicts,
|
||||
created,
|
||||
generate,
|
||||
ingest,
|
||||
reset,
|
||||
};
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -177,6 +177,16 @@
|
||||
- **服务缝**(`services/kimi_oauth.py`,注入 `AsyncHttpClient` Protocol = `httpx.AsyncClient.post` 子集,测试零联网):`start_device_authorization(http)->DeviceAuth`、`poll_token(http, device_code)->TokenSet`(一次尝试,`AuthorizationPending`/`SlowDown` 异常供调用方循环)、`refresh(http, refresh_token)->TokenSet`;`encrypt_oauth_bundle`/`decrypt_oauth_bundle`(Fernet 复用 `security/credentials` 的 `encrypt_api_key`/`decrypt_api_key`,工作在 JSON 串上);`needs_refresh(token)`。client_id env `KIMI_CLIENT_ID` 可覆盖默认 `17e5f671-...`;device authorization **带 `scope=kimi-code`**(coding entitlement;实测省略 scope→`api.kimi.com/coding/v1` 回 401,故重新带上。token 交换/刷新不带 scope)。
|
||||
- **@frontend 行动(K1.4)**:`pnpm gen:api` 纳入 OAuth start/disconnect/status + 3 schema;连接流 = start 拿 user_code + 开 verification_uri → 轮询 `GET /jobs/{job_id}` 到 done(已连接)/failed(过期/拒绝);status 端点查连接态;档位路由到 `kimi-code:kimi-for-coding`。
|
||||
|
||||
### C3 扩展(T6.2/T6.3, 2026-06-22)· 创作工具箱通用端点(独立 `routers/toolbox.py`,已注册) owner @backend 状态: 稳定
|
||||
- 通用生成器框架:声明驱动、一条执行路径——「加一个生成器」= 在 `ww_skills.TOOLBOX` 加一份 `GeneratorTool` 声明(descriptor 在代码、**无 DB 迁移**),无需新端点。snake_case;**新增 3 端点 + 新 schema → @frontend 必须 `cd apps/web && pnpm gen:api`**(T6 前端前置)。
|
||||
- `GET /skills/toolbox` → **200** `ToolboxListResponse{tools:[ToolDescriptorView{key,title,subtitle,genre?,is_legacy:bool,ingestable:bool,input_fields:[ToolInputFieldView{name,label,type,required,default?,help?}],legacy_route?:str}]}`(legacy 3 + 新 8,按 key 升序)。legacy 工具(worldbuilding/character/outline,`is_legacy=true`)携 `legacy_route`(`/projects/{id}/codex?gen=world` · `?gen=character` · `/projects/{id}/outline`)供前端跳现有页面,不回归既测。
|
||||
- `POST /projects/{project_id}/skills/{tool_key}/generate` ← `ToolGenerateRequest{brief:str="", chapter_no:int|None(ge1), count:int|None(1..12), kind:str|None}` → **200** `ToolGeneratePreviewResponse{tool_key, output_kind:str(产物 schema 名), preview:dict(各工具 output_schema 的 model_dump)}`。**预览不入库**(按 `spec.tier` 经 `get_tier_gateway_builder` 建网关 → `run_generator`);末尾 `commit()` 仅落 ledger。context 派发(PURE `services/toolbox_context.build_toolbox_context`):brief_only/with_project→`build_brief_context`、with_world→ + world_entities 硬规则卡、with_outline_chapter→ + 指定章 outline beats(**缺章/缺大纲→空节拍不报错**)。未知 tool_key→404;legacy 工具(spec=None)→**404**(前端走 legacy_route);项目不存在→404;无凭据→**503** `LLM_UNAVAILABLE`。
|
||||
- `POST /projects/{project_id}/skills/{tool_key}/ingest` ← `ToolIngestRequest{world_entities:[WorldEntityCardView], chapter_no:int|None, scenes:[OutlineSceneIngestView{idx,beat,purpose,conflict,hook}], acknowledge_conflicts:bool=false}` → **201** `ToolIngestResponse{table:str, created:list[str], rejected_tables:list[str]}`。**仅 `ingest!=None` 的工具**(golden-finger/glossary→world_entities,fine-outline→outline);其余→**422 VALIDATION**。入库 gate(仿 `POST /characters`):world_entities 走 **continuity 预检(analyst 网关)** 比对待入库实体 vs 既有世界观真相源——有冲突且 `acknowledge_conflicts=false`→**409 `CONFLICT_UNRESOLVED`** + `details{conflicts:[{type,where,refs,suggestion}],conflict_count}`;`partition_writes(spec,{table:items})` 白名单过滤(越权→`rejected_tables` 审计);schema→DB JSONB 形变写库(rules→`{"rules":[...]}`;细纲场景按 idx 升序拼成该章 beats,`outline_write_repo.upsert_chapter`)。outline(细纲)不做 continuity 预检(场景是粗节拍展开,非设定/角色比对),无 chapter_no→422。未知/legacy→404。提交边界:ledger + 写侧只 flush → 端点末尾一次 `commit()`(含 409 路径也 commit 落 precheck usage)。
|
||||
- **注册表**(`packages/skills/ww_skills/toolbox_registry.py`,经 `ww_skills` 导出):`TOOLBOX:dict[str,GeneratorTool]`(11 条)+ `get_tool(key)->GeneratorTool|None`。新工具的 `key==spec.name`、`output_schema is spec.output_schema`。
|
||||
- **注入缝**(`services/project_deps.py`):`get_tier_gateway_builder`→`TierGatewayBuilder=Callable[[Tier],Awaitable[Gateway]]`(按工具 tier 动态建网关);测试经 `app.dependency_overrides[get_tier_gateway_builder]` 注返 mock 网关的 builder + `get_world_entity_write_repo`/`get_outline_write_repo`/`get_memory_repos`/`get_project_repo` 注 fake(schema-routing fake 网关按 `req.output_schema` 返 IdeaListResult/ContinuityReview/…)。
|
||||
- **守不变量**:#2(spec 只声明 tier)/#3(预览不写库;入库经 continuity gate + 白名单)/#9(system_prompt 进缓存前块、注入材料进 input)。**无新迁移**(descriptor 在代码,复用 world_entities/outline 表)。
|
||||
- **@frontend 行动**:`pnpm gen:api` 纳入 `GET /skills/toolbox` + `POST .../skills/{tool_key}/generate` + `POST .../skills/{tool_key}/ingest`(新 schema `ToolboxListResponse`/`ToolDescriptorView`/`ToolInputFieldView`/`ToolGenerateRequest`/`ToolGeneratePreviewResponse`/`ToolIngestRequest`/`ToolIngestResponse`/`OutlineSceneIngestView`);工具箱落地页据 `GET /skills/toolbox` 渲染卡片栅格(legacy 走 `legacy_route` 跳页,新工具据 `input_fields` 声明驱动表单 → generate 预览 → 可入库者 ingest + 复用 409 冲突裁决)。
|
||||
|
||||
## C8 · Skill registry + 表权限沙箱 owner @backend 状态: 稳定(T5.5, 2026-06-19;M5 R3 迁入 ww_skills 包, 2026-06-19)
|
||||
- 来源:`ARCHITECTURE.md §5.6` / `PRODUCT_SPEC §5.5`(声明式 Skill = AgentSpec;「声明式权限 + apply 层白名单」,**非进程沙箱**)。消费方:M5 编排/生成入库层、技能库 UI(T5.6,经 C3)。
|
||||
- **实现位置(M5 R3 已迁移)**:实现现落 `packages/skills/ww_skills/{skill_registry,skill_permissions}.py`(`packages/skills` 已是 uv workspace member)。`ww_skills/__init__.py` **直接导出**(不再是再导出 ww_core 的门面)。消费方 `from ww_skills import SkillRegistry/SqlSkillRepo/SkillRecord/SkillRepo/partition_writes/filter_reads/validate_declaration/KNOWN_TABLES`。**`ww_core.domain` 已删除这两个模块 + 其 `__init__` 导出**(不再经 ww_core.domain 暴露 skill 符号)。历史:T5.5 曾因 skills 非 workspace member 暂落 ww_core.domain(见 decisions),现已迁回。
|
||||
|
||||
@@ -14,6 +14,13 @@
|
||||
|
||||
---
|
||||
|
||||
## [2026-06-22] 限流(rate limiting)原型期**延后**,记触发条件 — @backend (P2 code-review)
|
||||
- 背景:代码审查指出触 LLM 的端点(draft/review/accept/outline/style/refine 等)+ `/oauth/start` + `/settings/providers/test` 缺速率限制;审查约定「若接受延后,记入 memory/decisions.md」。
|
||||
- 选择:本期**不加**任何限流代码/依赖(不引 `slowapi` 等)。
|
||||
- 理由 / 取舍:原型显式为**单用户**(CLAUDE.md:auth/多租户化已 deferred,`users`/`owner_id` 是 stub),无公网暴露、无多调用方滥用面;KISS/YAGNI——为零真实风险的场景引入限流中间件 + 新依赖属过早工程。代价:若误暴露到公网,LLM 端点可被滥用刷成本(已记触发条件兜底)。
|
||||
- 触发(revisit when):**引入多租户/鉴权** 或 **应用对外公开暴露** 时,须补轻量限流——按 IP/token 对 LLM 端点 + `/oauth/start` + `/settings/providers/test` 加每窗口配额(届时再评估 `slowapi` 或反代层限流)。
|
||||
- 影响:无契约/代码改动;仅本条记录决策与重启条件。
|
||||
|
||||
## [2026-06-19] Kimi Code OAuth:token 存储包 + 刷新启发式 + 省略 scope + 后台轮询走 jobs — @backend (K1.3)
|
||||
- 背景:Kimi 订阅 plan 走 device-flow OAuth;需定 token 持久化形、何时刷新、device authorization 是否带 scope、后台轮询如何挂。
|
||||
- 选择:
|
||||
|
||||
@@ -83,3 +83,4 @@
|
||||
- [2026-06-19] @backend Kimi Code device authorization **必须带 `scope=kimi-code`**(`start_device_authorization` 请求体 = `{"client_id": <id>, "scope": "kimi-code"}`)。K1.5 联网实测:省略 scope 登录能拿到真 JWT,但调 `api.kimi.com/coding/v1` 回 `401 Invalid Authentication`——token 缺 coding entitlement。`scope=kimi-code` 是 coding-agent 文档化 scope(`ooojustin/opencode-kimi` `constants.ts` 发送;kimi-cli v1.41.0 已不发但服务端仍接受)。scope **只**放 device_authorization;token 交换(`poll_token`)/刷新(`refresh`)**不带** scope(device flow 惯例)。⚠️ 此前 K1.3 单测/decision/contract 写「省略 scope」(误信 kimi-cli v1.41.0),已全部修正。
|
||||
- [2026-06-19] @llm **Kimi Code coding API 伪造头真源 = `ooojustin/opencode-kimi`(`src/headers.ts`+`src/constants.ts`,1:1 镜像 kimi-cli v1.37.0),发完整 7 头、UA `KimiCLI/1.37.0`**(K1.2 校正,推翻上方 pi-kimi-coder UA-only 那条):`picassio/pi-kimi-coder` 是分歧/错误参考(只发 UA),照它裁成 UA-only 是误修,已回退。`kimi_code_headers()` 发 `User-Agent=KimiCLI/1.37.0` + `X-Msh-Platform=kimi_cli`(字面常量,非 OS 名)+ `X-Msh-Version=1.37.0`(==UA 版本)+ `X-Msh-Device-Name`(`socket.gethostname()` ASCII 化)+ `X-Msh-Device-Model`(macOS=`f"macOS {platform.mac_ver()[0]} {platform.machine()}"`,`machine()` 原样 `arm64`/`x86_64` 不归一化)+ `X-Msh-Os-Version`(`platform.version()`)+ `X-Msh-Device-Id`(稳定 32 位无连字符 `uuid4().hex`:env `KIMI_DEVICE_ID` 优先→否则读/建 `~/.kimi/device_id` 复用,**绝不每次随机**,否则 Kimi 侧每次当新设备)。源码若与此有出入以源码为准(本次核对 master 一致)。⚠️ HTTP 头值含非 ASCII 会被底层 fetch/httpx 拒,host 派生值(Device-Name/Model/Os-Version)须 ASCII 化(`asciiHeaderValue`:裁 `\x20-\x7e` 之外 + trim,空回退 `unknown`)。单测用 env `KIMI_DEVICE_ID` 注入固定 id 保 CI 确定(不触真 ~/.kimi)。ruff E501 按显示宽度算(中文占 2 列),含中文 docstring/注释的行别贴边 100。
|
||||
- [2026-06-19] @backend `assemble`(C5)此前只从 world_entities/characters/style/rules + cards/foreshadow/digests/outline.beats 组装,**从不含项目 premise/logline/theme/title,也无「写章」指令** → 全新项目(只有 premise、无世界观/角色/大纲)产出 `stable_core=""`+`volatile=""`,writer 的 user message 为空,LLM 回 `400 "the message at position 0 with role 'user' must not be empty"`。修复:`MemoryRepos` 加第 8 个 repo `project:ProjectSpecRepo`(`spec(project_id)->ProjectSpecView{title,logline?,premise?,theme?}`,按 project_id 读 `projects`,无 owner 过滤——owner 隔离归路由层);`assemble` 把「作品蓝本」放进 `stable_core` 首段(书级 spec=定型→缓存前缀,不违 #9),并让 `volatile` 始终含 `请创作第 {chapter_no} 章的正文。`(易变指令)。⚠️ 任何手搓 `MemoryRepos(...)` 的地方(含测试 fake)现在**必须**补 `project=` 字段,否则 dataclass 缺参报错。无 DDL 变更(只读既有 projects 列),无迁移。
|
||||
- [2026-06-22] @frontend/@orchestrator T6 创作工具箱通用端点的两处契约边界(加新生成器时注意):① `ToolGenerateRequest` 是**固定字段并集** `{brief, chapter_no?, count?, kind?}`——`GeneratorRunner` 只映射落在此集合内的 `input_field` 名;新生成器若声明了集合外的 `input_field`,前端会**静默忽略**该字段。新工具的 `input_fields` 须落在 `brief/chapter_no/count/kind` 内,否则需先扩 `ToolGenerateRequest` 契约(+`pnpm gen:api`)。② `ToolInputFieldView.type` 是自由字符串,后端可声明 `"select"` 但 descriptor **无 `options` 字段** → 前端把 select 当 text 输入渲染。若某工具需要真下拉选项,须先给 `InputField`/`ToolInputFieldView` 加 `options` 再改前端 `GeneratorRunner`。
|
||||
|
||||
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)
|
||||
@@ -492,7 +492,7 @@ async def test_m5_capability_degradation_marks_served_by_degraded() -> None:
|
||||
|
||||
# primary 不支持结构化输出,但仍能返回 parsed(自走 instructor JSON 提示)。
|
||||
degrade_adapter = _FakeGenAdapter(provider=_PRIMARY, structured=False)
|
||||
ledger: LedgerSink = _CapturingLedger() # type: ignore[assignment]
|
||||
ledger: LedgerSink = _CapturingLedger()
|
||||
gw = Gateway(
|
||||
adapters={_PRIMARY: degrade_adapter},
|
||||
ledger=ledger,
|
||||
|
||||
515
tests/test_t6_toolbox_e2e.py
Normal file
515
tests/test_t6_toolbox_e2e.py
Normal file
@@ -0,0 +1,515 @@
|
||||
"""T6.4 端到端:创作工具箱通用生成器端点(真实 DB,零 token,MOCK 网关)。
|
||||
|
||||
证明 T6 通用生成器框架闭环(声明驱动、一条执行路径 + 不变量 #3 预览不写库 / 入库必过
|
||||
continuity gate + 白名单)。镜像 `tests/test_m5_e2e.py` 的范式:真实 `Gateway` + 据
|
||||
`req.output_schema` 分支返回 `parsed` 的假适配器(绝不联网)+ 真实 `SqlAlchemyLedgerSink`
|
||||
(记账真落 pg)+ DB 真源断言。无 pg → skip。
|
||||
|
||||
可注入缝(Wave A):`get_tier_gateway_builder` 返回 `TierGatewayBuilder`
|
||||
(`Callable[[Tier], Awaitable[Gateway]]`)。toolbox 端点 generate 调 `build_gateway(spec.tier)`、
|
||||
ingest 预检调 `build_gateway("analyst")`——故 override **这一个 builder**,让它返回单个真
|
||||
`Gateway`(假适配器据 output_schema 分支:生成产物 vs ContinuityReview)即可覆盖两路。
|
||||
|
||||
用例:
|
||||
1. `GET /skills/toolbox` 返回全 11 key(legacy 3 带 legacy_route+is_legacy=true;新 8);
|
||||
brainstorm/glossary/golden-finger 的 ingestable 标志符合预期。
|
||||
2. brainstorm `generate` → 200 ToolGeneratePreviewResponse(output_kind + preview.ideas);
|
||||
断言 **ledger 真落行** 且 **业务表零新增**(world_entities 计数不变)——不变量 #3。
|
||||
3. glossary `ingest` → world_entities:先**不带 acknowledge** + 预检有冲突 → **409
|
||||
CONFLICT_UNRESOLVED**,断言 world_entities **此刻无新行**;再带 acknowledge_conflicts=true
|
||||
→ 201,world_entities 行**真落 pg**(rules list→JSONB dict 形变保留);rejected_tables 空。
|
||||
4. 未知 tool_key `generate` → 404 NOT_FOUND envelope。
|
||||
5. 无凭据(该 tier 无配置)→ 503 LLM_UNAVAILABLE envelope(不 override builder,走真依赖)。
|
||||
负向:响应体绝不泄露 token/secret;预览路径只 commit ledger。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from collections.abc import AsyncIterator, Awaitable, Callable
|
||||
from typing import Annotated
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from asgi_lifespan import LifespanManager
|
||||
from fastapi import Depends
|
||||
from sqlalchemy import delete, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||
from ww_agents import (
|
||||
Conflict,
|
||||
ContinuityReview,
|
||||
GlossaryResult,
|
||||
GlossaryTerm,
|
||||
GoldenFingerResult,
|
||||
IdeaListResult,
|
||||
)
|
||||
from ww_agents.schemas import GoldenFinger, Idea
|
||||
from ww_db import get_session, get_sessionmaker
|
||||
from ww_db.models import Project, UsageLedger, WorldEntity
|
||||
from ww_llm_gateway import Gateway, SqlAlchemyLedgerSink, resolve_route
|
||||
from ww_llm_gateway.adapters.base import (
|
||||
Capabilities,
|
||||
ProviderResult,
|
||||
ProviderUsage,
|
||||
StreamChunk,
|
||||
)
|
||||
from ww_llm_gateway.types import LlmRequest, Tier
|
||||
|
||||
# light/analyst/writer 默认都路由到 deepseek(config.tier_defaults)。
|
||||
_PROVIDER = "deepseek"
|
||||
|
||||
# 各档位假用量(喂记账;证明记账落库)。
|
||||
_USAGE = {
|
||||
"light": ProviderUsage(input_tokens=19, output_tokens=11),
|
||||
"analyst": ProviderUsage(input_tokens=23, output_tokens=7),
|
||||
"writer": ProviderUsage(input_tokens=31, output_tokens=17),
|
||||
}
|
||||
|
||||
# brainstorm 假产物:3 条脑洞(纯预览,不入库)。
|
||||
_IDEAS = IdeaListResult(
|
||||
ideas=[
|
||||
Idea(premise="天降系统但只在深夜生效", hook="主角被迫昼伏夜出", genre_fit="都市异能"),
|
||||
Idea(premise="转生成反派的家养灵宠", hook="一边卖萌一边算计", genre_fit="搞笑修真"),
|
||||
Idea(premise="末世里唯一能种田的人", hook="种子即权力", genre_fit="末世种田"),
|
||||
]
|
||||
)
|
||||
|
||||
# glossary 假产物(生成预览用;ingest 由前端映射成 world_entities,故端点不强依赖此形)。
|
||||
_GLOSSARY = GlossaryResult(
|
||||
terms=[
|
||||
GlossaryTerm(
|
||||
name="玄晶", type="货币", definition="修真界硬通货", rules=["不可伪造", "遇火则碎"]
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
# golden-finger 假产物(生成预览用)。
|
||||
_GOLDEN = GoldenFingerResult(
|
||||
systems=[
|
||||
GoldenFinger(
|
||||
name="九转炼体诀",
|
||||
mechanism="吞噬天地灵气淬体",
|
||||
growth="每转突破一境",
|
||||
limits="每转需一枚玄晶,不可逆修",
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
# 待入库的 world_entities(贴 WorldEntityCardView:type/name/rules)。
|
||||
_INGEST_ENTITIES = [
|
||||
{"type": "货币", "name": "玄晶", "rules": ["不可伪造", "遇火则碎"]},
|
||||
{"type": "力量体系", "name": "九转炼体诀", "rules": ["每转需一枚玄晶", "不可逆修"]},
|
||||
]
|
||||
|
||||
# precheck 假冲突(让入库 gate 触发 409)。
|
||||
_PRECHECK_CONFLICT = ContinuityReview(
|
||||
conflicts=[
|
||||
Conflict(
|
||||
type="设定违例",
|
||||
where="玄晶",
|
||||
refs=["既有货币体系"],
|
||||
suggestion="玄晶『遇火则碎』与既有设定冲突,请裁决",
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
class _FakeToolboxAdapter:
|
||||
"""实现 `ProviderAdapter` Protocol:按 `req.output_schema` 分支返回 parsed,绝不联网。
|
||||
|
||||
- `IdeaListResult` → 3 条脑洞(light)。
|
||||
- `GlossaryResult` → 术语表(analyst)。
|
||||
- `GoldenFingerResult` → 金手指(writer)。
|
||||
- `ContinuityReview` → 带冲突的预检结果(analyst)——触发 ingest 409 gate。
|
||||
每路带 `ProviderUsage` → 网关据此落 usage_ledger。
|
||||
"""
|
||||
|
||||
def __init__(self, provider: str = _PROVIDER) -> None:
|
||||
self.provider = provider
|
||||
|
||||
def capabilities(self) -> Capabilities:
|
||||
return Capabilities(structured_output=True)
|
||||
|
||||
async def complete(self, req: LlmRequest, model: str) -> ProviderResult:
|
||||
schema = req.output_schema
|
||||
if schema is IdeaListResult:
|
||||
return ProviderResult(
|
||||
text=_IDEAS.model_dump_json(), parsed=_IDEAS, usage=_USAGE["light"]
|
||||
)
|
||||
if schema is GlossaryResult:
|
||||
return ProviderResult(
|
||||
text=_GLOSSARY.model_dump_json(), parsed=_GLOSSARY, usage=_USAGE["analyst"]
|
||||
)
|
||||
if schema is GoldenFingerResult:
|
||||
return ProviderResult(
|
||||
text=_GOLDEN.model_dump_json(), parsed=_GOLDEN, usage=_USAGE["writer"]
|
||||
)
|
||||
if schema is ContinuityReview:
|
||||
return ProviderResult(
|
||||
text=_PRECHECK_CONFLICT.model_dump_json(),
|
||||
parsed=_PRECHECK_CONFLICT,
|
||||
usage=_USAGE["analyst"],
|
||||
)
|
||||
raise AssertionError(f"unexpected output_schema in T6 fake adapter: {schema!r}")
|
||||
|
||||
async def stream(self, req: LlmRequest, model: str) -> AsyncIterator[StreamChunk]:
|
||||
# 工具箱生成走 run()(结构化),不走 stream——但 Protocol 要求实现。
|
||||
yield StreamChunk(usage=_USAGE["light"])
|
||||
raise AssertionError("T6 toolbox generation must not stream")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def e2e_sm() -> AsyncIterator[async_sessionmaker[AsyncSession]]:
|
||||
"""真实 DB session 工厂;无 pg 时跳过(每测试清缓存重建 engine、结束 dispose)。"""
|
||||
get_sessionmaker.cache_clear()
|
||||
maker = get_sessionmaker()
|
||||
try:
|
||||
async with maker() as probe:
|
||||
await probe.execute(select(1))
|
||||
except Exception:
|
||||
pytest.skip("postgres not reachable")
|
||||
yield maker
|
||||
await maker.kw["bind"].dispose()
|
||||
get_sessionmaker.cache_clear()
|
||||
|
||||
|
||||
def _tier_builder_override(
|
||||
adapter: _FakeToolboxAdapter,
|
||||
) -> Callable[[AsyncSession], Callable[[Tier], Awaitable[Gateway]]]:
|
||||
"""依赖覆盖:`get_tier_gateway_builder` → 返回「按 tier 建网关」的 builder。
|
||||
|
||||
builder 忽略 tier 返回同一个真 `Gateway`(单 provider 假适配器据 output_schema 分支 +
|
||||
真实 ledger,请求 session)——同时覆盖 generate(按 spec.tier)与 ingest 预检(analyst)。
|
||||
"""
|
||||
|
||||
def _override(
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
) -> Callable[[Tier], Awaitable[Gateway]]:
|
||||
async def _build(_tier: Tier) -> Gateway:
|
||||
return Gateway(
|
||||
adapters={adapter.provider: adapter},
|
||||
ledger=SqlAlchemyLedgerSink(session),
|
||||
resolver=resolve_route,
|
||||
)
|
||||
|
||||
return _build
|
||||
|
||||
return _override
|
||||
|
||||
|
||||
async def _ledger_count(sm: async_sessionmaker[AsyncSession], project_uuid: uuid.UUID) -> int:
|
||||
async with sm() as s:
|
||||
return int(
|
||||
(
|
||||
await s.execute(
|
||||
select(func.count())
|
||||
.select_from(UsageLedger)
|
||||
.where(UsageLedger.project_id == project_uuid)
|
||||
)
|
||||
).scalar_one()
|
||||
)
|
||||
|
||||
|
||||
async def _world_count(sm: async_sessionmaker[AsyncSession], project_uuid: uuid.UUID) -> int:
|
||||
async with sm() as s:
|
||||
return int(
|
||||
(
|
||||
await s.execute(
|
||||
select(func.count())
|
||||
.select_from(WorldEntity)
|
||||
.where(WorldEntity.project_id == project_uuid)
|
||||
)
|
||||
).scalar_one()
|
||||
)
|
||||
|
||||
|
||||
async def _cleanup(sm: async_sessionmaker[AsyncSession], project_uuid: uuid.UUID) -> None:
|
||||
"""按 FK 顺序清理。"""
|
||||
async with sm() as cleanup:
|
||||
await cleanup.execute(delete(UsageLedger).where(UsageLedger.project_id == project_uuid))
|
||||
await cleanup.execute(delete(WorldEntity).where(WorldEntity.project_id == project_uuid))
|
||||
await cleanup.execute(delete(Project).where(Project.id == project_uuid))
|
||||
await cleanup.commit()
|
||||
|
||||
|
||||
def _assert_no_secret_leak(payload: object) -> None:
|
||||
"""负向:响应体序列化后绝不含 token/secret/api_key 等敏感字样。"""
|
||||
import json
|
||||
|
||||
blob = json.dumps(payload, ensure_ascii=False).lower()
|
||||
for needle in ("api_key", "secret", "token", "bearer", "sk-", "credential"):
|
||||
assert needle not in blob, f"sensitive marker leaked into response: {needle!r}"
|
||||
|
||||
|
||||
async def test_t6_list_toolbox_returns_all_eleven_tools(
|
||||
e2e_sm: async_sessionmaker[AsyncSession],
|
||||
) -> None:
|
||||
"""用例 1:GET /skills/toolbox 返回全 11 key;legacy 3 带 legacy_route,新 8 走通用执行器。"""
|
||||
from ww_api.main import create_app
|
||||
|
||||
app = create_app()
|
||||
transport = httpx.ASGITransport(app=app)
|
||||
async with LifespanManager(app):
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
resp = await client.get("/skills/toolbox")
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
|
||||
tools = {t["key"]: t for t in body["tools"]}
|
||||
# 全 11 key(legacy 3 + 新 8)。
|
||||
assert set(tools) == {
|
||||
"worldbuilding",
|
||||
"character",
|
||||
"outline",
|
||||
"brainstorm",
|
||||
"book-title",
|
||||
"blurb",
|
||||
"name",
|
||||
"golden-finger",
|
||||
"glossary",
|
||||
"opening",
|
||||
"fine-outline",
|
||||
}
|
||||
# legacy(spec=None):is_legacy=true + 带 legacy_route,不可经通用执行入库。
|
||||
legacy_keys = {"worldbuilding", "character", "outline"}
|
||||
for key in legacy_keys:
|
||||
assert tools[key]["is_legacy"] is True
|
||||
assert tools[key]["legacy_route"]
|
||||
# 新工具:is_legacy=false。
|
||||
for key in set(tools) - legacy_keys:
|
||||
assert tools[key]["is_legacy"] is False
|
||||
# ingestable 标志:glossary/golden-finger/fine-outline 可入库;brainstorm 纯预览。
|
||||
assert tools["glossary"]["ingestable"] is True
|
||||
assert tools["golden-finger"]["ingestable"] is True
|
||||
assert tools["fine-outline"]["ingestable"] is True
|
||||
assert tools["brainstorm"]["ingestable"] is False
|
||||
# brainstorm 描述符含一句话需求字段。
|
||||
assert any(f["name"] == "brief" for f in tools["brainstorm"]["input_fields"])
|
||||
_assert_no_secret_leak(body)
|
||||
|
||||
|
||||
async def test_t6_brainstorm_generate_previews_without_writing_business_tables(
|
||||
e2e_sm: async_sessionmaker[AsyncSession],
|
||||
) -> None:
|
||||
"""用例 2:brainstorm generate → 200 预览;ledger 真落行 + 业务表零新增(不变量 #3)。"""
|
||||
from ww_api.main import create_app
|
||||
from ww_api.services.project_deps import get_tier_gateway_builder
|
||||
|
||||
app = create_app()
|
||||
app.dependency_overrides[get_tier_gateway_builder] = _tier_builder_override(
|
||||
_FakeToolboxAdapter()
|
||||
)
|
||||
|
||||
transport = httpx.ASGITransport(app=app)
|
||||
project_uuid: uuid.UUID | None = None
|
||||
try:
|
||||
async with LifespanManager(app):
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
create_resp = await client.post(
|
||||
"/projects", json={"title": "T6 脑洞验证作品", "genre": "玄幻"}
|
||||
)
|
||||
assert create_resp.status_code == 201
|
||||
project_id = create_resp.json()["id"]
|
||||
project_uuid = uuid.UUID(project_id)
|
||||
|
||||
world_before = await _world_count(e2e_sm, project_uuid)
|
||||
|
||||
gen_resp = await client.post(
|
||||
f"/projects/{project_id}/skills/brainstorm/generate",
|
||||
json={"brief": "想要三个突破常规的脑洞"},
|
||||
)
|
||||
assert gen_resp.status_code == 200
|
||||
body = gen_resp.json()
|
||||
assert body["tool_key"] == "brainstorm"
|
||||
assert body["output_kind"] == "IdeaListResult"
|
||||
ideas = body["preview"]["ideas"]
|
||||
assert [i["premise"] for i in ideas] == [
|
||||
"天降系统但只在深夜生效",
|
||||
"转生成反派的家养灵宠",
|
||||
"末世里唯一能种田的人",
|
||||
]
|
||||
_assert_no_secret_leak(body)
|
||||
|
||||
# DB 真源:ledger 落 1 行(light);业务表(world_entities)零新增(预览不写库)。
|
||||
assert project_uuid is not None
|
||||
assert await _ledger_count(e2e_sm, project_uuid) == 1
|
||||
assert await _world_count(e2e_sm, project_uuid) == world_before
|
||||
async with e2e_sm() as verify:
|
||||
row = (
|
||||
(
|
||||
await verify.execute(
|
||||
select(UsageLedger).where(UsageLedger.project_id == project_uuid)
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.one()
|
||||
)
|
||||
assert row.provider == _PROVIDER
|
||||
assert row.input_tokens == _USAGE["light"].input_tokens
|
||||
finally:
|
||||
if project_uuid is not None:
|
||||
await _cleanup(e2e_sm, project_uuid)
|
||||
|
||||
|
||||
async def test_t6_glossary_ingest_gate_then_acknowledge_persists(
|
||||
e2e_sm: async_sessionmaker[AsyncSession],
|
||||
) -> None:
|
||||
"""用例 3:glossary ingest → 409 gate(无 ack + 有冲突)→ ack 放行 → world_entities 真落 pg。"""
|
||||
from ww_api.main import create_app
|
||||
from ww_api.services.project_deps import get_tier_gateway_builder
|
||||
|
||||
app = create_app()
|
||||
app.dependency_overrides[get_tier_gateway_builder] = _tier_builder_override(
|
||||
_FakeToolboxAdapter()
|
||||
)
|
||||
|
||||
transport = httpx.ASGITransport(app=app)
|
||||
project_uuid: uuid.UUID | None = None
|
||||
try:
|
||||
async with LifespanManager(app):
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
create_resp = await client.post(
|
||||
"/projects", json={"title": "T6 词条入库作品", "genre": "玄幻"}
|
||||
)
|
||||
assert create_resp.status_code == 201
|
||||
project_id = create_resp.json()["id"]
|
||||
project_uuid = uuid.UUID(project_id)
|
||||
|
||||
# 3a) 入库(未 acknowledge)→ precheck 有冲突 → 409 CONFLICT_UNRESOLVED。
|
||||
blocked = await client.post(
|
||||
f"/projects/{project_id}/skills/glossary/ingest",
|
||||
json={"world_entities": _INGEST_ENTITIES},
|
||||
)
|
||||
assert blocked.status_code == 409
|
||||
err = blocked.json()["error"]
|
||||
assert err["code"] == "CONFLICT_UNRESOLVED"
|
||||
details = err["details"]
|
||||
assert details["conflict_count"] == 1
|
||||
assert details["conflicts"][0]["type"] == "设定违例"
|
||||
assert details["conflicts"][0]["where"] == "玄晶"
|
||||
_assert_no_secret_leak(blocked.json())
|
||||
|
||||
# 入库被拦 → 此刻 world_entities 表应无行(不静默入库,守不变量 #3)。
|
||||
assert await _world_count(e2e_sm, project_uuid) == 0
|
||||
|
||||
# 3b) 作者裁决后带 acknowledge_conflicts=true 重发 → 201 入库。
|
||||
ok = await client.post(
|
||||
f"/projects/{project_id}/skills/glossary/ingest",
|
||||
json={"world_entities": _INGEST_ENTITIES, "acknowledge_conflicts": True},
|
||||
)
|
||||
assert ok.status_code == 201
|
||||
ok_body = ok.json()
|
||||
assert ok_body["table"] == "world_entities"
|
||||
assert ok_body["created"] == ["玄晶", "九转炼体诀"]
|
||||
# 合法路径(glossary writes=["world_entities"])无越权 → rejected_tables 空。
|
||||
assert ok_body["rejected_tables"] == []
|
||||
_assert_no_secret_leak(ok_body)
|
||||
|
||||
# DB 真源断言:world_entities 行真落 pg + rules list→JSONB dict 形变保留。
|
||||
assert project_uuid is not None
|
||||
async with e2e_sm() as verify:
|
||||
rows = (
|
||||
(
|
||||
await verify.execute(
|
||||
select(WorldEntity)
|
||||
.where(WorldEntity.project_id == project_uuid)
|
||||
.order_by(WorldEntity.name)
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
assert {r.name for r in rows} == {"玄晶", "九转炼体诀"}
|
||||
xuanjing = next(r for r in rows if r.name == "玄晶")
|
||||
assert xuanjing.type == "货币"
|
||||
# 形变:rules list → JSONB dict {"rules":[...]}(rules 列表保留)。
|
||||
assert xuanjing.rules == {"rules": ["不可伪造", "遇火则碎"]}
|
||||
|
||||
# 记账真落 pg:precheck(409 路径) + precheck(入库路径) = 2 条(入库本体不调网关)。
|
||||
ledger_rows = (
|
||||
(
|
||||
await verify.execute(
|
||||
select(UsageLedger).where(UsageLedger.project_id == project_uuid)
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
assert len(ledger_rows) == 2
|
||||
assert all(r.provider == _PROVIDER for r in ledger_rows)
|
||||
finally:
|
||||
if project_uuid is not None:
|
||||
await _cleanup(e2e_sm, project_uuid)
|
||||
|
||||
|
||||
async def test_t6_unknown_tool_key_generate_returns_404(
|
||||
e2e_sm: async_sessionmaker[AsyncSession],
|
||||
) -> None:
|
||||
"""用例 4:未知 tool_key generate → 404 NOT_FOUND envelope。"""
|
||||
from ww_api.main import create_app
|
||||
from ww_api.services.project_deps import get_tier_gateway_builder
|
||||
|
||||
app = create_app()
|
||||
# builder 即使被覆盖,也应在工具解析阶段 404(不触达网关)。
|
||||
app.dependency_overrides[get_tier_gateway_builder] = _tier_builder_override(
|
||||
_FakeToolboxAdapter()
|
||||
)
|
||||
|
||||
transport = httpx.ASGITransport(app=app)
|
||||
project_uuid: uuid.UUID | None = None
|
||||
try:
|
||||
async with LifespanManager(app):
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
create_resp = await client.post("/projects", json={"title": "T6 未知工具作品"})
|
||||
assert create_resp.status_code == 201
|
||||
project_id = create_resp.json()["id"]
|
||||
project_uuid = uuid.UUID(project_id)
|
||||
|
||||
resp = await client.post(
|
||||
f"/projects/{project_id}/skills/does-not-exist/generate",
|
||||
json={"brief": "x"},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
err = resp.json()["error"]
|
||||
assert err["code"] == "NOT_FOUND"
|
||||
assert err["details"]["tool_key"] == "does-not-exist"
|
||||
_assert_no_secret_leak(resp.json())
|
||||
finally:
|
||||
if project_uuid is not None:
|
||||
await _cleanup(e2e_sm, project_uuid)
|
||||
|
||||
|
||||
async def test_t6_no_provider_returns_503(
|
||||
e2e_sm: async_sessionmaker[AsyncSession],
|
||||
) -> None:
|
||||
"""用例 5:无凭据(不 override builder,走真 build_gateway_for_tier)→ 503 LLM_UNAVAILABLE。"""
|
||||
from ww_api.main import create_app
|
||||
|
||||
app = create_app()
|
||||
# 不 override get_tier_gateway_builder → 真依赖在无凭据时抛 LLM_UNAVAILABLE。
|
||||
|
||||
transport = httpx.ASGITransport(app=app)
|
||||
project_uuid: uuid.UUID | None = None
|
||||
try:
|
||||
async with LifespanManager(app):
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
create_resp = await client.post("/projects", json={"title": "T6 无凭据作品"})
|
||||
assert create_resp.status_code == 201
|
||||
project_id = create_resp.json()["id"]
|
||||
project_uuid = uuid.UUID(project_id)
|
||||
|
||||
resp = await client.post(
|
||||
f"/projects/{project_id}/skills/brainstorm/generate",
|
||||
json={"brief": "脑洞"},
|
||||
)
|
||||
assert resp.status_code == 503
|
||||
err = resp.json()["error"]
|
||||
assert err["code"] == "LLM_UNAVAILABLE"
|
||||
_assert_no_secret_leak(resp.json())
|
||||
|
||||
# 503 后业务表与 ledger 均无新增(凭据探测在生成前拦下)。
|
||||
assert await _world_count(e2e_sm, project_uuid) == 0
|
||||
assert await _ledger_count(e2e_sm, project_uuid) == 0
|
||||
finally:
|
||||
if project_uuid is not None:
|
||||
await _cleanup(e2e_sm, project_uuid)
|
||||
Reference in New Issue
Block a user