fix(rectification): pass birthDate into the choice-path gate
Independent Staging Quality Gate / validate (push) Successful in 10m27s
Independent Staging Quality Gate / publish (push) Successful in 8m35s

The post-choice decision used a looser plan than the followup builder.
Load compute once in persistApplied and feed the same birthDate to both.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jesse_Chen
2026-08-29 12:57:10 +08:00
parent 57e5f92d40
commit c960691ee5
3 changed files with 27 additions and 9 deletions
+2 -2
View File
@@ -6614,8 +6614,8 @@
- 用户现象:点选两道区分题后,助手口述家人采集问但没有选项卡。用户用自由文本回答后,服务端回没有出路的终结话术;`current_question``choice_card` 均为 nullcase 停在 `collecting_evidence`;该回合 receipt 的 phases / tools / tool_activities 全空,用户这句话里的新证据没有入账本。
- 触发条件:训练证据已齐、已答 dasha 边界探针、剩余探针含无年份 varga 与缺 `style_options``varga_style`;方法覆盖未齐时出题层回落到口述采集;随后用户在无 active choice focus 时发自由文本。
- 根因:`inspectDiscriminatorProbes``withCompletedContrastOptions` 把缺 `style_options``varga_style` 降级后仍视为可渲染,并选出无年份 varga 探针进入 `ask_candidate_discriminator``renderableContrastProbe` 仍走未降级的 `completeStyleOptions`,把同一探针静默丢弃。无年份桶只在覆盖齐时使用,于是回落到 `source=method_coverage` 的家人采集且不带 `choice_frame`。路由只信决策层:`persistServerOwnedFocus` skipped、`openQuestionFromPersistedFocus` 为 null 后直接返回终结话术,既不落证据也不跑 Agent。口述采集题不落 focus,刷新后问题丢失;卡片持久化失败时返回无正文下一步。
- 修复:出题层与决策层共用 `withCompletedContrastOptions`;丢弃探针写入 `dropped_probes`。仅当出题层给出 `intent=distinguish_candidates` 且带 `choice_frame` 的 followup 时才进入 `ask_candidate_discriminator`,否则落到采集。消息分支拿不到可渲染卡且仍有 followup 时落入 `runV9AgentTurn`;仅当 `next_followup` 为 null 时才收口,话术给出继续采集或按区间看盘。口述采集持久化不带 choice 的 collect focus;卡片持久化失败时落到口述采集下一步。冲突探针写回时保留 `style_options`。收尾加固:口述采集投影带 `kind=collect_spoken``current_probe` / `inference.next_probe` 只对可渲染选择题放行;降级口述采集剥掉区分探针身份(`semantic_key` / 年份 / `probe_id`),正文与 schema prompt 同一次生成;卡片失败后的假分支删除,focus 持久化失败按 `safeErrorCode` / caseId 打脱敏日志;`decideFromDossier` 把可选 `birthDate` 传给门控 plan,门控通过时 `decision.probe` 取 plan 真正选中的探针。Skill 版本保持 `10.0.13`。未放宽 confirmation gate,未增加「已确认唯一分钟」路径。
- 验证:`frontend/tests/rectification-discriminator-followup-consistency.test.ts` 锁定同一探针池上决策层与出题层一致、`decision.probe``plan.next_followup.semantic_key` 一致、缺 `style_options` 的 D9/D10 风格题在方法覆盖未齐时仍出区分卡、无星座时钟键 fail-closed 到采集、低龄年份探针传入 `birthDate` 时门控与真实 plan 结论一致、消息路径不再返回死路话术。`rectification-answer-choice.test.ts` 锁定口述采集后仍有 collect focus 与 `current_question`collect 时 `current_probe` 为空、选择题时非空,降级采集不含区分题身份。`rectification-server-focus.test.ts` 锁定 collect schema 不是未渲染区分卡,降级采集的 questionId / schema 不含区分题键且同一探针随后可出卡。`rectification-turn-intent-classifier.test.ts` 按新收口话术改断言。`npx tsc --noEmit``npx tsx --test tests/rectification-*.test.ts tests/birth-time-rectification-contract.test.ts tests/agentic-rectification-*.test.ts``npx tsx --test tests/*.test.ts`
- 修复:出题层与决策层共用 `withCompletedContrastOptions`;丢弃探针写入 `dropped_probes`。仅当出题层给出 `intent=distinguish_candidates` 且带 `choice_frame` 的 followup 时才进入 `ask_candidate_discriminator`,否则落到采集。消息分支拿不到可渲染卡且仍有 followup 时落入 `runV9AgentTurn`;仅当 `next_followup` 为 null 时才收口,话术给出继续采集或按区间看盘。口述采集持久化不带 choice 的 collect focus;卡片持久化失败时落到口述采集下一步。冲突探针写回时保留 `style_options`。收尾加固:口述采集投影带 `kind=collect_spoken``current_probe` / `inference.next_probe` 只对可渲染选择题放行;降级口述采集剥掉区分探针身份(`semantic_key` / 年份 / `probe_id`),正文与 schema prompt 同一次生成;卡片失败后的假分支删除,focus 持久化失败按 `safeErrorCode` / caseId 打脱敏日志;`decideFromDossier` 把可选 `birthDate` 传给门控 plan,门控通过时 `decision.probe` 取 plan 真正选中的探针。点选路径 `persistApplied` 先加载一次 compute,把同一 `birthDate` 喂给 `decideAfterInferenceChange` 与后续出题 plan。Skill 版本保持 `10.0.13`。未放宽 confirmation gate,未增加「已确认唯一分钟」路径。
- 验证:`frontend/tests/rectification-discriminator-followup-consistency.test.ts` 锁定同一探针池上决策层与出题层一致、`decision.probe``plan.next_followup.semantic_key` 一致、缺 `style_options` 的 D9/D10 风格题在方法覆盖未齐时仍出区分卡、无星座时钟键 fail-closed 到采集、低龄年份探针传入 `birthDate` 时门控与真实 plan 结论一致、消息路径不再返回死路话术。`rectification-answer-choice.test.ts` 锁定口述采集后仍有 collect focus 与 `current_question`collect 时 `current_probe` 为空、选择题时非空,降级采集不含区分题身份,点选路径 `persistApplied``decideAfterInferenceChange` 之前加载 compute 并传入同一 `birthDate``rectification-server-focus.test.ts` 锁定 collect schema 不是未渲染区分卡,降级采集的 questionId / schema 不含区分题键且同一探针随后可出卡。`rectification-turn-intent-classifier.test.ts` 按新收口话术改断言。`npx tsc --noEmit``npx tsx --test tests/rectification-*.test.ts tests/birth-time-rectification-contract.test.ts tests/agentic-rectification-*.test.ts``npx tsx --test tests/*.test.ts`
- 防复发:可渲染判定必须共用选项补全,不得一边降级接受一边静默丢弃。无卡时不得把用户消息当终结话术丢掉。有合法 choice schema 的 active focus 仍走轻量分类 + `applyRectificationChoice`。不得引入语义正则、A/B/C/D 位置推断或静态选项 fallback。口述采集题不得占用区分探针身份。无选择卡的问题必须由 Agent 正文问出来,`current_probe` 只对可渲染选择题放行。
- 相关记录:BUG-404、BUG-407、BUG-410、BUG-411
- 复发自:BUG-404
@@ -252,6 +252,7 @@ async function persistNextInterviewAfterChoice(input: {
dossier: Parameters<typeof decideAfterInferenceChange>[0]["dossier"];
decisionState: InferenceState | null;
nextAction: ReturnType<typeof publicNextAction>;
birthDate?: string | null;
}): Promise<{ hostNarration: string | null; choiceReady: boolean }> {
const latest = input.dossier.latestResult
? {
@@ -264,13 +265,7 @@ async function persistNextInterviewAfterChoice(input: {
: {
decisionReceipt: input.decisionState ? { inference_state: input.decisionState } : null,
};
let birthDate: string | null = null;
try {
const compute = await loadV9CaseCompute(input.accounting, input.userId, input.caseId);
birthDate = String(compute.baselineBirthSnapshot.birth_date ?? "") || null;
} catch {
// Ranking stays fail-open when compute is unavailable.
}
const birthDate = input.birthDate ?? null;
const catalog = rectificationFollowupCatalog(latest, input.dossier.evidence);
const sessionOutcome = typeof input.nextAction.session_outcome === "string"
? input.nextAction.session_outcome as SessionOutcomeKind
@@ -404,10 +399,18 @@ async function persistApplied(
inference: input.inference,
});
let birthDate: string | null = null;
try {
const compute = await loadV9CaseCompute(accounting, command.userId, command.caseId);
birthDate = String(compute.baselineBirthSnapshot.birth_date ?? "") || null;
} catch {
// Ranking stays fail-open when compute is unavailable.
}
const nextAction = publicNextAction(decideAfterInferenceChange({
dossier: input.dossier,
state: input.decisionState ?? null,
userStopped: input.userStopped === true,
birthDate,
}));
let narrationPersisted = false;
@@ -422,6 +425,7 @@ async function persistApplied(
dossier: input.dossier,
decisionState: input.decisionState ?? null,
nextAction,
birthDate,
});
nextChoiceReady = nextInterview.choiceReady;
if (nextInterview.hostNarration) {
@@ -846,6 +846,20 @@ test("failed card persist still leaves a spoken collect next step", () => {
assert.match(source, /当前几个候选已经构成可信区间/);
assert.match(source, /persist next focus failed case=/);
assert.doesNotMatch(source, /if \(fallback\.status === "created"/);
const applied = source.slice(source.indexOf("async function persistApplied"));
const loadAt = applied.indexOf("loadV9CaseCompute");
const decideAt = applied.indexOf("publicNextAction(decideAfterInferenceChange");
assert.ok(loadAt >= 0 && decideAt >= 0 && loadAt < decideAt);
assert.match(applied.slice(decideAt, decideAt + 280), /birthDate/);
assert.match(
applied.slice(applied.indexOf("persistNextInterviewAfterChoice"), applied.indexOf("persistNextInterviewAfterChoice") + 360),
/birthDate/,
);
const nextInterview = source.slice(
source.indexOf("async function persistNextInterviewAfterChoice"),
source.indexOf("async function persistFocusAfterChoice"),
);
assert.doesNotMatch(nextInterview, /loadV9CaseCompute/);
});
test("spoken collect focus keeps current_question after refresh", () => {