feat: T6.1 创作工具箱通用生成器框架地基 — 脑洞生成器 + run_generator

- schemas: Idea/IdeaListResult(脑洞生成器结构化产出,纯预览不入库)
- specs: brainstorm_spec(light 档,reads=[projects] writes=[])
- orchestrator: build_brief_context(brief_only 策略)+ 通用 run_generator
  (据 spec.output_schema 校验 parsed,不绑定具体类型,任一声明式生成器共用)
- 新增 13 测;两包 ruff/format/mypy 干净、252 passed 无回归

P1 第一波(@llm):证实「加生成器=加一份声明」。@backend T6.2/T6.3 待续。
This commit is contained in:
Yaojia Wang
2026-06-20 11:31:59 +02:00
parent 9b893a6c61
commit e8cccf7389
8 changed files with 308 additions and 1 deletions

View File

@@ -8,7 +8,25 @@
--- ---
## 当前阶段:K1 · Kimi Code OAuth订阅 plan 接入) ## 当前阶段:T6 · 创作工具箱(通用生成器框架)— P1 框架地基
> **背景**对标竞品「创作工具箱」——把每个「XX 生成器」实现为声明式 skill`AgentSpec` 的设计本意),用一条**通用执行路径**驱动。用户已定:① 走**通用 skill 框架**(非逐个硬编码)② 封面/图像本期跳过 ③ 文本生成器全做(上架已有 3 + 新建 8。完整设计见 `~/.claude/plans/creation-toolbox-generators.md`。
> **P1 目标DoD**`GeneratorTool` 描述符 + `TOOLBOX` 注册表 + 通用 `GET /skills/toolbox` & `POST /projects/{id}/skills/{tool_key}/generate` 端点 + **1 个最简生成器(脑洞)端到端打通**mock 网关 E2E 零 token。证实「加生成器 = 加一份声明」这条路。
> **执行波次**A(@llm 脑洞 spec + IdeaListResult schema + brief_only context 策略 + 通用 run 路径) → B(@backend GeneratorTool/TOOLBOX 注册表 + 通用 toolbox/generate 端点 + response schema + 接线 build_gateway_for_tier) → C(@qa 脑洞 generate E2E真 DB + mock 网关,断言 unknown key 404 / 无 provider 503 / 仅记 ledger 不写业务表)。
> **契约纪律**B 改 OpenAPI → `memory/contracts.md` 记 C 扩 + `PROGRESS.md` 记一笔(前端 P2 才 `pnpm gen:api`)。守不变量 #2只声明 tier/#3预览不写库。**预计无 DB 迁移**descriptor 在代码、复用既有表)。
| 任务 | 状态 | 负责 | 依赖 | 契约 | 备注 |
|---|---|---|---|---|---|
| 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` dictseed: 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 网关零 tokentoolbox 列表含 brainstorm → generate 预览 IdeaListResult → 断言仅 ledger 记账、零业务表写、unknown key 404、无 provider 503 |
**P1 出口DoD**通用执行器跑通脑洞生成器后端门禁绿ruff/format/mypy/alembic 无漂移/pytestE2E mock 网关零 token 走通。达成后进入 P2工具箱落地页 @frontend)。
---
## 已封板K1 · Kimi Code OAuth订阅 plan 接入)✅
> **背景**:用户要求接入 Kimi Code 订阅 plandevice-flow OAuth。**已知情接受 ToS/封号风险**——研究确认:用订阅 plan 调用需伪造官方客户端 `User-Agent` + `X-Msh-*` 头Kimi ToS 视为违规、可能封停会员(见变更日志研究结论)。用户明确选择此路径(非合规的 API Key 路径)。与既有 API-key провайдер `kimi` **分离**,新增独立 provider `kimi-code` 以便切换。 > **背景**:用户要求接入 Kimi Code 订阅 plandevice-flow OAuth。**已知情接受 ToS/封号风险**——研究确认:用订阅 plan 调用需伪造官方客户端 `User-Agent` + `X-Msh-*` 头Kimi ToS 视为违规、可能封停会员(见变更日志研究结论)。用户明确选择此路径(非合规的 API Key 路径)。与既有 API-key провайдер `kimi` **分离**,新增独立 provider `kimi-code` 以便切换。
> **集成契约(研究确认,实现时对照 `github.com/picassio/pi-kimi-coder` 校验)** > **集成契约(研究确认,实现时对照 `github.com/picassio/pi-kimi-coder` 校验)**
@@ -90,6 +108,7 @@ T0.1 monorepo 骨架 ✅ @devops · T0.2 16 MVP 表迁移无漂移users st
> 格式:`- [YYYY-MM-DD] @skill 完成/进展 Txx — 一句话结果 + 影响的契约/文件` > 格式:`- [YYYY-MM-DD] @skill 完成/进展 Txx — 一句话结果 + 影响的契约/文件`
- [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`。无契约变更。 - [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`。无契约变更。
- [2026-06-20] UX计划 B0(读)+F1 — **✅ 本章注入透明(读端点 + 右栏真面板)**。兑现差异化信任牌「看到的=写章用的」(不变量 #6把先前写作页右栏「M1 暂未接 / M2 开放」的过时假占位换成真数据。**后端**(@backend)`GET /projects/{id}/chapters/{no}/injection``InjectionResponse{project_id,chapter_no,selected:[{kind,name,reasons[]}],recent_n}`,调既有 `assemble()` 回放确定性 `SelectionTrace`**无 LLM、无 commit、无 DDL**项目不存在→404无大纲→`selected:[]`。文件:`schemas/injection.py`(新) · `routers/projects.py`(+GET+2 import) · `tests/test_injection.py`(新,3 测)。**前端**(@frontend)`gen:api` 纳入端点;纯逻辑 `lib/workbench/injection.ts`(理由/类型→中文徽标 + 4 vitest) + `useInjection` 读 hook`ChapterAssistant` 改 client 组件列出选中实体 + 入选理由徽标(本章点名/主角常驻/近章出现/伏笔窗口)+ 空/载/错三态,「四审」段改指向审稿页;`Workbench``projectId/chapterNo`。**全仓门禁绿**:后端 ruff/format/mypy **159 Success**/alembic 无漂移/pytest **454 passed**;前端 lint/typecheck/vitest **167 passed**/build。记 contracts C3扩。**B0 余项可控版PUT override pin/排除/recent_n + selection 加参 + draft 同读 override + 持久化)+ F1 可控控件待后续。** - [2026-06-20] UX计划 B0(读)+F1 — **✅ 本章注入透明(读端点 + 右栏真面板)**。兑现差异化信任牌「看到的=写章用的」(不变量 #6把先前写作页右栏「M1 暂未接 / M2 开放」的过时假占位换成真数据。**后端**(@backend)`GET /projects/{id}/chapters/{no}/injection``InjectionResponse{project_id,chapter_no,selected:[{kind,name,reasons[]}],recent_n}`,调既有 `assemble()` 回放确定性 `SelectionTrace`**无 LLM、无 commit、无 DDL**项目不存在→404无大纲→`selected:[]`。文件:`schemas/injection.py`(新) · `routers/projects.py`(+GET+2 import) · `tests/test_injection.py`(新,3 测)。**前端**(@frontend)`gen:api` 纳入端点;纯逻辑 `lib/workbench/injection.ts`(理由/类型→中文徽标 + 4 vitest) + `useInjection` 读 hook`ChapterAssistant` 改 client 组件列出选中实体 + 入选理由徽标(本章点名/主角常驻/近章出现/伏笔窗口)+ 空/载/错三态,「四审」段改指向审稿页;`Workbench``projectId/chapterNo`。**全仓门禁绿**:后端 ruff/format/mypy **159 Success**/alembic 无漂移/pytest **454 passed**;前端 lint/typecheck/vitest **167 passed**/build。记 contracts C3扩。**B0 余项可控版PUT override pin/排除/recent_n + selection 加参 + draft 同读 override + 持久化)+ F1 可控控件待后续。**
- [2026-06-20] @frontend**✅ 接上两个新读端点:大纲页 / 写作工作台重访时回显已存内容**。`pnpm gen:api` 纳入 `GET .../outline` + `GET .../chapters/{no}/draft` + schema `DraftView``lib/api/types.ts``DraftView` 别名。新增两个 RSC 读 helper仿 `fetchForeshadow`/`fetchProject` 服务端读、无 CORS`fetchOutline(projectId)``OutlineChapterView[]`200 解包 chapters**任何错误/空降级为 `[]`** 不阻塞进页),`fetchDraft(projectId,chapterNo)``DraftView|null`(复用 `getJsonOrNull`**404→null 当空编辑器**,不抛)。**大纲页**(`outline/page.tsx`) 进页 `fetchOutline``initialChapters` 种入 `OutlineEditor``useOutline` 已据非空初值置 `ready`)→ 重访显已生成大纲;生成流照常覆盖所显章节。**写作工作台**(`write/page.tsx` + `Workbench`) 进页 `fetchDraft``content` 作编辑器初值(`useState(initialText)`)→ 重访重载已写正文404/新章 → `""` 空编辑器;**SSE 流式写 + AbortController + PUT 自动保存全不动**(流起始空、仅 streaming/done/aborted 变化才覆盖,初值不反扑活跃流);导出 `WORKBENCH_CHAPTER_NO`(原 `M1_CHAPTER_NO`)供页面拉对应章草稿。新增 `lib/api/server.test.ts`(5 vitestmock fetch 验解包/降级/404→null)。前端门禁全绿gen:api / lint / typecheck / vitest **160**(155→+5) / build。文件`lib/api/{types,server,server.test}.ts` · `app/projects/[id]/{outline,write}/page.tsx` · `components/workbench/Workbench.tsx`。消费 contracts C3扩@backend 两读端点)。 - [2026-06-20] @frontend**✅ 接上两个新读端点:大纲页 / 写作工作台重访时回显已存内容**。`pnpm gen:api` 纳入 `GET .../outline` + `GET .../chapters/{no}/draft` + schema `DraftView``lib/api/types.ts``DraftView` 别名。新增两个 RSC 读 helper仿 `fetchForeshadow`/`fetchProject` 服务端读、无 CORS`fetchOutline(projectId)``OutlineChapterView[]`200 解包 chapters**任何错误/空降级为 `[]`** 不阻塞进页),`fetchDraft(projectId,chapterNo)``DraftView|null`(复用 `getJsonOrNull`**404→null 当空编辑器**,不抛)。**大纲页**(`outline/page.tsx`) 进页 `fetchOutline``initialChapters` 种入 `OutlineEditor``useOutline` 已据非空初值置 `ready`)→ 重访显已生成大纲;生成流照常覆盖所显章节。**写作工作台**(`write/page.tsx` + `Workbench`) 进页 `fetchDraft``content` 作编辑器初值(`useState(initialText)`)→ 重访重载已写正文404/新章 → `""` 空编辑器;**SSE 流式写 + AbortController + PUT 自动保存全不动**(流起始空、仅 streaming/done/aborted 变化才覆盖,初值不反扑活跃流);导出 `WORKBENCH_CHAPTER_NO`(原 `M1_CHAPTER_NO`)供页面拉对应章草稿。新增 `lib/api/server.test.ts`(5 vitestmock fetch 验解包/降级/404→null)。前端门禁全绿gen:api / lint / typecheck / vitest **160**(155→+5) / build。文件`lib/api/{types,server,server.test}.ts` · `app/projects/[id]/{outline,write}/page.tsx` · `components/workbench/Workbench.tsx`。消费 contracts C3扩@backend 两读端点)。

View File

@@ -0,0 +1,82 @@
"""T6 脑洞生成器契约测试spec + schema创作工具箱通用框架首个最简生成器
契约测试——构造符合 schema 的 mock 响应校验字段、tier、读写权限。
brainstorm`{ideas:[{premise,hook,genre_fit}]}`轻量档reads=projectswrites=[] 纯预览)。
不联网、无 DB。
"""
from __future__ import annotations
import pytest
from pydantic import ValidationError
from ww_agents import (
AgentSpec,
Idea,
IdeaListResult,
brainstorm_spec,
)
# ---- IdeaListResult schema ----
def test_idea_list_parses_mock_response() -> None:
# Arrange模拟网关 instructor 校验后的结构化脑洞产出
mock = {
"ideas": [
{
"premise": "废柴觉醒可吞噬规则的瞳术",
"hook": "每次升级都要付出一段记忆为代价",
"genre_fit": "玄幻·热血",
},
{"premise": "末世里唯一能种粮的农场主", "hook": "粮食成了最硬的通货"},
]
}
# Act
result = IdeaListResult.model_validate(mock)
# Assert
assert len(result.ideas) == 2
assert result.ideas[0].premise == "废柴觉醒可吞噬规则的瞳术"
assert result.ideas[0].hook == "每次升级都要付出一段记忆为代价"
assert result.ideas[0].genre_fit == "玄幻·热血"
assert result.ideas[1].genre_fit is None # 可缺
def test_idea_list_defaults_to_empty() -> None:
assert IdeaListResult().ideas == []
def test_idea_requires_premise_and_hook() -> None:
with pytest.raises(ValidationError):
Idea.model_validate({"premise": "只有前提没有钩子"})
# ---- brainstorm_spec 声明 ----
def test_brainstorm_spec_is_light_tier() -> None:
# 不变量 #2脑洞发散用轻量档只声明 tier
assert brainstorm_spec.tier == "light"
assert brainstorm_spec.name == "brainstorm"
def test_brainstorm_spec_reads_projects_writes_nothing() -> None:
# 纯预览:读作品设定、不写任何业务表(不变量 #3
assert brainstorm_spec.reads == ["projects"]
assert brainstorm_spec.writes == []
def test_brainstorm_spec_output_schema() -> None:
assert brainstorm_spec.output_schema is IdeaListResult
def test_brainstorm_spec_prompt_documents_diversification() -> None:
# 一次多条须逐条差异化——system_prompt 必须强调
assert "差异化" in brainstorm_spec.system_prompt
def test_brainstorm_spec_is_agent_spec_and_immutable() -> None:
assert isinstance(brainstorm_spec, AgentSpec)
with pytest.raises(ValidationError):
brainstorm_spec.tier = "writer"

View File

@@ -15,6 +15,8 @@ from .schemas import (
ForeshadowReview, ForeshadowReview,
ForeshadowSuggestion, ForeshadowSuggestion,
ForeshadowWindow, ForeshadowWindow,
Idea,
IdeaListResult,
OutlineChapter, OutlineChapter,
OutlineResult, OutlineResult,
PaceIssue, PaceIssue,
@@ -28,6 +30,7 @@ from .schemas import (
) )
from .specs import ( from .specs import (
AgentSpec, AgentSpec,
brainstorm_spec,
character_gen_spec, character_gen_spec,
continuity_spec, continuity_spec,
foreshadow_spec, foreshadow_spec,
@@ -50,6 +53,8 @@ __all__ = [
"ForeshadowReview", "ForeshadowReview",
"ForeshadowSuggestion", "ForeshadowSuggestion",
"ForeshadowWindow", "ForeshadowWindow",
"Idea",
"IdeaListResult",
"OutlineChapter", "OutlineChapter",
"OutlineResult", "OutlineResult",
"PaceIssue", "PaceIssue",
@@ -60,6 +65,7 @@ __all__ = [
"StyleFingerprintResult", "StyleFingerprintResult",
"WorldEntityCard", "WorldEntityCard",
"WorldGenResult", "WorldGenResult",
"brainstorm_spec",
"character_gen_spec", "character_gen_spec",
"continuity_spec", "continuity_spec",
"foreshadow_spec", "foreshadow_spec",

View File

@@ -298,3 +298,30 @@ class CharacterGenResult(BaseModel):
default_factory=list, default_factory=list,
description="生成的角色卡清单;无则空列表", description="生成的角色卡清单;无则空列表",
) )
# ---- 创作工具箱 · 脑洞生成器结构化输出T6 通用生成器框架 · brainstorm----
class Idea(BaseModel):
"""单条脑洞:前提 + 钩子 + 适配题材(创作工具箱最简生成器产物)。
纯预览产物——不映射任何业务表、不入库(`brainstorm_spec.writes=[]`,不变量 #3
`genre_fit` 可缺(作者未限定赛道时由模型留空)。
"""
premise: str = Field(description="一句话脑洞/设定前提")
hook: str = Field(description="抓人钩子:为何让读者想追读")
genre_fit: str | None = Field(default=None, description="适配题材/赛道(可缺)")
class IdeaListResult(BaseModel):
"""脑洞生成器结构化产出一组脑洞T6 · 通用生成器框架首个最简生成器)。
声明式只读(不变量 #3纯预览端点同步返回不写任何业务表仅记 ledger
"""
ideas: list[Idea] = Field(
default_factory=list,
description="生成的脑洞清单;无则空列表",
)

View File

@@ -16,6 +16,7 @@ from .schemas import (
CharacterGenResult, CharacterGenResult,
ContinuityReview, ContinuityReview,
ForeshadowReview, ForeshadowReview,
IdeaListResult,
OutlineResult, OutlineResult,
PaceReview, PaceReview,
StyleDriftReview, StyleDriftReview,
@@ -367,3 +368,36 @@ character_gen_spec = AgentSpec(
writes=["characters"], # 声明式(真写库经 T5.2 入库端点 + continuity 校验,不变量 #3 writes=["characters"], # 声明式(真写库经 T5.2 入库端点 + continuity 校验,不变量 #3
scope="builtin", scope="builtin",
) )
# ---- brainstorm脑洞生成器轻量档创作工具箱通用框架首个最简生成器T6----
BRAINSTORM_SYSTEM_PROMPT = """你是中文网文的「脑洞生成器」brainstorm轻量档\
职责:依据作品立意/题材与作者一句话需求,发散产出一组**差异化**的故事脑洞,\
每条给出一句话前提 + 抓人钩子 + 适配题材,供作者快速选种立项。
输入材料:
- 作品设定projects题材、立意、主线、卖点新立项时可能很少
- 作者的一句话需求/方向(可空——空则按题材自由发散)。
产出(每条一个 idea
- premise一句话脑洞/设定前提(核心创意,独立成立);
- hook抓人钩子——为何让读者想追读爽点/反差/悬念);
- genre_fit适配题材/赛道(若需求/设定已限定则贴合;否则可留空)。
纪律:
- 一次产出多条时**逐条差异化**——不同切入角度/赛道/爽点,避免一个模子;
- 贴合作品立意与题材;需求为空时按题材发散,不臆造与题材无关的设定;
- 你只产结构化脑洞清单,**不改稿、不写库**(纯预览,不变量 #3"""
brainstorm_spec = AgentSpec(
name="brainstorm",
tier="light", # 不变量 #2只声明档位不写 model脑洞发散用轻量档
system_prompt=BRAINSTORM_SYSTEM_PROMPT,
input_schema=None, # 注入材料为序列化文本(作品设定 + 一句话需求),非结构化入参
output_schema=IdeaListResult,
reads=["projects"],
writes=[], # 纯预览,不写库(不变量 #3
scope="builtin",
)

View File

@@ -0,0 +1,91 @@
"""T6 通用生成器执行器单测:`run_generator` + `build_brief_context`。
注入 mock 网关(产 IdeaListResult parsed无真 LLM、无真 Postgres。通用执行器据
`spec.output_schema` 校验 parsed不绑定具体类型只产结构化产物**不写库**(纯预览,
不变量 #3。asyncio_mode=auto。
"""
from __future__ import annotations
import uuid
import pytest
from fakes_orchestrator import FailingRunGateway, FakeRunGateway
from ww_agents import Idea, IdeaListResult, brainstorm_spec
from ww_core.orchestrator import build_brief_context, run_generator
PROJECT = uuid.UUID("00000000-0000-0000-0000-000000000001")
USER = uuid.UUID("00000000-0000-0000-0000-0000000000aa")
_IDEAS = IdeaListResult(
ideas=[
Idea(premise="废柴觉醒吞噬瞳", hook="升级要付记忆", genre_fit="玄幻"),
Idea(premise="末世种粮农场主", hook="粮食成硬通货"),
]
)
# ---- run_generator返回结构化产物据 output_schema 校验,只读不写库 ----
async def test_run_generator_returns_structured_result() -> None:
gateway = FakeRunGateway(_IDEAS)
result = await run_generator(
brainstorm_spec,
context=build_brief_context(brief="来点逆袭流", project_context="题材:玄幻"),
gateway=gateway,
user_id=USER,
project_id=PROJECT,
)
assert isinstance(result, IdeaListResult)
assert len(result.ideas) == 2
assert result.ideas[0].premise == "废柴觉醒吞噬瞳"
async def test_run_generator_passes_tier_and_schema_to_gateway() -> None:
# 不变量 #2只透传档位light不传 modeloutput_schema 据 spec 绑定
gateway = FakeRunGateway(_IDEAS)
await run_generator(
brainstorm_spec,
context="x",
gateway=gateway,
user_id=USER,
project_id=PROJECT,
)
assert gateway.last_request is not None
assert gateway.last_request.tier == "light"
assert gateway.last_request.output_schema is IdeaListResult
async def test_run_generator_raises_on_gateway_failure() -> None:
# 独立生成:网关失败直接上抛(端点处理),不静默吞
gateway = FailingRunGateway(RuntimeError("boom"))
with pytest.raises(RuntimeError, match="boom"):
await run_generator(
brainstorm_spec,
context="x",
gateway=gateway,
user_id=USER,
project_id=PROJECT,
)
# ---- build_brief_context确定性、空需求降级占位 ----
def test_build_brief_context_is_deterministic() -> None:
a = build_brief_context(brief="逆袭", project_context="题材:玄幻")
b = build_brief_context(brief="逆袭", project_context="题材:玄幻")
assert a == b
assert "题材:玄幻" in a
assert "逆袭" in a
def test_build_brief_context_empty_brief_falls_back() -> None:
ctx = build_brief_context(brief=" ", project_context="题材:都市")
assert "按题材自由发散" in ctx

View File

@@ -21,11 +21,13 @@ from .collect import (
extract_style, extract_style,
) )
from .generation_node import ( from .generation_node import (
build_brief_context,
build_character_gen_context, build_character_gen_context,
build_precheck_context, build_precheck_context,
build_worldbuilder_context, build_worldbuilder_context,
precheck_generated_cards, precheck_generated_cards,
run_character_gen, run_character_gen,
run_generator,
run_worldbuilder, run_worldbuilder,
) )
from .graph import ( from .graph import (
@@ -102,6 +104,7 @@ __all__ = [
"GatewayStream", "GatewayStream",
"ReviewRecorder", "ReviewRecorder",
"SseEvent", "SseEvent",
"build_brief_context",
"build_character_gen_context", "build_character_gen_context",
"build_outline_request", "build_outline_request",
"build_precheck_context", "build_precheck_context",
@@ -128,6 +131,7 @@ __all__ = [
"pace_event", "pace_event",
"precheck_generated_cards", "precheck_generated_cards",
"run_character_gen", "run_character_gen",
"run_generator",
"run_outline", "run_outline",
"run_review", "run_review",
"run_style_extraction", "run_style_extraction",

View File

@@ -28,6 +28,7 @@ from collections.abc import Sequence
from typing import Protocol from typing import Protocol
import structlog import structlog
from pydantic import BaseModel
from ww_agents import ( from ww_agents import (
AgentSpec, AgentSpec,
CharacterCard, CharacterCard,
@@ -73,6 +74,49 @@ def _build_request(
) )
# ---- 通用生成器执行器T6 · 创作工具箱通用框架)----
def build_brief_context(*, brief: str, project_context: str) -> str:
"""通用「brief_only」上下文作品设定 + 作者一句话需求(确定性,无时间戳)。
覆盖最简生成器(脑洞/书名…):仅需作品设定 + 一句话方向。需求为空时给降级占位,
交由 system_prompt 的「按题材自由发散」纪律处理。
"""
brief_block = brief.strip() or "(作者未填具体方向,按题材自由发散)"
return f"## 作品设定\n{project_context or '(暂无设定)'}\n\n## 创作需求\n{brief_block}"
async def run_generator(
spec: AgentSpec,
*,
context: str,
gateway: GatewayRun,
user_id: uuid.UUID,
project_id: uuid.UUID,
) -> BaseModel:
"""通用生成器执行器T6构请求 → `gateway.run` → 按 `spec.output_schema` 校验 parsed。
任一声明式生成器共用此路径(不绑定具体产物类型,据 spec 的 output_schema 校验)。
**只读、不写库**(纯预览,不变量 #3独立生成——网关失败直接上抛端点处理不静默吞。
"""
expected = spec.output_schema
if expected is None:
raise ValueError(f"generator spec {spec.name!r} has no output_schema")
req = _build_request(spec, context=context, user_id=user_id, project_id=project_id)
resp = await gateway.run(req)
parsed = resp.parsed
if not isinstance(parsed, expected):
log.error(
"generator_node_missing_parsed",
spec=spec.name,
project_id=str(project_id),
parsed_type=type(parsed).__name__,
)
raise ValueError(f"gateway returned no parsed {expected.__name__} for {spec.name} request")
return parsed
# ---- worldbuilder ---- # ---- worldbuilder ----