import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import test from "node:test"; import { applyAnswerToState, buildInferenceState, candidateSetId } from "../src/lib/rectification-agentic/core/build-state.ts"; import { publicNextAction } from "../src/lib/rectification-agentic/core/rectification-decision.ts"; import type { ConflictProbe, InferenceState } from "../src/lib/rectification-agentic/core/types.ts"; import { decideFromDossier, rectificationFollowupCatalog, type DecisionDossier, } from "../src/lib/rectification-agentic/v9/decision-from-dossier.ts"; import { buildMethodFollowupPlan, spokenFollowupForUser } from "../src/lib/rectification-agentic/v9/method-followup.ts"; import { focusStatusForAnswer } from "../src/lib/rectification-agentic/v9/choice-action.ts"; import { persistServerOwnedFocus } from "../src/lib/rectification-agentic/v9/server-focus.ts"; import { projectCurrentQuestion } from "../src/lib/rectification-agentic/v9/turn-decision.ts"; import { shouldContinueAgentForDatedEvent, shouldDeclineCollectFocus, } from "../src/lib/rectification-agentic/v9/turn-intent-classifier.ts"; import { applyCollectFocusDenial, persistNextInterviewAfterChoice, persistNextInterviewIfIdle, } from "../src/lib/rectification-agentic/v9/answer-choice.ts"; import { evidenceLedgerFingerprint } from "../src/lib/rectification-agentic/v9/tool-service.ts"; import { RECTIFICATION_SKILL_VERSION } from "../src/lib/rectification-agentic/v9/case-status.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 CANDIDATES = [ { id: "04:47", time: "04:47", relative_support: 8 }, { id: "04:51", time: "04:51", relative_support: 9 }, { id: "04:53", time: "04:53", relative_support: 9 }, { id: "04:59", time: "04:59", relative_support: 10 }, { id: "05:00", time: "05:00", relative_support: 18 }, { id: "05:07", time: "05:07", relative_support: 16 }, { id: "05:12", time: "05:12", relative_support: 11 }, { id: "05:14", time: "05:14", relative_support: 7 }, { id: "05:15", time: "05:15", relative_support: 6 }, ] as const; const EVIDENCE = [ { 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", }, { id: "e-rel-end", status: "confirmed", domain: "relationship", datePrecision: "day", occurredFrom: "2024-08-08", occurredTo: null, eventKind: "relationship_end", }, { id: "e-rel-start", status: "confirmed", domain: "relationship", datePrecision: "month", occurredFrom: "2024-05-01", occurredTo: null, eventKind: "relationship_start", }, ] as const; const LIVE_CASE_EVIDENCE = [ ...EVIDENCE, { id: "e-education", status: "confirmed", domain: "education", datePrecision: "year", occurredFrom: "2016-01-01", occurredTo: null, eventKind: "education_start", }, ] as const; const FAMILY_2021_COLLECT = { year: 2021, year_label: "2021 年前后", domain: "family" as const, event_family: "家人结婚、添丁或住院", source: "age_band" as const, tracks: ["vimshottari", "narayana"] as const, tracks_agree: false, unique_minute_claim: false as const, user_meaning: "时间范围锁定 2021 年前后;领域锁定 family。", role: "collect" as const, phase: "evidence_collection" as const, information_gain: 0, semantic_key: "family.2021", candidate_split_hash: "family:2021", candidate_ids: [] as const, expected_outcomes: [] as const, choice_kind: "existence" as const, }; function vargaExistence(input: { layer: string; gain: number; domain: string; question: string; supports: readonly string[]; conflicts: readonly string[]; }): ConflictProbe { const key = `varga.${input.layer}.${input.supports.join("/")}`; return { id: `contrast:${key}`, semantic_key: key, candidate_split_hash: key, domain: input.domain, year: 0, question: input.question, candidate_ids: [...new Set([...input.supports, ...input.conflicts])], expected_outcomes: [ { answer_class: "yes", supports: input.conflicts, conflicts: input.supports }, { answer_class: "weak_yes", supports: input.conflicts, conflicts: [] }, { answer_class: "no", supports: input.supports, conflicts: input.conflicts }, { answer_class: "unsure", supports: [], conflicts: [] }, ], information_gain: input.gain, source: "varga_contrast", choice_kind: "existence", style_options: EXISTENCE_OPTIONS, }; } const D9: ConflictProbe = { id: "contrast:varga.d9.巨蟹座/狮子座", semantic_key: "varga.d9.巨蟹座/狮子座", candidate_split_hash: "varga.d9.巨蟹座/狮子座", domain: "relationship", year: 0, question: "亲密关系里更接近下面哪一种相处方式?", candidate_ids: ["05:00", "05:07"], expected_outcomes: [ { answer_class: "yes", supports: ["05:00"], conflicts: ["05:07"] }, { answer_class: "weak_yes", supports: ["05:07"], conflicts: ["05:00"] }, { answer_class: "no", supports: [], conflicts: [] }, { answer_class: "unsure", supports: [], conflicts: [] }, ], information_gain: 1.1, source: "varga_contrast", choice_kind: "varga_style", }; const D10: ConflictProbe = { id: "contrast:varga.d10.天秤座/天蝎座", semantic_key: "varga.d10.天秤座/天蝎座", candidate_split_hash: "varga.d10.天秤座/天蝎座", domain: "career", year: 0, question: "平时做事更接近下面哪一种职责风格?", candidate_ids: ["05:00", "05:07"], expected_outcomes: [ { answer_class: "yes", supports: ["05:00"], conflicts: ["05:07"] }, { answer_class: "weak_yes", supports: ["05:07"], conflicts: ["05:00"] }, { answer_class: "no", supports: [], conflicts: [] }, { answer_class: "unsure", supports: [], conflicts: [] }, ], information_gain: 1.05, source: "varga_contrast", choice_kind: "varga_style", }; const CAREER_2023: ConflictProbe = { id: "probe:career.2023.dasha_boundary", semantic_key: "career.2023.dasha_boundary", candidate_split_hash: "career.2023", domain: "career", year: 2023, question: "2023 年前后有没有入职或换工作?", candidate_ids: ["05:00", "05:07"], expected_outcomes: [ { answer_class: "yes", supports: ["05:00"], conflicts: ["05:07"] }, { answer_class: "no", supports: ["05:07"], conflicts: ["05:00"] }, { answer_class: "unsure", supports: [], conflicts: [] }, ], information_gain: 0.72, source: "dasha_boundary", choice_kind: "existence", style_options: EXISTENCE_OPTIONS, }; const CAREER_2023_ACTIVATION: 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: CANDIDATES.map((candidate) => candidate.time), expected_outcomes: [ { answer_class: "yes", supports: ["05:15"], conflicts: ["04:47", "04:51", "04:53", "04:59", "05:00", "05:07", "05:12", "05:14"], }, { answer_class: "weak_yes", supports: [], conflicts: [] }, { answer_class: "no", supports: ["04:47", "04:51", "04:53", "04:59", "05:00", "05:07", "05:12", "05:14"], conflicts: ["05:15"], }, { answer_class: "unsure", supports: [], conflicts: [] }, ], information_gain: 0.56, source: "dasha_activation", choice_kind: "existence", style_options: EXISTENCE_OPTIONS, }; const RELOCATION_2015: ConflictProbe = { id: "probe:relocation.2015.dasha_boundary", semantic_key: "relocation.2015.dasha_boundary", candidate_split_hash: "relocation.2015", domain: "relocation", year: 2015, question: "2015 年前后有没有搬家或长期住到外地?", candidate_ids: ["05:00", "05:07"], expected_outcomes: [ { answer_class: "yes", supports: ["05:00"], conflicts: ["05:07"] }, { answer_class: "no", supports: ["05:07"], conflicts: ["05:00"] }, { answer_class: "unsure", supports: [], conflicts: [] }, ], information_gain: 0.61, source: "dasha_boundary", choice_kind: "existence", style_options: EXISTENCE_OPTIONS, }; const D12 = vargaExistence({ layer: "d12", gain: 1.89, domain: "family", question: "家里有没有结婚、添丁或住院这类事?", supports: ["05:00", "05:07", "05:12"], conflicts: ["04:47", "04:51", "04:53", "04:59"], }); const D24 = vargaExistence({ layer: "d24", gain: 2.5, domain: "education", question: "有没有学业或考试发挥明显失常、压力特别大的时候?", supports: ["05:00"], conflicts: ["05:07"], }); const D7 = vargaExistence({ layer: "d7", gain: 1.35, domain: "family", question: "有没有子女或子嗣相关的家里变化?", supports: ["05:00", "05:07"], conflicts: ["04:47"], }); const D4 = vargaExistence({ layer: "d4", gain: 0.99, domain: "relocation", question: "有没有搬家或长期住到外地?", supports: ["05:00"], conflicts: ["05:07"], }); const D5 = vargaExistence({ layer: "d5", gain: 0.5, domain: "education", question: "有没有记得住年份的升学或考试?", supports: ["05:00"], conflicts: ["05:07"], }); const DATED_RELOCATION_2016: ConflictProbe = { id: "probe:relocation.2016.dasha_boundary", semantic_key: "relocation.2016.dasha_boundary", candidate_split_hash: "relocation.2016", domain: "relocation", year: 2016, question: "2016 年前后有没有搬家或长期住到外地?", candidate_ids: [ "04:47", "04:51", "04:53", "04:59", "05:00", "05:07", "05:12", "05:14", "05:15", ], expected_outcomes: [ { answer_class: "yes", supports: ["04:47", "04:51", "04:53", "04:59"], conflicts: ["05:00", "05:07", "05:12", "05:14", "05:15"] }, { answer_class: "no", supports: ["05:00", "05:07", "05:12", "05:14", "05:15"], conflicts: ["04:47", "04:51", "04:53", "04:59"] }, { answer_class: "unsure", supports: [], conflicts: [] }, ], information_gain: 0.8, source: "dasha_boundary", choice_kind: "existence", style_options: EXISTENCE_OPTIONS, }; function revision5State(extraProbes: readonly ConflictProbe[] = []) { const probes = [ D9, D10, CAREER_2023, RELOCATION_2015, D24, D12, D7, D4, D5, ...extraProbes, ]; let state = buildInferenceState({ range_start: "04:47", range_end: "05:15", candidates: CANDIDATES, events: [ { id: "e-career-entry", domain: "career", year: 2020, precision: "month" }, { id: "e-career-exit", domain: "career", year: 2020, precision: "month" }, { id: "e-rel-end", domain: "relationship", year: 2024, precision: "day" }, { id: "e-rel-start", domain: "relationship", year: 2024, precision: "month" }, ], probes, }); const answers: ReadonlyArray<{ id: string; answer: "yes" | "weak_yes" | "no" }> = [ { id: D9.id, answer: "weak_yes" }, { id: D10.id, answer: "weak_yes" }, { id: CAREER_2023.id, answer: "no" }, { id: RELOCATION_2015.id, answer: "no" }, ]; for (const item of answers) { state = applyAnswerToState(state, item.id, item.answer); } return state; } function revision5Dossier( state = revision5State(), extra?: { declinedTopics?: ReadonlyArray>; activeFocus?: DecisionDossier["conversationSummary"]["activeFocus"] }, ): DecisionDossier { return { evidence: EVIDENCE, conversationSummary: { activeFocus: extra?.activeFocus ?? null, declinedSkippedTopics: extra?.declinedTopics ?? [], }, latestResult: { 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, evidence_collection_probes: [FAMILY_2021_COLLECT], }, }, case: { acceptedTime: null }, }; } function planFrom( dossier: DecisionDossier, extra: Partial[0]> = {}, ) { const catalog = rectificationFollowupCatalog(dossier.latestResult, dossier.evidence); return buildMethodFollowupPlan({ evidence: dossier.evidence, declinedTopics: dossier.conversationSummary.declinedSkippedTopics, closedCollectFocuses: dossier.conversationSummary.declinedSkippedTopics, sessionOutcome: "discriminate_candidates", ...catalog, candidatesSeparated: false, ...extra, }); } function rpcDossier(decision: DecisionDossier, activeFocus?: Record | null) { return dossierFixture({ evidence: 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"}`, status: item.status, supersedes_evidence_id: null, created_at: "2026-08-29T00:00:00.000Z", })), latestResult: candidateSnapshotFixture({ candidates: decision.latestResult?.candidates?.map((item, index) => ({ candidate_id: `00000000-0000-4000-8000-${String(index + 1).padStart(12, "0")}`, time: item.time, rank: item.rank ?? index + 1, relative_support: Math.max(0, Math.min(100, item.relativeSupport ?? 0)), tied_minute_count: item.tiedMinuteCount ?? 1, })), representativeTime: decision.latestResult?.representativeTime ?? null, evidenceLedgerFingerprint: decision.latestResult?.evidenceLedgerFingerprint, decisionReceipt: { ...(decision.latestResult?.decisionReceipt ?? {}) }, }), conversationSummary: { confirmed_evidence_summary: [], pending_revisions: [], active_focus: activeFocus ?? null, declined_skipped_topics: decision.conversationSummary.declinedSkippedTopics, candidate_divergence_summary: null, missing_evidence_categories: [], last_result_policy: null, summary_version: 1, updated_at: "2026-08-29T00:00:00.000Z", }, }); } test("skill version stays 10.0.14 for this collect-stall fix", () => { assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.14"); }); test("revision 5 with uncovered relatives asks the dated family collect, not a yearless D12 card", () => { const state = revision5State(); assert.equal(state.revision, 5); assert.equal(state.answered_probes.length, 4); assert.equal(state.events.find((item) => item.id === "e-rel-start")?.usage, "holdout"); const unanswered = state.probes.filter((probe) => ( !state.answered_probes.some((item) => item.probe_id === probe.id) )); assert.equal(unanswered.some((item) => item.semantic_key.startsWith("varga.d12")), true); assert.equal(unanswered.find((item) => item.semantic_key.startsWith("varga.d12"))?.information_gain, 1.89); const plan = planFrom(revision5Dossier(state)); assert.equal(plan.methods.find((item) => item.method_id === "relatives")?.status, "uncovered"); assert.equal(plan.next_followup?.intent, "collect_method_evidence"); assert.equal(plan.next_followup?.domain, "family"); assert.equal(plan.next_followup?.choice_frame, null); assert.equal(plan.next_followup?.probe_year, 2021); assert.match(plan.next_followup?.year_label ?? "", /2021/); assert.equal(plan.dropped_probes.some((item) => ( item.semantic_key.startsWith("varga.d12") && item.reason === "yearless_ungrounded_contrast" )), true); }); test("denying the dated family collect declines relatives and leaves the D12 card unasked", () => { const plan = planFrom(revision5Dossier()); assert.equal(plan.next_followup?.domain, "family"); assert.equal(plan.next_followup?.choice_frame, null); assert.equal(focusStatusForAnswer("no", "C"), "declined"); const next = planFrom(revision5Dossier(revision5State(), { declinedTopics: [{ target_domain: "family", status: "declined" }], })); assert.equal(next.methods.find((item) => item.method_id === "relatives")?.status, "covered"); assert.notEqual(next.next_followup?.domain, "family"); if (next.next_followup?.choice_frame) { assert.equal(next.next_followup.intent, "distinguish_candidates"); assert.doesNotMatch(next.next_followup.semantic_key ?? "", /^varga\.d(12|24|7|4|5)\./); } else { assert.equal(next.next_followup?.domain, "occupation"); assert.equal(next.next_followup?.intent, "collect_method_evidence"); } }); test("persistServerOwnedFocus writes family as target_domain so a no answer becomes declined coverage", async () => { const plan = planFrom(revision5Dossier()); const accounting = fakeAccounting({ ...receiptHandlers, set_agentic_rectification_conversation_focus: (_fn, args) => ({ focus: { 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-08-29T00:00:00.000Z", resolved_at: null, }, idempotent: false, }), }); const persisted = await persistServerOwnedFocus({ accounting: accounting.client, userId: USER_ID, caseId: CASE_ID, activeFocus: null, decisionReceipt: revision5Dossier().latestResult?.decisionReceipt, followup: plan.next_followup, }); assert.equal(persisted.status, "created"); const call = accounting.calls.find((item) => item.fn === "set_agentic_rectification_conversation_focus"); assert.equal(call?.args.p_target_domain, "family"); assert.equal(call?.args.p_intent, "collect_method_evidence"); }); test("occupation collect denial declines the focus and advances coverage to horary", async () => { assert.equal(shouldDeclineCollectFocus({ intent: "answer_current_focus", answer_class: "no", }), true); assert.equal(shouldDeclineCollectFocus({ intent: "provide_new_evidence", answer_class: null, }), false); assert.equal(shouldDeclineCollectFocus({ intent: "answer_current_focus", answer_class: "unsure", }), false); const askedYearless = [D24, D12, D7, D4, D5].map((item) => item.semantic_key); const occupationPlan = planFrom(revision5Dossier(revision5State(), { declinedTopics: [{ target_domain: "family", status: "declined" }], }), { askedProbeKeys: askedYearless }); assert.equal(occupationPlan.next_followup?.domain, "occupation"); assert.equal(occupationPlan.next_followup?.intent, "collect_method_evidence"); assert.equal(occupationPlan.next_followup?.choice_frame, null); const occupationFocus = { id: FOCUS_ID, case_id: CASE_ID, question_id: "occupation:occupation", intent: "collect_method_evidence", target_evidence_id: null, target_domain: "occupation", target_kind: null, expected_answer_schema: { prompt: "你长期做什么工作?", collect: true, }, status: "active", asked_at: "2026-08-29T00:00:00.000Z", resolved_at: null, }; let loads = 0; const accounting = fakeAccounting({ ...receiptHandlers, get_agentic_rectification_case_dossier: () => { loads += 1; if (loads === 1) { return rpcDossier(revision5Dossier(revision5State(), { declinedTopics: [{ target_domain: "family", status: "declined" }], }), occupationFocus); } return rpcDossier(revision5Dossier(revision5State(), { declinedTopics: [ { target_domain: "family", status: "declined" }, { target_domain: "occupation", status: "declined" }, ], })); }, get_agentic_rectification_case_compute: () => computeFixture(), resolve_agentic_rectification_conversation_focus: (_fn, args) => ({ focus_id: args.p_focus_id, status: args.p_status, evidence_id: null, idempotent: false, }), set_agentic_rectification_conversation_focus: (_fn, args) => ({ focus: { id: "acacacac-acac-4cac-8cac-acacacacacac", 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-08-29T00:00:00.000Z", resolved_at: null, }, idempotent: false, }), }); const applied = await applyCollectFocusDenial(accounting.client, { userId: USER_ID, caseId: CASE_ID, focusId: FOCUS_ID, }); const resolve = accounting.calls.find((item) => item.fn === "resolve_agentic_rectification_conversation_focus"); assert.equal(resolve?.args.p_status, "declined"); assert.notEqual(resolve?.args.p_status, "resolved"); const setFocus = accounting.calls.find((item) => item.fn === "set_agentic_rectification_conversation_focus"); assert.equal(setFocus?.args.p_target_domain, "horary"); assert.equal(setFocus?.args.p_intent, "collect_method_evidence"); assert.ok(applied.narration); const horary = planFrom(revision5Dossier(revision5State(), { declinedTopics: [ { target_domain: "family", status: "declined" }, { target_domain: "occupation", status: "declined" }, ], }), { askedProbeKeys: askedYearless }); assert.equal(horary.next_followup?.domain, "horary"); }); test("message and opening turns persist the next followup so current_question is not null", async () => { const route = readFileSync(new URL("../src/app/api/rectification/agent/route.ts", import.meta.url), "utf8"); const afterRun = route.slice(route.indexOf("const result = await runV9AgentTurn")); assert.doesNotMatch(afterRun, /if \(action === "message" \|\| action === "opening"\)/); assert.match(afterRun, /persistNextInterviewIfIdle/); assert.match(afterRun, /ensureNonTerminalTurnExit/); assert.ok(afterRun.indexOf("result.ok") < afterRun.indexOf("persistNextInterviewIfIdle")); const occupationPlan = planFrom(revision5Dossier(revision5State(), { declinedTopics: [{ target_domain: "family", status: "declined" }], })); assert.ok(occupationPlan.next_followup); const accounting = fakeAccounting({ ...receiptHandlers, get_agentic_rectification_case_dossier: () => rpcDossier(revision5Dossier(revision5State(), { declinedTopics: [{ target_domain: "family", status: "declined" }], })), get_agentic_rectification_case_compute: () => computeFixture(), set_agentic_rectification_conversation_focus: (_fn, args) => ({ focus: { 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-08-29T00:00:00.000Z", resolved_at: null, }, idempotent: false, }), }); const persisted = await persistNextInterviewIfIdle({ accounting: accounting.client, userId: USER_ID, caseId: CASE_ID, }); assert.equal(persisted.persisted, true); assert.ok(persisted.hostNarration || persisted.choiceReady); const setFocus = accounting.calls.find((item) => item.fn === "set_agentic_rectification_conversation_focus"); assert.ok(setFocus); const question = projectCurrentQuestion({ id: FOCUS_ID, questionId: String(setFocus?.args.p_question_id ?? ""), intent: String(setFocus?.args.p_intent ?? ""), targetDomain: typeof setFocus?.args.p_target_domain === "string" ? setFocus.args.p_target_domain : null, expectedAnswerSchema: setFocus?.args.p_expected_answer_schema as Record, }); assert.notEqual(question, null); assert.ok(question?.kind === "collect_spoken" || question?.kind === "choice"); }); function occupationCollectFocus(id = FOCUS_ID) { return { id, case_id: CASE_ID, question_id: "collect:occupation:collect_method_evidence", intent: "collect_method_evidence", target_evidence_id: null, target_domain: "occupation", target_kind: null, expected_answer_schema: { collect: true, prompt: "你长期做什么工作?", }, status: "active", asked_at: "2026-08-30T00:00:00.000Z", resolved_at: null, }; } function createdFocusFromArgs(args: Record, id = FOCUS_ID) { return { 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-08-30T00:00:00.000Z", resolved_at: null, }; } function occupationDossier() { return revision5Dossier(revision5State(), { declinedTopics: [{ target_domain: "family", status: "declined" }], }); } function liveCaseState(): InferenceState { const times = CANDIDATES.map((candidate) => candidate.time); const answered = [D9, D10, RELOCATION_2015, D24, D7]; const active = [ { time: "05:00", score: 18, probability: 0.4 }, { time: "05:07", score: 16, probability: 0.33 }, { time: "04:53", score: 14, probability: 0.27 }, ] as const; const eliminated = times.filter((time) => !active.some((candidate) => candidate.time === time)); const orderedTimes = [...active.map((candidate) => candidate.time), ...eliminated]; return { algorithm_version: revision5State().algorithm_version, candidate_set_id: candidateSetId("04:47", "05:15", orderedTimes), revision: 6, phase: "discrimination", result_status: "discriminating", range_start: "04:47", range_end: "05:15", candidates: [ ...active.map((candidate, index) => ({ id: candidate.time, time: candidate.time, cluster_range: [candidate.time, candidate.time] as const, prior_score: candidate.score, posterior_score: candidate.score, probability: candidate.probability, status: "active", rank: index + 1, strong_conflict_count: 0, } as const)), ...eliminated.map((time, index) => ({ id: time, time, cluster_range: [time, time] as const, prior_score: 4 - index, posterior_score: 4 - index, probability: 0, status: "eliminated" as const, rank: active.length + index + 1, strong_conflict_count: 3, })), ], events: [ { id: "e-career-entry", domain: "career", year: 2020, precision: "month", usage: "training" }, { id: "e-career-exit", domain: "career", year: 2020, precision: "month", usage: "training" }, { id: "e-rel-end", domain: "relationship", year: 2024, precision: "day", usage: "training" }, { id: "e-rel-start", domain: "relationship", year: 2024, precision: "month", usage: "training" }, { id: "e-education", domain: "education", year: 2016, precision: "year", usage: "training" }, ], probes: [...answered, CAREER_2023_ACTIVATION], answered_probes: answered.map((probe) => ({ probe_id: probe.id, semantic_key: probe.semantic_key, candidate_split_hash: probe.candidate_split_hash, answer_class: probe === D24 ? "unsure" : "no", classified_from: "choice", })), rounds: [], last_inference_round: null, entropy: 1.08, representative_time: "05:00", credible_range: ["04:53", "05:07"], holdout_passed: null, }; } function liveCaseDossier(): DecisionDossier { const state = liveCaseState(); return { evidence: LIVE_CASE_EVIDENCE, conversationSummary: { activeFocus: null, declinedSkippedTopics: [{ target_domain: "family", status: "declined" }], }, latestResult: { resultId: "55555555-5555-4555-8555-555555555555", selectionAllowed: true, confirmationAllowed: false, evidenceLedgerFingerprint: evidenceLedgerFingerprint(LIVE_CASE_EVIDENCE as never), candidates: state.candidates.map((candidate, index) => ({ candidateId: `77777777-7777-4777-8777-${String(index + 1).padStart(12, "0")}`, time: candidate.time, rank: candidate.rank, relativeSupport: Math.round(candidate.posterior_score), })), representativeTime: state.representative_time, decisionReceipt: { acceptance_allowed: true, accept_allowed: true, propose_allowed: true, selection_allowed: true, confirmation_allowed: false, inference_state: state, }, }, case: { acceptedTime: null }, }; } async function persistOccupationAfterChoice(accounting: ReturnType["client"]) { const dossier = occupationDossier(); const decision = decideFromDossier(dossier, { birthDate: "1997-08-08" }); return persistNextInterviewAfterChoice({ accounting, userId: USER_ID, caseId: CASE_ID, dossier, decisionState: revision5State(), nextAction: publicNextAction(decision), birthDate: "1997-08-08", }); } test("duplicate collect focus reloads the active question instead of returning null narration", async () => { const accounting = fakeAccounting({ ...receiptHandlers, get_agentic_rectification_case_dossier: () => rpcDossier(occupationDossier(), occupationCollectFocus()), set_agentic_rectification_conversation_focus: () => { throw new Error("focus_idempotency_conflict"); }, }); const persisted = await persistOccupationAfterChoice(accounting.client); assert.equal(persisted.hostNarration, "你长期做什么工作?"); const currentQuestion = projectCurrentQuestion({ id: FOCUS_ID, questionId: "collect:occupation:collect_method_evidence", intent: "collect_method_evidence", targetDomain: "occupation", expectedAnswerSchema: { collect: true, prompt: "你长期做什么工作?" }, }); assert.equal(currentQuestion?.question_id, "collect:occupation:collect_method_evidence"); assert.equal(accounting.calls.filter((item) => item.fn === "set_agentic_rectification_conversation_focus").length, 2); }); test("skipped collect focus reloads once and retries persistence", async () => { let writes = 0; const accounting = fakeAccounting({ ...receiptHandlers, get_agentic_rectification_case_dossier: () => rpcDossier(occupationDossier()), set_agentic_rectification_conversation_focus: (_fn, args) => { writes += 1; if (writes === 1) throw new Error("temporary focus write failure"); return { focus: createdFocusFromArgs(args), idempotent: false }; }, }); const persisted = await persistOccupationAfterChoice(accounting.client); assert.equal(persisted.hostNarration, "你长期做什么工作?"); assert.equal(writes, 2); const write = accounting.calls.filter((item) => item.fn === "set_agentic_rectification_conversation_focus").at(-1); const currentQuestion = projectCurrentQuestion({ id: FOCUS_ID, questionId: String(write?.args.p_question_id ?? ""), intent: String(write?.args.p_intent ?? ""), targetDomain: typeof write?.args.p_target_domain === "string" ? write.args.p_target_domain : null, expectedAnswerSchema: write?.args.p_expected_answer_schema as Record, }); assert.equal(currentQuestion?.question_id, "collect:occupation:collect_method_evidence"); }); test("nonterminal turn exit deterministically restores a spoken question", async () => { const answerChoiceModule = await import("../src/lib/rectification-agentic/v9/answer-choice.ts") as Record; const ensureExit = answerChoiceModule.ensureNonTerminalTurnExit as undefined | ((input: { accounting: ReturnType["client"]; userId: string; caseId: string; }) => Promise<{ hostNarration: string | null; persisted: boolean }>); assert.equal(typeof ensureExit, "function"); let activeFocus: Record | null = null; const accounting = fakeAccounting({ ...receiptHandlers, get_agentic_rectification_case_dossier: () => rpcDossier(occupationDossier(), activeFocus), set_agentic_rectification_conversation_focus: (_fn, args) => { activeFocus = createdFocusFromArgs(args); return { focus: activeFocus, idempotent: false }; }, }); const repaired = await ensureExit!({ accounting: accounting.client, userId: USER_ID, caseId: CASE_ID, }); assert.equal(repaired.persisted, true); assert.match(repaired.hostNarration ?? "", /当前可信区间/); assert.match(repaired.hostNarration ?? "", /05:07/); assert.match(repaired.hostNarration ?? "", /代表分钟/); const write = accounting.calls.find((item) => item.fn === "set_agentic_rectification_conversation_focus"); const currentQuestion = projectCurrentQuestion({ id: FOCUS_ID, questionId: String(write?.args.p_question_id ?? ""), intent: String(write?.args.p_intent ?? ""), targetDomain: typeof write?.args.p_target_domain === "string" ? write.args.p_target_domain : null, expectedAnswerSchema: write?.args.p_expected_answer_schema as Record, }); assert.equal(currentQuestion?.kind, "collect_spoken"); assert.ok(currentQuestion?.prompt); }); test("live five-evidence case ends on the occupation spoken collect", async () => { const dossier = liveCaseDossier(); const state = liveCaseState(); assert.equal(dossier.evidence.length, 5); assert.equal(state.answered_probes.length, 5); assert.deepEqual(state.candidates.filter((candidate) => candidate.status === "active").map((candidate) => candidate.time), [ "05:00", "05:07", "04:53", ]); const decision = decideFromDossier(dossier, { birthDate: "1997-08-08" }); assert.equal(decision.probe, null); assert.equal(decision.nextAction, "offer_provisional_range"); assert.equal(decision.canAdopt, false); assert.ok(decision.droppedProbes.some((probe) => ( probe.semantic_key === CAREER_2023_ACTIVATION.semantic_key && probe.reason === "no_split_among_active" )), JSON.stringify(decision.droppedProbes)); const plan = planFrom(dossier); assert.equal(plan.next_followup?.method_id, "occupation"); assert.equal(plan.next_followup?.intent, "collect_method_evidence"); assert.equal(spokenFollowupForUser(plan.next_followup), "你长期做什么工作?"); const accounting = fakeAccounting({ ...receiptHandlers, get_agentic_rectification_case_dossier: () => rpcDossier(dossier), set_agentic_rectification_conversation_focus: (_fn, args) => ({ focus: createdFocusFromArgs(args), idempotent: false, }), }); const persisted = await persistNextInterviewAfterChoice({ accounting: accounting.client, userId: USER_ID, caseId: CASE_ID, dossier, decisionState: state, nextAction: publicNextAction(decision), birthDate: "1997-08-08", }); assert.match(persisted.hostNarration, /你长期做什么工作?/); const write = accounting.calls.find((item) => item.fn === "set_agentic_rectification_conversation_focus"); const currentQuestion = projectCurrentQuestion({ id: FOCUS_ID, questionId: String(write?.args.p_question_id ?? ""), intent: String(write?.args.p_intent ?? ""), targetDomain: typeof write?.args.p_target_domain === "string" ? write.args.p_target_domain : null, expectedAnswerSchema: write?.args.p_expected_answer_schema as Record, }); assert.equal(currentQuestion?.question_id, "collect:occupation:collect_method_evidence"); }); test("coverage incomplete still prefers a dated discriminator over a same-turn yearless varga card", () => { const state = revision5State([DATED_RELOCATION_2016]); const plan = planFrom(revision5Dossier(state)); assert.equal(plan.next_followup?.semantic_key, DATED_RELOCATION_2016.semantic_key); assert.ok(plan.next_followup?.choice_frame); assert.notEqual(plan.next_followup?.source, "method_coverage"); assert.doesNotMatch(plan.next_followup?.semantic_key ?? "", /varga\.d12/); }); test("yearless varga in another domain stays behind uncovered-method spoken collect", () => { const evidence = [ { id: "e-career", status: "confirmed", domain: "career", datePrecision: "year" as const, occurredFrom: "2020-01-01", occurredTo: null, eventKind: "career_entry", }, { id: "e-rel", status: "confirmed", domain: "relationship", datePrecision: "year" as const, occurredFrom: "2024-01-01", occurredTo: null, eventKind: "relationship_end", }, { id: "e-edu", status: "confirmed", domain: "education", datePrecision: "year" as const, occurredFrom: "2016-01-01", occurredTo: null, eventKind: "education_start", }, { id: "e-job", status: "confirmed", domain: "occupation", datePrecision: "unknown" as const, occurredFrom: null, occurredTo: null, eventKind: "occupation_note", }, ]; const plan = buildMethodFollowupPlan({ evidence, contrastPacket: { candidateSetVersion: "05:00-05:07", vargaDifferences: [], probes: [{ probeId: D24.id, candidateSetVersion: "05:00-05:07", question: D24.question, expectedOutcomes: D24.expected_outcomes.map((row) => ({ outcomeId: row.answer_class, supportsCandidateIds: [...row.supports], conflictsCandidateIds: [...row.conflicts], })), candidateSplitHash: D24.candidate_split_hash, informationGain: D24.information_gain, sourceFeatures: [{ technique: "D24", calculationResultId: null }], domain: "education", year: null, semanticKey: D24.semantic_key, choiceKind: "existence", styleOptions: EXISTENCE_OPTIONS.map((item) => ({ label: item.label, answerClass: item.answer_class, })), }], }, candidatesSeparated: false, }); assert.equal(plan.next_followup?.domain, "family"); assert.doesNotMatch(plan.next_followup?.semantic_key ?? "", /varga\.d24/); }); test("collect-focus classifier and resolve-focus copy do not treat explicit no as resolved", () => { const route = readFileSync(new URL("../src/app/api/rectification/agent/route.ts", import.meta.url), "utf8"); const classifier = readFileSync(new URL("../src/lib/rectification-agentic/v9/turn-intent-classifier.ts", import.meta.url), "utf8"); const tools = readFileSync(new URL("../src/mastra/rectification-v9-tools.ts", import.meta.url), "utf8"); const fastPath = route.slice( route.indexOf('if (action === "message")'), route.indexOf("const requestTime"), ); assert.match(fastPath, /isCollectFocusSchema/); assert.match(fastPath, /shouldDeclineCollectFocus/); assert.match(fastPath, /applyCollectFocusDenial/); assert.match(classifier, /collect === true|isCollectFocusSchema/); assert.doesNotMatch(classifier + fastPath, /USER_STOP_PATTERN|parseChoiceKeyFromUserMessage/); const resolveTool = tools.slice( tools.indexOf('id: "rectification-resolve-focus"'), tools.indexOf("inputSchema: z.object({", tools.indexOf('id: "rectification-resolve-focus"')), ); assert.match(resolveTool, /declined/); assert.match(resolveTool, /采集题|确实没有/); assert.match(resolveTool, /resolved 只用于已落证据|已落证据/); assert.match(resolveTool, /没有 active focus|没有当前.*focus/); }); test("has_new_dated_event continues into the agent after applying the answer", () => { const route = readFileSync(new URL("../src/app/api/rectification/agent/route.ts", import.meta.url), "utf8"); const classifier = readFileSync(new URL("../src/lib/rectification-agentic/v9/turn-intent-classifier.ts", import.meta.url), "utf8"); const fastPath = route.slice( route.indexOf('if (action === "message")'), route.indexOf("const requestTime"), ); assert.match(classifier, /has_new_dated_event/); assert.match(classifier, /带大概时间的经历/); assert.match(classifier, /intent 仍为 answer_current_focus/); assert.match(classifier, /不要改成 provide_new_evidence/); assert.doesNotMatch(classifier + fastPath, /USER_STOP_PATTERN|parseChoiceKeyFromUserMessage/); assert.doesNotMatch(classifier + fastPath, /(?:userMessage|user_message|message)\.(?:match|search|includes|startsWith|endsWith)\(/); assert.match(fastPath, /shouldContinueAgentForDatedEvent/); assert.match(fastPath, /deferFollowup:\s*continueToAgent/); const choiceApply = fastPath.slice( fastPath.indexOf("if (classified.intent === \"answer_current_focus\")"), fastPath.indexOf("if (classified.intent === \"stop_rectification\")"), ); assert.ok(choiceApply.indexOf("applyRectificationChoice") < choiceApply.indexOf("if (!continueToAgent)")); assert.match(choiceApply, /return completedMessageResponse\(applied\.narration/); assert.ok(choiceApply.indexOf("if (!continueToAgent)") < choiceApply.indexOf("return completedMessageResponse(applied.narration")); const collectApply = fastPath.slice( fastPath.indexOf("if (shouldDeclineCollectFocus(classified))"), fastPath.indexOf("} else {"), ); assert.ok(collectApply.indexOf("applyCollectFocusDenial") < collectApply.indexOf("if (!continueToAgent)")); assert.match(collectApply, /persistV9DeterministicTurn/); assert.ok(collectApply.indexOf("if (!continueToAgent)") < collectApply.indexOf("persistV9DeterministicTurn")); assert.ok(route.indexOf("if (action === \"message\")") < route.indexOf("runV9AgentTurn({")); const answerChoice = readFileSync(new URL("../src/lib/rectification-agentic/v9/answer-choice.ts", import.meta.url), "utf8"); const persistApplied = answerChoice.slice(answerChoice.indexOf("async function persistApplied")); assert.match(persistApplied, /command\.deferFollowup !== true/); assert.equal(shouldContinueAgentForDatedEvent({ intent: "answer_current_focus", answer_class: "no", has_new_dated_event: true, }), true); assert.equal(shouldContinueAgentForDatedEvent({ intent: "answer_current_focus", answer_class: "no", }), false); }); test("collect denial with a new dated event does not persist the next interview before the agent", async () => { const occupationFocus = { id: FOCUS_ID, case_id: CASE_ID, question_id: "occupation:occupation", intent: "collect_method_evidence", target_evidence_id: null, target_domain: "occupation", target_kind: null, expected_answer_schema: { prompt: "你长期做什么工作?", collect: true, }, status: "active", asked_at: "2026-08-29T00:00:00.000Z", resolved_at: null, }; let loads = 0; const accounting = fakeAccounting({ ...receiptHandlers, get_agentic_rectification_case_dossier: () => { loads += 1; if (loads === 1) { return rpcDossier(revision5Dossier(revision5State(), { declinedTopics: [{ target_domain: "family", status: "declined" }], }), occupationFocus); } return rpcDossier(revision5Dossier(revision5State(), { declinedTopics: [ { target_domain: "family", status: "declined" }, { target_domain: "occupation", status: "declined" }, ], })); }, get_agentic_rectification_case_compute: () => computeFixture(), resolve_agentic_rectification_conversation_focus: (_fn, args) => ({ focus_id: args.p_focus_id, status: args.p_status, evidence_id: null, idempotent: false, }), set_agentic_rectification_conversation_focus: (_fn, args) => ({ focus: { id: "acacacac-acac-4cac-8cac-acacacacacac", 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-08-29T00:00:00.000Z", resolved_at: null, }, idempotent: false, }), }); const applied = await applyCollectFocusDenial(accounting.client, { userId: USER_ID, caseId: CASE_ID, focusId: FOCUS_ID, deferFollowup: true, }); const resolve = accounting.calls.find((item) => item.fn === "resolve_agentic_rectification_conversation_focus"); assert.equal(resolve?.args.p_status, "declined"); assert.equal( accounting.calls.some((item) => item.fn === "set_agentic_rectification_conversation_focus"), false, ); assert.equal(applied.nextInterviewPersisted, false); }); test("persistNextInterviewIfIdle uses the dossier decision sessionOutcome once", async () => { const source = readFileSync(new URL("../src/lib/rectification-agentic/v9/answer-choice.ts", import.meta.url), "utf8"); const idle = source.slice( source.indexOf("export async function persistNextInterviewIfIdle"), source.indexOf("async function persistApplied"), ); assert.equal(idle.split("decideFromDossier").length - 1, 1); assert.match(idle, /sessionOutcome:\s*decision\.sessionOutcome/); assert.doesNotMatch(idle, /sessionOutcome:\s*"collect_evidence"/); assert.match(idle, /publicNextAction\(decision\)/); const covered = revision5Dossier(revision5State([DATED_RELOCATION_2016]), { declinedTopics: [ { target_domain: "family", status: "declined" }, { target_domain: "occupation", status: "declined" }, { target_domain: "horary", status: "declined" }, ], }); const decision = decideFromDossier(covered); assert.notEqual(decision.sessionOutcome, "collect_evidence"); const catalog = rectificationFollowupCatalog(covered.latestResult, covered.evidence); const collectPlan = buildMethodFollowupPlan({ evidence: covered.evidence, declinedTopics: covered.conversationSummary.declinedSkippedTopics, sessionOutcome: "collect_evidence", ...catalog, candidatesSeparated: false, }); const decisionPlan = buildMethodFollowupPlan({ evidence: covered.evidence, declinedTopics: covered.conversationSummary.declinedSkippedTopics, sessionOutcome: decision.sessionOutcome, ...catalog, candidatesSeparated: false, }); assert.ok(decisionPlan.next_followup); assert.notEqual(collectPlan.next_followup?.intent, decisionPlan.next_followup?.intent); const accounting = fakeAccounting({ ...receiptHandlers, get_agentic_rectification_case_dossier: () => rpcDossier(covered), get_agentic_rectification_case_compute: () => computeFixture(), set_agentic_rectification_conversation_focus: (_fn, args) => ({ focus: { 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-08-29T00:00:00.000Z", resolved_at: null, }, idempotent: false, }), }); const persisted = await persistNextInterviewIfIdle({ accounting: accounting.client, userId: USER_ID, caseId: CASE_ID, }); assert.equal(persisted.persisted, true); assert.ok(persisted.hostNarration || persisted.choiceReady); const setFocus = accounting.calls.find((item) => item.fn === "set_agentic_rectification_conversation_focus"); assert.ok(setFocus); assert.notEqual(setFocus?.args.p_intent, "collect_method_evidence"); });