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:
@@ -15,6 +15,7 @@
|
||||
"opening": "c3b442a26dddbfac0a3c1b01ab5b726b34b656ebd640c19abd25b900a27093b5",
|
||||
"outliner": "3086ba81fe8028687bf079db2c2fb227ba5a162b7fc09210914e6f4ebd9c0d2e",
|
||||
"pace": "c6a023cb93fde4879a0fb93cd28e0227694a4cf867e64e768319ee3135d82746",
|
||||
"project-plan": "0b0afeee63c04a3dbfcf6ebc9bd4cf4f3e93a7ce5c7be15e013be23b38d97dfe",
|
||||
"refiner": "65a4baa298bedce4592829c02d6a16c15a19ef6fdbb2098278f9decc8ffeb813",
|
||||
"style": "3a7b2c078b62e4e08f62af91ecfae8d9a27adc598ca60f59927c3bcd136393c5",
|
||||
"style_extract": "998c30ea0d0eab3936e1d6b319e832645eefaa7f7dd4a1c86d5c1ec9467e8b8c",
|
||||
|
||||
97
packages/agents/tests/test_project_plan_spec.py
Normal file
97
packages/agents/tests/test_project_plan_spec.py
Normal 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
|
||||
@@ -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())
|
||||
|
||||
|
||||
Reference in New Issue
Block a user