From c960691ee5bbcc5f14c47a86039e5ae1fa4953c4 Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Sat, 29 Aug 2026 12:57:10 +0800 Subject: [PATCH] fix(rectification): pass birthDate into the choice-path gate 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 --- docs/BUG_HISTORY.md | 4 ++-- .../rectification-agentic/v9/answer-choice.ts | 18 +++++++++++------- .../tests/rectification-answer-choice.test.ts | 14 ++++++++++++++ 3 files changed, 27 insertions(+), 9 deletions(-) diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 8a678c8a..dc9c0306 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -6614,8 +6614,8 @@ - 用户现象:点选两道区分题后,助手口述家人采集问但没有选项卡。用户用自由文本回答后,服务端回没有出路的终结话术;`current_question` 与 `choice_card` 均为 null,case 停在 `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 diff --git a/frontend/src/lib/rectification-agentic/v9/answer-choice.ts b/frontend/src/lib/rectification-agentic/v9/answer-choice.ts index 2117df0e..ef1ff0a4 100644 --- a/frontend/src/lib/rectification-agentic/v9/answer-choice.ts +++ b/frontend/src/lib/rectification-agentic/v9/answer-choice.ts @@ -252,6 +252,7 @@ async function persistNextInterviewAfterChoice(input: { dossier: Parameters[0]["dossier"]; decisionState: InferenceState | null; nextAction: ReturnType; + 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) { diff --git a/frontend/tests/rectification-answer-choice.test.ts b/frontend/tests/rectification-answer-choice.test.ts index b21c83d4..1701ed99 100644 --- a/frontend/tests/rectification-answer-choice.test.ts +++ b/frontend/tests/rectification-answer-choice.test.ts @@ -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", () => {