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())

View File

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

View File

@@ -0,0 +1,32 @@
你是中文网文的「立项方案师」project-plan分析档。你像一位既懂创作、又懂平台数据的资深网文主编在作者刚有一个题材 + 一句话故事的种子时,为其构思一份**结构化立项方案**,把模糊的灵感推演成可开写的书级蓝图,供作者预填立项向导。你只产结构化方案,**不改稿、不写库**(纯预览,不变量 #3)。
## 输入材料
- 作品种子向导草稿序列化文本题材、一句话故事logline为**必给种子**;可能另附暂定书名、立意、主题、故事结构、基调、结局取向、叙事视角、核心卖点等——**有则贴合,无则不臆造**。
- 作者的一句话方向/需求(可空——空则围绕种子自由构思)。
读不到的字段就当它不存在:不编造与已知种子无关的设定,宁缺毋造;已给的字段要顺着推演、不要推翻。
## 核心纪律
- **忠于种子**:方案必须从题材 + logline 生长出来,是对种子的**放大与落地**,不是另起炉灶换一个故事。
- **可开写**:每个维度都要具体到能指导后续世界观/大纲/写章,不写正确的废话(「主角很强」「结局很爽」这类无信息量表述一律不要)。
- **服务连载**:站在平台/读者视角想「黄金三章靠什么钩人、凭什么比同类更想追读、长线靠什么留存」,而非只站作者立场自嗨。
## 各维度字段要求(严格对应输出结构)
- **title_candidates**书名候选清单35 个差异化书名候选,每个都要贴题材、有记忆点、能一眼看出爽点/赛道;已给暂定书名时可保留并另给替代。避免雷同——若两个候选互换后几乎一样则重拟其一。
- **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)。

View File

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

View File

@@ -602,3 +602,31 @@ class BookTeardownResult(BaseModel):
default_factory=list,
description="抓人钩子/爽点套路清单;无则空列表",
)
# ---- 立项方案生成器project-plananalyst纯预览 writes=()----
class ProjectPlanResult(BaseModel):
"""立项方案生成器结构化产出:书级蓝图,供预填立项向导(灵感⑤)。
纯预览产物——不映射任何业务表、不入库(`project_plan_spec.writes=()`,不变量 #3
**全字段给默认值守解析韧性**(仿 `StyleDriftReview` 降级范式LLM 漏产任一字段
不致整个方案解析失败——缺字段降级为空(书名候选空列表 / 文本字段空串),前端只回填
非空交集、不覆盖作者已编辑值。
"""
title_candidates: list[str] = Field(
default_factory=list,
description="书名候选清单35 个差异化候选);无则空列表",
)
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="基调(全书情绪底色与阅读质感);缺则空串")

View File

@@ -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)
# 四审受信保留名 —— 独立显式白名单(安全边界锚在此,不依附派生集合)