向导阶段(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。
98 lines
3.7 KiB
Python
98 lines
3.7 KiB
Python
"""立项方案生成器(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
|