feat(web): AI 立项方案生成+预填(种子门控 + 按字段接受)

向导阶段(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。
This commit is contained in:
Yaojia Wang
2026-07-06 16:26:08 +02:00
parent 821ace5989
commit 02d19019f6
22 changed files with 1187 additions and 9 deletions

View File

@@ -15,6 +15,7 @@
"opening": "c3b442a26dddbfac0a3c1b01ab5b726b34b656ebd640c19abd25b900a27093b5",
"outliner": "3086ba81fe8028687bf079db2c2fb227ba5a162b7fc09210914e6f4ebd9c0d2e",
"pace": "c6a023cb93fde4879a0fb93cd28e0227694a4cf867e64e768319ee3135d82746",
"project-plan": "0b0afeee63c04a3dbfcf6ebc9bd4cf4f3e93a7ce5c7be15e013be23b38d97dfe",
"refiner": "65a4baa298bedce4592829c02d6a16c15a19ef6fdbb2098278f9decc8ffeb813",
"style": "3a7b2c078b62e4e08f62af91ecfae8d9a27adc598ca60f59927c3bcd136393c5",
"style_extract": "998c30ea0d0eab3936e1d6b319e832645eefaa7f7dd4a1c86d5c1ec9467e8b8c",

View File

@@ -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

View File

@@ -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 即 keydict 已去重;逐项确认 key == spec.name无错位
assert all(key == spec.name for key, spec in SPECS.items())