import assert from "node:assert/strict"; import test from "node:test"; import { DELIVERY_OUTCOMES, decideRectification, deliveryNarrationAllowed, sessionOutcomeAllowsDelivery, type DecisionSessionOutcome, } from "../src/lib/rectification-agentic/core/rectification-decision.ts"; import { canShowRectificationReadonlyRange, canShowRectificationSelectionCards, parseRectificationCandidateResult, } from "../src/lib/rectification-candidate-result.ts"; import { persistNextInterviewIfIdle, } from "../src/lib/rectification-agentic/v9/answer-choice.ts"; import { decideFromDossier } from "../src/lib/rectification-agentic/v9/decision-from-dossier.ts"; import { refreshDatedDiscriminatorPoolIfNeeded, resetRefreshDiscriminatorProbesForTests, setRefreshDiscriminatorProbesForTests, } from "../src/lib/rectification-agentic/v9/refresh-discriminator-probes.ts"; import { parseV9CaseDossier } from "../src/lib/rectification-agentic/v9/tool-service.ts"; import { candidateSetId } from "../src/lib/rectification-agentic/core/build-state.ts"; import { INFERENCE_ALGORITHM_VERSION } from "../src/lib/rectification-agentic/core/types.ts"; import { RECTIFICATION_DELIVERED_COPY, RECTIFICATION_QUESTION_RETRY_LIMIT, RECTIFICATION_QUESTION_UNAVAILABLE_COPY, rectificationQuestionGapState, } from "../src/lib/rectification-surface-state.ts"; import { CASE_ID, OPEN_ENGINE_CAPABILITY_CEILING, TURN_ID, USER_ID, candidateSnapshotFixture, computeFixture, dossierFixture, fakeAccounting, receiptHandlers, } from "./rectification-v9-test-support.ts"; const TIED_CANDIDATES = [ { candidateId: "88888888-8888-4888-8888-888888888881", time: "04:53", rank: 1, relativeSupport: 16 }, { candidateId: "88888888-8888-4888-8888-888888888882", time: "05:00", rank: 2, relativeSupport: 16 }, { candidateId: "88888888-8888-4888-8888-888888888883", time: "05:06", rank: 3, relativeSupport: 15 }, { candidateId: "88888888-8888-4888-8888-888888888884", time: "04:51", rank: 4, relativeSupport: 14 }, { candidateId: "88888888-8888-4888-8888-888888888885", time: "04:59", rank: 5, relativeSupport: 13 }, ] as const; const SNAPSHOT_BASE = { resultId: "11111111-1111-4111-8111-111111111111", candidates: [ { candidateId: "88888888-8888-4888-8888-888888888881", rank: 1, time: "04:53", relativeSupport: 16, tiedMinuteCount: 2 }, { candidateId: "88888888-8888-4888-8888-888888888882", rank: 2, time: "05:00", relativeSupport: 16, tiedMinuteCount: 2 }, ], overallConfidence: "medium" as const, selectionAllowed: true, canAdopt: false, confirmationAllowed: false, representativeTime: "04:53", selectedTime: null, selectionKind: null, credibleRange: ["04:48", "05:07"] as const, decisionReceipt: { display_allowed: true, selection_allowed: true, acceptance_allowed: true, propose_allowed: true, confirmation_allowed: false, accept_allowed: true, confirm_allowed: false, }, }; function datedEvidenceRpc() { return [ { id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa1", source_turn_id: TURN_ID, subject: "self", event_kind: "career_entry", domain: "career", occurred_from: "2016-09-01", occurred_to: null, date_precision: "month", summary: "入职", status: "confirmed", supersedes_evidence_id: null, created_at: "2026-08-12T10:00:06.000Z", }, { id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa2", source_turn_id: TURN_ID, subject: "self", event_kind: "education_completion", domain: "education", occurred_from: "2012-06-01", occurred_to: null, date_precision: "month", summary: "毕业", status: "confirmed", supersedes_evidence_id: null, created_at: "2026-08-12T10:00:07.000Z", }, { id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa3", source_turn_id: TURN_ID, subject: "self", event_kind: "family_event", domain: "family", occurred_from: "2018-03-01", occurred_to: null, date_precision: "month", summary: "家里添丁", status: "confirmed", supersedes_evidence_id: null, created_at: "2026-08-12T10:00:08.000Z", }, { id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa4", source_turn_id: TURN_ID, subject: "self", event_kind: "relationship_start", domain: "relationship", occurred_from: "2020-05-01", occurred_to: null, date_precision: "month", summary: "恋爱", status: "confirmed", supersedes_evidence_id: null, created_at: "2026-08-12T10:00:09.000Z", }, ]; } function tiedInference(extra: Record = {}) { const times = TIED_CANDIDATES.map((item) => item.time); return { algorithm_version: INFERENCE_ALGORITHM_VERSION, candidate_set_id: candidateSetId("04:48", "05:07", times), revision: 6, phase: "discrimination", result_status: "discriminating", range_start: "04:48", range_end: "05:07", candidates: TIED_CANDIDATES.map((item) => ({ id: item.candidateId, time: item.time, cluster_range: ["04:48", "05:07"] as const, prior_score: item.relativeSupport, posterior_score: item.relativeSupport, probability: item.relativeSupport === 16 ? 0.2592219508007118 : item.relativeSupport / 74, status: "active" as const, rank: item.rank, strong_conflict_count: 0, })), events: datedEvidenceRpc().map((item, index) => ({ id: item.id, domain: item.domain, year: 2012 + index, precision: "month" as const, usage: "training" as const, })), probes: [], answered_probes: [ { probe_id: "probe-1", semantic_key: "career.2016.test", candidate_split_hash: "split-1", answer_class: "yes" as const, classified_from: "choice" as const, }, ], rounds: [], entropy: 1, representative_time: "04:53", credible_range: ["04:48", "05:07"] as const, refresh_attempts: [], ...extra, }; } function tiedRpcDossier() { const evidence = datedEvidenceRpc(); return dossierFixture({ evidenceCount: 4, evidence, latestResult: candidateSnapshotFixture({ representativeTime: "04:53", selectionAllowed: true, candidates: TIED_CANDIDATES.map((item) => ({ candidate_id: item.candidateId, rank: item.rank, time: item.time, relative_support: item.relativeSupport, tied_minute_count: 2, })), decisionReceipt: { acceptance_allowed: true, selection_allowed: true, propose_allowed: true, confirmation_allowed: false, inference_state: tiedInference(), }, }), }); } test("tied first place delivers a range even when narrowing flags are still open", () => { // 原值: targetedCollectExhausted: false 仍 complete_with_range // 新值: targetedCollectExhausted: true 才交付;false 时继续定向补事 // 原因: BUG-683 并列早退缺 probe / 定向补事守卫,新案子答两题就被判可采用 const decision = decideRectification({ engineCeiling: OPEN_ENGINE_CAPABILITY_CEILING, methodCoverageAll: false, trainingGateOpen: true, candidateScores: TIED_CANDIDATES.map((item) => ({ time: item.time, score: item.relativeSupport })), discriminatorProbe: null, holdoutValidation: "unavailable", datedEventCount: 3, datedDomainCount: 3, refreshExhausted: false, targetedCollectExhausted: true, }); assert.equal(decision.stopReason, "tied_first"); assert.equal(decision.canOfferRange, true); assert.equal(decision.nextAction, "complete_with_range"); assert.equal(sessionOutcomeAllowsDelivery(decision.sessionOutcome), true); }); test("refresh persist failure is not recorded as an attempt", async () => { resetRefreshDiscriminatorProbesForTests(); setRefreshDiscriminatorProbesForTests(async ({ state }) => ({ state: { ...state, candidates: [] }, eventProbes: [], candidateSetId: state.candidate_set_id, refreshCount: 1, })); const rpc = tiedRpcDossier(); const dossier = parseV9CaseDossier(rpc); assert.ok(dossier); const warnings: string[] = []; const original = console.warn; console.warn = (value: unknown) => { warnings.push(String(value)); }; try { const refreshed = await refreshDatedDiscriminatorPoolIfNeeded({ accounting: fakeAccounting({ ...receiptHandlers, get_agentic_rectification_case_compute: () => computeFixture(), append_agentic_rectification_inference_transition: () => { throw new Error("candidate_state_inconsistent"); }, }).client, userId: USER_ID, caseId: CASE_ID, dossier: dossier!, hasDatedProbe: false, }); assert.equal(refreshed.attemptRecorded, false); assert.equal(refreshed.refreshed, false); assert.ok(warnings.some((line) => line.includes("rectification_refresh_attempt_persist_failed"))); const before = decideFromDossier(dossier!, { birthDate: "1997-08-08" }); const after = decideFromDossier(refreshed.dossier, { birthDate: "1997-08-08" }); assert.equal(after.sessionOutcome, before.sessionOutcome); assert.equal(after.canOfferRange, before.canOfferRange); assert.equal(after.stopReason, before.stopReason); } finally { console.warn = original; resetRefreshDiscriminatorProbesForTests(); } }); test("POST idle persist and GET decideFromDossier agree on a first-place tie", async () => { const rpc = tiedRpcDossier(); const dossier = parseV9CaseDossier(rpc); assert.ok(dossier); const getDecision = decideFromDossier(dossier!, { birthDate: "1997-08-08", snapshotCurrent: true }); const accounting = fakeAccounting({ ...receiptHandlers, get_agentic_rectification_case_dossier: () => rpc, get_agentic_rectification_case_compute: () => computeFixture(), append_agentic_rectification_inference_transition: () => { throw new Error("candidate_state_inconsistent"); }, set_agentic_rectification_conversation_focus: (_fn, args) => { throw new Error(`must not persist ${String(args.p_question_id)}`); }, append_agentic_rectification_turn: () => ({ turn_id: "33333333-3333-4333-8333-333333333333", idempotent: false, }), finalize_agentic_rectification_turn: () => ({ turn_id: "33333333-3333-4333-8333-333333333333", status: "completed", idempotent: false, }), get_agentic_rectification_turn_receipt: () => null, }); const idle = await persistNextInterviewIfIdle({ accounting: accounting.client, userId: USER_ID, caseId: CASE_ID, }); const getDelivers = sessionOutcomeAllowsDelivery(getDecision.sessionOutcome); const postDelivers = idle.terminalNote === true && Boolean(idle.hostNarration); // 原值: getDelivers=true(头名并列即交付) // 新值: getDelivers=false // 原因: D1——并列时 precision_gate_met 恒为假;D6——门槛未达不交付。 // 题名要的「POST 与 GET 口径一致」仍由下面一条保证(BUG-751) assert.equal(getDecision.stopReason, "tied_first"); assert.equal(getDelivers, false); assert.equal(postDelivers, getDelivers); }); test("completed_with_range with selection allowed shows the delivery card, not the collect range line", () => { const result = parseRectificationCandidateResult({ ...SNAPSHOT_BASE, session_outcome: "completed_with_range", selectionAllowed: true, canAdopt: false, }); assert.equal(canShowRectificationSelectionCards(result), true); assert.equal(canShowRectificationReadonlyRange(result), false); }); test("delivery narration and delivery cards stay true together for every outcome", () => { const outcomes: DecisionSessionOutcome[] = [ "collect_evidence", "compare_blocks", "widen_window", "discriminate_candidates", "validate_holdout", "provisional_range", "provisional_range_user_stopped", "completed_with_range", "validated_range", "exact_minute_confirmed", "adopt_representative", "awaiting_confirmation", ]; for (const outcome of outcomes) { const adopt = outcome === "adopt_representative" || outcome === "provisional_range_user_stopped" || outcome === "awaiting_confirmation" || outcome === "validated_range"; const nextAction = outcome === "provisional_range" ? "offer_provisional_range" : outcome === "completed_with_range" ? "complete_with_range" : "ask_fact_collection"; const narrate = deliveryNarrationAllowed({ canAdopt: adopt, sessionOutcome: outcome, nextAction, selectionAllowed: true, }); const result = parseRectificationCandidateResult({ ...SNAPSHOT_BASE, session_outcome: outcome, canAdopt: adopt, selectionAllowed: true, }); const cards = canShowRectificationSelectionCards(result); if (narrate) { assert.equal(cards, true, outcome); } if (DELIVERY_OUTCOMES.has(outcome) && outcome !== "completed_with_range" && outcome !== "provisional_range") { assert.equal(narrate, true, outcome); } } }); test("a delivered tie is not the unavailable repair gap", () => { assert.equal( rectificationQuestionGapState({ liveQuestionVisible: false, questionMissing: true, questionLoadFailed: false, busy: false, readonly: false, regenerating: false, snapshotLoaded: true, resumableCase: true, retryAttempts: RECTIFICATION_QUESTION_RETRY_LIMIT, sessionOutcome: "collect_evidence", stopReason: "tied_first", }), "delivered", ); assert.equal( rectificationQuestionGapState({ liveQuestionVisible: false, questionMissing: true, questionLoadFailed: false, busy: false, readonly: false, regenerating: false, snapshotLoaded: true, resumableCase: true, retryAttempts: RECTIFICATION_QUESTION_RETRY_LIMIT, sessionOutcome: "completed_with_range", }), "delivered", ); assert.equal( rectificationQuestionGapState({ liveQuestionVisible: false, questionMissing: true, questionLoadFailed: true, busy: false, readonly: false, regenerating: false, snapshotLoaded: true, resumableCase: true, retryAttempts: RECTIFICATION_QUESTION_RETRY_LIMIT, }), "unavailable", ); assert.equal(RECTIFICATION_DELIVERED_COPY.includes("没有拿到下一个问题"), false); assert.equal(RECTIFICATION_QUESTION_UNAVAILABLE_COPY, "没有拿到下一个问题。"); });