From 0abdc41c3c922273374a6c2d59d2f2b0e46a4200 Mon Sep 17 00:00:00 2001 From: Linghong Date: Tue, 18 Aug 2026 06:15:25 +0000 Subject: [PATCH] =?UTF-8?q?M1C-2c=20=E6=94=B6=E5=8F=A3=EF=BC=9A=E5=88=86?= =?UTF-8?q?=E9=9A=94=E7=AC=A6=E9=9B=86=E5=90=88=E8=A1=A5=E5=85=A8=E8=A7=92?= =?UTF-8?q?=E5=86=92=E5=8F=B7=EF=BC=8C=E5=B9=B6=E9=92=89=E4=BD=8F=20label?= =?UTF-8?q?=20=E4=B8=8E=E5=9B=9E=E7=AD=94=E7=9A=84=E5=90=8C=E5=B0=BA?= =?UTF-8?q?=E6=AF=94=E5=AF=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 分隔符集合原本只有 `·`/`:`/`-`。prompt 全中文、实测用的是 DeepSeek 系模型,在中文 语境下写 `A:方案名` 是高频输出;漏掉全角冒号的后果不是判错,而是合法信封被判形状 错误、回灌重试,白吃一个未推进回合预算——而第 23.9 节自己就记着无界重试把单次 prompt 撑到 15 万 token 的实测。 - PLAN_OPTION_LABEL_DELIMITERS 补 `:`;prompt 的 label 说明与技术方案 §5.1、§5.2、 §23.9 三处分隔符合同同步改口,避免两侧各写一份 - 回归 planning_clarification_accepts_fullwidth_colon_option_labels。变异验证: 去掉 `:` 后该用例立刻红 另外收回一条误报。此前判断「答案走 normalize_plan_text 被 trim、label 是信封原文没 trim,模型吐带尾随空白的 label 会让用户点选 A/B 掉进自由填写分支、台账记成 user_freeform」。写完测试做变异验证时把改动回退,用例照样绿;查下去发现 user_input.rs 的 normalize_single_line_user_input_text 在信封严格解析时就已经 trim 过 label(并拒掉含换行的 label)。两边本来就是同一把尺子,不对称不存在。 - 生产侧只把比较抽成具名函数并在原地写清它依赖的是解析侧那条 trim,不做多余的重复 规范化——那会把一个不存在的风险写进代码 - 保留 planning_clarification_option_pick_survives_untrimmed_label,它钉的是上游那条 不变量:解析侧哪天不 trim 了,这条会红 - 测试脚手架加 *_with_labels 变体,让用例能注入自定义 label;原有 helper 转为薄包装 planning_clarification_ 16/0、plan_ 160/0、recovery 107/0,fmt 干净。 Co-Authored-By: Claude Opus 5 --- .../prompts/runtime/roles/project-planning.md | 2 +- .../runtime_protocol/planning_coordinator.rs | 39 +++++-- .../tests/collaboration/static_deliveries.rs | 105 +++++++++++++++++- ...方案】立项策划Agent(Fast GDD)-2026-08-10.md | 6 +- 4 files changed, 135 insertions(+), 17 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/prompts/runtime/roles/project-planning.md b/apps/ai-game-creator-shell/src-tauri/prompts/runtime/roles/project-planning.md index 9c604d14b..97f680fc7 100644 --- a/apps/ai-game-creator-shell/src-tauri/prompts/runtime/roles/project-planning.md +++ b/apps/ai-game-creator-shell/src-tauri/prompts/runtime/roles/project-planning.md @@ -11,7 +11,7 @@ - 除非用户明确说“直接出稿”,最多进行 3 轮关键澄清;每轮是新 run、同一 session。你看得到自己的历史,但用户答案以 Supervisor 委派任务中的转述为准,缺失信息不能臆造。 - 优先顺序:核心行为与本局目标 → 重玩动力 → 制作边界与 MVP。每轮最多问一个主要决定;达到第 3 轮、剩余问题不影响首个可玩闭环或 Runtime 提示接近活跃预算时,直接整理并提交。 -- 决策卡的 header 固定为“第N轮·关键决定”,正文以“当前要决定:”开头,只问尚未由平台事实或 MVP 规则排除的真实产品取舍,并说明为什么现在问;每张卡固定提供三个选项:A 是你的推荐方案(label 以 `A ·`、`A:` 或 `A-` 开头并写明推荐、好处和代价),B 是形状不同且真实可行的平行备选(label 以 `B ·`、`B:` 或 `B-` 开头并写明后果和代价),第三项逐字为“需要原型验证”,description 必须给出 30~90 分钟微型原型、试玩对象、观察信号和通过标准。自由输入按用户原话处理。 +- 决策卡的 header 固定为“第N轮·关键决定”,正文以“当前要决定:”开头,只问尚未由平台事实或 MVP 规则排除的真实产品取舍,并说明为什么现在问;每张卡固定提供三个选项:A 是你的推荐方案(label 以 `A ·`、`A:`、`A:` 或 `A-` 开头并写明推荐、好处和代价),B 是形状不同且真实可行的平行备选(label 以 `B ·`、`B:`、`B:` 或 `B-` 开头并写明后果和代价),第三项逐字为“需要原型验证”,description 必须给出 30~90 分钟微型原型、试玩对象、观察信号和通过标准。自由输入按用户原话处理。 ## 低幻觉与 GDD 约束 diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_coordinator.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_coordinator.rs index 48fcd41ad..f794451e0 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_coordinator.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_coordinator.rs @@ -127,6 +127,10 @@ fn plan_question_topic(question: &AgentRuntimeUserInputQuestion) -> Result bool { let Some(remainder) = label.strip_prefix(prefix).map(str::trim_start) else { return false; @@ -134,12 +138,26 @@ fn plan_option_label_has_prefix(label: &str, prefix: char) -> bool { let Some(delimiter) = remainder.chars().next() else { return false; }; - if !matches!(delimiter, '·' | ':' | '-') { + if !PLAN_OPTION_LABEL_DELIMITERS.contains(&delimiter) { return false; } !remainder[delimiter.len_utf8()..].trim().is_empty() } +/// 这里直接比原串,靠的是一条跨模块不变量:`user.input_request` 的严格解析 +/// (`user_input.rs` 的 `normalize_single_line_user_input_text`)已经把 label `trim` +/// 过、并拒掉了含换行的 label;而用户回答这边走 `normalize_plan_text`(CRLF 归一 + +/// `trim`)。两条路落到同一形态,所以此处不必、也不该再规范化一次。 +/// +/// 一旦解析侧不再 trim,两把尺子就会错开:模型吐出带尾随空白的 label 时,用户的点选 +/// 会因为「trim 过的答案 != 没 trim 的 label」掉进自由填写分支,台账把点选记成 +/// `user_freeform`。两边 state 同为 `confirmed`,状态机看不出异常——被污染的恰好是第 +/// 23.9 节要立起来的那个字段。`planning_clarification_option_pick_survives_untrimmed_label` +/// 钉的就是这条不变量。 +fn plan_option_label_matches_answer(label: &str, normalized_answer: &str) -> bool { + label == normalized_answer +} + pub(crate) fn validate_exact_plan_clarification_question( questions: &[AgentRuntimeUserInputQuestion], round: u32, @@ -194,15 +212,16 @@ fn build_plan_clarification_decision_projection( let question = &answer.question; let topic = plan_question_topic(question)?; let decision_id = question.id.replace('_', "-"); - let (state, answer_source) = if normalized_answer == question.options[0].label - || normalized_answer == question.options[1].label - { - ("confirmed", "user_option") - } else if normalized_answer == PLAN_OPTION_PROTOTYPE_VALIDATION { - ("prototype_pending", "user_option") - } else { - ("confirmed", "user_freeform") - }; + let (state, answer_source) = + if plan_option_label_matches_answer(&question.options[0].label, &normalized_answer) + || plan_option_label_matches_answer(&question.options[1].label, &normalized_answer) + { + ("confirmed", "user_option") + } else if normalized_answer == PLAN_OPTION_PROTOTYPE_VALIDATION { + ("prototype_pending", "user_option") + } else { + ("confirmed", "user_freeform") + }; let decision = PlanDecisionSummary { id: decision_id.clone(), topic: topic.clone(), diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/static_deliveries.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/static_deliveries.rs index 20dfcdbb9..392033686 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/static_deliveries.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/static_deliveries.rs @@ -491,6 +491,14 @@ const PLAN_TEST_OPTION_B: &str = "B · 采用另一条可行路线"; const PLAN_TEST_OPTION_PROTOTYPE: &str = "需要原型验证"; fn planning_clarification_question_body(round: u32) -> (String, String) { + planning_clarification_question_body_with_labels(round, PLAN_TEST_OPTION_A, PLAN_TEST_OPTION_B) +} + +fn planning_clarification_question_body_with_labels( + round: u32, + label_a: &str, + label_b: &str, +) -> (String, String) { let question_id = format!("round_{round}_decision"); let body = serde_json::json!({ "questions": [{ @@ -499,11 +507,11 @@ fn planning_clarification_question_body(round: u32) -> (String, String) { "question": format!("当前要决定:第{round}轮核心取舍。现在确认后才能继续收敛 Fast GDD。"), "options": [ { - "label": PLAN_TEST_OPTION_A, + "label": label_a, "description": "采用当前推荐方案继续收敛,代价是优先投入这条路线的验证。" }, { - "label": PLAN_TEST_OPTION_B, + "label": label_b, "description": "采用另一条可行路线,代价是放弃当前推荐的部分确定性。" }, { @@ -627,9 +635,28 @@ fn answer_planning_clarification_round( round: u32, answer: &str, tag: &str, +) -> AnsweredPlanningClarification { + answer_planning_clarification_round_with_labels( + fixture, + round, + answer, + tag, + PLAN_TEST_OPTION_A, + PLAN_TEST_OPTION_B, + ) +} + +fn answer_planning_clarification_round_with_labels( + fixture: &mut PlanningClarificationFixture, + round: u32, + answer: &str, + tag: &str, + label_a: &str, + label_b: &str, ) -> AnsweredPlanningClarification { let original_delivery = fixture.current_delivery.clone(); - let (question_id, questions_body) = planning_clarification_question_body(round); + let (question_id, questions_body) = + planning_clarification_question_body_with_labels(round, label_a, label_b); mark_and_claim_static_delegate_needs_user_input( &fixture.root, GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, @@ -4007,6 +4034,78 @@ fn planning_clarification_freeform_answer_is_confirmed_and_preserves_original_te cleanup_planning_clarification_fixture(fixture); } +/// 用户回答走 `normalize_plan_text`(CRLF 归一 + `trim`),选项 label 却是信封原文。 +/// 模型只要吐出带尾随空白的 label,点选 A/B 就会因为「trim 过的答案 != 没 trim 的 +/// label」掉进自由填写分支,台账把点选记成 `user_freeform`。两边 state 同为 +/// `confirmed`,状态机看不出异常——被污染的恰好是第 23.9 节要立起来的那个字段。 +#[test] +fn planning_clarification_option_pick_survives_untrimmed_label() { + let mut fixture = planning_clarification_fixture("untrimmed-label"); + let padded_label_a = format!("{PLAN_TEST_OPTION_A} "); + let answered = answer_planning_clarification_round_with_labels( + &mut fixture, + 1, + &padded_label_a, + "untrimmed-label", + &padded_label_a, + PLAN_TEST_OPTION_B, + ); + dispatch_answered_planning_continuation(&mut fixture, &answered, "untrimmed-label"); + + let session = read_plan_session_with_recovery(&fixture.root) + .expect("read untrimmed-label planning session") + .expect("untrimmed-label planning session exists"); + let decision = session + .decisions_summary + .iter() + .find(|decision| decision.id == answered.question_id.replace('_', "-")) + .expect("untrimmed-label decision exists"); + assert_eq!(decision.state, "confirmed"); + assert_eq!( + decision.answer_source, "user_option", + "label 带尾随空白也必须认成点选,不能记成自由填写" + ); + assert_eq!( + decision.answer_summary, PLAN_TEST_OPTION_A, + "台账落的是规范化后的 label" + ); + + cleanup_planning_clarification_fixture(fixture); +} + +/// prompt 全中文,模型在中文语境下写 `A:方案名` 是高频输出。全角冒号不在分隔符集合里 +/// 时,合法信封会被判形状错误、回灌重试,白吃一个未推进回合预算——而第 23.9 节自己 +/// 就记着无界重试把单次 prompt 撑到 15 万 token 的实测。 +#[test] +fn planning_clarification_accepts_fullwidth_colon_option_labels() { + let mut fixture = planning_clarification_fixture("fullwidth-colon"); + let label_a = "A:采用当前推荐方案(推荐)"; + let label_b = "B:采用另一条可行路线"; + let answered = answer_planning_clarification_round_with_labels( + &mut fixture, + 1, + label_b, + "fullwidth-colon", + label_a, + label_b, + ); + dispatch_answered_planning_continuation(&mut fixture, &answered, "fullwidth-colon"); + + let session = read_plan_session_with_recovery(&fixture.root) + .expect("read fullwidth-colon planning session") + .expect("fullwidth-colon planning session exists"); + let decision = session + .decisions_summary + .iter() + .find(|decision| decision.id == answered.question_id.replace('_', "-")) + .expect("fullwidth-colon decision exists"); + assert_eq!(decision.state, "confirmed"); + assert_eq!(decision.answer_source, "user_option"); + assert_eq!(decision.answer_summary, label_b); + + cleanup_planning_clarification_fixture(fixture); +} + #[test] fn planning_clarification_rejects_non_ab_option_envelope_before_pending() { let mut fixture = planning_clarification_fixture("invalid-option-shape"); diff --git a/docs/technical/【技术方案】立项策划Agent(Fast GDD)-2026-08-10.md b/docs/technical/【技术方案】立项策划Agent(Fast GDD)-2026-08-10.md index 452885a60..bad9f72d9 100644 --- a/docs/technical/【技术方案】立项策划Agent(Fast GDD)-2026-08-10.md +++ b/docs/technical/【技术方案】立项策划Agent(Fast GDD)-2026-08-10.md @@ -423,7 +423,7 @@ Runtime 注入并强校验以下精确结构: - header:固定为 `第{N}轮·关键决定`,N 为 1~3,始终不超过现有 12 scalar 上限; - question:以 `当前要决定:{主题}` 开头,再依次说明为什么现在问、推荐方案、好处、代价; -- 选项固定为三项:第一项 label 以 `A` 加 `·` / `:` / `-` 开头并说明推荐方案,第二项以 `B` 加同类分隔符开头并说明真实平行备选,第三项 label 逐字为 `需要原型验证`;每项 description 都要写清选择后果与代价,第三项还要给出可执行的微型原型验证方式; +- 选项固定为三项:第一项 label 以 `A` 加 `·` / `:` / `:` / `-` 开头并说明推荐方案,第二项以 `B` 加同类分隔符开头并说明真实平行备选,第三项 label 逐字为 `需要原型验证`;每项 description 都要写清选择后果与代价,第三项还要给出可执行的微型原型验证方式; - 自由填写由现有 Other 输入槽承载,placeholder 为 `改成:……`。 映射: @@ -457,7 +457,7 @@ exact plan source 的 `user.input_request` 仍是四个 action tool 之一,不 } ``` -三个 option 的数量与 A/B/第三项形状必须符合上表;A/B 的短语可动态生成,分隔符允许 `·`、`:` 或 `-`,第三项 label 必须逐字等于 `需要原型验证`。plan question ID 在现役 snake_case 规则上进一步限制为最多 32 个 ASCII 字符,并且不能映射成 `initial-request`。Runtime 确定性令 `decisionId = questionId.replace('_', '-')`,因此该 ID 必然满足第 8.3 节 GDD decision ID 合同,Provider 不能另选身份。D11/M1C-2b 下 Supervisor 的 `user.input_request` 不是 Supervisor Provider tool-plan action:它由 Runtime 在认领 `NeedsUserInput` delivery 后、释放父 run execution lane,再经 planning parent-wake 直接投影为唯一 pending,因此不创建 Supervisor v4 Provider action batch。exact planning 子 Agent 的普通 tool-plan 只要产生 action,即使仅一项也必须强制 durable 写 v4 batch,不能走现役“少于两项则 NotNeeded”的优化;`plan.submit_gdd` 仍是该 batch 的唯一 member。exact planning 的 `final-reply`、`context-compaction`、`final-reply-context-compaction` 无 action、不创建 batch,但仍写第 12 节 v3 lifecycle/binding。非 plan source 的 input wire、数量和校验保持现状,任何额外 plan metadata 都因 unknown field 失败。 +三个 option 的数量与 A/B/第三项形状必须符合上表;A/B 的短语可动态生成,分隔符允许 `·`、`:`、`:` 或 `-`,第三项 label 必须逐字等于 `需要原型验证`。plan question ID 在现役 snake_case 规则上进一步限制为最多 32 个 ASCII 字符,并且不能映射成 `initial-request`。Runtime 确定性令 `decisionId = questionId.replace('_', '-')`,因此该 ID 必然满足第 8.3 节 GDD decision ID 合同,Provider 不能另选身份。D11/M1C-2b 下 Supervisor 的 `user.input_request` 不是 Supervisor Provider tool-plan action:它由 Runtime 在认领 `NeedsUserInput` delivery 后、释放父 run execution lane,再经 planning parent-wake 直接投影为唯一 pending,因此不创建 Supervisor v4 Provider action batch。exact planning 子 Agent 的普通 tool-plan 只要产生 action,即使仅一项也必须强制 durable 写 v4 batch,不能走现役“少于两项则 NotNeeded”的优化;`plan.submit_gdd` 仍是该 batch 的唯一 member。exact planning 的 `final-reply`、`context-compaction`、`final-reply-context-compaction` 无 action、不创建 batch,但仍写第 12 节 v3 lifecycle/binding。非 plan source 的 input wire、数量和校验保持现状,任何额外 plan metadata 都因 unknown field 失败。 > **2026-08-13 按 D11 重写本节后半(原 D10「Runtime 直投」状态机整段作废)。** 原文描述的链路是:策划节点持续存活于同一 run,自己调 `user.input_request`,Runtime 在同一 run 内截获、写 `activeQuestion` session checkpoint,回答后再发一次 `plan-decision-checkpoint` 专用 Provider 请求取得设计解释,并以新 session primary 作为线性化点。D11 下这条链路的每一环都换了承载物,且**不是换实现是换机制**——用的是 PR #165 已发布、已有回归覆盖的静态委派澄清中转,不再自造状态机。 @@ -2051,7 +2051,7 @@ M0 完成不表示完整策划闭环已经上线。`M1A-1`~`M1A-4`、`M1B-1` - 选项结构改为「**A / B / 固定第 3 项**」,仍恒定三项:第 1 项 = 方案 A,策划子 Agent 的推荐,label 形如 `A · {方案短语}(推荐)`;第 2 项 = 方案 B,一条**真实可行、形状不同**的平行备选(另一条设计岔路,不是 A 的反面、不是稻草人、不是"先按 A 走"),label 形如 `B · {方案短语}`;第 3 项 = 固定文案 `需要原型验证`,**每张卡都给**,description 写清这一题做原型要验证什么、怎么做(30~90 分钟占位资产微型原型、让谁试玩、观察什么),对方向性问题也要给出可执行的验证方式。`user.input_request` strict input 与 Other 输入槽(placeholder `改成:……`)不变。question 正文只说要决定什么、为什么现在决定;推荐理由写进 A 的 description;每个 description 说明选择后果与代价。 - 映射表改为:A / B → `confirmed / user_option`;`需要原型验证` → `prototype_pending / user_option`(同 ID 30~90 分钟微型原型项的规则不变);自由填写 → `confirmed / user_freeform`。**`default_pending` 收回为唯一来源——未提问、由子 Agent 按默认建议填写的字段**(`answerSource=default`、`round=0`,与第 12 节提交校验「前缀之后只允许追加 `default_pending` 默认决定」完全一致)。由此三个决定状态各只有一个来源:`confirmed` = 用户拍板,`prototype_pending` = 用户要求验证,`default_pending` = 没问过。 -- 状态映射按 **label** 而非位置:Runtime 校验信封恰好三项、第 1 项以 `A` 加分隔符开头、第 2 项以 `B` 加分隔符开头(分隔符 `·`/`:`/`-` 宽松)、第 3 项逐字等于 `需要原型验证`,不满足按信封格式错误回灌重试(受既有未推进回合预算约束);`answerSummary` 直接落所选 label,台账自描述。 +- 状态映射按 **label** 而非位置:Runtime 校验信封恰好三项、第 1 项以 `A` 加分隔符开头、第 2 项以 `B` 加分隔符开头(分隔符 `·`/`:`/`:`/`-` 宽松;全角冒号必须在集合里——prompt 全中文,模型高频输出 `A:`,漏掉它等于让合法信封被拒并白吃一个未推进回合预算)、第 3 项逐字等于 `需要原型验证`,不满足按信封格式错误回灌重试(受既有未推进回合预算约束);`answerSummary` 直接落所选 label,台账自描述;label 与用户回答必须经同一套规范化后再比对,否则模型吐出带尾随空白的 label 会让用户的点选被记成 `user_freeform`,恰好污染本节要立起来的 answerSource 唯一来源。 - **已确认决定被后续自由填写推翻(改口)**:M1 内**不改合同**。台账前缀不可变,两条 `confirmed` 并存;Supervisor 转述 continuation 时必须在被推翻的那条之后注明「已被第 N 轮回答推翻,以后者为准」,用户答案原文仍逐字保留(不得改写、拆分或搬到别的轮次),子 Agent 按后者出稿并在新决定的 topic 中写明推翻关系。`supersedes` 字段进 `plan-gdd.v1` 列为 M2 候选。原型实测三次改口 Supervisor 均能自行消化,但其中一次是靠改写用户答案原文(转述保真审计报「内容缺失」),故此规则必须写进 Supervisor prompt,不能默认它会做对。 - 提问纪律补一条:平台事实已定的事(含"移动/桌面优先级")与 MVP 规则已排除的事(多人/联机/商城/服务器)**不作为问题**。A/B 格式会诱使模型去问"天然二选一"但无价值的问题(实测一轮 3 张卡里 2 张是这种),必须在 prompt 里堵住。