调整策划 V2 决定 ID 由 Runtime 分配

删除 plan_submit_gdd Provider schema 中的 decisions[].id 和 prototypeValidationItems[].id

由 Runtime 生成 initial-request 与后续决定 ID,并绑定原型验证项

同步更新 Planning V2 技术方案和项目决策记录
This commit is contained in:
2026-09-04 12:15:45 +00:00
parent c454b22428
commit 0c7ddf26db
3 changed files with 193 additions and 99 deletions
@@ -36,7 +36,6 @@ pub(crate) struct PlanningQuestionOptionV2 {
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub(crate) struct PlanningGddDecisionInputV2 {
pub id: String,
pub topic: String,
pub state: String,
#[serde(default)]
@@ -45,6 +44,15 @@ pub(crate) struct PlanningGddDecisionInputV2 {
pub answer_summary: String,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub(crate) struct PlanningPrototypeValidationItemInputV2 {
pub question: String,
pub micro_prototype: String,
pub observation: String,
pub pass_criterion: String,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub(crate) struct PlanningGddInputV2 {
@@ -52,7 +60,7 @@ pub(crate) struct PlanningGddInputV2 {
pub schema_version: Option<String>,
pub game: PlanSubmitGame,
pub decisions: Vec<PlanningGddDecisionInputV2>,
pub prototype_validation_items: Vec<PlanPrototypeValidationItem>,
pub prototype_validation_items: Vec<PlanningPrototypeValidationItemInputV2>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
@@ -359,7 +367,6 @@ pub(crate) fn planning_v2_function_tools() -> Vec<platform_llm::LlmFunctionTool>
"type": "object",
"additionalProperties": false,
"properties": {
"id": {"type": "string", "description": "决定 id"},
"topic": {"type": "string", "description": "主题"},
"state": decision_state.clone(),
"answerSource": {
@@ -373,23 +380,23 @@ pub(crate) fn planning_v2_function_tools() -> Vec<platform_llm::LlmFunctionTool>
},
"answerSummary": {"type": "string", "description": "结论摘要"}
},
"required": ["id", "topic", "state", "round", "answerSummary"]
"description": "决定内容",
"required": ["topic", "state", "round", "answerSummary"]
}
},
"prototypeValidationItems": {
"type": "array",
"description": "无 prototype_pending 时为 [];有则按决定 id 对应填写",
"description": "无 prototype_pending 时为 [];有则按 decisions 中 prototype_pending 决定的顺序填写",
"items": {
"type": "object",
"additionalProperties": false,
"properties": {
"id": {"type": "string", "description": "与待验证决定对应的 id"},
"question": {"type": "string", "description": "要验证的问题"},
"microPrototype": {"type": "string", "description": "最小原型做法"},
"observation": {"type": "string", "description": "观察什么"},
"passCriterion": {"type": "string", "description": "通过标准"}
},
"required": ["id", "question", "microPrototype", "observation", "passCriterion"]
"required": ["question", "microPrototype", "observation", "passCriterion"]
}
}
},
@@ -584,101 +591,138 @@ fn validate_v2_game(game: &PlanGddGame) -> Result<(), String> {
.map_err(|error| format!("PLANNING_INVALID_GDD: {error}"))
}
fn validate_v2_decisions(
fn validate_v2_decision_content(
index: usize,
topic: &str,
state: &str,
answer_source: Option<String>,
round: u32,
answer_summary: &str,
) -> Result<(), String> {
validate_text(topic, &format!("decisions[{index}].topic"), 1, 120)
.map_err(|error| error.to_string())?;
normalize_v2_decision_state(state)?;
normalize_answer_source(state, answer_source)?;
validate_text(
answer_summary,
&format!("decisions[{index}].answerSummary"),
1,
if index == 0 {
PLAN_INITIAL_REQUEST_MAX_CHARS
} else {
PLAN_DECISION_ANSWER_SUMMARY_MAX_CHARS
},
)
.map_err(|error| error.to_string())?;
if index == 0 && (normalize_v2_decision_state(state)? != "confirmed" || round != 0) {
return Err("PLANNING_INVALID_GDD: decisions 首项必须是 confirmed 且 round=0".to_string());
}
Ok(())
}
fn validate_v2_prototype_content(
question: &str,
micro_prototype: &str,
observation: &str,
pass_criterion: &str,
) -> Result<(), String> {
for (label, value) in [
("question", question),
("microPrototype", micro_prototype),
("observation", observation),
("passCriterion", pass_criterion),
] {
validate_text(value, &format!("prototypeValidationItems.{label}"), 1, 400)
.map_err(|error| error.to_string())?;
}
Ok(())
}
fn validate_v2_decision_inputs(
decisions: &[PlanningGddDecisionInputV2],
prototype_items: &[PlanPrototypeValidationItem],
prototype_items: &[PlanningPrototypeValidationItemInputV2],
) -> Result<(), String> {
if !(1..=64).contains(&decisions.len()) {
return Err("PLANNING_INVALID_GDD: decisions 必须有 164 项".to_string());
}
let mut ids = std::collections::BTreeSet::new();
for (index, decision) in decisions.iter().enumerate() {
validate_v2_decision_content(
index,
&decision.topic,
&decision.state,
decision.answer_source.clone(),
decision.round,
&decision.answer_summary,
)?;
}
let prototype_count = decisions
.iter()
.filter(|decision| decision.state.trim() == "prototype_pending")
.count();
if prototype_items.len() > 3 || prototype_count != prototype_items.len() {
return Err("PLANNING_INVALID_GDD: prototypeValidationItems 必须逐项对应 prototype_pending 决定且最多 3 项".to_string());
}
for item in prototype_items {
validate_v2_prototype_content(
&item.question,
&item.micro_prototype,
&item.observation,
&item.pass_criterion,
)?;
}
Ok(())
}
fn validate_v2_durable_decisions(
decisions: &[PlanDecision],
prototype_items: &[PlanPrototypeValidationItem],
) -> Result<(), String> {
// Provider 输入不携带 ID;这里只校验 Runtime 已生成或已持久化产物的
// 内部引用完整性,避免把模型负责的内容校验与产物身份校验混在一起。
if !(1..=64).contains(&decisions.len()) {
return Err("PLANNING_INVALID_GDD: decisions 必须有 164 项".to_string());
}
let mut prototype_ids = std::collections::BTreeSet::new();
for (index, decision) in decisions.iter().enumerate() {
if decision.id != "initial-request"
&& (decision.id.len() > 64
|| !decision.id.chars().enumerate().all(|(position, value)| {
(position == 0 && value.is_ascii_lowercase())
|| (position > 0
&& (value.is_ascii_lowercase()
|| value.is_ascii_digit()
|| value == '-'))
}))
{
return Err(format!(
"PLANNING_INVALID_GDD: decisions[{index}].id 必须是 kebab-case"
));
}
validate_text(&decision.id, &format!("decisions[{index}].id"), 1, 64)
.map_err(|error| error.to_string())?;
validate_text(
validate_v2_decision_content(
index,
&decision.topic,
&format!("decisions[{index}].topic"),
1,
120,
)
.map_err(|error| error.to_string())?;
normalize_v2_decision_state(&decision.state)?;
normalize_answer_source(&decision.state, decision.answer_source.clone())?;
validate_text(
&decision.state,
Some(decision.answer_source.clone()),
decision.round,
&decision.answer_summary,
&format!("decisions[{index}].answerSummary"),
1,
if decision.id == "initial-request" {
PLAN_INITIAL_REQUEST_MAX_CHARS
} else {
PLAN_DECISION_ANSWER_SUMMARY_MAX_CHARS
},
)
.map_err(|error| error.to_string())?;
if !ids.insert(decision.id.as_str()) {
return Err(format!(
"PLANNING_INVALID_GDD: decisions.id 不能重复:{}",
decision.id
));
}
if normalize_v2_decision_state(&decision.state)? == "prototype_pending" {
)?;
if decision.state == "prototype_pending" {
prototype_ids.insert(decision.id.as_str());
}
}
if decisions.first().map(|value| value.id.as_str()) != Some("initial-request") {
return Err("PLANNING_INVALID_GDD: decisions 第一项必须是 initial-request".to_string());
}
let first = &decisions[0];
if first.state != "confirmed" || first.round != 0 {
return Err("PLANNING_INVALID_GDD: initial-request 必须 confirmed 且 round=0".to_string());
}
if prototype_items.len() > 3 || prototype_ids.len() != prototype_items.len() {
let mut item_ids = std::collections::BTreeSet::new();
if prototype_ids.len() != prototype_items.len() {
return Err("PLANNING_INVALID_GDD: prototypeValidationItems 必须逐项对应 prototype_pending 决定且最多 3 项".to_string());
}
let mut item_ids = std::collections::BTreeSet::new();
for item in prototype_items {
if !item_ids.insert(item.id.as_str()) || !prototype_ids.contains(item.id.as_str()) {
return Err("PLANNING_INVALID_GDD: prototypeValidationItems.id 必须与 prototype_pending 决定双射".to_string());
}
if !item.id.chars().enumerate().all(|(position, value)| {
(position == 0 && value.is_ascii_lowercase())
|| (position > 0
&& (value.is_ascii_lowercase() || value.is_ascii_digit() || value == '-'))
}) {
return Err(
"PLANNING_INVALID_GDD: prototypeValidationItems.id 必须是 kebab-case".to_string(),
);
}
validate_text(&item.id, "prototypeValidationItems.id", 1, 64)
.map_err(|error| error.to_string())?;
for (label, value) in [
("question", &item.question),
("microPrototype", &item.micro_prototype),
("observation", &item.observation),
("passCriterion", &item.pass_criterion),
] {
validate_text(value, &format!("prototypeValidationItems.{label}"), 1, 400)
.map_err(|error| error.to_string())?;
}
validate_v2_prototype_content(
&item.question,
&item.micro_prototype,
&item.observation,
&item.pass_criterion,
)?;
}
Ok(())
}
fn runtime_decision_id(index: usize) -> String {
if index == 0 {
"initial-request".to_string()
} else {
format!("decision-{index}")
}
}
fn build_gdd_v2(
project_id: &str,
version: u32,
@@ -690,9 +734,10 @@ fn build_gdd_v2(
let decisions = input
.decisions
.into_iter()
.map(|value| {
.enumerate()
.map(|(index, value)| {
Ok(PlanDecision {
id: value.id,
id: runtime_decision_id(index),
topic: value.topic,
state: normalize_v2_decision_state(&value.state)?,
answer_source: normalize_answer_source(&value.state, value.answer_source)?,
@@ -702,6 +747,23 @@ fn build_gdd_v2(
})
})
.collect::<Result<Vec<_>, String>>()?;
let prototype_ids = decisions
.iter()
.filter(|decision| decision.state == "prototype_pending")
.map(|decision| decision.id.clone())
.collect::<Vec<_>>();
let prototype_validation_items = input
.prototype_validation_items
.into_iter()
.zip(prototype_ids)
.map(|(value, id)| PlanPrototypeValidationItem {
id,
question: value.question,
micro_prototype: value.micro_prototype,
observation: value.observation,
pass_criterion: value.pass_criterion,
})
.collect::<Vec<_>>();
let mut gdd = PlanningGddV2 {
schema_version: PLAN_GDD_V2_SCHEMA_VERSION.to_string(),
project_id: project_id.to_string(),
@@ -710,7 +772,7 @@ fn build_gdd_v2(
created_at_utc: current_plan_timestamp_utc(),
game,
decisions,
prototype_validation_items: input.prototype_validation_items,
prototype_validation_items,
fingerprint: String::new(),
};
gdd.fingerprint = fingerprint_gdd_v2(&gdd)?;
@@ -723,7 +785,7 @@ pub(crate) fn validate_planning_policy_output_v2(
match output {
PlanningPolicyOutputV2::Question(question) => validate_question_v2(question),
PlanningPolicyOutputV2::Gdd(input) => {
validate_v2_decisions(&input.decisions, &input.prototype_validation_items)?;
validate_v2_decision_inputs(&input.decisions, &input.prototype_validation_items)?;
let game = planning_gdd_game_from_input(&input.game)?;
validate_v2_game(&game)
}
@@ -758,19 +820,7 @@ fn validate_gdd_v2(value: &PlanningGddV2, project_id: &str) -> Result<(), String
validate_uuid_prefixed(&value.gdd_id, "gdd-", "gddId").map_err(|error| error.to_string())?;
validate_timestamp(&value.created_at_utc, "createdAtUtc").map_err(|error| error.to_string())?;
validate_v2_game(&value.game)?;
let decisions = value
.decisions
.iter()
.map(|decision| PlanningGddDecisionInputV2 {
id: decision.id.clone(),
topic: decision.topic.clone(),
state: decision.state.clone(),
answer_source: Some(decision.answer_source.clone()),
round: decision.round,
answer_summary: decision.answer_summary.clone(),
})
.collect::<Vec<_>>();
validate_v2_decisions(&decisions, &value.prototype_validation_items)?;
validate_v2_durable_decisions(&value.decisions, &value.prototype_validation_items)?;
let expected = fingerprint_gdd_v2(value)?;
if expected != value.fingerprint {
return Err("PLANNING_INVALID_GDD: GDD fingerprint 不匹配".to_string());
@@ -1575,7 +1625,7 @@ mod tests {
"outOfScope": ["多人联机"],
"creatorTips": {"doFirst": "先做一张可走完的地图", "deferForNow": "暂缓复杂成长线", "howToVerify": "观察玩家是否能说出选择后果", "expandWhen": "连续三局都能理解后再扩展"}
},
"decisions": [{"id": "initial-request", "topic": "初始需求", "state": "confirmed", "round": 0, "answerSummary": "做一个短局守夜策略游戏"}],
"decisions": [{"topic": "初始需求", "state": "confirmed", "round": 0, "answerSummary": "做一个短局守夜策略游戏"}],
"prototypeValidationItems": []
})
}
@@ -1649,12 +1699,48 @@ mod tests {
assert!(gdd_properties.contains_key("game"));
assert!(gdd_properties.contains_key("decisions"));
assert!(!gdd_properties.contains_key("schemaVersion"));
let decision_properties = gdd_properties["decisions"]["items"]["properties"]
.as_object()
.expect("decision properties");
assert!(!decision_properties.contains_key("id"));
assert!(!gdd_properties["decisions"]["items"]["required"]
.as_array()
.expect("decision required")
.iter()
.any(|value| value == "id"));
let prototype_properties = gdd_properties["prototypeValidationItems"]["items"]
["properties"]
.as_object()
.expect("prototype properties");
assert!(!prototype_properties.contains_key("id"));
assert!(gdd_properties["game"]["properties"]
.as_object()
.expect("game properties")
.contains_key("title"));
}
#[test]
fn runtime_assigns_decision_and_prototype_ids() {
let mut value = sample_gdd_value();
value["decisions"] = serde_json::json!([
{"topic": "初始需求", "state": "confirmed", "round": 0, "answerSummary": "做一个短局守夜策略游戏"},
{"topic": "核心回路", "state": "prototype_pending", "round": 1, "answerSummary": "验证核心回路"}
]);
value["prototypeValidationItems"] = serde_json::json!([
{"question": "玩家是否理解核心回路?", "microPrototype": "做一个最小交互原型", "observation": "观察玩家行为", "passCriterion": "多数玩家完成目标"}
]);
let input: PlanningGddInputV2 = serde_json::from_value(value).expect("input");
let gdd = build_gdd_v2("project-v2", 1, input).expect("build gdd");
assert_eq!(
gdd.decisions
.iter()
.map(|decision| decision.id.as_str())
.collect::<Vec<_>>(),
vec!["initial-request", "decision-1"]
);
assert_eq!(gdd.prototype_validation_items[0].id, "decision-1");
}
fn sample_gdd_input() -> PlanningGddInputV2 {
serde_json::from_value(sample_gdd_value()).expect("sample gdd input")
}
@@ -15,6 +15,14 @@
- 关联文档:相关 PRD、技术文档、提交或 Issue
```
## 2026-09-04 Planning V2 将决定 ID 从 Provider 输入移回 Runtime
- 背景:`plan_submit_gdd` 原先要求模型生成 `decisions[].id` 及原型验证项引用 ID。该字段既不是方案内容,又容易出现 `initial_request``initialRequest` 或错误层级,导致合法 GDD 在 Runtime 事后校验阶段失败。
- 决策:Provider-facing `plan_submit_gdd` schema 和入参删除决定/原型验证项 ID。Runtime 按决定数组顺序生成首项 `initial-request`、后续 `decision-{序号}`,并按 `prototype_pending` 决定顺序给原型验证项绑定同一 ID。最终持久化 `plan-gdd.v2` 仍保留 ID,供审批、引用和 fingerprint 使用。V2 尚未上线,不为旧 Provider 输入或历史 V2 artifact 增加兼容转换;不符合新契约的历史数据按现有失败策略处理。
- 影响范围:`planning_policy_v2.rs` 的工具 schema、Provider 入参解析、Runtime 产物构建与定向测试;Planning V2 技术方案。
- 验证方式:定向 Rust 测试确认 schema 不含 ID、无 ID 输入可生成 Runtime ID;并运行 `cargo fmt --check``npm run check:encoding``git diff --check`
- 关联文档:`docs/technical/【技术方案】策划会话RuntimeV2接入与旧链路退役-2026-09-03.md``apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_policy_v2.rs``apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_session_v2.rs`
## 2026-09-04 Planning V2 把 `gdd.vN.json` 创建成功当作提交点
- 背景:V2 persist 先 create-only 写入不可变 GDD,再更新 index、Markdown、conversation 和 session。后续任一步失败会把 session 标成 `provider_failed`,但不回滚已创建文件;重试会重新生成 UUID/时间戳并撞上“已存在且内容不同”,hydrate 又只信 `current_artifact_version`,项目会卡死。
@@ -720,7 +720,7 @@ hydrate_planning_session_v2
- 对已存在 V2 Session 的项目,打开项目时先 hydrate V2;没有 V2 authority 的旧项目继续走旧读取路径,避免误把旧项目数据当成 V2。
- P3 已完成;旧会话 `legacy_retired` 封存、入口彻底关闭和真实 Provider/UI 全链路回归仍属于 P4/P5。
P3 之后的协议修正:V2 不再用正文 JSON 输出问询/GDDProvider 请求挂 `plan_ask_question` / `plan_submit_gdd``tool_choice=auto`,形状由工具 schema 承担。system prompt 只保留三项核心闭环等策略和当前问询进度;数量字数`initial-request`由既有校验器在失败时回灌。入参不必回声 `schemaVersion`,落盘 GDD 仍写 `plan-gdd.v2`。失败结果不重复渲染,严格解析和失败不落盘成功产物的规则保持不变。
P3 之后的协议修正:V2 不再用正文 JSON 输出问询/GDDProvider 请求挂 `plan_ask_question` / `plan_submit_gdd``tool_choice=auto`,形状由工具 schema 承担。system prompt 只保留三项核心闭环等策略和当前问询进度;数量字数由既有校验器在失败时回灌`plan_submit_gdd` 的工具参数只包含决定和原型验证内容,不包含任何 Runtime 分配的 ID;Runtime 在落盘前为决定分配首项 `initial-request`、后续 `decision-{序号}`,并按 `prototype_pending` 顺序绑定原型验证项。入参不必回声 `schemaVersion`,落盘 GDD 仍写 `plan-gdd.v2`。失败结果不重复渲染,严格解析和失败不落盘成功产物的规则保持不变。历史 V2 数据不做兼容转换,按现有恢复/失败策略处理。
### P4:灰度、真实 Provider 与回归验收