diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 509a15a3..f855f394 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -7102,3 +7102,19 @@ - 相关记录:BUG-450、BUG-452、BUG-460 - 复发自:BUG-460 - 修复版本:待发布 + +## BUG-463 | 采用门把分离与 holdout 绑死导致引擎可出牌时用户无法采用 + +- 状态:resolved +- 首次发现:2026-09-01 +- 最近更新:2026-09-01 +- 影响面:`deliveryCapability` / `decideRectification`、GET case overlay、`persistNextInterviewIfIdle`、候选卡采用 RPC 投影 +- 用户现象:多条可评分证据、多领域、区分题已答完,引擎 receipt 已允许 acceptance/selection/propose,界面仍是 `can_adopt=false`、没有时间卡、不能保存,并继续轮询财务/职业等采集题。 +- 触发条件:方法覆盖(含职业确认或拒答关闭)已满足,候选领先不足 `MIN_SEPARATION_LEAD`,或 holdout 未通过/不可用;引擎四门自洽且 `confirmation_allowed=false`。 +- 根因:策略死结,不是实现回归。引擎 receipt 与 Skill `10.0.14` 都把分离/holdout 留在唯一分钟确认门,交付物是代表分钟加可信区间。TypeScript `deliveryCapability` 却把这两项写进 `canAdopt`,overlay 再与引擎取交集,于是引擎的“可以”永远被 TS 盖成“不行”。上一轮 `TASK-rectification-nonterminal-exit-20260901.md` 与 BUG-460/462 的防复发写了“不得放宽 `canAdopt`”;该红线已由产品负责人于 2026-09-01 显式推翻,本条记录授权变更。 +- 修复:`locallySelectable` 只保留候选存在、方法覆盖完成、以及证据下限 `keep_collecting`。分离充分、holdout passed 与引擎 `confirmation_allowed` 只进入 `canConfirmExactMinute`。覆盖完成后 idle 持久化不再追问,GET overlay 放出候选卡;采用 RPC 仍读引擎 snapshot 的 `selection_allowed`,写入 `accepted` 不写 `confirmed`。 +- 验证:事故形状 1a–4 与交付 B1–B3;授权组断言(分离不足/holdout/并列/`offer_provisional_range` 不采用)改为 provisional 采用且确认门仍关;证据下限、引擎 ceiling、确认门、非终态出口闸与 `read_only` 保持全绿。`rectification-*.test.ts` 与 `skill-registry.test.ts` 最终数字见本次交付;`./node_modules/.bin/tsc --noEmit` 必须通过。未进行真实 staging smoke。 +- 防复发:`canAdopt` / `selectionAllowed` / `proposeAllowed` / `canConfirmExactMinute` 只在 `deliveryCapability` 计算并经 `...capability` 展开,出口不得覆写。不得把分离或 holdout 重新绑回采用门。不得放宽确认门、表达边界、3/2 证据下限或引擎 ceiling fail-closed。不得 bump skill 版本或改 Python 引擎来绕过本条。 +- 相关记录:BUG-456、BUG-460、BUG-462 +- 复发自:无(授权策略变更,不是同一实现回归) +- 修复版本:待发布 diff --git a/frontend/src/lib/rectification-agentic/core/rectification-decision.ts b/frontend/src/lib/rectification-agentic/core/rectification-decision.ts index 55e5ca4f..650ff35e 100644 --- a/frontend/src/lib/rectification-agentic/core/rectification-decision.ts +++ b/frontend/src/lib/rectification-agentic/core/rectification-decision.ts @@ -199,19 +199,18 @@ function deliveryCapability(input: { holdout: HoldoutValidationStatus; engineCeiling: EngineCapabilityCeiling; confirmationAllowed: boolean; - coverageBlocks: boolean; + coverageComplete: boolean; }): DeliveryCapability { const locallySelectable = input.separation.ranked.length > 0 - && !input.coverageBlocks - && input.stopClass?.kind !== "keep_collecting" - && input.stopClass?.kind !== "exhausted" - && input.separation.sufficient - && input.holdout === "passed"; + && input.coverageComplete + && input.stopClass?.kind !== "keep_collecting"; return { canAdopt: locallySelectable && input.engineCeiling.acceptanceAllowed, selectionAllowed: locallySelectable && input.engineCeiling.selectionAllowed, proposeAllowed: locallySelectable && input.engineCeiling.proposeAllowed, canConfirmExactMinute: locallySelectable + && input.separation.sufficient + && input.holdout === "passed" && input.confirmationAllowed && input.engineCeiling.confirmationAllowed, }; @@ -241,7 +240,7 @@ export function decideRectification(input: DecideRectificationInput): Rectificat holdout, engineCeiling: input.engineCeiling, confirmationAllowed, - coverageBlocks, + coverageComplete: input.methodCoverageAll && input.trainingGateOpen !== false, }); if (input.snapshotCurrent === false) { @@ -278,7 +277,7 @@ export function decideRectification(input: DecideRectificationInput): Rectificat if (probe) { return discriminateOrExhaust(input, separation, holdout, range, probe, capability, stopReason); } - if (holdout === "not_started") { + if (holdout === "not_started" && !capability.canAdopt) { return holdoutValidation(separation, range, capability); } if (input.datedMethodCollectOpen === true) { @@ -322,7 +321,7 @@ export function decideRectification(input: DecideRectificationInput): Rectificat capability, }); } - if (holdout === "not_started") { + if (holdout === "not_started" && !capability.canAdopt) { return holdoutValidation(separation, range, capability); } if (holdout === "failed") { @@ -403,7 +402,7 @@ function offerRangeWithoutAdopt( return { phase: "discrimination", nextAction: "offer_provisional_range", - sessionOutcome: "provisional_range", + sessionOutcome: capability.canAdopt ? "adopt_representative" : "provisional_range", resultStatus: "insufficient_evidence", canOfferRange: true, ...capability, @@ -518,11 +517,13 @@ function completeWithRange( const userStopped = kind === "user_stopped"; const terminal = kind !== "offer"; const nextAction = terminal ? "complete_with_range" : "offer_provisional_range"; - const sessionOutcome = userStopped - ? "provisional_range_user_stopped" - : terminal - ? "completed_with_range" - : "provisional_range"; + const sessionOutcome = capability.canAdopt + ? "adopt_representative" + : userStopped + ? "provisional_range_user_stopped" + : terminal + ? "completed_with_range" + : "provisional_range"; return { phase: terminal ? "completed" : "discrimination", nextAction, diff --git a/frontend/src/lib/rectification-agentic/v9/answer-choice.ts b/frontend/src/lib/rectification-agentic/v9/answer-choice.ts index e7cf0e83..295c3e8d 100644 --- a/frontend/src/lib/rectification-agentic/v9/answer-choice.ts +++ b/frontend/src/lib/rectification-agentic/v9/answer-choice.ts @@ -526,6 +526,21 @@ export async function persistNextInterviewIfIdle(input: { birthDate = null; } const decision = decideFromDossier(dossier, { birthDate }); + if ( + decision.canAdopt + && decision.nextAction !== "ask_fact_collection" + && decision.nextAction !== "ask_candidate_discriminator" + && decision.nextAction !== "ask_holdout_validation" + ) { + return { + persisted: false, + choiceReady: false, + hostNarration: `${nonConvergingRangeNarration({ + credibleRange: decision.credibleRange, + representativeTime: decision.representativeTime, + })} 可以从下面的时间里选一个采用。`, + }; + } if (isNonConvergingRangeOffer(decision)) { return persistExhaustionCollect({ accounting: input.accounting, @@ -686,7 +701,7 @@ ${nextInterview.hostNarration}` nextInterviewPersisted = true; } } - const adoptionNarration = nextAction.can_offer_range && nextAction.can_adopt + const adoptionNarration = nextAction.can_adopt ? `${nonConvergingRangeNarration({ credibleRange: nextAction.credible_range, representativeTime: nextAction.representative_time, diff --git a/frontend/src/lib/rectification-agentic/v9/method-followup.ts b/frontend/src/lib/rectification-agentic/v9/method-followup.ts index b400ade1..ceb0b0ac 100644 --- a/frontend/src/lib/rectification-agentic/v9/method-followup.ts +++ b/frontend/src/lib/rectification-agentic/v9/method-followup.ts @@ -968,7 +968,7 @@ const EXHAUSTION_REMAINING_COLLECT_ORDER = [ ] as const; function datedCollectFollowup( - domain: keyof typeof YEARLESS_COLLECT_LEAD, + domain: keyof typeof REVERSE_VERIFY_THEME, evidence: readonly MethodFollowupEvidence[], ): MethodFollowup | null { const lead = YEARLESS_COLLECT_LEAD[domain]; diff --git a/frontend/tests/rectification-convergence-budget.test.ts b/frontend/tests/rectification-convergence-budget.test.ts index 6b7b3e4a..02a92f6e 100644 --- a/frontend/tests/rectification-convergence-budget.test.ts +++ b/frontend/tests/rectification-convergence-budget.test.ts @@ -144,7 +144,11 @@ test("every persisted discrimination budget terminates before asking another pro ]) { const decision = decideWithBudget(budget); assert.equal(decision.nextAction, "complete_with_range"); - assert.equal(decision.sessionOutcome, "completed_with_range"); + // 原断言 sessionOutcome=completed_with_range → 新断言 adopt_representative。 + // 为什么:预算耗尽只结束提问;覆盖完成且引擎可出牌时应交付代表性采用,不挡在 review-only 区间。 + assert.equal(decision.sessionOutcome, "adopt_representative"); + assert.equal(decision.canAdopt, true); + assert.equal(decision.canConfirmExactMinute, false); assert.notEqual(decision.nextAction, "ask_candidate_discriminator"); } }); @@ -156,7 +160,10 @@ test("repeated declined or unsure answers reach the existing plateau terminal", plateauRounds: RECTIFICATION_POLICY.maxPlateauRounds, }); assert.equal(decision.nextAction, "complete_with_range"); - assert.equal(decision.sessionOutcome, "completed_with_range"); + // 原断言 sessionOutcome=completed_with_range → 新断言 adopt_representative。 + assert.equal(decision.sessionOutcome, "adopt_representative"); + assert.equal(decision.canAdopt, true); + assert.equal(decision.canConfirmExactMinute, false); }); test("exhausted discrimination still delivers the credible candidate range", () => { @@ -166,10 +173,12 @@ test("exhausted discrimination still delivers the credible candidate range", () plateauRounds: 0, }); assert.equal(decision.resultStatus, "completed_with_range"); - assert.equal(decision.sessionOutcome, "completed_with_range"); + // 原断言 sessionOutcome=completed_with_range / canAdopt=false → 新断言 adopt_representative / canAdopt=true。 + // 为什么:exhausted 只结束区分轮,不挡代表性采用;唯一分钟确认门仍关。 + assert.equal(decision.sessionOutcome, "adopt_representative"); assert.equal(decision.canOfferRange, true); - // 原值是 true;候选未分开且区分预算耗尽只能交付 review-only range,不能采用。 - assert.equal(decision.canAdopt, false); + assert.equal(decision.canAdopt, true); + assert.equal(decision.canConfirmExactMinute, false); assert.deepEqual(decision.credibleRange, ["05:00", "05:07"]); }); @@ -242,9 +251,12 @@ test("exhausted evidence-state stops complete with a usable range and the fixed for (const item of cases) { const decision = decideRectification({ ...BASE_INPUT, ...item.input }); assert.equal(decision.nextAction, "complete_with_range"); - assert.equal(decision.sessionOutcome, "completed_with_range"); + // 原断言 sessionOutcome=completed_with_range → 新断言 adopt_representative。 + assert.equal(decision.sessionOutcome, "adopt_representative"); assert.equal(decision.resultStatus, "completed_with_range"); assert.equal(decision.canOfferRange, true); + assert.equal(decision.canAdopt, true); + assert.equal(decision.canConfirmExactMinute, false); assert.ok(decision.credibleRange); assert.equal(decision.stopReason, item.reason); assert.equal(decision.terminationCopy, RECTIFICATION_TERMINATION_COPY); diff --git a/frontend/tests/rectification-credible-range-projection.test.ts b/frontend/tests/rectification-credible-range-projection.test.ts index 1dac685a..b8e073f2 100644 --- a/frontend/tests/rectification-credible-range-projection.test.ts +++ b/frontend/tests/rectification-credible-range-projection.test.ts @@ -129,7 +129,10 @@ test("producer 58/42 lead keeps both candidates and can close without opening th const decision = decideFromDossier(dossierFor(latest)); assert.equal(decision.separation.sufficient, true); - assert.equal(decision.nextAction, "ask_holdout_validation"); + // 原断言 nextAction=ask_holdout_validation → 新断言 ready_to_adopt。 + // 为什么:分离充分仍不够开确认门;holdout 只挡唯一分钟,provisional 采用应直接出牌。 + assert.equal(decision.nextAction, "ready_to_adopt"); + assert.equal(decision.canAdopt, true); assert.equal(decision.canConfirmExactMinute, false); assert.notEqual(decision.sessionOutcome, "exact_minute_confirmed"); }); @@ -142,9 +145,10 @@ test("producer 58/42 with no holdout remains a review-only range", () => { const decision = decideFromDossier(dossierFor(latest)); assert.equal(authoritativeCandidateProjection(latest).consistent, true); assert.equal(decision.separation.sufficient, true); - // 原值是 ready_to_adopt;没有 holdout 不能进入采用态,只能展示 review-only range。 + // 原断言 canAdopt=false(holdout unavailable 不放行)→ 新断言 canAdopt=true。 + // 为什么:无 holdout 只挡唯一分钟确认,不挡代表性采用。 assert.equal(decision.nextAction, "offer_provisional_range"); - assert.equal(decision.canAdopt, false); + assert.equal(decision.canAdopt, true); assert.equal(decision.canConfirmExactMinute, false); assert.notEqual(decision.sessionOutcome, "exact_minute_confirmed"); }); diff --git a/frontend/tests/rectification-decide-next-action.test.ts b/frontend/tests/rectification-decide-next-action.test.ts index 4fe8fb6c..4063eb45 100644 --- a/frontend/tests/rectification-decide-next-action.test.ts +++ b/frontend/tests/rectification-decide-next-action.test.ts @@ -507,9 +507,11 @@ test("a single candidate is sole_candidate, not a parallel range", () => { candidateScores: [{ time: "05:00", score: 80 }], holdoutValidation: "unavailable", }); - // 原值是 adopt_representative;holdout unavailable 且用户未停止只能提供 review-only range。 - assert.equal(decision.sessionOutcome, "provisional_range"); - assert.equal(decision.canAdopt, false); + // 原断言 sessionOutcome=provisional_range 且 canAdopt=false + // → 新断言 sessionOutcome=adopt_representative 且 canAdopt=true。 + // holdout unavailable 只挡确认门;单候选仍是代表性采用,不是唯一分钟确认。 + assert.equal(decision.sessionOutcome, "adopt_representative"); + assert.equal(decision.canAdopt, true); assert.equal(decision.canConfirmExactMinute, false); assert.doesNotMatch( buildSkillVerificationReport({ diff --git a/frontend/tests/rectification-decision-authority.test.ts b/frontend/tests/rectification-decision-authority.test.ts index 94dc7d58..3c1884f9 100644 --- a/frontend/tests/rectification-decision-authority.test.ts +++ b/frontend/tests/rectification-decision-authority.test.ts @@ -113,7 +113,7 @@ test("invariant 2: insufficient dated evidence never permits selection or adopti } }); -test("invariant 3: tied leading candidates remain review-only when the user stops", () => { +test("invariant 3: tied leading candidates can provisionally adopt, but exact-minute confirm stays closed", () => { const tiedScores = [ { time: "04:48", score: 12 }, { time: "04:49", score: 12 }, @@ -125,7 +125,10 @@ test("invariant 3: tied leading candidates remain review-only when the user stop userStopped: true, }); assert.equal(stopped.completionStatus, "provisional_range_user_stopped"); - assert.equal(stopped.canAdopt, false); + // 原断言 canAdopt=false(并列不得采用)→ 新断言 canAdopt=true。 + // 产品 2026-09-01 授权:并列只挡唯一分钟确认门,不挡代表性采用。 + assert.equal(stopped.canAdopt, true); + assert.equal(stopped.canConfirmExactMinute, false); const continuing = decideWithEngineCeiling(ENGINE_OPEN, { candidateScores: tiedScores, @@ -133,17 +136,21 @@ test("invariant 3: tied leading candidates remain review-only when the user stop userStopped: false, }); assert.equal(continuing.separation.tiedForFirst, true); - assert.equal(continuing.canAdopt, false); + assert.equal(continuing.canAdopt, true); + assert.equal(continuing.canConfirmExactMinute, false); }); -test("invariant 4: unavailable holdout stays review-only whether or not the user stops", () => { +test("invariant 4: unavailable holdout still allows provisional adopt; exact-minute confirm stays closed", () => { for (const userStopped of [false, true]) { const decision = decideWithEngineCeiling(ENGINE_OPEN, { confirmationAllowed: false, holdoutValidation: "unavailable", userStopped, }); - assert.equal(decision.canAdopt, false, `userStopped=${userStopped}`); + // 原断言 canAdopt=false(holdout unavailable 不放行)→ 新断言 canAdopt=true。 + // holdout 只保留给唯一分钟确认门;引擎 ceiling 仍是硬上限。 + assert.equal(decision.canAdopt, true, `userStopped=${userStopped}`); + assert.equal(decision.canConfirmExactMinute, false, `userStopped=${userStopped}`); } }); @@ -323,7 +330,7 @@ test("nonterminal invariant 2: exhausted probes take the next available server-o holdoutValidation: "not_started" as const, datedMethodCollectOpen: false, nakshatraBoundaryProbe: null, - nextAction: "ask_holdout_validation", + nextAction: "offer_provisional_range", }, { name: "dated collect", @@ -368,7 +375,10 @@ test("nonterminal invariant 2: exhausted probes take the next available server-o engineCeiling: ENGINE_OPEN, }); assert.equal(decision.separation.sufficient, false, fixture.name); - assert.equal(decision.canAdopt, false, fixture.name); + // 原断言 canAdopt=false(分离不足不得采用)→ 新断言 canAdopt=true。 + // 分离不足只挡确认门;coverage/证据下限仍挡采用。本夹具覆盖已齐且事件够数。 + assert.equal(decision.canAdopt, true, fixture.name); + assert.equal(decision.canConfirmExactMinute, false, fixture.name); assert.equal(decision.nextAction, fixture.nextAction, fixture.name); } }); diff --git a/frontend/tests/rectification-eight-method.test.ts b/frontend/tests/rectification-eight-method.test.ts index 65b7d7d1..ba4fd6ee 100644 --- a/frontend/tests/rectification-eight-method.test.ts +++ b/frontend/tests/rectification-eight-method.test.ts @@ -1614,7 +1614,8 @@ test("lagna_frame after classic coverage does not keep a tie in discrimination w { time: "05:01", score: 33 }, { time: "05:02", score: 33 }, ], - }), "provisional_range"); + // 原断言 provisional_range(并列不采用)→ 新断言 adopt_representative。 + }), "adopt_representative"); }); test("precision stage d4 asks home change not family, and d5 asks education", () => { @@ -1869,7 +1870,9 @@ test("draft occupation_note without a date covers occupation and does not adopt }); assert.equal(plan.methods.find((item) => item.method_id === "occupation")?.status, "covered"); assert.notEqual(plan.next_followup?.method_id, "occupation"); - assert.notEqual(conversationalSessionOutcome({ + // 原断言 sessionOutcome≠adopt_representative(并列不采用)→ 新断言 adopt_representative。 + // 为什么:occupation 覆盖后并列候选仍应交付代表性采用;确认门保持关闭。 + assert.equal(conversationalSessionOutcome({ selectionAllowed: true, proposeAllowed: true, confirmationAllowed: false, @@ -1906,7 +1909,9 @@ test("stale occupation collect focus does not keep interviewing after occupation targetKind: "occupation_note", }, }); - assert.notEqual(conversationalSessionOutcome({ + // 原断言 sessionOutcome≠adopt_representative → 新断言 adopt_representative。 + // 为什么:职业已覆盖后不得继续追问;并列候选走代表性采用,而不是停在无出口访谈。 + assert.equal(conversationalSessionOutcome({ selectionAllowed: true, proposeAllowed: true, confirmationAllowed: false, @@ -2473,7 +2478,8 @@ test("provisional range still exposes method coverage followup", () => { methods: plan.methods, discriminatorProbe: null, candidateScores: DUMP_SCORES, - }), "provisional_range"); + // 原断言 provisional_range → 新断言 adopt_representative。 + }), "adopt_representative"); const action = buildNextUserAction({ scorableCount: 6, evidenceCount: 8, @@ -2501,7 +2507,9 @@ test("structured paused state ends evidence collection without parsing user copy { time: "05:06", score: 33 }, { time: "05:07", score: 33 }, ], - }), "provisional_range_user_stopped"); + // 原断言 provisional_range_user_stopped → 新断言 adopt_representative。 + // 为什么:用户停止后仍可交付代表性采用;validated / 确认门保持关闭。 + }), "adopt_representative"); }); @@ -3098,7 +3106,8 @@ test("offer-candidates allows a 34/33/33 tie after method coverage when remainin const projection = await (tools["rectification-offer-candidates"] as unknown as { execute(input: unknown): Promise<{ session_outcome: string }>; }).execute({ caseId: CASE_ID }); - assert.equal(projection.session_outcome, "provisional_range"); + // 原断言 session_outcome=provisional_range(并列不采用)→ 新断言 adopt_representative。 + assert.equal(projection.session_outcome, "adopt_representative"); assert.equal( accounting.calls.some((call) => call.fn === "transition_agentic_rectification_case_status" diff --git a/frontend/tests/rectification-hidden-e2e.test.ts b/frontend/tests/rectification-hidden-e2e.test.ts index 795ce96f..8abb9cf1 100644 --- a/frontend/tests/rectification-hidden-e2e.test.ts +++ b/frontend/tests/rectification-hidden-e2e.test.ts @@ -162,7 +162,10 @@ test("hidden case walks collection through holdout to a range or representative candidateScores: scores, holdoutValidation: "not_started", }); - assert.equal(afterAnswers.nextAction, "ask_holdout_validation"); + // 原断言 nextAction=ask_holdout_validation → 新断言 ready_to_adopt。 + // 为什么:holdout 只保留给唯一分钟确认;覆盖完成即可 provisional 采用。 + assert.equal(afterAnswers.nextAction, "ready_to_adopt"); + assert.equal(afterAnswers.canAdopt, true); assert.equal(afterAnswers.canConfirmExactMinute, false); const holdoutPassed = applyHoldoutAnswer(answered.state, "yes"); @@ -292,9 +295,13 @@ test("user stop is an unvalidated range, not a holdout pass", () => { ], holdoutValidation: "not_started", }); - assert.equal(stopped.sessionOutcome, "provisional_range_user_stopped"); + // 原断言 sessionOutcome=provisional_range_user_stopped → 新断言 adopt_representative。 + // 为什么:用户停止仍是未独立核对的区间,但覆盖完成时应可代表性采用。 + assert.equal(stopped.sessionOutcome, "adopt_representative"); assert.equal(stopped.validated, false); assert.equal(stopped.completionStatus, "provisional_range_user_stopped"); + assert.equal(stopped.canAdopt, true); + assert.equal(stopped.canConfirmExactMinute, false); }); test("tied candidates without a high-information probe offer a range instead of inventing left/right", () => { @@ -311,7 +318,10 @@ test("tied candidates without a high-information probe offer a range instead of holdoutValidation: "unavailable", }); assert.equal(next.nextAction, "offer_provisional_range"); - assert.equal(next.sessionOutcome, "provisional_range"); + // 原断言 sessionOutcome=provisional_range(并列不采用)→ 新断言 adopt_representative。 + assert.equal(next.sessionOutcome, "adopt_representative"); + assert.equal(next.canAdopt, true); + assert.equal(next.canConfirmExactMinute, false); assert.equal(next.validated, false); assert.equal(next.probe, null); }); diff --git a/frontend/tests/rectification-holdout-renderable.test.ts b/frontend/tests/rectification-holdout-renderable.test.ts index 5ba0f98e..868af901 100644 --- a/frontend/tests/rectification-holdout-renderable.test.ts +++ b/frontend/tests/rectification-holdout-renderable.test.ts @@ -132,7 +132,12 @@ test("sticky holdout with unknown precision stays review-only", () => { 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 datedDecision = decideFromDossier(dated); + // 原断言 nextAction=ask_holdout_validation → 新断言 ready_to_adopt。 + // 为什么:dated holdout 只挡确认门;覆盖完成即可代表性采用。 + assert.equal(datedDecision.nextAction, "ready_to_adopt"); + assert.equal(datedDecision.canAdopt, true); + assert.equal(datedDecision.canConfirmExactMinute, false); const undated = COVERED_EVIDENCE.map((item) => ( item.id === holdout.id @@ -147,21 +152,24 @@ test("sticky holdout with unknown precision stays review-only", () => { const decision = decideFromDossier(dossier); assert.notEqual(decision.nextAction, "ask_holdout_validation"); - // 原值是 ready_to_adopt;unknown precision 让 holdout 不可用,不能进入采用态。 - assert.equal(decision.nextAction, "offer_provisional_range"); - // 原值是 adopt_representative;未通过独立核对只能交付临时区间。 - assert.equal(decision.sessionOutcome, "provisional_range"); + // 原断言 offer_provisional_range + canAdopt=false → 新断言可采用的区间出口。 + // 为什么:holdout unavailable 只挡唯一分钟确认,不挡代表性采用。 + assert.ok(decision.nextAction === "offer_provisional_range" || decision.nextAction === "ready_to_adopt"); + assert.equal(decision.sessionOutcome, "adopt_representative"); assert.notEqual(decision.sessionOutcome, "validated_range"); assert.equal(decision.validated, false); - // 原值是 true;holdout unavailable 必须 fail-closed。 - assert.equal(decision.canAdopt, 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"); + // 原断言 nextAction=ask_holdout_validation → 新断言 ready_to_adopt。 + // 为什么:默认决策不再用 holdout 挡住 provisional 采用;确认门仍关。 + // holdout 卡片在 validate_holdout 会话里仍可渲染,供确认路径使用。 + assert.equal(decision.nextAction, "ready_to_adopt"); + assert.equal(decision.canAdopt, true); assert.equal(decision.canConfirmExactMinute, false); const plan = holdoutFollowup(dossier); diff --git a/frontend/tests/rectification-provisional-adopt.test.ts b/frontend/tests/rectification-provisional-adopt.test.ts new file mode 100644 index 00000000..26477482 --- /dev/null +++ b/frontend/tests/rectification-provisional-adopt.test.ts @@ -0,0 +1,600 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +import { candidateSetId } from "../src/lib/rectification-agentic/core/build-state.ts"; +import { + decideRectification, + engineCapabilityCeilingFromReceipt, + publicDecisionFields, +} from "../src/lib/rectification-agentic/core/rectification-decision.ts"; +import type { ConflictProbe, InferenceState } from "../src/lib/rectification-agentic/core/types.ts"; +import { + decideFromDossier, + overlayPublicDecision, + type DecisionDossier, +} from "../src/lib/rectification-agentic/v9/decision-from-dossier.ts"; +import { persistNextInterviewIfIdle } from "../src/lib/rectification-agentic/v9/answer-choice.ts"; +import { + buildMethodFollowupPlan, + buildNextUserAction, +} from "../src/lib/rectification-agentic/v9/method-followup.ts"; +import { projectCurrentQuestion } from "../src/lib/rectification-agentic/v9/turn-decision.ts"; +import { evidenceLedgerFingerprint, parseV9CaseDossier } from "../src/lib/rectification-agentic/v9/tool-service.ts"; +import { + canRenderRectificationSelectionCards, + parseRectificationCandidateResult, +} from "../src/lib/rectification-candidate-result.ts"; +import { + CASE_ID, + FOCUS_ID, + TURN_ID, + USER_ID, + candidateSnapshotFixture, + computeFixture, + dossierFixture, + fakeAccounting, + receiptHandlers, +} from "./rectification-v9-test-support.ts"; + +const EXISTENCE_OPTIONS = [ + { label: "明确发生且时间吻合", answer_class: "yes" as const }, + { label: "发生过但程度较弱", answer_class: "weak_yes" as const }, + { label: "明确没有发生", answer_class: "no" as const }, + { label: "这段记不清楚", answer_class: "unsure" as const }, +]; + +const INCIDENT_TIMES = ["05:00", "05:07", "04:53"] as const; +const INCIDENT_SCORES = { "05:00": 24, "05:07": 19, "04:53": 8 } as const; +const INCIDENT_CANDIDATE_IDS = { + "05:00": "88888888-8888-4888-8888-888888888881", + "05:07": "88888888-8888-4888-8888-888888888882", + "04:53": "88888888-8888-4888-8888-888888888883", +} as const; + +const ENGINE_OPEN = { + acceptanceAllowed: true, + selectionAllowed: true, + proposeAllowed: true, + confirmationAllowed: true, +} as const; + +const INCIDENT_EVIDENCE = [ + { + id: "e-education", + status: "confirmed", + domain: "education", + datePrecision: "year", + occurredFrom: "2016-01-01", + occurredTo: null, + eventKind: "education_start", + }, + { + id: "e-rel-start", + status: "confirmed", + domain: "relationship", + datePrecision: "month", + occurredFrom: "2024-05-01", + occurredTo: null, + eventKind: "relationship_start", + }, + { + id: "e-rel-end", + status: "confirmed", + domain: "relationship", + datePrecision: "day", + occurredFrom: "2024-08-08", + occurredTo: null, + eventKind: "relationship_end", + }, + { + id: "e-career-entry", + status: "confirmed", + domain: "career", + datePrecision: "month", + occurredFrom: "2020-04-01", + occurredTo: null, + eventKind: "career_entry", + }, + { + id: "e-career-exit", + status: "confirmed", + domain: "career", + datePrecision: "month", + occurredFrom: "2020-10-01", + occurredTo: null, + eventKind: "career_exit", + }, +] as const; + +function answeredProbe(index: number, domain: string, year: number): ConflictProbe { + const semantic = `${domain}.${year}.dasha_boundary`; + return { + id: `probe:${semantic}`, + semantic_key: semantic, + candidate_split_hash: `${semantic}:split`, + domain, + year, + question: `${year} 年前后这件事有没有发生?`, + candidate_ids: [...INCIDENT_TIMES], + expected_outcomes: [ + { answer_class: "yes", supports: ["05:00", "05:07", "04:53"], conflicts: [] }, + { answer_class: "weak_yes", supports: [], conflicts: [] }, + { answer_class: "no", supports: [], conflicts: ["05:00", "05:07", "04:53"] }, + { answer_class: "unsure", supports: [], conflicts: [] }, + ], + information_gain: 0.4, + source: "dasha_boundary", + choice_kind: "existence", + style_options: EXISTENCE_OPTIONS, + }; +} + +const ANSWERED_PROBES: ConflictProbe[] = [ + answeredProbe(1, "education", 2016), + answeredProbe(2, "relationship", 2024), + answeredProbe(3, "relationship", 2023), + answeredProbe(4, "career", 2020), + answeredProbe(5, "career", 2023), +]; + +const REMAINING_UNSPLIT: ConflictProbe = { + id: "probe:career.2023.dasha_activation", + semantic_key: "career.2023.dasha_activation", + candidate_split_hash: "career.2023.activation", + domain: "career", + year: 2023, + question: "2023 年前后大运有没有启动?", + candidate_ids: [...INCIDENT_TIMES], + expected_outcomes: [ + { answer_class: "yes", supports: ["05:00", "05:07", "04:53"], conflicts: [] }, + { answer_class: "weak_yes", supports: [], conflicts: [] }, + { answer_class: "no", supports: [], conflicts: ["05:00", "05:07", "04:53"] }, + { answer_class: "unsure", supports: [], conflicts: [] }, + ], + information_gain: 0.56, + source: "dasha_activation", + choice_kind: "existence", + style_options: EXISTENCE_OPTIONS, +}; + +function incidentEvents(evidence: typeof INCIDENT_EVIDENCE | readonly DecisionDossier["evidence"][number][]) { + return evidence.map((item) => { + const yearRaw = item.occurredFrom ? Number(String(item.occurredFrom).slice(0, 4)) : NaN; + const year = Number.isInteger(yearRaw) ? yearRaw : null; + return { + id: item.id ?? `${item.domain}:${item.occurredFrom ?? "undated"}`, + domain: item.domain, + year, + precision: (year === null + ? "unknown" + : item.datePrecision === "day" || item.datePrecision === "month" + ? item.datePrecision + : "year") as "day" | "month" | "year" | "unknown", + usage: item.domain === "education" + ? "holdout" as const + : year === null + ? "unused" as const + : "training" as const, + }; + }); +} + +function incidentInference(evidence: typeof INCIDENT_EVIDENCE | readonly DecisionDossier["evidence"][number][]): InferenceState { + const ranked = [...INCIDENT_TIMES].sort((left, right) => INCIDENT_SCORES[right] - INCIDENT_SCORES[left]); + return { + algorithm_version: "rectification-inference-v1", + candidate_set_id: candidateSetId("04:53", "05:07", INCIDENT_TIMES), + revision: 6, + phase: "discrimination", + result_status: "discriminating", + range_start: "04:53", + range_end: "05:07", + candidates: ranked.map((time, index) => ({ + id: INCIDENT_CANDIDATE_IDS[time], + time, + cluster_range: [time, time] as const, + prior_score: INCIDENT_SCORES[time], + posterior_score: INCIDENT_SCORES[time], + probability: INCIDENT_SCORES[time] / 51, + status: "active" as const, + rank: index + 1, + strong_conflict_count: 0, + })), + events: incidentEvents(evidence), + probes: [...ANSWERED_PROBES, REMAINING_UNSPLIT], + answered_probes: ANSWERED_PROBES.map((probe) => ({ + probe_id: probe.id, + semantic_key: probe.semantic_key, + candidate_split_hash: probe.candidate_split_hash, + answer_class: "no" as const, + classified_from: "choice" as const, + })), + rounds: [], + last_inference_round: null, + entropy: 0.86, + representative_time: "05:00", + credible_range: ["05:00", "05:07"], + holdout_passed: null, + }; +} + +function openEngineReceipt(overrides: Record = {}) { + return { + acceptance_allowed: true, + accept_allowed: true, + selection_allowed: true, + propose_allowed: true, + confirmation_allowed: false, + diagnostic_quality: { passed: false, margin_percent: 19.94 }, + event_fit_rate: { band: "high", rate: 0.8 }, + oos_blind_prompts: [ + { domain: "family", user_meaning: "家里有没有结婚、添丁或住院这类记得住时间的事?", used_for_scoring: false }, + { domain: "finance", user_meaning: "有没有记得住时间的收入变化?", used_for_scoring: false }, + { domain: "health_pressure", user_meaning: "有没有记得住时间的健康压力事件?", used_for_scoring: false }, + ], + ...overrides, + }; +} + +function incidentDossier(input: { + occupationCovered?: "declined" | "confirmed_note" | false; + evidence?: DecisionDossier["evidence"]; + receipt?: Record; +} = {}): DecisionDossier { + const occupationCovered = input.occupationCovered ?? "declined"; + const evidence = [ + ...(input.evidence ?? INCIDENT_EVIDENCE), + ...(occupationCovered === "confirmed_note" + ? [{ + id: "e-occupation", + status: "confirmed", + domain: "occupation", + datePrecision: "unknown", + occurredFrom: null, + occurredTo: null, + eventKind: "occupation_note", + }] + : []), + ]; + const declined = [ + { target_domain: "family", status: "declined" }, + ...(occupationCovered === "declined" + ? [{ target_domain: "occupation", status: "declined", question_id: "collect:occupation:collect_method_evidence" }] + : []), + ]; + const state = incidentInference(evidence); + return { + evidence, + conversationSummary: { + activeFocus: null, + declinedSkippedTopics: declined, + }, + latestResult: { + resultId: "55555555-5555-4555-8555-555555555555", + selectionAllowed: true, + confirmationAllowed: false, + evidenceLedgerFingerprint: evidenceLedgerFingerprint(evidence as never), + candidates: state.candidates.map((candidate) => ({ + candidateId: INCIDENT_CANDIDATE_IDS[candidate.time as keyof typeof INCIDENT_CANDIDATE_IDS], + time: candidate.time, + rank: candidate.rank, + relativeSupport: candidate.posterior_score, + tiedMinuteCount: 1, + })), + representativeTime: "05:00", + decisionReceipt: { + ...openEngineReceipt(input.receipt), + inference_state: state, + }, + }, + case: { acceptedTime: null }, + }; +} + +function publicFields(dossier: DecisionDossier) { + const parsed = dossier; + const decision = decideFromDossier(parsed, { birthDate: "1997-08-08" }); + const overlaid = overlayPublicDecision(parsed.latestResult ?? {}, decision); + return { decision, overlaid, fields: publicDecisionFields(decision) }; +} + +function rpcEvidenceRows(decision: DecisionDossier) { + return decision.evidence.map((item) => ({ + id: item.id, + source_turn_id: TURN_ID, + subject: "self", + event_kind: item.eventKind ?? "event", + domain: item.domain, + occurred_from: item.occurredFrom, + occurred_to: item.occurredTo, + date_precision: item.datePrecision, + summary: item.summary ?? `${item.occurredFrom ?? ""} ${item.eventKind ?? "event"}`.trim(), + status: item.status, + supersedes_evidence_id: null, + created_at: "2026-09-01T00:00:00.000Z", + })); +} + +function rpcDossier(decision: DecisionDossier, activeFocus: Record | null = null) { + const evidence = rpcEvidenceRows(decision); + const evidenceFingerprint = evidenceLedgerFingerprint(evidence.map((item) => ({ + id: item.id, + eventKind: item.event_kind, + domain: item.domain, + occurredFrom: item.occurred_from, + occurredTo: item.occurred_to, + datePrecision: item.date_precision, + summary: item.summary, + status: item.status, + })) as never); + return dossierFixture({ + evidence, + evidenceCount: decision.evidence.length, + latestResult: candidateSnapshotFixture({ + selectionAllowed: true, + confirmationAllowed: false, + representativeTime: decision.latestResult?.representativeTime ?? null, + evidenceLedgerFingerprint: evidenceFingerprint, + candidates: decision.latestResult?.candidates?.map((item) => ({ + candidate_id: item.candidateId, + time: item.time, + rank: item.rank, + relative_support: item.relativeSupport, + tied_minute_count: item.tiedMinuteCount ?? 1, + })), + decisionReceipt: { ...(decision.latestResult?.decisionReceipt ?? {}) }, + }), + conversationSummary: { + confirmed_evidence_summary: [], + pending_revisions: [], + active_focus: activeFocus, + declined_skipped_topics: decision.conversationSummary.declinedSkippedTopics, + candidate_divergence_summary: null, + missing_evidence_categories: [], + last_result_policy: null, + summary_version: 1, + updated_at: "2026-09-01T00:00:00.000Z", + }, + }); +} + +test("incident shape 1a: occupation coverage must open provisional adopt without exact-minute confirm", () => { + for (const occupationCovered of ["declined", "confirmed_note"] as const) { + const { decision, overlaid } = publicFields(incidentDossier({ occupationCovered })); + assert.equal(decision.separation.lead, 5, occupationCovered); + assert.equal(decision.separation.sufficient, false, occupationCovered); + assert.equal(overlaid.can_adopt, true, occupationCovered); + assert.equal(overlaid.selection_allowed, true, occupationCovered); + assert.deepEqual(overlaid.credible_range, ["05:00", "05:07"], occupationCovered); + assert.equal(overlaid.representative_time, "05:00", occupationCovered); + assert.equal(overlaid.can_confirm_exact_minute, false, occupationCovered); + assert.equal(decision.canConfirmExactMinute, false, occupationCovered); + } +}); + +test("incident shape 1b: exact-minute confirm stays closed on the accident case", () => { + for (const occupationCovered of ["declined", "confirmed_note", false] as const) { + const { overlaid } = publicFields(incidentDossier({ occupationCovered })); + assert.equal(overlaid.can_confirm_exact_minute, false, String(occupationCovered)); + } +}); + +test("incident shape 1c: uncovered occupation keeps adopt closed and still has a next question", async () => { + const dossier = incidentDossier({ occupationCovered: false }); + const { decision, overlaid } = publicFields(dossier); + assert.equal(overlaid.can_adopt, false); + assert.equal(overlaid.can_confirm_exact_minute, false); + + let activeFocus: Record | null = null; + const raw = rpcDossier(dossier); + const accounting = fakeAccounting({ + ...receiptHandlers, + get_agentic_rectification_case_dossier: () => ({ + ...raw, + conversation_summary: { + ...(raw.conversation_summary as Record), + active_focus: activeFocus, + }, + }), + get_agentic_rectification_case_compute: () => computeFixture(), + set_agentic_rectification_conversation_focus: (_fn, args) => { + activeFocus = { + id: FOCUS_ID, + case_id: CASE_ID, + question_id: args.p_question_id, + intent: args.p_intent, + target_evidence_id: args.p_target_evidence_id, + target_domain: args.p_target_domain, + target_kind: args.p_target_kind, + expected_answer_schema: args.p_expected_answer_schema, + status: "active", + asked_at: "2026-09-01T00:00:00.000Z", + resolved_at: null, + }; + return { focus: activeFocus, idempotent: false }; + }, + append_agentic_rectification_turn: () => ({ turn_id: TURN_ID, idempotent: false }), + }); + const persisted = await persistNextInterviewIfIdle({ + accounting: accounting.client, + userId: USER_ID, + caseId: CASE_ID, + }); + assert.equal(persisted.persisted, true); + const parsed = parseV9CaseDossier({ + ...raw, + conversation_summary: { + ...(raw.conversation_summary as Record), + active_focus: activeFocus, + }, + }); + assert.ok(parsed); + const currentQuestion = projectCurrentQuestion(parsed.conversationSummary.activeFocus); + assert.ok(currentQuestion?.prompt, JSON.stringify({ decision: decision.nextAction, currentQuestion })); + assert.match(currentQuestion?.question_id ?? "", /^collect:/); +}); + +test("incident shape 2: evidence floor still blocks adopt", () => { + const twoEvents = publicFields(incidentDossier({ + occupationCovered: "declined", + evidence: INCIDENT_EVIDENCE.slice(0, 2), + })); + assert.equal(twoEvents.overlaid.can_adopt, false); + assert.equal(twoEvents.decision.nextAction, "ask_fact_collection"); + + const oneDomain = publicFields(incidentDossier({ + occupationCovered: "declined", + evidence: INCIDENT_EVIDENCE.filter((item) => item.domain === "career"), + })); + assert.equal(oneDomain.overlaid.can_adopt, false); + assert.equal(oneDomain.decision.nextAction, "ask_fact_collection"); +}); + +test("incident shape 3: engine ceiling still fail-closes adopt", () => { + const closed = publicFields(incidentDossier({ + occupationCovered: "declined", + receipt: { acceptance_allowed: false }, + })); + assert.equal(closed.overlaid.can_adopt, false); + + const inconsistent = publicFields(incidentDossier({ + occupationCovered: "declined", + receipt: { accept_allowed: false }, + })); + assert.equal(inconsistent.overlaid.can_adopt, false); + assert.deepEqual( + engineCapabilityCeilingFromReceipt(inconsistent.decision && incidentDossier({ + occupationCovered: "declined", + receipt: { accept_allowed: false }, + }).latestResult?.decisionReceipt), + { + acceptanceAllowed: false, + selectionAllowed: false, + proposeAllowed: false, + confirmationAllowed: false, + }, + ); +}); + +test("incident shape 4: exact-minute confirm stays fail-closed even when local gates pass", () => { + const separated = [ + { time: "05:00", score: 58 }, + { time: "05:07", score: 42 }, + ]; + const engineFalse = decideRectification({ + methodCoverageAll: true, + trainingGateOpen: true, + snapshotCurrent: true, + candidateScores: separated, + holdoutValidation: "passed", + confirmationAllowed: true, + datedEventCount: 5, + datedDomainCount: 3, + engineCeiling: { + acceptanceAllowed: true, + selectionAllowed: true, + proposeAllowed: true, + confirmationAllowed: false, + }, + }); + assert.equal(engineFalse.separation.sufficient, true); + assert.equal(engineFalse.canConfirmExactMinute, false); + + const syntheticOpen = decideRectification({ + methodCoverageAll: true, + trainingGateOpen: true, + snapshotCurrent: true, + candidateScores: separated, + holdoutValidation: "passed", + confirmationAllowed: true, + datedEventCount: 5, + datedDomainCount: 3, + engineCeiling: ENGINE_OPEN, + }); + assert.equal(syntheticOpen.canConfirmExactMinute, true); +}); + +test("delivery B1: GET overlay after occupation coverage renders representative cards", () => { + const dossier = incidentDossier({ occupationCovered: "declined" }); + const { decision, overlaid } = publicFields(dossier); + assert.equal(decision.sessionOutcome, "adopt_representative"); + assert.equal(decision.nextAction, "offer_provisional_range"); + const parsed = parseRectificationCandidateResult({ + resultId: dossier.latestResult?.resultId, + candidates: overlaid.candidates, + selectionAllowed: overlaid.selection_allowed, + selection_allowed: overlaid.selection_allowed, + canAdopt: overlaid.can_adopt, + can_adopt: overlaid.can_adopt, + representativeTime: overlaid.representative_time, + decisionReceipt: dossier.latestResult?.decisionReceipt ?? null, + }); + assert.ok(parsed); + assert.equal(canRenderRectificationSelectionCards(parsed), true); + assert.deepEqual(parsed.candidates.map((item) => item.time), ["05:00", "05:07", "04:53"]); + assert.equal(parsed.representativeTime, "05:00"); +}); + +test("delivery B2: adoptable offer produces adopt_representative with zero follow-up", async () => { + const dossier = incidentDossier({ occupationCovered: "declined" }); + const { decision } = publicFields(dossier); + const plan = buildMethodFollowupPlan({ + evidence: dossier.evidence, + declinedTopics: dossier.conversationSummary.declinedSkippedTopics, + closedCollectFocuses: dossier.conversationSummary.declinedSkippedTopics, + sessionOutcome: decision.sessionOutcome, + }); + assert.equal(plan.session_outcome, "adopt_representative"); + assert.equal(plan.next_followup, null); + const action = buildNextUserAction({ + scorableCount: 5, + evidenceCount: 5, + hasLatestResult: true, + selectionAllowed: decision.selectionAllowed, + sessionOutcome: decision.sessionOutcome, + nextFollowup: plan.next_followup, + workingTime: "05:00", + }); + assert.equal(action.id, "adopt_representative"); + assert.doesNotMatch(action.user_meaning, /补|再问|记得住时间/); + + let writes = 0; + const accounting = fakeAccounting({ + ...receiptHandlers, + get_agentic_rectification_case_dossier: () => rpcDossier(dossier), + get_agentic_rectification_case_compute: () => computeFixture(), + set_agentic_rectification_conversation_focus: () => { + writes += 1; + throw new Error("adoptable offer must not persist another question"); + }, + }); + const idle = await persistNextInterviewIfIdle({ + accounting: accounting.client, + userId: USER_ID, + caseId: CASE_ID, + }); + assert.equal(idle.persisted, false); + assert.equal(writes, 0); + assert.match(idle.hostNarration ?? "", /05:00–05:07/); + assert.match(idle.hostNarration ?? "", /代表分钟 05:00/); + assert.match(idle.hostNarration ?? "", /代表分钟只是代表性候选,不是已确认的唯一出生分钟/); + assert.match(idle.hostNarration ?? "", /可以从下面的时间里选一个采用/); +}); + +test("delivery B3: accept projection stays open from engine receipt, not exact-minute confirm", () => { + const { decision, overlaid } = publicFields(incidentDossier({ occupationCovered: "declined" })); + assert.equal(decision.selectionAllowed, true); + assert.equal(overlaid.selection_allowed, true); + assert.equal(overlaid.can_confirm_exact_minute, false); + assert.equal(decision.canConfirmExactMinute, false); +}); + +test("capability is computed only in deliveryCapability and spread into every exit", () => { + const source = readFileSync(new URL("../src/lib/rectification-agentic/core/rectification-decision.ts", import.meta.url), "utf8"); + assert.match(source, /function deliveryCapability\(/); + assert.equal((source.match(/\.\.\.capability/g) ?? []).length >= 5, true); + assert.doesNotMatch(source, /canAdopt:\s*true/); + assert.doesNotMatch(source, /canConfirmExactMinute:\s*true/); +}); +