From e8cccf7389806b05e1725280f782abd3503b1de0 Mon Sep 17 00:00:00 2001 From: Yaojia Wang Date: Sat, 20 Jun 2026 11:31:59 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20T6.1=20=E5=88=9B=E4=BD=9C=E5=B7=A5?= =?UTF-8?q?=E5=85=B7=E7=AE=B1=E9=80=9A=E7=94=A8=E7=94=9F=E6=88=90=E5=99=A8?= =?UTF-8?q?=E6=A1=86=E6=9E=B6=E5=9C=B0=E5=9F=BA=20=E2=80=94=20=E8=84=91?= =?UTF-8?q?=E6=B4=9E=E7=94=9F=E6=88=90=E5=99=A8=20+=20run=5Fgenerator?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 待续。 --- PROGRESS.md | 21 ++++- packages/agents/tests/test_brainstorm_spec.py | 82 +++++++++++++++++ packages/agents/ww_agents/__init__.py | 6 ++ packages/agents/ww_agents/schemas.py | 27 ++++++ packages/agents/ww_agents/specs.py | 34 +++++++ packages/core/tests/test_generator_node.py | 91 +++++++++++++++++++ .../core/ww_core/orchestrator/__init__.py | 4 + .../ww_core/orchestrator/generation_node.py | 44 +++++++++ 8 files changed, 308 insertions(+), 1 deletion(-) create mode 100644 packages/agents/tests/test_brainstorm_spec.py create mode 100644 packages/core/tests/test_generator_node.py diff --git a/PROGRESS.md b/PROGRESS.md index 4c7397f..be29486 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -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` 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 | + +**P1 出口(DoD)**:通用执行器跑通脑洞生成器;后端门禁绿(ruff/format/mypy/alembic 无漂移/pytest);E2E mock 网关零 token 走通。达成后进入 P2(工具箱落地页 @frontend)。 + +--- + +## 已封板:K1 · Kimi Code OAuth(订阅 plan 接入)✅ > **背景**:用户要求接入 Kimi Code 订阅 plan(device-flow OAuth)。**已知情接受 ToS/封号风险**——研究确认:用订阅 plan 调用需伪造官方客户端 `User-Agent` + `X-Msh-*` 头,Kimi ToS 视为违规、可能封停会员(见变更日志研究结论)。用户明确选择此路径(非合规的 API Key 路径)。与既有 API-key провайдер `kimi` **分离**,新增独立 provider `kimi-code` 以便切换。 > **集成契约(研究确认,实现时对照 `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 — 一句话结果 + 影响的契约/文件` +- [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` 在 ` 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" diff --git a/packages/agents/ww_agents/__init__.py b/packages/agents/ww_agents/__init__.py index 34ad08c..1377893 100644 --- a/packages/agents/ww_agents/__init__.py +++ b/packages/agents/ww_agents/__init__.py @@ -15,6 +15,8 @@ from .schemas import ( ForeshadowReview, ForeshadowSuggestion, ForeshadowWindow, + Idea, + IdeaListResult, OutlineChapter, OutlineResult, PaceIssue, @@ -28,6 +30,7 @@ from .schemas import ( ) from .specs import ( AgentSpec, + brainstorm_spec, character_gen_spec, continuity_spec, foreshadow_spec, @@ -50,6 +53,8 @@ __all__ = [ "ForeshadowReview", "ForeshadowSuggestion", "ForeshadowWindow", + "Idea", + "IdeaListResult", "OutlineChapter", "OutlineResult", "PaceIssue", @@ -60,6 +65,7 @@ __all__ = [ "StyleFingerprintResult", "WorldEntityCard", "WorldGenResult", + "brainstorm_spec", "character_gen_spec", "continuity_spec", "foreshadow_spec", diff --git a/packages/agents/ww_agents/schemas.py b/packages/agents/ww_agents/schemas.py index 962c0da..cd34681 100644 --- a/packages/agents/ww_agents/schemas.py +++ b/packages/agents/ww_agents/schemas.py @@ -298,3 +298,30 @@ class CharacterGenResult(BaseModel): default_factory=list, 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="生成的脑洞清单;无则空列表", + ) diff --git a/packages/agents/ww_agents/specs.py b/packages/agents/ww_agents/specs.py index 33ee2ba..253d28a 100644 --- a/packages/agents/ww_agents/specs.py +++ b/packages/agents/ww_agents/specs.py @@ -16,6 +16,7 @@ from .schemas import ( CharacterGenResult, ContinuityReview, ForeshadowReview, + IdeaListResult, OutlineResult, PaceReview, StyleDriftReview, @@ -367,3 +368,36 @@ character_gen_spec = AgentSpec( writes=["characters"], # 声明式(真写库经 T5.2 入库端点 + continuity 校验,不变量 #3) 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", +) diff --git a/packages/core/tests/test_generator_node.py b/packages/core/tests/test_generator_node.py new file mode 100644 index 0000000..100a5e9 --- /dev/null +++ b/packages/core/tests/test_generator_node.py @@ -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),不传 model;output_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 diff --git a/packages/core/ww_core/orchestrator/__init__.py b/packages/core/ww_core/orchestrator/__init__.py index 1f3ea90..0f0edc9 100644 --- a/packages/core/ww_core/orchestrator/__init__.py +++ b/packages/core/ww_core/orchestrator/__init__.py @@ -21,11 +21,13 @@ from .collect import ( extract_style, ) from .generation_node import ( + build_brief_context, build_character_gen_context, build_precheck_context, build_worldbuilder_context, precheck_generated_cards, run_character_gen, + run_generator, run_worldbuilder, ) from .graph import ( @@ -102,6 +104,7 @@ __all__ = [ "GatewayStream", "ReviewRecorder", "SseEvent", + "build_brief_context", "build_character_gen_context", "build_outline_request", "build_precheck_context", @@ -128,6 +131,7 @@ __all__ = [ "pace_event", "precheck_generated_cards", "run_character_gen", + "run_generator", "run_outline", "run_review", "run_style_extraction", diff --git a/packages/core/ww_core/orchestrator/generation_node.py b/packages/core/ww_core/orchestrator/generation_node.py index 03bf24b..2b7b8c5 100644 --- a/packages/core/ww_core/orchestrator/generation_node.py +++ b/packages/core/ww_core/orchestrator/generation_node.py @@ -28,6 +28,7 @@ from collections.abc import Sequence from typing import Protocol import structlog +from pydantic import BaseModel from ww_agents import ( AgentSpec, 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 ----