M1C-2c 收口:分隔符集合补全角冒号,并钉住 label 与回答的同尺比对
分隔符集合原本只有 `·`/`:`/`-`。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 <noreply@anthropic.com>
This commit is contained in:
@@ -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 约束
|
||||
|
||||
|
||||
+29
-10
@@ -127,6 +127,10 @@ fn plan_question_topic(question: &AgentRuntimeUserInputQuestion) -> Result<Strin
|
||||
normalize_plan_text(topic, "plan question topic", 1, 80).map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
/// 全角冒号必须在集合里:prompt 全中文,模型在中文语境下写 `A:方案名` 是高频输出,
|
||||
/// 而不在集合里的后果是整个信封被拒、回灌重试,白吃一个未推进回合预算。
|
||||
const PLAN_OPTION_LABEL_DELIMITERS: [char; 4] = ['·', ':', ':', '-'];
|
||||
|
||||
fn plan_option_label_has_prefix(label: &str, prefix: char) -> 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(),
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 里堵住。
|
||||
|
||||
|
||||
Reference in New Issue
Block a user