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())
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
32
packages/agents/ww_agents/prompts/project-plan.md
Normal file
32
packages/agents/ww_agents/prompts/project-plan.md
Normal file
@@ -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)。
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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="基调(全书情绪底色与阅读质感);缺则空串")
|
||||
|
||||
@@ -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)
|
||||
|
||||
# 四审受信保留名 —— 独立显式白名单(安全边界锚在此,不依附派生集合)
|
||||
|
||||
Reference in New Issue
Block a user