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/); });