diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index b87db76..bfb2853 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -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 | diff --git a/PRODUCT_SPEC.md b/PRODUCT_SPEC.md index ce68dc7..7b763c0 100644 --- a/PRODUCT_SPEC.md +++ b/PRODUCT_SPEC.md @@ -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 diff --git a/PROGRESS.md b/PROGRESS.md index a27f500..d0c7f86 100644 --- a/PROGRESS.md +++ b/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` 在 `能力不符>时间线倒错>地理矛盾>性格漂移,未知殿后)+ 组计数徽标 + 「跳到下一条未裁决」(按显示顺序环回)+ 主次裁决按钮(采纳改法=朱砂主、忽略/手改=次级)——新纯函数 `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)。 diff --git a/apps/api/tests/test_toolbox_context.py b/apps/api/tests/test_toolbox_context.py new file mode 100644 index 0000000..d5a1439 --- /dev/null +++ b/apps/api/tests/test_toolbox_context.py @@ -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] diff --git a/apps/api/tests/test_toolbox_endpoints.py b/apps/api/tests/test_toolbox_endpoints.py new file mode 100644 index 0000000..68ab02b --- /dev/null +++ b/apps/api/tests/test_toolbox_endpoints.py @@ -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 diff --git a/apps/api/ww_api/main.py b/apps/api/ww_api/main.py index 870d7ef..56f34c4 100644 --- a/apps/api/ww_api/main.py +++ b/apps/api/ww_api/main.py @@ -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 diff --git a/apps/api/ww_api/routers/toolbox.py b/apps/api/ww_api/routers/toolbox.py new file mode 100644 index 0000000..c24fb3f --- /dev/null +++ b/apps/api/ww_api/routers/toolbox.py @@ -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) diff --git a/apps/api/ww_api/schemas/toolbox.py b/apps/api/ww_api/schemas/toolbox.py new file mode 100644 index 0000000..c087d4a --- /dev/null +++ b/apps/api/ww_api/schemas/toolbox.py @@ -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 diff --git a/apps/api/ww_api/services/project_deps.py b/apps/api/ww_api/services/project_deps.py index a6d143a..8637f10 100644 --- a/apps/api/ww_api/services/project_deps.py +++ b/apps/api/ww_api/services/project_deps.py @@ -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: diff --git a/apps/api/ww_api/services/toolbox_context.py b/apps/api/ww_api/services/toolbox_context.py new file mode 100644 index 0000000..9a6f6de --- /dev/null +++ b/apps/api/ww_api/services/toolbox_context.py @@ -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}") diff --git a/apps/web/app/projects/[id]/toolbox/page.tsx b/apps/web/app/projects/[id]/toolbox/page.tsx new file mode 100644 index 0000000..becf65d --- /dev/null +++ b/apps/web/app/projects/[id]/toolbox/page.tsx @@ -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= 由命令面板 action-gen- 跳转,进页直开对应生成器(仅新工具)。 +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 ( + + ); + } catch { + notFound(); + } +} diff --git a/apps/web/components/command/CommandPalette.tsx b/apps/web/components/command/CommandPalette.tsx index 727dfc0..d4fe8a3 100644 --- a/apps/web/components/command/CommandPalette.tsx +++ b/apps/web/components/command/CommandPalette.tsx @@ -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//...)。 function projectIdFromPath(pathname: string): string | null { @@ -31,14 +33,17 @@ export function CommandPalette({ pathname }: CommandPaletteProps) { const [highlight, setHighlight] = useState(0); const inputRef = useRef(null); const dialogRef = useRef(null); + // 工具箱生成器(命令面板发现性):首次打开惰性拉取一次,失败静默降级为空。 + const [tools, setTools] = useState([]); + const toolsLoadedRef = useRef(false); const projectId = projectIdFromPath(pathname); const commands = useMemo( () => 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- 命令。 + 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]); diff --git a/apps/web/components/toolbox/GeneratorRunner.tsx b/apps/web/components/toolbox/GeneratorRunner.tsx new file mode 100644 index 0000000..751fa8a --- /dev/null +++ b/apps/web/components/toolbox/GeneratorRunner.tsx @@ -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(() => + initialFieldValues(tool.input_fields), + ); + // 入库勾选:预览行下标集合(与 gen.rawPreview 行对齐)。 + const [selected, setSelected] = useState>(new Set()); + + const setField = useCallback((name: string, value: string): void => { + setValues((prev) => ({ ...prev, [name]: value })); + }, []); + + const onGenerate = useCallback(async (): Promise => { + 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 => { + 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 ( +
+
+
+

{tool.title}

+

{tool.subtitle}

+
+ +
+ +
{ + e.preventDefault(); + void onGenerate(); + }} + > + {(tool.input_fields ?? []).map((field) => ( + setField(field.name, v)} + /> + ))} + + + + {gen.genStatus === "preview" && gen.preview ? ( +
+

+ 预览({gen.preview.items.length}) + {canIngest ? "(勾选后可入库)" : null} +

+
    + {gen.preview.items.map((item, i) => ( + + ))} +
+ + {canIngest && gen.ingestStatus !== "conflict" ? ( + + ) : null} + + {gen.ingestStatus === "conflict" && gen.conflicts ? ( + void runIngest(true)} + onCancel={() => gen.reset()} + /> + ) : null} + + {gen.ingestStatus === "done" ? ( +

+ 已入库 {gen.created.length} 项。 +

+ ) : null} +
+ ) : null} +
+ ); +} + +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 ( +