调整策划 V2 决定 ID 由 Runtime 分配
删除 plan_submit_gdd Provider schema 中的 decisions[].id 和 prototypeValidationItems[].id 由 Runtime 生成 initial-request 与后续决定 ID,并绑定原型验证项 同步更新 Planning V2 技术方案和项目决策记录
This commit is contained in:
+184
-98
@@ -36,7 +36,6 @@ pub(crate) struct PlanningQuestionOptionV2 {
|
|||||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||||
pub(crate) struct PlanningGddDecisionInputV2 {
|
pub(crate) struct PlanningGddDecisionInputV2 {
|
||||||
pub id: String,
|
|
||||||
pub topic: String,
|
pub topic: String,
|
||||||
pub state: String,
|
pub state: String,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -45,6 +44,15 @@ pub(crate) struct PlanningGddDecisionInputV2 {
|
|||||||
pub answer_summary: String,
|
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)]
|
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||||
pub(crate) struct PlanningGddInputV2 {
|
pub(crate) struct PlanningGddInputV2 {
|
||||||
@@ -52,7 +60,7 @@ pub(crate) struct PlanningGddInputV2 {
|
|||||||
pub schema_version: Option<String>,
|
pub schema_version: Option<String>,
|
||||||
pub game: PlanSubmitGame,
|
pub game: PlanSubmitGame,
|
||||||
pub decisions: Vec<PlanningGddDecisionInputV2>,
|
pub decisions: Vec<PlanningGddDecisionInputV2>,
|
||||||
pub prototype_validation_items: Vec<PlanPrototypeValidationItem>,
|
pub prototype_validation_items: Vec<PlanningPrototypeValidationItemInputV2>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||||
@@ -359,7 +367,6 @@ pub(crate) fn planning_v2_function_tools() -> Vec<platform_llm::LlmFunctionTool>
|
|||||||
"type": "object",
|
"type": "object",
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"properties": {
|
"properties": {
|
||||||
"id": {"type": "string", "description": "决定 id"},
|
|
||||||
"topic": {"type": "string", "description": "主题"},
|
"topic": {"type": "string", "description": "主题"},
|
||||||
"state": decision_state.clone(),
|
"state": decision_state.clone(),
|
||||||
"answerSource": {
|
"answerSource": {
|
||||||
@@ -373,23 +380,23 @@ pub(crate) fn planning_v2_function_tools() -> Vec<platform_llm::LlmFunctionTool>
|
|||||||
},
|
},
|
||||||
"answerSummary": {"type": "string", "description": "结论摘要"}
|
"answerSummary": {"type": "string", "description": "结论摘要"}
|
||||||
},
|
},
|
||||||
"required": ["id", "topic", "state", "round", "answerSummary"]
|
"description": "决定内容",
|
||||||
|
"required": ["topic", "state", "round", "answerSummary"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"prototypeValidationItems": {
|
"prototypeValidationItems": {
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"description": "无 prototype_pending 时为 [];有则按决定 id 对应填写",
|
"description": "无 prototype_pending 时为 [];有则按 decisions 中 prototype_pending 决定的顺序填写",
|
||||||
"items": {
|
"items": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"properties": {
|
"properties": {
|
||||||
"id": {"type": "string", "description": "与待验证决定对应的 id"},
|
|
||||||
"question": {"type": "string", "description": "要验证的问题"},
|
"question": {"type": "string", "description": "要验证的问题"},
|
||||||
"microPrototype": {"type": "string", "description": "最小原型做法"},
|
"microPrototype": {"type": "string", "description": "最小原型做法"},
|
||||||
"observation": {"type": "string", "description": "观察什么"},
|
"observation": {"type": "string", "description": "观察什么"},
|
||||||
"passCriterion": {"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}"))
|
.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],
|
decisions: &[PlanningGddDecisionInputV2],
|
||||||
prototype_items: &[PlanPrototypeValidationItem],
|
prototype_items: &[PlanningPrototypeValidationItemInputV2],
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
if !(1..=64).contains(&decisions.len()) {
|
if !(1..=64).contains(&decisions.len()) {
|
||||||
return Err("PLANNING_INVALID_GDD: decisions 必须有 1~64 项".to_string());
|
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();
|
let mut prototype_ids = std::collections::BTreeSet::new();
|
||||||
for (index, decision) in decisions.iter().enumerate() {
|
for (index, decision) in decisions.iter().enumerate() {
|
||||||
if decision.id != "initial-request"
|
validate_v2_decision_content(
|
||||||
&& (decision.id.len() > 64
|
index,
|
||||||
|| !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(
|
|
||||||
&decision.topic,
|
&decision.topic,
|
||||||
&format!("decisions[{index}].topic"),
|
&decision.state,
|
||||||
1,
|
Some(decision.answer_source.clone()),
|
||||||
120,
|
decision.round,
|
||||||
)
|
|
||||||
.map_err(|error| error.to_string())?;
|
|
||||||
normalize_v2_decision_state(&decision.state)?;
|
|
||||||
normalize_answer_source(&decision.state, decision.answer_source.clone())?;
|
|
||||||
validate_text(
|
|
||||||
&decision.answer_summary,
|
&decision.answer_summary,
|
||||||
&format!("decisions[{index}].answerSummary"),
|
)?;
|
||||||
1,
|
if decision.state == "prototype_pending" {
|
||||||
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" {
|
|
||||||
prototype_ids.insert(decision.id.as_str());
|
prototype_ids.insert(decision.id.as_str());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if decisions.first().map(|value| value.id.as_str()) != Some("initial-request") {
|
let mut item_ids = std::collections::BTreeSet::new();
|
||||||
return Err("PLANNING_INVALID_GDD: decisions 第一项必须是 initial-request".to_string());
|
if prototype_ids.len() != prototype_items.len() {
|
||||||
}
|
|
||||||
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() {
|
|
||||||
return Err("PLANNING_INVALID_GDD: prototypeValidationItems 必须逐项对应 prototype_pending 决定且最多 3 项".to_string());
|
return Err("PLANNING_INVALID_GDD: prototypeValidationItems 必须逐项对应 prototype_pending 决定且最多 3 项".to_string());
|
||||||
}
|
}
|
||||||
let mut item_ids = std::collections::BTreeSet::new();
|
|
||||||
for item in prototype_items {
|
for item in prototype_items {
|
||||||
if !item_ids.insert(item.id.as_str()) || !prototype_ids.contains(item.id.as_str()) {
|
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());
|
return Err("PLANNING_INVALID_GDD: prototypeValidationItems.id 必须与 prototype_pending 决定双射".to_string());
|
||||||
}
|
}
|
||||||
if !item.id.chars().enumerate().all(|(position, value)| {
|
validate_v2_prototype_content(
|
||||||
(position == 0 && value.is_ascii_lowercase())
|
&item.question,
|
||||||
|| (position > 0
|
&item.micro_prototype,
|
||||||
&& (value.is_ascii_lowercase() || value.is_ascii_digit() || value == '-'))
|
&item.observation,
|
||||||
}) {
|
&item.pass_criterion,
|
||||||
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())?;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn runtime_decision_id(index: usize) -> String {
|
||||||
|
if index == 0 {
|
||||||
|
"initial-request".to_string()
|
||||||
|
} else {
|
||||||
|
format!("decision-{index}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn build_gdd_v2(
|
fn build_gdd_v2(
|
||||||
project_id: &str,
|
project_id: &str,
|
||||||
version: u32,
|
version: u32,
|
||||||
@@ -690,9 +734,10 @@ fn build_gdd_v2(
|
|||||||
let decisions = input
|
let decisions = input
|
||||||
.decisions
|
.decisions
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|value| {
|
.enumerate()
|
||||||
|
.map(|(index, value)| {
|
||||||
Ok(PlanDecision {
|
Ok(PlanDecision {
|
||||||
id: value.id,
|
id: runtime_decision_id(index),
|
||||||
topic: value.topic,
|
topic: value.topic,
|
||||||
state: normalize_v2_decision_state(&value.state)?,
|
state: normalize_v2_decision_state(&value.state)?,
|
||||||
answer_source: normalize_answer_source(&value.state, value.answer_source)?,
|
answer_source: normalize_answer_source(&value.state, value.answer_source)?,
|
||||||
@@ -702,6 +747,23 @@ fn build_gdd_v2(
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
.collect::<Result<Vec<_>, String>>()?;
|
.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 {
|
let mut gdd = PlanningGddV2 {
|
||||||
schema_version: PLAN_GDD_V2_SCHEMA_VERSION.to_string(),
|
schema_version: PLAN_GDD_V2_SCHEMA_VERSION.to_string(),
|
||||||
project_id: project_id.to_string(),
|
project_id: project_id.to_string(),
|
||||||
@@ -710,7 +772,7 @@ fn build_gdd_v2(
|
|||||||
created_at_utc: current_plan_timestamp_utc(),
|
created_at_utc: current_plan_timestamp_utc(),
|
||||||
game,
|
game,
|
||||||
decisions,
|
decisions,
|
||||||
prototype_validation_items: input.prototype_validation_items,
|
prototype_validation_items,
|
||||||
fingerprint: String::new(),
|
fingerprint: String::new(),
|
||||||
};
|
};
|
||||||
gdd.fingerprint = fingerprint_gdd_v2(&gdd)?;
|
gdd.fingerprint = fingerprint_gdd_v2(&gdd)?;
|
||||||
@@ -723,7 +785,7 @@ pub(crate) fn validate_planning_policy_output_v2(
|
|||||||
match output {
|
match output {
|
||||||
PlanningPolicyOutputV2::Question(question) => validate_question_v2(question),
|
PlanningPolicyOutputV2::Question(question) => validate_question_v2(question),
|
||||||
PlanningPolicyOutputV2::Gdd(input) => {
|
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)?;
|
let game = planning_gdd_game_from_input(&input.game)?;
|
||||||
validate_v2_game(&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_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_timestamp(&value.created_at_utc, "createdAtUtc").map_err(|error| error.to_string())?;
|
||||||
validate_v2_game(&value.game)?;
|
validate_v2_game(&value.game)?;
|
||||||
let decisions = value
|
validate_v2_durable_decisions(&value.decisions, &value.prototype_validation_items)?;
|
||||||
.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)?;
|
|
||||||
let expected = fingerprint_gdd_v2(value)?;
|
let expected = fingerprint_gdd_v2(value)?;
|
||||||
if expected != value.fingerprint {
|
if expected != value.fingerprint {
|
||||||
return Err("PLANNING_INVALID_GDD: GDD fingerprint 不匹配".to_string());
|
return Err("PLANNING_INVALID_GDD: GDD fingerprint 不匹配".to_string());
|
||||||
@@ -1575,7 +1625,7 @@ mod tests {
|
|||||||
"outOfScope": ["多人联机"],
|
"outOfScope": ["多人联机"],
|
||||||
"creatorTips": {"doFirst": "先做一张可走完的地图", "deferForNow": "暂缓复杂成长线", "howToVerify": "观察玩家是否能说出选择后果", "expandWhen": "连续三局都能理解后再扩展"}
|
"creatorTips": {"doFirst": "先做一张可走完的地图", "deferForNow": "暂缓复杂成长线", "howToVerify": "观察玩家是否能说出选择后果", "expandWhen": "连续三局都能理解后再扩展"}
|
||||||
},
|
},
|
||||||
"decisions": [{"id": "initial-request", "topic": "初始需求", "state": "confirmed", "round": 0, "answerSummary": "做一个短局守夜策略游戏"}],
|
"decisions": [{"topic": "初始需求", "state": "confirmed", "round": 0, "answerSummary": "做一个短局守夜策略游戏"}],
|
||||||
"prototypeValidationItems": []
|
"prototypeValidationItems": []
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -1649,12 +1699,48 @@ mod tests {
|
|||||||
assert!(gdd_properties.contains_key("game"));
|
assert!(gdd_properties.contains_key("game"));
|
||||||
assert!(gdd_properties.contains_key("decisions"));
|
assert!(gdd_properties.contains_key("decisions"));
|
||||||
assert!(!gdd_properties.contains_key("schemaVersion"));
|
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"]
|
assert!(gdd_properties["game"]["properties"]
|
||||||
.as_object()
|
.as_object()
|
||||||
.expect("game properties")
|
.expect("game properties")
|
||||||
.contains_key("title"));
|
.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 {
|
fn sample_gdd_input() -> PlanningGddInputV2 {
|
||||||
serde_json::from_value(sample_gdd_value()).expect("sample gdd input")
|
serde_json::from_value(sample_gdd_value()).expect("sample gdd input")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,14 @@
|
|||||||
- 关联文档:相关 PRD、技术文档、提交或 Issue
|
- 关联文档:相关 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` 创建成功当作提交点
|
## 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`,项目会卡死。
|
- 背景: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。
|
- 对已存在 V2 Session 的项目,打开项目时先 hydrate V2;没有 V2 authority 的旧项目继续走旧读取路径,避免误把旧项目数据当成 V2。
|
||||||
- P3 已完成;旧会话 `legacy_retired` 封存、入口彻底关闭和真实 Provider/UI 全链路回归仍属于 P4/P5。
|
- 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 与回归验收
|
### P4:灰度、真实 Provider 与回归验收
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user