diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 5e160eba..674204f7 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -6461,6 +6461,22 @@ - 复发自:无 - 修复版本:待发布 +## BUG-426 | 无年份 sticky holdout 仍进入核对,点选卡却渲染不出来 + +- 状态:resolved +- 首次发现:2026-08-28 +- 最近更新:2026-08-28 +- 影响面:`holdoutStatusFromInference` / `holdoutStatusFromState`、`decideFromDossier`、holdout 点选卡 +- 用户现象:保留的 holdout 后来变成未知精度、没有年份后,决策仍要 `ask_holdout_validation`,出题计划却给不出可渲染卡片。 +- 触发条件:`stickyHoldoutEvents` 把已保留 holdout 钉住;该事件 `year === null` 或 `precision: "unknown"`;或 `validate_holdout` 时既没有 `oosBlindPrompts` 也没有带年份的 holdout。 +- 根因:holdout 状态只看 `usage === "holdout"`。能否出题看的是带年份的事件或 oos 提示。两套口径不一致。 +- 修复:抽出共用 helper:能问(oos 提示或 dated holdout)才是 `not_started`;否则 `unavailable`。`unavailable` 且用户未停走既有 `adopt_representative`,不当成 `passed`。带年份 holdout 追问补上存在题选项和 `probe_year`,好渲染 choice frame。不改 `applyHoldoutAnswer`,不改 stickiness。 +- 验证:`rectification-holdout-renderable` 锁定未知精度 sticky holdout 不进入 `ask_holdout_validation` 且可 `ready_to_adopt`;带日期 holdout 有可渲染 `choice_frame`;通过为 `validated_range`;失败路径不变;`nextAction === ask_holdout_validation` 时 followup 非空。`canConfirmExactMinute === false`。 +- 防复发:不得只凭 `usage === "holdout"` 打开核对。不得把 `unavailable` 当成已通过。不得打开 unique-minute 门。 +- 相关记录:BUG-424、BUG-410 +- 复发自:无 +- 修复版本:待发布 + ## BUG-410 | 训练已齐仍因家人/职业方法层停在采集,Agent 只确认后截断 - 状态:resolved diff --git a/frontend/src/lib/rectification-agentic/v9/decision-from-dossier.ts b/frontend/src/lib/rectification-agentic/v9/decision-from-dossier.ts index 6a14df4d..5bf70d5e 100644 --- a/frontend/src/lib/rectification-agentic/v9/decision-from-dossier.ts +++ b/frontend/src/lib/rectification-agentic/v9/decision-from-dossier.ts @@ -17,6 +17,7 @@ import { import { decideRectification, publicDecisionFields, + type HoldoutValidationStatus, type RectificationDecision, } from "../core/rectification-decision.ts"; import type { InferenceState } from "../core/types.ts"; @@ -110,25 +111,51 @@ export function discriminatorCandidateTimes( return candidateScoresFromDossier(latest).map((item) => item.time); } -function holdoutStatusFromInference(inference: ReturnType) { - if (!inference) return "unavailable" as const; - const hasHoldout = inference.events.some((item) => item.usage === "holdout"); - if (!hasHoldout) return "unavailable" as const; - if (inference.holdout_passed === true) return "passed" as const; - if (inference.holdout_passed === false || inference.result_status === "validation_failed") { - return "failed" as const; - } - return "not_started" as const; +function canAskHoldout(input: { + events?: readonly Readonly<{ usage: string; year: number | null }>[]; + oosBlindPrompts?: readonly unknown[] | null; +}): boolean { + if ((input.oosBlindPrompts?.length ?? 0) > 0) return true; + return Boolean(input.events?.some((item) => item.usage === "holdout" && item.year !== null)); } -function holdoutStatusFromState(state: InferenceState) { - const hasHoldout = state.events.some((item) => item.usage === "holdout"); - if (state.holdout_passed === true) return "passed" as const; - if (state.holdout_passed === false || state.result_status === "validation_failed") { - return "failed" as const; +function holdoutValidationStatus(input: { + events?: readonly Readonly<{ usage: string; year: number | null }>[]; + holdoutPassed?: boolean | null; + resultStatus?: string | null; + oosBlindPrompts?: readonly unknown[] | null; +}): HoldoutValidationStatus { + if (input.holdoutPassed === true) return "passed"; + if (input.holdoutPassed === false || input.resultStatus === "validation_failed") { + return "failed"; } - if (hasHoldout) return "not_started" as const; - return "unavailable" as const; + if (canAskHoldout(input)) return "not_started"; + return "unavailable"; +} + +function holdoutStatusFromInference( + inference: ReturnType, + oosBlindPrompts?: readonly unknown[] | null, +) { + if (!inference) return "unavailable" as const; + return holdoutValidationStatus({ + events: inference.events, + holdoutPassed: inference.holdout_passed, + resultStatus: inference.result_status, + oosBlindPrompts, + }); +} + +function holdoutStatusFromState( + state: InferenceState, + oosBlindPrompts?: readonly unknown[] | null, +) { + return holdoutValidationStatus({ + events: state.events, + holdoutPassed: state.holdout_passed, + resultStatus: state.result_status, + oosBlindPrompts, + }); } export function contrastPacketFromLatestResult( @@ -294,6 +321,9 @@ export function decideFromDossier( options?: { currentEvidenceFingerprint?: string | null }, ): RectificationDecision { const inference = previousInferenceFromReceipt(dossier.latestResult?.decisionReceipt ?? null); + const oosBlindPrompts = refinementFromDecisionReceipt( + dossier.latestResult?.decisionReceipt ?? null, + ).oos_blind_prompts; const trainingGate = trainingScoreableGate(dossier.evidence); const collecting = buildMethodFollowupPlan({ evidence: dossier.evidence, @@ -326,7 +356,7 @@ export function decideFromDossier( snapshotCurrent, candidateScores: candidateScoresFromDossier(dossier.latestResult), discriminatorProbe: inspected.selected, - holdoutValidation: holdoutStatusFromInference(inference), + holdoutValidation: holdoutStatusFromInference(inference, oosBlindPrompts), accepted: Boolean(dossier.case.acceptedTime), inferenceCredibleRange: inference?.credible_range ?? null, }), @@ -366,7 +396,10 @@ export function decideAfterInferenceChange(input: { .filter((item) => item.status !== "eliminated") .map((item) => ({ time: item.time, score: item.posterior_score })), discriminatorProbe: inspected.selected, - holdoutValidation: holdoutStatusFromState(input.state), + holdoutValidation: holdoutStatusFromState( + input.state, + refinementFromDecisionReceipt(input.dossier.latestResult?.decisionReceipt ?? null).oos_blind_prompts, + ), inferenceCredibleRange: input.state.credible_range, userStopped: input.userStopped, accepted: Boolean(input.dossier.case.acceptedTime), diff --git a/frontend/src/lib/rectification-agentic/v9/method-followup.ts b/frontend/src/lib/rectification-agentic/v9/method-followup.ts index b5911327..7a499623 100644 --- a/frontend/src/lib/rectification-agentic/v9/method-followup.ts +++ b/frontend/src/lib/rectification-agentic/v9/method-followup.ts @@ -74,6 +74,7 @@ import { completeStyleOptions, isRenderableProbe, rankDiscriminatorScore, + EXISTENCE_STYLE_OPTIONS, type ProbeStyleOption, } from "./probe-question-contract.ts"; import type { SessionOutcomeKind } from "./confirmation-gate.ts"; @@ -1032,6 +1033,39 @@ export function buildNextUserAction(input: { return { id: explain.id, user_meaning: explain.user_meaning, on_user_stop: explain }; } +function holdoutAskFields( + prompt: OosBlindPrompt | null | undefined, + reserved: Readonly<{ domain: string; year: number | null }> | null, +): Omit | null { + if (prompt) { + return { + method_id: "oos_blind", + intent: "out_of_sample_check", + ask_theme: "holdout", + domain: prompt.domain, + kind_hint: null, + user_prompt_hint: prompt.user_meaning, + source: "oos_blind", + choice_kind: "existence", + style_options: EXISTENCE_STYLE_OPTIONS, + }; + } + if (reserved?.year == null) return null; + return { + method_id: "holdout_validation", + intent: "out_of_sample_check", + ask_theme: "holdout", + domain: reserved.domain, + kind_hint: null, + user_prompt_hint: `${reserved.year} 年前后这件事还要单独核对一次,不计入候选分数。`, + source: "oos_blind", + probe_year: reserved.year, + year_label: `${reserved.year} 年前后`, + choice_kind: "existence", + style_options: EXISTENCE_STYLE_OPTIONS, + }; +} + export function buildMethodFollowupPlan(input: { evidence: readonly MethodFollowupEvidence[]; activeFocus?: MethodFollowupFocus | null; @@ -1230,32 +1264,13 @@ export function buildMethodFollowupPlan(input: { } if (sessionOutcome === "validate_holdout") { - const prompt = input.oosBlindPrompts?.[0] ?? null; - const reserved = (input.holdoutEvents ?? []).find((item) => item.year !== null) ?? null; - const holdoutNext = prompt - ? makeFollowup({ - method_id: "oos_blind", - intent: "out_of_sample_check", - ask_theme: "holdout", - domain: prompt.domain, - kind_hint: null, - user_prompt_hint: prompt.user_meaning, - source: "oos_blind", - }, false, true) - : reserved - ? makeFollowup({ - method_id: "holdout_validation", - intent: "out_of_sample_check", - ask_theme: "holdout", - domain: reserved.domain, - kind_hint: null, - user_prompt_hint: `${reserved.year} 年前后这件事还要单独核对一次,不计入候选分数。`, - source: "oos_blind", - }, false, true) - : null; + const fields = holdoutAskFields( + input.oosBlindPrompts?.[0], + (input.holdoutEvents ?? []).find((item) => item.year !== null) ?? null, + ); return { methods, - next_followup: holdoutNext, + next_followup: fields ? makeFollowup(fields, false, true) : null, deferred_followup: null, session_outcome: sessionOutcome ?? "collect_evidence", stop_domain_rotation: true, @@ -1663,29 +1678,11 @@ export function buildMethodFollowupPlan(input: { source: "nakshatra_boundary", }); } else if (input.holdoutValidation === "not_started") { - const prompt = input.oosBlindPrompts?.[0]; - const reserved = (input.holdoutEvents ?? []).find((item) => item.year !== null); - if (prompt) { - next = makeFollowup({ - method_id: "oos_blind", - intent: "out_of_sample_check", - ask_theme: "holdout", - domain: prompt.domain, - kind_hint: null, - user_prompt_hint: prompt.user_meaning, - source: "oos_blind", - }, false, true); - } else if (reserved) { - next = makeFollowup({ - method_id: "holdout_validation", - intent: "out_of_sample_check", - ask_theme: "holdout", - domain: reserved.domain, - kind_hint: null, - user_prompt_hint: `${reserved.year} 年前后这件事还要单独核对一次,不计入候选分数。`, - source: "oos_blind", - }, false, true); - } + const fields = holdoutAskFields( + input.oosBlindPrompts?.[0], + (input.holdoutEvents ?? []).find((item) => item.year !== null) ?? null, + ); + if (fields) next = makeFollowup(fields, false, true); } else if (horaryStatus === "uncovered") { next = makeFollowup({ method_id: "horary", diff --git a/frontend/tests/rectification-holdout-renderable.test.ts b/frontend/tests/rectification-holdout-renderable.test.ts new file mode 100644 index 00000000..299b2455 --- /dev/null +++ b/frontend/tests/rectification-holdout-renderable.test.ts @@ -0,0 +1,212 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { applyHoldoutAnswer } from "../src/lib/rectification-agentic/core/build-state.ts"; +import { decideFromDossier, rectificationFollowupCatalog } from "../src/lib/rectification-agentic/v9/decision-from-dossier.ts"; +import { buildCaseInferenceState } from "../src/lib/rectification-agentic/v9/inference-adapter.ts"; +import { buildMethodFollowupPlan } from "../src/lib/rectification-agentic/v9/method-followup.ts"; +import { evidenceLedgerFingerprint } from "../src/lib/rectification-agentic/v9/tool-service.ts"; +import type { InferenceState } from "../src/lib/rectification-agentic/core/types.ts"; + +const COVERED_EVIDENCE = [ + { + id: "e-edu", + status: "confirmed", + domain: "education", + datePrecision: "year", + occurredFrom: "2016-01-01", + occurredTo: null, + eventKind: "education_milestone", + }, + { + id: "e-career-a", + status: "confirmed", + domain: "career", + datePrecision: "year", + occurredFrom: "2018-01-01", + occurredTo: null, + eventKind: "career_entry", + }, + { + id: "e-career-b", + status: "confirmed", + domain: "career", + datePrecision: "year", + occurredFrom: "2020-01-01", + occurredTo: null, + eventKind: "career_change", + }, + { + id: "e-rel", + status: "confirmed", + domain: "relationship", + datePrecision: "year", + occurredFrom: "2024-01-01", + occurredTo: null, + eventKind: "relationship_start", + }, + { + id: "e-fam", + status: "confirmed", + domain: "family", + datePrecision: "year", + occurredFrom: "2023-01-01", + occurredTo: null, + eventKind: "family_event", + }, + { + id: "e-occ", + status: "confirmed", + domain: "occupation", + datePrecision: "unknown", + occurredFrom: null, + occurredTo: null, + eventKind: "occupation_note", + }, +] as const; + +function producedDossier( + evidence: readonly Readonly<{ + id: string; + status: string; + domain: string; + datePrecision: string; + occurredFrom: string | null; + occurredTo: string | null; + eventKind?: string | null; + }>[], + options: { previous?: InferenceState | null } = {}, +) { + const state = buildCaseInferenceState({ + range: { start_time: "04:55", end_time: "05:02" }, + candidates: [ + { candidateId: "05:02", time: "05:02", relativeSupport: 58 }, + { candidateId: "04:55", time: "04:55", relativeSupport: 42 }, + ], + evidence, + probes: [], + previous: options.previous, + }); + const latest = { + resultId: "55555555-5555-4555-8555-555555555555", + candidates: state.candidates.map((item) => ({ + candidateId: item.id, + time: item.time, + rank: item.rank, + relativeSupport: Math.round(item.posterior_score), + })), + representativeTime: state.representative_time, + evidenceLedgerFingerprint: evidenceLedgerFingerprint(evidence as never), + decisionReceipt: { inference_state: state }, + }; + return { + state, + dossier: { + evidence, + conversationSummary: { activeFocus: null, declinedSkippedTopics: [] }, + latestResult: latest, + case: { acceptedTime: null }, + }, + }; +} + +function holdoutFollowup(dossier: ReturnType["dossier"]) { + const catalog = rectificationFollowupCatalog(dossier.latestResult, dossier.evidence); + return buildMethodFollowupPlan({ + evidence: dossier.evidence, + declinedTopics: dossier.conversationSummary.declinedSkippedTopics, + sessionOutcome: "validate_holdout", + ...catalog, + candidatesSeparated: true, + }); +} + +test("sticky holdout with unknown precision does not ask validation and can adopt", () => { + const { state, dossier: dated } = producedDossier(COVERED_EVIDENCE); + const holdout = state.events.find((item) => item.usage === "holdout"); + assert.ok(holdout); + assert.notEqual(holdout.year, null); + assert.equal(decideFromDossier(dated).nextAction, "ask_holdout_validation"); + + const undated = COVERED_EVIDENCE.map((item) => ( + item.id === holdout.id + ? { ...item, datePrecision: "unknown", occurredFrom: null, occurredTo: null } + : item + )); + const { state: sticky, dossier } = producedDossier(undated, { previous: state }); + const stickyHoldout = sticky.events.find((item) => item.id === holdout.id); + assert.equal(stickyHoldout?.usage, "holdout"); + assert.equal(stickyHoldout?.year, null); + assert.equal(stickyHoldout?.precision, "unknown"); + + const decision = decideFromDossier(dossier); + assert.notEqual(decision.nextAction, "ask_holdout_validation"); + assert.equal(decision.nextAction, "ready_to_adopt"); + assert.equal(decision.sessionOutcome, "adopt_representative"); + assert.notEqual(decision.sessionOutcome, "validated_range"); + assert.equal(decision.validated, false); + assert.equal(decision.canAdopt, true); + assert.equal(decision.canConfirmExactMinute, false); +}); + +test("dated holdout asks validation with a renderable followup card", () => { + const { dossier } = producedDossier(COVERED_EVIDENCE); + const decision = decideFromDossier(dossier); + assert.equal(decision.nextAction, "ask_holdout_validation"); + assert.equal(decision.canConfirmExactMinute, false); + + const plan = holdoutFollowup(dossier); + assert.ok(plan.next_followup); + assert.ok(plan.next_followup.choice_frame, "holdout card must be renderable"); + assert.equal(plan.next_followup.choice_frame.scoring, false); + assert.ok(plan.next_followup.choice_frame.prompt); +}); + +test("passed holdout is a validated range, not a unique minute", () => { + const { state, dossier } = producedDossier(COVERED_EVIDENCE); + const passed = applyHoldoutAnswer(state, "yes"); + const decided = decideFromDossier({ + ...dossier, + latestResult: { + ...dossier.latestResult, + decisionReceipt: { inference_state: passed }, + }, + }); + assert.equal(decided.sessionOutcome, "validated_range"); + assert.equal(decided.canAdopt, true); + assert.equal(decided.validated, true); + assert.equal(decided.canConfirmExactMinute, false); + assert.notEqual(decided.nextAction, "ask_holdout_validation"); +}); + +test("failed holdout keeps existing non-validated close or discriminate path", () => { + const { state, dossier } = producedDossier(COVERED_EVIDENCE); + const failed = applyHoldoutAnswer(state, "no"); + assert.equal(failed.holdout_passed, false); + const decided = decideFromDossier({ + ...dossier, + latestResult: { + ...dossier.latestResult, + decisionReceipt: { inference_state: failed }, + }, + }); + assert.equal(decided.holdoutValidation, "failed"); + assert.notEqual(decided.nextAction, "ask_holdout_validation"); + assert.notEqual(decided.sessionOutcome, "validated_range"); + assert.equal(decided.validated, false); + assert.equal(decided.canConfirmExactMinute, false); +}); + +test("ask_holdout_validation is only returned when the same dossier can build a holdout followup", () => { + const fixtures = [ + producedDossier(COVERED_EVIDENCE).dossier, + producedDossier(COVERED_EVIDENCE.filter((item) => item.domain !== "occupation")).dossier, + ]; + for (const dossier of fixtures) { + const decision = decideFromDossier(dossier); + if (decision.nextAction !== "ask_holdout_validation") continue; + const plan = holdoutFollowup(dossier); + assert.ok(plan.next_followup, "ask_holdout_validation requires a holdout followup"); + assert.equal(decision.canConfirmExactMinute, false); + } +});