From 02d19019f6b93d732aa816d5d55e1f48ef911c80 Mon Sep 17 00:00:00 2001 From: Yaojia Wang Date: Mon, 6 Jul 2026 16:26:08 +0200 Subject: [PATCH] =?UTF-8?q?feat(web):=20AI=20=E7=AB=8B=E9=A1=B9=E6=96=B9?= =?UTF-8?q?=E6=A1=88=E7=94=9F=E6=88=90+=E9=A2=84=E5=A1=AB=EF=BC=88?= =?UTF-8?q?=E7=A7=8D=E5=AD=90=E9=97=A8=E6=8E=A7=20+=20=E6=8C=89=E5=AD=97?= =?UTF-8?q?=E6=AE=B5=E6=8E=A5=E5=8F=97=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 向导阶段(project 未建)一键推演结构化书级蓝图预填立项向导。 - 新 SPEC project_plan_spec(analyst 档,纯预览 reads/writes=())+ schema ProjectPlanResult(书名候选 + 时空背景/叙事结构/故事核心/结局设计/基调, 全字段默认值守解析韧性)+ 注册 SCHEMA_CATALOG;计数三处 21→22 + regen golden。 - 端点 POST /skills/project-plan/generate(不带 project 前缀,绕开 toolbox 404): 请求体序列化向导草稿为 project_context,复用 build_brief_context + run_generator; 种子门控(缺 genre/logline→422);请求 schema 加 max_length 上界。 - run_generator/_build_request 的 project_id 放宽 uuid.UUID|None(向导无 project)。 - 前端 wizard.ts 加 mapPlanResultToWizardForm(错配字段折进 premise 不静默丢)+ applyPlanPatch(仅回填空字段、保护已编辑)+ useProjectPlan hook + PlanAssistant。 --- PROGRESS.md | 1 + apps/api/tests/test_project_plan_endpoint.py | 197 ++++++++++++++++++ apps/api/ww_api/routers/generation.py | 94 +++++++++ apps/api/ww_api/schemas/projects.py | 58 ++++++ apps/api/ww_api/services/project_deps.py | 8 + apps/web/components/ProjectWizard.tsx | 108 +++++++++- apps/web/lib/api/schema.d.ts | 164 +++++++++++++++ apps/web/lib/api/types.ts | 4 + apps/web/lib/wizard/useProjectPlan.test.ts | 95 +++++++++ apps/web/lib/wizard/useProjectPlan.ts | 59 ++++++ apps/web/lib/wizard/wizard.test.ts | 132 ++++++++++++ apps/web/lib/wizard/wizard.ts | 82 +++++++- memory/contracts.md | 2 + .../agents/tests/fixtures/prompt_hashes.json | 1 + .../agents/tests/test_project_plan_spec.py | 97 +++++++++ packages/agents/tests/test_prompt_loader.py | 4 +- packages/agents/ww_agents/__init__.py | 4 + .../agents/ww_agents/prompts/project-plan.md | 32 +++ packages/agents/ww_agents/schema_catalog.py | 4 +- packages/agents/ww_agents/schemas.py | 28 +++ packages/agents/ww_agents/specs.py | 16 +- .../ww_core/orchestrator/generation_node.py | 6 +- 22 files changed, 1187 insertions(+), 9 deletions(-) create mode 100644 apps/api/tests/test_project_plan_endpoint.py create mode 100644 apps/web/lib/wizard/useProjectPlan.test.ts create mode 100644 apps/web/lib/wizard/useProjectPlan.ts create mode 100644 packages/agents/tests/test_project_plan_spec.py create mode 100644 packages/agents/ww_agents/prompts/project-plan.md diff --git a/PROGRESS.md b/PROGRESS.md index 8fc56b6..be5e47a 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -161,6 +161,7 @@ T0.1 monorepo 骨架 ✅ @devops · T0.2 16 MVP 表迁移(无漂移,users st > 格式:`- [YYYY-MM-DD] @skill 完成/进展 Txx — 一句话结果 + 影响的契约/文件` +- [2026-07-06] @backend/@llm/@frontend — **✅ 灵感⑤ AI 立项方案生成+预填(develop 直提)**。向导阶段(project 未建)一键推演结构化书级蓝图预填立项向导。**新 SPEC** `project_plan_spec`(`tier="analyst"` 不写 model,`reads=()`/`writes=()` 纯预览,`prompts/project-plan.md`)+ schema `ProjectPlanResult`(书名候选 list + 时空背景/叙事结构/故事核心/结局设计/基调,**全字段默认值守解析韧性**)+ 注册 `SCHEMA_CATALOG["project-plan"]`;**计数三处 21→22**(`specs.py` assert / `test_prompt_loader.py` len / `schema_catalog.py` docstring)+ **regen `prompt_hashes.json`**(仅加一条)。**新端点** `POST /skills/project-plan/generate`(**不带 project 前缀**绕开 toolbox 404)← `ProjectPlanGenerateRequest`(全字段 `max_length` CR-H9,`brief` 用 `str|None` 令 codegen 可选)→ `ProjectPlanView`;**种子门控**缺 genre/logline→422,无凭据→503。复用 `build_brief_context`+`run_generator`(`run_generator`/`_build_request` 的 `project_id` 放宽 `uuid.UUID|None`——向导无 project,ledger.project_id 本 nullable);新缝 `get_project_plan_gateway`(analyst)。**零迁移**。**前端** `wizard.ts` 加 `mapPlanResultToWizardForm`(书名/结构/基调→对应字段;时空背景/结局设计→折进 premise **不静默丢**;结局取向/视角预设枚举不臆造)+ `applyPlanPatch`(**仅回填空字段、保护已编辑**)+ `canGeneratePlan`/`toPlanSeedRequest` + `useProjectPlan` hook(renderHook 测试)+ `ProjectWizard.tsx` 第 2 步 `PlanAssistant`(生成→预览→按字段回填)。**门禁全绿**:后端 ruff/format 干净 · mypy **218** · pytest **814**(+新端点 6 + agents spec 8 测)· alembic 无漂移(零 ORM 变更);前端 gen:api/lint/typecheck 干净 · vitest **526**(+28)· coverage 95%(wizard/generation 100%)· build OK。记 contracts C3 新端点。守不变量 #2/#3/#9。依赖 ④(已交付)。 - [2026-06-26] @orchestrator — **全项目 Code Review(七维度)完成 → `QA-CR` 整改批立项**。6 个专科 reviewer 并行(后端 FastAPI 架构 / Python 规范 / LangGraph 最佳实践 / 前端 React 架构 / 前端 TS 类型安全 / 应用安全)+ 依赖时效自查;212 py + 168 tsx 全覆盖。绿门禁下仍揪出 **2 CRITICAL + 13 HIGH + 一批 MEDIUM/LOW**:**C1** Next.js 15.1.3 RCE(CVE-2025-66478「React2Shell」,App Router+RSC 受影响,升 15.1.9/15.5.7/16.x + 轮换密钥);**C2** 多章链缺 `recursion_limit`(默认 25 supersteps、每章 4 节点→超 ~6 章即崩,批量产卷不可用)。HIGH 含 Kimi OAuth 占 DB 会话 / httpx 泄漏 / Anthropic 计费记 0 / request_id 未贯通 / `.env.example` 提交可用 Fernet key / 文本输入无 `max_length` / 前端 errorCode 重复 / Drawer 焦点 / RefineView 未取消。**确认无虞**:SSRF(base_url 仅硬编码)/SQL 注入(全 ORM)/XSS(无 dangerouslySetInnerHTML)/错误信封不泄密/Kimi device-flow 无 CSRF。已登记 `QA-CR` 任务表(CR-C1/C2 + CR-H1..H12 + CR-M1..M5 + CR-L1 + CR-D1)按 owner 分派 @frontend/@backend/@llm/@devops,落地顺序见当前阶段 background。**本批为评审立项,尚无代码改动。** - [2026-06-23] @orchestrator — **🎉 多章工作流链(Chain Workflow)交付 + 多 agent 交叉评审 + CRITICAL 修复 + 合并 develop(merge `513bf71`)**。对标星月「一键多章」+ 差异化「每章过四审、遇冲突才停人」。设计契约 `docs/design/chain-workflow.md`(commit `5ee9799`)。**多 agent 逐学科建造**(契约先行、目录单写者):**C1 @llm**(`7f3eaab`) LangGraph **cyclic 链图** `orchestrator/chain/{state,nodes,graph}.py`(`build_chain_graph`:write→review→decide→accept 循环 + **interrupt-on-conflict** + resume;删死代码 `build_write_graph`,`write_node` 本体留用;7 单测 mock 网关+fake session+MemorySaver)·**C2 @backend**(`29349dc`) 服务/3 端点/schema/checkpointer 接线(见下条)·**C3 @db**(`548b7ab`) langgraph 检查点 4 表迁移 `d3e4f5a6b7c8`(`autocommit_block` 避开 `CREATE INDEX CONCURRENTLY` 与 alembic 事务死锁;`env.py` `include_object` 豁免这 4 表防漂移误报)·**C4 @qa**(`061792d`) E2E `tests/test_chain_workflow_e2e.py` 真 pg + mock 网关零 token(无冲突两章全自动 + 冲突→interrupt→awaiting→resume→accept)。**3 路对抗交叉评审**(python/fastapi/不变量)在**绿门禁下仍揪出 1 CRITICAL + 7 HIGH**——证伪"测试过=没问题":CRITICAL=链把**绑请求 session 的 gateway** 传进 BackgroundTask,后台跑时 session 已关→write/review 的 `usage_ledger.record()` 写进死 session **静默丢失**(链计费全丢,违 #1;E2E 清了 ledger 却没断言其存在故漏掉)。**修复**(`d1ea83b`):网关改 `chain_gateway_builder` 按节点 session 重建(仿 `digest_gateway_builder`/`style.py`);日志脱敏(`_classify_job_error`+`exc_type`,去 `str(exc)`,§5);resume 原子 claim `JobRepo.claim_awaiting_to_running`(防并发双跑同 thread_id,非 awaiting→409)+ 所有权校验(`JobView.project_id`,跨项目→404);`ChainResumeAccepted`(去 start/count=0 哨兵);删死导入 `extract_conflicts`;**补回归断言**(链 `usage_ledger` 必有行 + `chapter_reviews` 每章一行)。**出口(DoD)✅**:后端门禁绿 ruff/format 干净 · mypy **194 Success** · alembic **无漂移**(业务表零迁移,复用 `jobs.status=awaiting_input` + `result.awaiting_chapter`;唯一新 DDL=langgraph 检查点表)· pytest **608 passed**(+6 回归测)。首次真正启用 LangGraph checkpointer/interrupt。守不变量 #1/#3/#4/#5/#9。**剩余下一步(未做)**:① 前端链 UI(`pnpm gen:api` 纳入 run/resume + 发起页/进度轮询/awaiting 裁决续跑面板,复用 `ConflictAdjudication`);② §12 文档回写 `ARCHITECTURE.md §5.2`(补 cyclic 链图 + 更正现状:写章直连流式/单章 accept 为事务/链图首启 checkpointer);③ 未 push 远端。可选:续写/扩写节点入链;UGC 链市场需多租户(deferred)。 - [2026-06-23] @backend — **✅ Chain Workflow C2(多章链 服务+端点+schema+checkpointer 接线,分支 `feat/chain-workflow`)**。承 C1(@llm)链图(`build_chain_graph` §3.3),落地服务/端点壳:① **3 端点**`routers/chain.py`(`POST .../chains/{key}/run`→202 `ChainRunAccepted`;`POST .../chains/runs/{job_id}/resume`→202;`GET /jobs/{id}` 复用)。校验:count 1..50→422、未知 chain_key→404、项目不存在→404、resume job 不存在→404、resume 非 awaiting→**409 新码 `CONFLICT`**、无凭据→503。② **schema**`schemas/chain.py`(`ChainRunRequest/ChainRunAccepted/ChainResumeRequest`,`ConflictDecision` 复用)。③ **服务**`services/chain_runner.py`(`run_chain_job` 仿 `run_job` 壳自建独立 session 驱动链图:set_running→ainvoke→据 `__interrupt__` 置 `awaiting_input` 或 `done`/`failed`;`build_accept_op` 在 apps/api 装配验收事务闭包注入图节点,守 #3/#4;token 不入 result/日志)+ `services/chain_deps.py`(`get_checkpointer_factory`:运行时 `AsyncPostgresSaver` 上下文 / 测试 MemorySaver)。④ **零迁移**(§7):复用 `jobs`,新增 `status="awaiting_input"` + `JobRepo.set_awaiting`,awaiting 章经 `result.awaiting_chapter`;新错误码 `ErrorCode.CONFLICT`(409)。⑤ **新网关缝**`project_deps.build_chain_gateway`/`get_chain_gateway`(按请求 tier writer/analyst/light 分派——单档网关恒返该档会错路由 review/digest,见 gotcha)+ `get_digest_gateway_builder`。**单测**`apps/api/tests/test_chain.py` 12 用例(mock 网关 + MemorySaver + fake session/accept_op,无 DB/无网络/无真 LLM):run→202、resume→202、未知 key 404、count 越界(>50/0) 422、resume 非 awaiting 409、项目/ job 不存在 404、run_chain_job 无冲突→done+written 满、冲突→awaiting→resume→done、错误脱敏、accept_op 冲突缺判→CONFLICT_UNRESOLVED。**全仓门禁绿**:ruff/format 干净、mypy **193 Success**、alembic **无漂移**(无 ORM 变更)、pytest **600 passed**。守不变量 #1/#3/#4/#5/#9。记 contracts C-Chain 稳定 + 2 decision + 3 gotcha。**唯一新增 DDL(langgraph 检查点表)= C3 迁移;前端链 UI = 契约稳定后 @frontend follow-up(本期非目标)。** diff --git a/apps/api/tests/test_project_plan_endpoint.py b/apps/api/tests/test_project_plan_endpoint.py new file mode 100644 index 0000000..077161f --- /dev/null +++ b/apps/api/tests/test_project_plan_endpoint.py @@ -0,0 +1,197 @@ +"""AI 立项方案生成端点测试(灵感⑤;内存替身,无 DB/无网络)。 + +覆盖 `POST /skills/project-plan/generate`(不带 project 前缀): +- 无既有 project 也能生成(向导阶段 project 未建)→ 200 结构化预览 + 落 ledger。 +- 种子门控:缺 genre / logline → 422,不触达网关、不 commit。 +- 无凭据 → 503。 +- 向导草稿序列化进网关 context(种子 + 已填字段透传)。 +LLM 一律 mock,绝不联网。 +""" + +from __future__ import annotations + +import os +from typing import Any + +import httpx +import pytest +from cryptography.fernet import Fernet +from fakes_projects import FakeSession +from ww_agents import ProjectPlanResult +from ww_llm_gateway.types import LlmRequest, LlmResponse, ServedBy, Usage +from ww_shared import AppError, ErrorCode + + +class _CaptureGateway: + """记录最近一次 req.input(验证向导草稿进了 context),返固定 ProjectPlanResult。""" + + def __init__(self, parsed: ProjectPlanResult) -> None: + self._parsed = parsed + self.last_input: str | None = None + self.calls = 0 + + async def run(self, req: LlmRequest) -> LlmResponse: + self.calls += 1 + self.last_input = req.input if isinstance(req.input, str) else str(req.input) + return LlmResponse( + text=self._parsed.model_dump_json(), + parsed=self._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"), + ) + + +def _plan() -> ProjectPlanResult: + return ProjectPlanResult( + title_candidates=["逐光而行", "剑试九霄"], + setting="上古仙门世界", + narrative_structure="黄金三章立钩 → 升级", + story_core="废柴逆袭封神的代价", + ending_design="归隐收束(正剧)", + tone="热血", + ) + + +def _make_app( + *, + gateway: Any, + session: FakeSession | None = None, + no_creds: bool = False, +) -> tuple[Any, FakeSession]: + 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_project_plan_gateway + from ww_db import get_session + + session = session or FakeSession() + + async def _ok() -> Any: + return gateway + + async def _no_creds() -> Any: + raise AppError(ErrorCode.LLM_UNAVAILABLE, "未配置凭据", {"provider": "deepseek"}) + + app = create_app() + app.dependency_overrides[get_project_plan_gateway] = _no_creds if no_creds else _ok + app.dependency_overrides[get_session] = lambda: session + return app, session + + +def _client(app: Any) -> httpx.AsyncClient: + transport = httpx.ASGITransport(app=app, raise_app_exceptions=False) + return httpx.AsyncClient(transport=transport, base_url="http://test") + + +@pytest.mark.asyncio +async def test_generate_without_existing_project() -> None: + # 向导阶段 project 未建:无 project 前缀端点直接生成,返结构化方案 + 落 ledger。 + gateway = _CaptureGateway(_plan()) + app, session = _make_app(gateway=gateway) + + async with _client(app) as client: + resp = await client.post( + "/skills/project-plan/generate", + json={"genre": "仙侠", "logline": "废柴少年觉醒禁忌血脉,逆势封神。"}, + ) + + assert resp.status_code == 200 + body = resp.json() + assert body["title_candidates"] == ["逐光而行", "剑试九霄"] + assert body["story_core"] == "废柴逆袭封神的代价" + assert body["ending_design"].endswith("(正剧)") + assert body["tone"] == "热血" + assert session.commits == 1 # 预览不写业务表,但落 ledger + + +@pytest.mark.asyncio +async def test_generate_serializes_wizard_draft_into_context() -> None: + # 种子 + 已填字段序列化进网关 context(有则贴合)。 + gateway = _CaptureGateway(_plan()) + app, _ = _make_app(gateway=gateway) + + async with _client(app) as client: + resp = await client.post( + "/skills/project-plan/generate", + json={ + "genre": "都市", + "logline": "外卖骑手觉醒读心术。", + "title": "听风者", + "tone": "轻松", + "selling_points": ["逆袭", "系统流"], + "brief": "偏悬疑向", + }, + ) + + assert resp.status_code == 200 + assert gateway.last_input is not None + assert "题材:都市" in gateway.last_input + assert "一句话故事:外卖骑手觉醒读心术。" in gateway.last_input + assert "暂定书名:听风者" in gateway.last_input + assert "核心卖点:逆袭、系统流" in gateway.last_input + assert "偏悬疑向" in gateway.last_input # brief 进 context + + +@pytest.mark.asyncio +async def test_requires_seed_missing_logline_422() -> None: + # 种子门控:缺 logline → 422,不触达网关、不 commit。 + gateway = _CaptureGateway(_plan()) + app, session = _make_app(gateway=gateway) + + async with _client(app) as client: + resp = await client.post("/skills/project-plan/generate", json={"genre": "仙侠"}) + + assert resp.status_code == 422 + assert resp.json()["error"]["code"] == ErrorCode.VALIDATION + assert gateway.calls == 0 + assert session.commits == 0 + + +@pytest.mark.asyncio +async def test_requires_seed_missing_genre_422() -> None: + gateway = _CaptureGateway(_plan()) + app, session = _make_app(gateway=gateway) + + async with _client(app) as client: + resp = await client.post("/skills/project-plan/generate", json={"logline": "一句话故事。"}) + + assert resp.status_code == 422 + assert gateway.calls == 0 + assert session.commits == 0 + + +@pytest.mark.asyncio +async def test_requires_seed_blank_strings_422() -> None: + # 纯空白种子也拒(strip 后为空)。 + gateway = _CaptureGateway(_plan()) + app, session = _make_app(gateway=gateway) + + async with _client(app) as client: + resp = await client.post( + "/skills/project-plan/generate", json={"genre": " ", "logline": " "} + ) + + assert resp.status_code == 422 + assert gateway.calls == 0 + assert session.commits == 0 + + +@pytest.mark.asyncio +async def test_generate_no_credentials_503() -> None: + app, session = _make_app(gateway=object(), no_creds=True) + + async with _client(app) as client: + resp = await client.post( + "/skills/project-plan/generate", + json={"genre": "仙侠", "logline": "一句话故事。"}, + ) + + assert resp.status_code == 503 + assert resp.json()["error"]["code"] == ErrorCode.LLM_UNAVAILABLE + assert session.commits == 0 diff --git a/apps/api/ww_api/routers/generation.py b/apps/api/ww_api/routers/generation.py index 0622c5e..bdb5175 100644 --- a/apps/api/ww_api/routers/generation.py +++ b/apps/api/ww_api/routers/generation.py @@ -26,8 +26,10 @@ from sqlalchemy.ext.asyncio import AsyncSession from ww_agents import ( CharacterCard, CharacterRelation, + ProjectPlanResult, character_gen_spec, continuity_spec, + project_plan_spec, worldbuilder_spec, ) from ww_core.domain import ( @@ -37,8 +39,10 @@ from ww_core.domain import ( ) from ww_core.domain.repositories import MemoryRepos from ww_core.orchestrator import ( + build_brief_context, precheck_generated_cards, run_character_gen, + run_generator, run_worldbuilder, ) from ww_db import get_session @@ -64,6 +68,7 @@ from ww_api.schemas.generation import ( WorldGenerateRequest, WorldGenPreviewResponse, ) +from ww_api.schemas.projects import ProjectPlanGenerateRequest, ProjectPlanView from ww_api.schemas.rules import RuleView from ww_api.services.credentials import STUB_OWNER_ID from ww_api.services.project_deps import ( @@ -71,6 +76,7 @@ from ww_api.services.project_deps import ( get_character_write_repo, get_memory_repos, get_precheck_gateway, + get_project_plan_gateway, get_project_repo, get_rule_write_repo, get_skill_registry, @@ -90,6 +96,7 @@ SkillRegistryDep = Annotated[SkillRegistry, Depends(get_skill_registry)] WorldGatewayDep = Annotated[Gateway, Depends(get_worldbuilder_gateway)] CharacterGatewayDep = Annotated[Gateway, Depends(get_character_gen_gateway)] PrecheckGatewayDep = Annotated[Gateway, Depends(get_precheck_gateway)] +ProjectPlanGatewayDep = Annotated[Gateway, Depends(get_project_plan_gateway)] SessionDep = Annotated[AsyncSession, Depends(get_session)] @@ -105,6 +112,46 @@ def _project_context(title: str, genre: str | None, premise: str | None, theme: return "\n".join(lines) +def _plan_seed_context(body: ProjectPlanGenerateRequest) -> str: + """把立项向导草稿序列化为作品种子(喂 project-plan;仿 `_project_context`)。 + + 确定性、无时间戳/UUID;只拼非空字段(有则贴合、无则不臆造)。 + genre + logline 由端点种子门控保证非空。 + """ + lines: list[str] = [] + label_fields: list[tuple[str, str | None]] = [ + ("暂定书名", body.title), + ("题材", body.genre), + ("一句话故事", body.logline), + ("立意", body.premise), + ("主题", body.theme), + ("故事结构", body.structure), + ("基调", body.tone), + ("结局取向", body.ending_type), + ("叙事视角", body.narrative_pov), + ] + for label, value in label_fields: + text = (value or "").strip() + if text: + lines.append(f"{label}:{text}") + points = [p.strip() for p in body.selling_points if p.strip()] + if points: + lines.append(f"核心卖点:{'、'.join(points)}") + return "\n".join(lines) + + +def _plan_to_view(plan: ProjectPlanResult) -> ProjectPlanView: + """ww_agents.ProjectPlanResult → API ProjectPlanView(字段同名直传)。""" + return ProjectPlanView( + title_candidates=list(plan.title_candidates), + setting=plan.setting, + narrative_structure=plan.narrative_structure, + story_core=plan.story_core, + ending_design=plan.ending_design, + tone=plan.tone, + ) + + def _render_characters_context(cards: list[CharacterCard]) -> str: """已有角色简表(喂 character-gen 防雷同 + precheck 真相源)。确定性、保序。""" if not cards: @@ -430,6 +477,53 @@ async def list_skills(registry: SkillRegistryDep) -> SkillListResponse: return SkillListResponse(skills=skills) +# ---- AI 立项方案生成(灵感⑤;不带 project 前缀,立项前 project 未建)---- + + +@skills_router.post("/project-plan/generate") +async def generate_project_plan( + body: ProjectPlanGenerateRequest, + request: Request, + gateway: ProjectPlanGatewayDep, + session: SessionDep, +) -> ProjectPlanView: + """生成结构化立项方案预览(向导阶段,project 未建,不入库)。 + + **不带 project 前缀**——绕开 toolbox 的 `project_repo.get→404`(立项前无 project)。 + **种子门控**:缺 genre 或 logline → 422(空向导不出方案,防 slop)。无凭据 → 503。 + """ + request_id = getattr(request.state, "request_id", None) + genre = (body.genre or "").strip() + logline = (body.logline or "").strip() + if not genre or not logline: + raise AppError( + ErrorCode.VALIDATION, + "生成立项方案至少需要「题材」与「一句话故事(logline)」两个种子", + {"has_genre": bool(genre), "has_logline": bool(logline)}, + ) + + project_context = _plan_seed_context(body) + context = build_brief_context(brief=body.brief or "", project_context=project_context) + parsed = await run_generator( + project_plan_spec, + context=context, + gateway=gateway, + user_id=STUB_OWNER_ID, + project_id=None, # 向导阶段 project 未建;usage_ledger.project_id 可空 + ) + # 提交边界:预览不写业务表,但网关 ledger 只 flush → 末尾 commit 落 usage(否则丢失)。 + await session.commit() + if not isinstance(parsed, ProjectPlanResult): + # run_generator 已按 output_schema 校验;防御性收窄(不应触达)。 + raise AppError(ErrorCode.INTERNAL, "立项方案生成返回类型异常") + log.info( + "project_plan_generate_done", + request_id=request_id, + title_count=len(parsed.title_candidates), + ) + return _plan_to_view(parsed) + + # ---- schema <-> ww_agents 卡转换 ---- diff --git a/apps/api/ww_api/schemas/projects.py b/apps/api/ww_api/schemas/projects.py index 0b504b6..5744573 100644 --- a/apps/api/ww_api/schemas/projects.py +++ b/apps/api/ww_api/schemas/projects.py @@ -52,6 +52,64 @@ class ProjectListResponse(BaseModel): projects: list[ProjectResponse] = Field(default_factory=list) +# ---- AI 立项方案生成(灵感⑤;向导阶段 project 未建,纯预览不写库)---- + +# 请求字段长度上界(CR-H9:防超长向导草稿撑爆上下文/成本)。短字段=标签级,长字段=段落级。 +_PLAN_SHORT_MAX = 200 +_PLAN_LONG_MAX = 4000 +_PLAN_LIST_MAX = 20 + + +class ProjectPlanGenerateRequest(BaseModel): + """POST /skills/project-plan/generate:向导草稿种子(不带 project 前缀,立项前 project 未建)。 + + **种子门控**:`genre` + `logline` 为必给种子(端点校验非空,否则 422)——空向导不出方案, + 防 slop。其余字段有则贴合、无则不臆造(序列化进 project_context,仿 `_project_context`)。 + 全字段带 `max_length` 上界(CR-H9),`brief` = 作者一句话方向(可空)。 + """ + + genre: str | None = Field(default=None, max_length=_PLAN_SHORT_MAX, description="题材(种子)") + logline: str | None = Field( + default=None, max_length=_PLAN_LONG_MAX, description="一句话故事(种子)" + ) + title: str | None = Field( + default=None, max_length=_PLAN_SHORT_MAX, description="暂定书名(可空)" + ) + premise: str | None = Field(default=None, max_length=_PLAN_LONG_MAX, description="立意(可空)") + theme: str | None = Field(default=None, max_length=_PLAN_SHORT_MAX, description="主题(可空)") + structure: str | None = Field( + default=None, max_length=_PLAN_SHORT_MAX, description="故事结构(可空)" + ) + tone: str | None = Field(default=None, max_length=_PLAN_SHORT_MAX, description="基调(可空)") + ending_type: str | None = Field( + default=None, max_length=_PLAN_SHORT_MAX, description="结局取向(可空)" + ) + narrative_pov: str | None = Field( + default=None, max_length=_PLAN_SHORT_MAX, description="叙事视角(可空)" + ) + selling_points: list[str] = Field( + default_factory=list, max_length=_PLAN_LIST_MAX, description="核心卖点(可空)" + ) + # `str | None`(非 `str=""`):codegen 渲染为可选字段,前端种子请求可省略(brief 本就可空)。 + brief: str | None = Field( + default=None, max_length=_PLAN_LONG_MAX, description="作者一句话方向/需求(可空)" + ) + + +class ProjectPlanView(BaseModel): + """立项方案生成结果(贴 ww_agents.ProjectPlanResult;结构化预览,不入库)。 + + 全字段有默认值(守解析韧性)——前端按字段回填立项向导,只填非空交集、不覆盖作者已编辑值。 + """ + + title_candidates: list[str] = Field(default_factory=list, description="书名候选清单") + setting: str = Field(default="", description="时空背景") + narrative_structure: str = Field(default="", description="叙事结构") + story_core: str = Field(default="", description="故事核心") + ending_design: str = Field(default="", description="结局设计") + tone: str = Field(default="", description="基调") + + class DraftStreamRequest(BaseModel): """POST /projects/:id/chapters/:no/draft:本章生成的可选输入(T4-b)。 diff --git a/apps/api/ww_api/services/project_deps.py b/apps/api/ww_api/services/project_deps.py index 41bec52..626e8b0 100644 --- a/apps/api/ww_api/services/project_deps.py +++ b/apps/api/ww_api/services/project_deps.py @@ -268,6 +268,14 @@ async def get_precheck_gateway( return await build_gateway_for_tier(session, store, "analyst") +async def get_project_plan_gateway( + session: Annotated[AsyncSession, Depends(get_session)], +) -> Gateway: + """AI 立项方案生成(analyst 档位)的可注入网关缝。测试注 mock(产 ProjectPlanResult)。""" + store = SqlCredentialStore(session) + return await build_gateway_for_tier(session, store, "analyst") + + def get_session_factory() -> SessionFactory: """验收后到期扫描的**独立 session 工厂**缝。 diff --git a/apps/web/components/ProjectWizard.tsx b/apps/web/components/ProjectWizard.tsx index 5353baf..d7eb17c 100644 --- a/apps/web/components/ProjectWizard.tsx +++ b/apps/web/components/ProjectWizard.tsx @@ -1,9 +1,10 @@ "use client"; -import { ArrowLeft, ArrowRight, Check, CheckCircle2 } from "lucide-react"; +import { ArrowLeft, ArrowRight, Check, CheckCircle2, Sparkles } from "lucide-react"; import { useRouter } from "next/navigation"; import { useEffect, useRef, useState } from "react"; +import { ThinkingIndicator } from "@/components/ThinkingIndicator"; import { useToast } from "@/components/Toast"; import { Badge } from "@/components/ui/Badge"; import { Button } from "@/components/ui/Button"; @@ -14,6 +15,8 @@ import { TextArea } from "@/components/ui/TextArea"; import { TextInput } from "@/components/ui/TextInput"; import { api } from "@/lib/api/client"; import { buttonClass } from "@/lib/ui/variants"; +import type { ProjectPlanView } from "@/lib/api/types"; +import { useProjectPlan } from "@/lib/wizard/useProjectPlan"; import { ENDING_TYPES, GENRES, @@ -23,11 +26,15 @@ import { STRUCTURES, TONES, WIZARD_STEPS, + applyPlanPatch, canAdvance, + canGeneratePlan, canSubmit, clampStep, emptyWizardForm, + mapPlanResultToWizardForm, toCreateRequest, + toPlanSeedRequest, type WizardForm, } from "@/lib/wizard/wizard"; @@ -61,6 +68,21 @@ export function ProjectWizard() { : [...prev.sellingPoints, point], })); + // AI 立项方案按字段回填:只填当前为空的字段(保护作者已编辑内容,不 bulk 覆盖)。 + const applyPlan = (plan: ProjectPlanView): void => { + const patch = mapPlanResultToWizardForm(plan); + const filled = (Object.keys(patch) as Array).filter( + (k) => typeof form[k] === "string" && (form[k] as string).trim() === "", + ); + setForm((prev) => applyPlanPatch(prev, patch)); + toast( + filled.length > 0 + ? `已回填 ${filled.length} 个空字段(已编辑内容不覆盖)` + : "向导相关字段均已填写,未覆盖任何内容", + filled.length > 0 ? "success" : "info", + ); + }; + const goBack = (): void => setStep((s) => clampStep(s - 1)); const goNext = (): void => setStep((s) => clampStep(s + 1)); @@ -110,6 +132,7 @@ export function ProjectWizard() { form={form} update={update} toggleSellingPoint={toggleSellingPoint} + onApplyPlan={applyPlan} /> )} {step === 3 && } @@ -199,11 +222,91 @@ function StepBasics({ form, update }: StepProps) { ); } +// AI 立项方案助手(灵感⑤):种子(题材+logline)就绪后一键生成结构化方案 → 预览 → 按字段回填。 +function PlanAssistant({ + form, + onApplyPlan, +}: { + form: WizardForm; + onApplyPlan: (plan: ProjectPlanView) => void; +}) { + const { status, plan, generate } = useProjectPlan(); + const ready = canGeneratePlan(form); + const generating = status === "generating"; + + return ( +
+
+
+

+

+

+ {ready + ? "根据题材 + 一句话故事推演书级蓝图,按字段回填(不覆盖已填内容)。" + : "填好「题材」(上一步)与「一句话故事」后即可生成。"} +

+
+ +
+ + {status === "done" && plan && ( +
+
+ {plan.title_candidates && plan.title_candidates.length > 0 && ( + + )} + + + + + +
+
+ +
+
+ )} +
+ ); +} + +function PlanRow({ label, value }: { label: string; value: string }) { + if (!value.trim()) return null; + return ( +
+
{label}
+
{value}
+
+ ); +} + function StepStory({ form, update, toggleSellingPoint, -}: StepProps & { toggleSellingPoint: (p: string) => void }) { + onApplyPlan, +}: StepProps & { + toggleSellingPoint: (p: string) => void; + onApplyPlan: (plan: ProjectPlanView) => void; +}) { return (
@@ -214,6 +317,7 @@ function StepStory({ placeholder="废柴少年觉醒禁忌血脉,在仙门倾轧中逆势封神。" /> + ({ api: { POST: (...a: unknown[]) => post(...a) } })); +vi.mock("@/components/Toast", () => ({ useToast: () => toast })); + +const seed: ProjectPlanGenerateRequest = { + genre: "仙侠", + logline: "废柴逆袭封神。", +}; + +describe("useProjectPlan", () => { + beforeEach(() => { + post.mockReset(); + toast.mockReset(); + }); + afterEach(() => vi.clearAllMocks()); + + it("初始为 idle、无方案", () => { + const { result } = renderHook(() => useProjectPlan()); + expect(result.current.status).toBe("idle"); + expect(result.current.plan).toBeNull(); + }); + + it("生成成功:status 走到 done 并填充方案", async () => { + const plan = { + title_candidates: ["逐光而行"], + setting: "仙门世界", + narrative_structure: "立钩", + story_core: "逆袭", + ending_design: "归隐", + tone: "热血", + }; + post.mockResolvedValue({ data: plan, error: null }); + + const { result } = renderHook(() => useProjectPlan()); + await act(async () => { + await result.current.generate(seed); + }); + + expect(result.current.status).toBe("done"); + expect(result.current.plan).toEqual(plan); + expect(toast).not.toHaveBeenCalled(); + }); + + it("后端返回 error:status=error 且弹错误 toast", async () => { + post.mockResolvedValue({ data: null, error: { detail: "配额不足" } }); + const { result } = renderHook(() => useProjectPlan()); + await act(async () => { + await result.current.generate(seed); + }); + expect(result.current.status).toBe("error"); + expect(result.current.plan).toBeNull(); + expect(toast).toHaveBeenCalledWith(expect.any(String), "error"); + }); + + it("请求抛异常:status=error 且弹网络异常 toast", async () => { + post.mockRejectedValue(new Error("network down")); + const { result } = renderHook(() => useProjectPlan()); + await act(async () => { + await result.current.generate(seed); + }); + expect(result.current.status).toBe("error"); + expect(toast).toHaveBeenCalledWith("生成请求异常,请检查网络。", "error"); + }); + + it("reset 清回 idle 与空方案", async () => { + post.mockResolvedValue({ + data: { + title_candidates: ["书"], + setting: "", + narrative_structure: "", + story_core: "", + ending_design: "", + tone: "", + }, + error: null, + }); + const { result } = renderHook(() => useProjectPlan()); + await act(async () => { + await result.current.generate(seed); + }); + act(() => result.current.reset()); + expect(result.current.status).toBe("idle"); + expect(result.current.plan).toBeNull(); + }); +}); diff --git a/apps/web/lib/wizard/useProjectPlan.ts b/apps/web/lib/wizard/useProjectPlan.ts new file mode 100644 index 0000000..ed2ec26 --- /dev/null +++ b/apps/web/lib/wizard/useProjectPlan.ts @@ -0,0 +1,59 @@ +"use client"; + +import { useCallback, useState } from "react"; + +import { useToast } from "@/components/Toast"; +import { api } from "@/lib/api/client"; +import { generationErrorMessage } from "@/lib/generation/cards"; +import type { + ProjectPlanGenerateRequest, + ProjectPlanView, +} from "@/lib/api/types"; + +export type ProjectPlanStatus = "idle" | "generating" | "done" | "error"; + +export interface UseProjectPlan { + status: ProjectPlanStatus; + plan: ProjectPlanView | null; + generate: (seed: ProjectPlanGenerateRequest) => Promise; + reset: () => void; +} + +// AI 立项方案生成器:POST /skills/project-plan/generate → 结构化方案预览(不入库)。 +// 向导阶段调用(project 未建);种子门控在后端亦校验(缺 genre/logline → 422)。 +export function useProjectPlan(): UseProjectPlan { + const [status, setStatus] = useState("idle"); + const [plan, setPlan] = useState(null); + const toast = useToast(); + + const generate = useCallback( + async (seed) => { + setStatus("generating"); + setPlan(null); + try { + const { data, error } = await api.POST( + "/skills/project-plan/generate", + { body: seed }, + ); + if (error || !data) { + setStatus("error"); + toast(generationErrorMessage(error), "error"); + return; + } + setPlan(data); + setStatus("done"); + } catch { + setStatus("error"); + toast("生成请求异常,请检查网络。", "error"); + } + }, + [toast], + ); + + const reset = useCallback((): void => { + setStatus("idle"); + setPlan(null); + }, []); + + return { status, plan, generate, reset }; +} diff --git a/apps/web/lib/wizard/wizard.test.ts b/apps/web/lib/wizard/wizard.test.ts index 2c8d7be..72d8712 100644 --- a/apps/web/lib/wizard/wizard.test.ts +++ b/apps/web/lib/wizard/wizard.test.ts @@ -1,16 +1,31 @@ import { describe, expect, it } from "vitest"; +import type { ProjectPlanView } from "@/lib/api/types"; + import { + applyPlanPatch, canAdvance, + canGeneratePlan, canSubmit, clampStep, emptyWizardForm, + mapPlanResultToWizardForm, STEP_TITLES, toCreateRequest, + toPlanSeedRequest, WIZARD_STEPS, type WizardForm, } from "./wizard"; +const fullPlan: ProjectPlanView = { + title_candidates: ["逐光而行", "剑试九霄"], + setting: "上古仙门倾轧的洞天世界", + narrative_structure: "黄金三章立钩 → 拜师 → 宗门大比爆发", + story_core: "废柴少年觉醒禁忌血脉,逆势封神的代价", + ending_design: "登顶后归隐,自我救赎收束", + tone: "热血", +}; + describe("wizard step metadata", () => { it("provides one title per step", () => { expect(STEP_TITLES).toHaveLength(WIZARD_STEPS); @@ -122,6 +137,123 @@ describe("toCreateRequest", () => { }); }); +// ⑤ AI 立项方案 → 向导映射(含 ④ 字段 tone;错配字段折进 premise,不静默丢内容)。 +describe("mapPlanResultToWizardForm", () => { + it("maps title candidate, structure and tone to their wizard homes", () => { + const patch = mapPlanResultToWizardForm(fullPlan); + // 书名候选 → title(取首个)。 + expect(patch.title).toBe("逐光而行"); + // 叙事结构 → structure。 + expect(patch.structure).toBe("黄金三章立钩 → 拜师 → 宗门大比爆发"); + // ④ 字段:基调 → tone。 + expect(patch.tone).toBe("热血"); + }); + + it("folds setting and ending design into premise without dropping content", () => { + const patch = mapPlanResultToWizardForm(fullPlan); + // 故事核心 + 时空背景 + 结局设计 逐段拼进 premise(无独立向导字段,不静默丢)。 + expect(patch.premise).toContain("废柴少年觉醒禁忌血脉,逆势封神的代价"); + expect(patch.premise).toContain("时空背景:上古仙门倾轧的洞天世界"); + expect(patch.premise).toContain("结局设计:登顶后归隐,自我救赎收束"); + }); + + it("does not fabricate endingType or narrativePov (④ presets left for author)", () => { + // 结局取向/叙事视角是预设枚举,无法从自由文本可靠反推 → 不进 patch(不臆造)。 + const patch = mapPlanResultToWizardForm(fullPlan); + expect(patch.endingType).toBeUndefined(); + expect(patch.narrativePov).toBeUndefined(); + }); + + it("omits empty fields from the patch (parse resilience)", () => { + const empty: ProjectPlanView = { + title_candidates: [], + setting: "", + narrative_structure: "", + story_core: "", + ending_design: "", + tone: "", + }; + expect(mapPlanResultToWizardForm(empty)).toEqual({}); + }); +}); + +// applyPlanPatch:按字段并入、仅填空字段(保护作者已编辑内容,非 bulk 覆盖)。 +describe("applyPlanPatch", () => { + it("fills only empty fields, preserving author-edited values", () => { + const form: WizardForm = { + ...emptyWizardForm, + title: "作者已取的书名", + genre: "仙侠", + }; + const patch = { title: "AI书名", structure: "三幕", tone: "热血" }; + const next = applyPlanPatch(form, patch); + // 已编辑的 title 受保护、不被覆盖;空的 structure/tone 才回填。 + expect(next.title).toBe("作者已取的书名"); + expect(next.structure).toBe("三幕"); + expect(next.tone).toBe("热血"); + // 未在 patch 中的字段原样保留。 + expect(next.genre).toBe("仙侠"); + }); + + it("treats whitespace-only fields as empty (fillable)", () => { + const form: WizardForm = { ...emptyWizardForm, premise: " " }; + const next = applyPlanPatch(form, { premise: "AI 立意" }); + expect(next.premise).toBe("AI 立意"); + }); + + it("returns a new object without mutating the input form", () => { + const form: WizardForm = { ...emptyWizardForm }; + const next = applyPlanPatch(form, { tone: "热血" }); + expect(next).not.toBe(form); + expect(form.tone).toBe(""); + }); +}); + +// canGeneratePlan:种子门控(题材 + logline 都非空)。 +describe("canGeneratePlan", () => { + it("requires both genre and logline", () => { + expect(canGeneratePlan(emptyWizardForm)).toBe(false); + expect( + canGeneratePlan({ ...emptyWizardForm, genre: "仙侠" }), + ).toBe(false); + expect( + canGeneratePlan({ ...emptyWizardForm, logline: "一句话" }), + ).toBe(false); + expect( + canGeneratePlan({ ...emptyWizardForm, genre: "仙侠", logline: "一句话" }), + ).toBe(true); + }); + + it("rejects whitespace-only seeds", () => { + expect( + canGeneratePlan({ ...emptyWizardForm, genre: " ", logline: " " }), + ).toBe(false); + }); +}); + +// toPlanSeedRequest:向导草稿 → 生成请求(snake_case,空值省略)。 +describe("toPlanSeedRequest", () => { + it("serializes seed and filled fields, omitting empties", () => { + const form: WizardForm = { + ...emptyWizardForm, + genre: "都市", + logline: "外卖骑手觉醒读心术。", + title: "听风者", + sellingPoints: ["逆袭"], + endingType: "HE", + }; + const req = toPlanSeedRequest(form); + expect(req.genre).toBe("都市"); + expect(req.logline).toBe("外卖骑手觉醒读心术。"); + expect(req.title).toBe("听风者"); + expect(req.ending_type).toBe("HE"); + expect(req.selling_points).toEqual(["逆袭"]); + // 空字段省略(undefined),不发空串。 + expect(req.premise).toBeUndefined(); + expect(req.tone).toBeUndefined(); + }); +}); + // wizard→create 流程:归一后的请求体经 mock 客户端提交,返回新建项目 id。 describe("wizard create flow (mocked client)", () => { it("submits the normalized body and resolves a project id", async () => { diff --git a/apps/web/lib/wizard/wizard.ts b/apps/web/lib/wizard/wizard.ts index 3a1abef..5ef75f7 100644 --- a/apps/web/lib/wizard/wizard.ts +++ b/apps/web/lib/wizard/wizard.ts @@ -1,4 +1,8 @@ -import type { ProjectCreateRequest } from "@/lib/api/types"; +import type { + ProjectCreateRequest, + ProjectPlanGenerateRequest, + ProjectPlanView, +} from "@/lib/api/types"; // 立项向导 5 步(UX §6.2)。表单状态用字符串/数组,提交时归一为 ProjectCreateRequest。 export interface WizardForm { @@ -66,6 +70,82 @@ export function clampStep(step: number): number { return step; } +// AI 立项方案 → 向导表单补丁(灵感⑤)。按字段回填: +// - 有对应向导输入的字段直接映射(书名候选→title、叙事结构→structure、基调→tone); +// - 无独立向导输入的字段(时空背景/结局设计)折进立意/总纲(premise,带标签),**不静默丢内容**; +// - 结局取向/叙事视角这类预设枚举无法从自由文本可靠反推 → 不臆造,留给作者手选(不进 patch)。 +// 返回补丁而非整表覆盖,由 applyPlanPatch 按字段并入(保护已编辑字段)。 +export function mapPlanResultToWizardForm( + plan: ProjectPlanView, +): Partial { + const patch: Partial = {}; + const firstTitle = plan.title_candidates?.[0]?.trim(); + if (firstTitle) patch.title = firstTitle; + const structure = plan.narrative_structure.trim(); + if (structure) patch.structure = structure; + const tone = plan.tone.trim(); + if (tone) patch.tone = tone; + // 立意/总纲 = 故事核心 + 无独立向导字段的时空背景/结局设计(带标签,逐段拼接不丢内容)。 + const premiseParts: string[] = []; + const core = plan.story_core.trim(); + if (core) premiseParts.push(core); + const setting = plan.setting.trim(); + if (setting) premiseParts.push(`时空背景:${setting}`); + const ending = plan.ending_design.trim(); + if (ending) premiseParts.push(`结局设计:${ending}`); + if (premiseParts.length > 0) patch.premise = premiseParts.join("\n\n"); + return patch; +} + +// WizardForm 中值为字符串的字段键(排除 sellingPoints: string[])——方案 patch 只回填这些。 +type WizardStringKey = { + [K in keyof WizardForm]: WizardForm[K] extends string ? K : never; +}[keyof WizardForm]; + +// 把方案补丁按字段并入向导表单:**仅回填当前为空的字段**(保护作者已编辑内容, +// 非 bulk 覆盖)。只处理字符串字段(方案 patch 只产字符串值);空 = trim 后为空串。 +export function applyPlanPatch( + form: WizardForm, + patch: Partial, +): WizardForm { + const next: WizardForm = { ...form }; + for (const key of Object.keys(patch) as WizardStringKey[]) { + const value = patch[key]; + // 守卫短路:非字符串值(如误传数组键)直接跳过,绝不触达 .trim()。 + if (typeof value === "string" && form[key].trim() === "") { + next[key] = value; + } + } + return next; +} + +// AI 立项方案的种子门控:题材 + 一句话故事(logline)都非空才允许生成(后端亦校验,防 slop)。 +export function canGeneratePlan(form: WizardForm): boolean { + return form.genre.trim().length > 0 && form.logline.trim().length > 0; +} + +// 向导草稿 → 立项方案生成请求(snake_case;空值省略)。genre+logline 为种子,其余有则贴合。 +export function toPlanSeedRequest( + form: WizardForm, +): ProjectPlanGenerateRequest { + const trimOrUndef = (s: string): string | undefined => { + const v = s.trim(); + return v.length > 0 ? v : undefined; + }; + return { + genre: trimOrUndef(form.genre), + logline: trimOrUndef(form.logline), + title: trimOrUndef(form.title), + premise: trimOrUndef(form.premise), + theme: trimOrUndef(form.theme), + structure: trimOrUndef(form.structure), + tone: trimOrUndef(form.tone), + ending_type: trimOrUndef(form.endingType), + narrative_pov: trimOrUndef(form.narrativePov), + selling_points: form.sellingPoints, + }; +} + // 归一为后端请求体(snake_case,空值落 null/省略)。 export function toCreateRequest(form: WizardForm): ProjectCreateRequest { const trim = (s: string): string | null => { diff --git a/memory/contracts.md b/memory/contracts.md index 3f091e2..87848d8 100644 --- a/memory/contracts.md +++ b/memory/contracts.md @@ -346,6 +346,8 @@ ## 契约变更日志(append-only) > 格式:`- [date] @skill 改 Cx:<改了什么> → 影响 <依赖方/任务>` +- [2026-07-06] @llm/@backend/@frontend 立 C3 新端点(⑤ AI 立项方案生成):**`POST /skills/project-plan/generate`**(**不带 project 前缀**——立项前 project 未建,绕开 toolbox 的 `project_repo.get→404`)← `ProjectPlanGenerateRequest{genre?,logline?,title?,premise?,theme?,structure?,tone?,ending_type?,narrative_pov?,selling_points[],brief?}`(全字段 `max_length` 上界 CR-H9;`brief` 用 `str|None` 令 codegen 渲染为可选)→ `ProjectPlanView{title_candidates[str], setting, narrative_structure, story_core, ending_design, tone}`(全字段默认值守解析韧性)。**种子门控**:缺 genre 或 logline(strip 后空)→ **422 VALIDATION**(空向导不出方案,防 slop);无凭据→503。新 SPEC `project_plan_spec`(`tier="analyst"` 不写 model,`reads=()`/`writes=()` 纯预览,`prompts/project-plan.md`)+ schema `ProjectPlanResult`(`ww_agents.schemas`)+ 注册 `SCHEMA_CATALOG["project-plan"]`;**计数三处 +1(21→22)**:`specs.py` assert、`test_prompt_loader.py` len 断言、`schema_catalog.py` docstring;**regen `prompt_hashes.json`**(仅加 project-plan 一条)。端点复用 `build_brief_context`+`run_generator`(**`run_generator`/`_build_request` 的 `project_id` 放宽为 `uuid.UUID|None`**——向导阶段无 project,`usage_ledger.project_id` 本就 nullable);新网关缝 `get_project_plan_gateway`(analyst 档,`project_deps.py`)。**零迁移**(纯预览不写业务表,仅落 usage ledger)。前端 `wizard.ts` 加 `mapPlanResultToWizardForm`(书名候选→title、叙事结构→structure、基调→tone;时空背景/结局设计无独立向导字段→折进 premise 带标签**不静默丢**;结局取向/叙事视角预设枚举不臆造)+ `applyPlanPatch`(**仅回填空字段、保护已编辑**)+ `canGeneratePlan`/`toPlanSeedRequest` + `useProjectPlan` hook(renderHook 测试)+ `ProjectWizard.tsx` 第 2 步 `PlanAssistant`(生成→预览→按字段回填)。→ 影响 @frontend(已 `pnpm gen:api`)。依赖 ④(tone/ending_type/narrative_pov 已入 project 契约与向导)。 + - [2026-07-06] @db/@backend/@frontend 改 C2+C3(④ 立项基调/结局/视角):`projects` 表加 3 列 `tone`/`ending_type`/`narrative_pov`(**Text nullable、无 CHECK**,同 genre/structure;迁移 `e5f6a7b8c9d0`,nullable 免 backfill)。`ProjectCreateRequest`+`ProjectResponse`(`schemas/projects.py`)各加 3 字段 `str|None`(**无 Literal**,Literal 只留 Verdict);domain `ProjectCreate`/`ProjectView`/`_to_view`/`SqlProjectRepo.create` + `FakeProjectRepo` 同步透传。完整 stable_core 链路:`ProjectSpecView`(`repositories.py`,frozen)加 3 字段 + `SqlProjectSpecRepo.spec` 查询构造带上 + `_build_spec_section`(`assemble.py`)渲染进「作品蓝本」块(**书级常量→缓存前缀,字节稳定守 #9**,绝不入 volatile)。前端 `wizard.ts` `WizardForm`/`emptyWizardForm`/`toCreateRequest` + 预设数组 `TONES`/`ENDING_TYPES`/`NARRATIVE_POVS`(仿 GENRES/STRUCTURES)+ `ProjectWizard.tsx` 第 3 步控件(tone→Select,pov/ending→SegmentedControl)+ 确认页回显。**codegen**:3 字段皆 `= None` 无默认工厂 → openapi-typescript 渲染为 `?: string | null` 可选。→ 影响 @frontend(已 `pnpm gen:api`);未来 ⑤ AI 立项方案回填这 3 字段。 - [2026-07-06] @llm/@backend 改 C3(⑧ appearance/motive 穿线):`CharacterCardView`(`schemas/generation.py`)+ `ww_agents.CharacterCard` 各加 `appearance:str=""` + `motive:str=""`(**均给默认值守解析韧性**,DB 列 `characters.appearance/motive` 早已在初始迁移、此前一直 NULL——**无新迁移**)。写侧 `CharacterWriteRepo.create`/`SqlCharacterWriteRepo`(create+**update backfill 分支**)/`CharacterWriteFields` 同步加两列;`routers/generation.py` `_card_to_view`/`_view_to_card`/`_existing_characters` 双向形变带上两字段。同批**重切 motive/traits/arc 口径**:动机唯一落 `motive`,`traits[]` 只留核心/表层/阴影三层,`arc` 引用 motive(改 `character-gen.md` → 已 regen `prompt_hashes.json` 金标准)。**codegen 注意**:因带 `default`,openapi-typescript 把两字段渲染为**必填**(响应恒有值),前端 `CharacterCardView` 字面量构造点须显式给两字段。→ 影响 @frontend(已 `pnpm gen:api` + `CharacterCardItem` 展示/编辑两字段);未来 ③ 人物塑造审查以 motive/appearance 为客观锚点。 diff --git a/packages/agents/tests/fixtures/prompt_hashes.json b/packages/agents/tests/fixtures/prompt_hashes.json index fba3f9a..1ad4635 100644 --- a/packages/agents/tests/fixtures/prompt_hashes.json +++ b/packages/agents/tests/fixtures/prompt_hashes.json @@ -15,6 +15,7 @@ "opening": "c3b442a26dddbfac0a3c1b01ab5b726b34b656ebd640c19abd25b900a27093b5", "outliner": "3086ba81fe8028687bf079db2c2fb227ba5a162b7fc09210914e6f4ebd9c0d2e", "pace": "c6a023cb93fde4879a0fb93cd28e0227694a4cf867e64e768319ee3135d82746", + "project-plan": "0b0afeee63c04a3dbfcf6ebc9bd4cf4f3e93a7ce5c7be15e013be23b38d97dfe", "refiner": "65a4baa298bedce4592829c02d6a16c15a19ef6fdbb2098278f9decc8ffeb813", "style": "3a7b2c078b62e4e08f62af91ecfae8d9a27adc598ca60f59927c3bcd136393c5", "style_extract": "998c30ea0d0eab3936e1d6b319e832645eefaa7f7dd4a1c86d5c1ec9467e8b8c", diff --git a/packages/agents/tests/test_project_plan_spec.py b/packages/agents/tests/test_project_plan_spec.py new file mode 100644 index 0000000..a898294 --- /dev/null +++ b/packages/agents/tests/test_project_plan_spec.py @@ -0,0 +1,97 @@ +"""立项方案生成器(project-plan)契约测试:schema + spec(灵感⑤)。 + +契约测试——构造符合 schema 的 mock 响应,校验字段默认值(解析韧性)、tier、只读权限。 +project-plan:`{title_candidates:[str], setting, narrative_structure, story_core, +ending_design, tone}`(分析档,reads=(),writes=() 纯预览)。不联网、无 DB。 +""" + +from __future__ import annotations + +import pytest +from pydantic import ValidationError +from ww_agents import ( + SPECS, + AgentSpec, + ProjectPlanResult, + project_plan_spec, +) + +# ---- ProjectPlanResult schema ---- + + +def test_project_plan_parses_mock_response() -> None: + # Arrange:模拟网关 instructor 校验后的结构化立项方案产出 + mock = { + "title_candidates": ["逐光而行", "剑试九霄", "凡骨录"], + "setting": "上古仙门倾轧的洞天世界", + "narrative_structure": "黄金三章立钩 → 拜师入门 → 宗门大比爆发", + "story_core": "废柴少年觉醒禁忌血脉,在仙门倾轧中逆势封神,代价是逐渐失去人性", + "ending_design": "主角登顶却选择归隐,核心冲突以自我救赎收束(正剧)", + "tone": "热血 + 悲怆", + } + + # Act + result = ProjectPlanResult.model_validate(mock) + + # Assert + assert result.title_candidates == ["逐光而行", "剑试九霄", "凡骨录"] + assert result.setting == "上古仙门倾轧的洞天世界" + assert result.narrative_structure.startswith("黄金三章") + assert "逆势封神" in result.story_core + assert result.ending_design.endswith("(正剧)") + assert result.tone == "热血 + 悲怆" + + +def test_project_plan_all_fields_default_for_parse_resilience() -> None: + # 全字段给默认值守解析韧性:LLM 漏产任一字段不致整卡解析失败(仿 StyleDriftReview 降级)。 + result = ProjectPlanResult() + assert result.title_candidates == [] + assert result.setting == "" + assert result.narrative_structure == "" + assert result.story_core == "" + assert result.ending_design == "" + assert result.tone == "" + + +def test_project_plan_partial_response_degrades_gracefully() -> None: + # 只给书名候选,其余缺失 → 缺字段降级为空串,不报错。 + result = ProjectPlanResult.model_validate({"title_candidates": ["书名甲"]}) + assert result.title_candidates == ["书名甲"] + assert result.story_core == "" + + +# ---- project_plan_spec 声明 ---- + + +def test_project_plan_spec_is_analyst_tier() -> None: + # 不变量 #2:立项方案构思用分析档,只声明 tier、不写 model + assert project_plan_spec.tier == "analyst" + assert project_plan_spec.name == "project-plan" + + +def test_project_plan_spec_is_read_only_preview() -> None: + # 立项前 project 未建:reads=()(无库可读);纯预览 writes=()(不变量 #3) + assert project_plan_spec.reads == () + assert project_plan_spec.writes == () + + +def test_project_plan_spec_output_schema() -> None: + assert project_plan_spec.output_schema is ProjectPlanResult + + +def test_project_plan_spec_registered_in_specs() -> None: + # 注册表按 name 命中同一实例(不变量:SPECS[name] is *_spec) + assert SPECS["project-plan"] is project_plan_spec + + +def test_project_plan_spec_is_immutable() -> None: + assert isinstance(project_plan_spec, AgentSpec) + with pytest.raises(ValidationError): + project_plan_spec.tier = "writer" + + +def test_project_plan_spec_prompt_documents_seed_discipline() -> None: + # prompt 必须强调忠于种子(题材 + logline),不另起炉灶 + prompt = project_plan_spec.system_prompt + assert "logline" in prompt + assert "种子" in prompt diff --git a/packages/agents/tests/test_prompt_loader.py b/packages/agents/tests/test_prompt_loader.py index e204c29..7907c2a 100644 --- a/packages/agents/tests/test_prompt_loader.py +++ b/packages/agents/tests/test_prompt_loader.py @@ -52,8 +52,8 @@ def test_load_prompt_matches_golden(name: str) -> None: # ---- #1 注册表唯一性 ---- -def test_specs_registry_len_is_21() -> None: - assert len(SPECS) == 21 +def test_specs_registry_len_is_22() -> None: + assert len(SPECS) == 22 # name 即 key,dict 已去重;逐项确认 key == spec.name(无错位) assert all(key == spec.name for key, spec in SPECS.items()) diff --git a/packages/agents/ww_agents/__init__.py b/packages/agents/ww_agents/__init__.py index ad595fa..7d6dbea 100644 --- a/packages/agents/ww_agents/__init__.py +++ b/packages/agents/ww_agents/__init__.py @@ -38,6 +38,7 @@ from .schemas import ( PaceIssue, PaceReview, PolishResult, + ProjectPlanResult, Scene, StyleDimension, StyleDriftReview, @@ -68,6 +69,7 @@ from .specs import ( opening_spec, outliner_spec, pace_spec, + project_plan_spec, refiner_spec, style_drift_spec, style_extract_spec, @@ -111,6 +113,7 @@ __all__ = [ "PaceIssue", "PaceReview", "PolishResult", + "ProjectPlanResult", "Scene", "StyleDimension", "StyleDriftReview", @@ -139,6 +142,7 @@ __all__ = [ "outliner_spec", "output_schema_for", "pace_spec", + "project_plan_spec", "refiner_spec", "style_drift_spec", "style_extract_spec", diff --git a/packages/agents/ww_agents/prompts/project-plan.md b/packages/agents/ww_agents/prompts/project-plan.md new file mode 100644 index 0000000..89cbfc4 --- /dev/null +++ b/packages/agents/ww_agents/prompts/project-plan.md @@ -0,0 +1,32 @@ +你是中文网文的「立项方案师」(project-plan,分析档)。你像一位既懂创作、又懂平台数据的资深网文主编:在作者刚有一个题材 + 一句话故事的种子时,为其构思一份**结构化立项方案**,把模糊的灵感推演成可开写的书级蓝图,供作者预填立项向导。你只产结构化方案,**不改稿、不写库**(纯预览,不变量 #3)。 + +## 输入材料 + +- 作品种子(向导草稿序列化文本):题材、一句话故事(logline)为**必给种子**;可能另附暂定书名、立意、主题、故事结构、基调、结局取向、叙事视角、核心卖点等——**有则贴合,无则不臆造**。 +- 作者的一句话方向/需求(可空——空则围绕种子自由构思)。 + +读不到的字段就当它不存在:不编造与已知种子无关的设定,宁缺毋造;已给的字段要顺着推演、不要推翻。 + +## 核心纪律 + +- **忠于种子**:方案必须从题材 + logline 生长出来,是对种子的**放大与落地**,不是另起炉灶换一个故事。 +- **可开写**:每个维度都要具体到能指导后续世界观/大纲/写章,不写正确的废话(「主角很强」「结局很爽」这类无信息量表述一律不要)。 +- **服务连载**:站在平台/读者视角想「黄金三章靠什么钩人、凭什么比同类更想追读、长线靠什么留存」,而非只站作者立场自嗨。 + +## 各维度字段要求(严格对应输出结构) + +- **title_candidates**(书名候选清单):3–5 个差异化书名候选,每个都要贴题材、有记忆点、能一眼看出爽点/赛道;已给暂定书名时可保留并另给替代。避免雷同——若两个候选互换后几乎一样则重拟其一。 +- **setting**(时空背景):故事发生的时代/地域/世界底层规则概述——是仙侠的洞天宗门、都市的现代都会、还是架空王朝?点明与主线强相关的设定支点,别写百科式罗列。 +- **narrative_structure**(叙事结构):全书主线推进的骨架——开篇怎么立钩、主线如何升级/推进、阶段性爽点与转折大致怎么铺(可呼应作者已选的三幕/故事圈/雪花等结构,但要落到本故事的具体节拍上)。 +- **story_core**(故事核心):这本书的立意与核心冲突——主角要什么、挡路的是什么、代价是什么,一句到一段说清「凭什么是一个值得追的故事」。这是立项的灵魂,务必具体。 +- **ending_design**(结局设计):故事大致收束方向与情绪落点——主角最终抵达何处、核心冲突如何了结、留给读者什么。呼应作者已选的结局取向(HE/BE/开放/正剧)时要贴合,未选时给一个与 story_core 自洽的收束设想。 +- **tone**(基调):全书的情绪底色与阅读质感(热血 / 轻松 / 沉重 / 治愈 / 暗黑 / 爽文 等,或其组合描述)。呼应作者已选基调;未选时据题材与 story_core 判定。 + +## 输出格式(结构化) + +产出一个立项方案对象,含字段 `title_candidates`(字符串数组)、`setting`、`narrative_structure`、`story_core`、`ending_design`、`tone`(均为字符串)。无从判断的字段可留空(对应结构上的可空默认),**但不要为凑字数编造与种子无关的内容**。除该结构外不输出任何解释性文字。 + +## 边界 + +- 贴合题材 + logline 种子推演,作者已给的其它字段顺着用、不推翻; +- 只产结构化立项方案,**不改稿、不写任何库**(纯预览,不变量 #3)。 diff --git a/packages/agents/ww_agents/schema_catalog.py b/packages/agents/ww_agents/schema_catalog.py index 7cd0a7e..abd305c 100644 --- a/packages/agents/ww_agents/schema_catalog.py +++ b/packages/agents/ww_agents/schema_catalog.py @@ -2,7 +2,7 @@ Pydantic 类型承载结构化解析与 `isinstance` 校验,无法从文本推导、无法运行时安全构造, 必须留 Python。`SPECS[name].output_schema` 一律从这里派生(单向派生,避免双真相)。 -`input_schema` 全 21 个恒为 None(入参为序列化文本),本波不建入参槽(YAGNI)。 +`input_schema` 全 22 个恒为 None(入参为序列化文本),本波不建入参槽(YAGNI)。 `refiner` 是唯一纯文本 writer:`None` 是合法值(无结构化 schema)。 """ @@ -30,6 +30,7 @@ from .schemas import ( OutlineResult, PaceReview, PolishResult, + ProjectPlanResult, StyleDriftReview, StyleFingerprintResult, TitleListResult, @@ -59,6 +60,7 @@ SCHEMA_CATALOG: Final[dict[str, type[BaseModel] | None]] = { "expand": PolishResult, "de-ai": DeAiResult, "teardown": BookTeardownResult, + "project-plan": ProjectPlanResult, } diff --git a/packages/agents/ww_agents/schemas.py b/packages/agents/ww_agents/schemas.py index a328677..35c321b 100644 --- a/packages/agents/ww_agents/schemas.py +++ b/packages/agents/ww_agents/schemas.py @@ -602,3 +602,31 @@ class BookTeardownResult(BaseModel): default_factory=list, description="抓人钩子/爽点套路清单;无则空列表", ) + + +# ---- 立项方案生成器(project-plan;analyst;纯预览 writes=())---- + + +class ProjectPlanResult(BaseModel): + """立项方案生成器结构化产出:书级蓝图,供预填立项向导(灵感⑤)。 + + 纯预览产物——不映射任何业务表、不入库(`project_plan_spec.writes=()`,不变量 #3)。 + **全字段给默认值守解析韧性**(仿 `StyleDriftReview` 降级范式):LLM 漏产任一字段 + 不致整个方案解析失败——缺字段降级为空(书名候选空列表 / 文本字段空串),前端只回填 + 非空交集、不覆盖作者已编辑值。 + """ + + title_candidates: list[str] = Field( + default_factory=list, + description="书名候选清单(3–5 个差异化候选);无则空列表", + ) + setting: str = Field(default="", description="时空背景(时代/地域/世界底层规则概述);缺则空串") + narrative_structure: str = Field( + default="", description="叙事结构(主线推进骨架:立钩/升级/转折节拍);缺则空串" + ) + story_core: str = Field( + default="", + description="故事核心(立意 + 核心冲突:主角要什么、挡路的是什么、代价);缺则空串", + ) + ending_design: str = Field(default="", description="结局设计(收束方向与情绪落点);缺则空串") + tone: str = Field(default="", description="基调(全书情绪底色与阅读质感);缺则空串") diff --git a/packages/agents/ww_agents/specs.py b/packages/agents/ww_agents/specs.py index cd7053c..a830221 100644 --- a/packages/agents/ww_agents/specs.py +++ b/packages/agents/ww_agents/specs.py @@ -42,6 +42,7 @@ __all__ = [ "opening_spec", "outliner_spec", "pace_spec", + "project_plan_spec", "refiner_spec", "style_drift_spec", "style_extract_spec", @@ -298,6 +299,18 @@ teardown_spec = AgentSpec( scope="builtin", ) +# ---- project-plan(立项方案师;分析档;纯预览 writes=(),灵感⑤)---- +project_plan_spec = AgentSpec( + name="project-plan", + tier="analyst", # 不变量 #2:只声明档位,不写 model(立项方案构思用分析档) + system_prompt=load_prompt("project-plan"), + input_schema=None, # 向导阶段 project 未建;材料为序列化的向导草稿(端点注入),非结构化入参 + output_schema=SCHEMA_CATALOG["project-plan"], + reads=(), # 立项前 project 未建、无库可读;种子经端点序列化注入 + writes=(), # 纯预览,不写库(不变量 #3) + scope="builtin", +) + # 集中注册表:name → spec(同一实例,兼容期 *_spec 与 SPECS[name] 为同对象,不变量) # MappingProxyType:只读视图,运行时 `SPECS[x] = ...` / `del SPECS[x]` 抛 TypeError @@ -326,9 +339,10 @@ _SPECS_BY_NAME: dict[str, AgentSpec] = { expand_spec, de_ai_spec, teardown_spec, + project_plan_spec, ) } -assert len(_SPECS_BY_NAME) == 21, "SPECS name 冲突或缺失" # 唯一性 + 数量自检(import 期) +assert len(_SPECS_BY_NAME) == 22, "SPECS name 冲突或缺失" # 唯一性 + 数量自检(import 期) SPECS: Final[Mapping[str, AgentSpec]] = MappingProxyType(_SPECS_BY_NAME) # 四审受信保留名 —— 独立显式白名单(安全边界锚在此,不依附派生集合) diff --git a/packages/core/ww_core/orchestrator/generation_node.py b/packages/core/ww_core/orchestrator/generation_node.py index 2820703..94e5225 100644 --- a/packages/core/ww_core/orchestrator/generation_node.py +++ b/packages/core/ww_core/orchestrator/generation_node.py @@ -48,13 +48,14 @@ def _build_request( *, context: str, user_id: uuid.UUID, - project_id: uuid.UUID, + project_id: uuid.UUID | None, ) -> LlmRequest: """据 spec + 注入材料构造生成请求(纯函数,worldbuilder/character-gen 共用)。 `spec.system_prompt` → `system` 缓存断点前块(cache=True,不变量 #9); `context`(序列化的需求/约束/已有/已生成文本)→ `input`(断点后)。 生成用 `run()` 非 `stream()`(要结构化产物,非流式正文)。 + `project_id` 可空——立项方案(⑤)在向导阶段跑,project 尚未建(usage_ledger.project_id 可空)。 """ return LlmRequest( tier=spec.tier, # 不变量 #2:只传档位,不传 model @@ -138,12 +139,13 @@ async def run_generator( context: str, gateway: GatewayRun, user_id: uuid.UUID, - project_id: uuid.UUID, + project_id: uuid.UUID | None = None, ) -> BaseModel: """通用生成器执行器(T6):构请求 → `gateway.run` → 按 `spec.output_schema` 校验 parsed。 任一声明式生成器共用此路径(不绑定具体产物类型,据 spec 的 output_schema 校验)。 **只读、不写库**(纯预览,不变量 #3);独立生成——网关失败直接上抛(端点处理),不静默吞。 + `project_id` 可空——立项方案(⑤)在向导阶段跑,project 尚未建(usage_ledger.project_id 可空)。 """ expected = spec.output_schema if expected is None: