diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_policy_v2.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_policy_v2.rs index 9e56f6b2c..4f2f497f8 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_policy_v2.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_policy_v2.rs @@ -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, pub game: PlanSubmitGame, pub decisions: Vec, - pub prototype_validation_items: Vec, + pub prototype_validation_items: Vec, } #[derive(Clone, Debug, Deserialize, Serialize)] @@ -359,7 +367,6 @@ pub(crate) fn planning_v2_function_tools() -> Vec "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 }, "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, + 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 必须有 1~64 项".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 必须有 1~64 项".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::, String>>()?; + let prototype_ids = decisions + .iter() + .filter(|decision| decision.state == "prototype_pending") + .map(|decision| decision.id.clone()) + .collect::>(); + 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::>(); 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::>(); - 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!["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") } diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 76c56f173..9f0c6dd29 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -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`,项目会卡死。 diff --git a/docs/technical/【技术方案】策划会话RuntimeV2接入与旧链路退役-2026-09-03.md b/docs/technical/【技术方案】策划会话RuntimeV2接入与旧链路退役-2026-09-03.md index c4e2ec6b4..2f0d97a0b 100644 --- a/docs/technical/【技术方案】策划会话RuntimeV2接入与旧链路退役-2026-09-03.md +++ b/docs/technical/【技术方案】策划会话RuntimeV2接入与旧链路退役-2026-09-03.md @@ -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 输出问询/GDD;Provider 请求挂 `plan_ask_question` / `plan_submit_gdd`,`tool_choice=auto`,形状由工具 schema 承担。system prompt 只保留三项核心闭环等策略和当前问询进度;数量、字数和 `initial-request` 仍由既有校验器在失败时回灌。入参不必回声 `schemaVersion`,落盘 GDD 仍写 `plan-gdd.v2`。失败结果不重复渲染,严格解析和失败不落盘成功产物的规则保持不变。 +P3 之后的协议修正:V2 不再用正文 JSON 输出问询/GDD;Provider 请求挂 `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 与回归验收