import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import test from "node:test"; import { collectionProgressFromReceipt, meetsAcceptanceEventQuality, } from "../src/lib/rectification-agentic/v9/evidence-model.ts"; import { buildMethodFollowupPlan, exhaustionSpokenCollectFollowup, holdoutFollowupFor, nextDatedCollectFollowup, spokenFollowupForUser, type MethodFollowupEvidence, } from "../src/lib/rectification-agentic/v9/method-followup.ts"; import { persistNextInterviewAfterChoice } from "../src/lib/rectification-agentic/v9/answer-choice.ts"; import { projectTurnDecision } from "../src/lib/rectification-agentic/v9/turn-decision.ts"; import { parseV9CaseDossier } from "../src/lib/rectification-agentic/v9/tool-service.ts"; import { USER_COLLECT_QUESTION } from "../src/lib/rectification-agentic/user-copy.ts"; import { createRectificationV9Tools } from "../src/mastra/rectification-v9-tools.ts"; import { CASE_ID, FOCUS_ID, TURN_ID, USER_ID, candidateSnapshotFixture, computeFixture, dossierFixture, fakeAccounting, receiptHandlers, } from "./rectification-v9-test-support.ts"; type ExecutableTool = { execute(input: unknown): Promise; }; const OOS_PROMPTS = [ { domain: "family", user_meaning: "校时还没用过家人这条线。有没有一件没提过、但记得大概时间的家人变化?", used_for_scoring: false as const, }, { domain: "education", user_meaning: "学业这条线还没单独核对。有没有记得大概时间的升学或考试?", used_for_scoring: false as const, }, { domain: "finance", user_meaning: "钱的方面还没单独核对。有没有记得大概时间的收入变化?", used_for_scoring: false as const, }, ]; function dated( domain: string, year: string, extra: { id?: string; eventKind?: string } = {}, ): MethodFollowupEvidence & { id: string; eventKind?: string } { const yearless = extra.eventKind === "occupation_note"; return { id: extra.id ?? `e-${domain}-${year}`, status: "confirmed", domain, datePrecision: yearless ? "unknown" : "year", occurredFrom: yearless ? null : `${year}-03-01`, occurredTo: null, eventKind: extra.eventKind, }; } const TWO_SCOREABLE = [ dated("career", "2011", { eventKind: "career_entry" }), dated("relationship", "2014", { eventKind: "relationship_start" }), ] as const; const FOUR_SCOREABLE = [ ...TWO_SCOREABLE, dated("education", "2008", { eventKind: "education_start" }), dated("finance", "2017", { eventKind: "income_change" }), ] as const; const FAMILY_DECLINED = [{ target_domain: "family", status: "declined", intent: "collect_method_evidence", }]; function collectPlan( evidence: readonly MethodFollowupEvidence[], extra: Partial[0]> = {}, ) { return buildMethodFollowupPlan({ evidence, declinedTopics: FAMILY_DECLINED, holdoutValidation: "not_started", oosBlindPrompts: OOS_PROMPTS, ...extra, }); } test("method rotation asks relationship and career before dated collect", () => { const careerOnly = buildMethodFollowupPlan({ evidence: [dated("career", "2011", { eventKind: "career_entry" })], holdoutValidation: "not_started", }); assert.equal(careerOnly.next_followup?.method_id, "d9_relationship"); assert.equal(careerOnly.next_followup?.domain, "relationship"); const relationshipOnly = buildMethodFollowupPlan({ evidence: [dated("relationship", "2014", { eventKind: "relationship_start" })], holdoutValidation: "not_started", }); assert.equal(relationshipOnly.next_followup?.method_id, "d10_career"); assert.equal(relationshipOnly.next_followup?.domain, "career"); const both = buildMethodFollowupPlan({ evidence: TWO_SCOREABLE, holdoutValidation: "not_started", }); assert.equal(both.next_followup?.method_id, "relatives"); assert.equal(both.next_followup?.domain, "family"); const familyDeclined = collectPlan(TWO_SCOREABLE); assert.equal(familyDeclined.next_followup?.method_id, "d5_education"); assert.equal(familyDeclined.next_followup?.domain, "education"); assert.notEqual(familyDeclined.next_followup?.domain, "occupation"); const educationDeclined = collectPlan(TWO_SCOREABLE, { declinedTopics: [ ...FAMILY_DECLINED, { target_domain: "education", status: "declined", intent: "collect_method_evidence" }, ], }); assert.equal(educationDeclined.next_followup?.method_id, "d2_finance"); assert.equal(educationDeclined.next_followup?.domain, "finance"); }); test("two scoreable events do not open OOS even when holdout prompts exist", () => { assert.equal(meetsAcceptanceEventQuality(TWO_SCOREABLE), false); const declined = new Set(["family"]); assert.equal(holdoutFollowupFor({ evidence: TWO_SCOREABLE, oosBlindPrompts: OOS_PROMPTS, }, declined), null); const plan = collectPlan(TWO_SCOREABLE); assert.notEqual(plan.next_followup?.intent, "out_of_sample_check"); assert.equal(plan.next_followup?.intent, "collect_method_evidence"); assert.equal(plan.next_followup?.domain, "education"); assert.match(spokenFollowupForUser(plan.next_followup) ?? "", /上学|升学/); }); test("four scoreable events skip declined OOS domain and ask education holdout", () => { assert.equal(meetsAcceptanceEventQuality(FOUR_SCOREABLE), true); const plan = collectPlan(FOUR_SCOREABLE, { candidatesSeparated: true, eventProbes: [], contrastPacket: { candidateSetVersion: "04:50-05:10", vargaDifferences: [], probes: [] }, }); assert.equal(plan.next_followup?.intent, "out_of_sample_check"); assert.equal(plan.next_followup?.source, "oos_blind"); assert.equal(plan.next_followup?.domain, "education"); }); test("validate_holdout with every OOS domain declined and no dated holdout asks nothing", () => { const declinedAll = [ { target_domain: "family", status: "declined", intent: "collect_method_evidence" }, { target_domain: "education", status: "declined", intent: "collect_method_evidence" }, { target_domain: "finance", status: "declined", intent: "collect_method_evidence" }, ]; const plan = collectPlan(FOUR_SCOREABLE, { sessionOutcome: "validate_holdout", declinedTopics: declinedAll, holdoutEvents: [], candidatesSeparated: true, eventProbes: [], }); assert.equal(plan.next_followup, null); }); test("dated collect order walks remaining year-bearing domains then occupation", () => { const declined = new Set(["family"]); assert.equal(nextDatedCollectFollowup(TWO_SCOREABLE, declined)?.domain, "education"); const educationClosed = collectPlan(TWO_SCOREABLE, { declinedTopics: [ ...FAMILY_DECLINED, { target_domain: "education", status: "declined", intent: "collect_method_evidence" }, ], }); assert.equal(educationClosed.next_followup?.domain, "finance"); const remainingDeclined = [ "family", "education", "finance", "relocation", "health_pressure", ].map((domain) => ({ target_domain: domain, status: "declined", intent: "collect_method_evidence", })); const occupation = collectPlan(TWO_SCOREABLE, { declinedTopics: remainingDeclined }); assert.equal(occupation.next_followup?.domain, "occupation"); const occupationClosed = collectPlan(TWO_SCOREABLE, { declinedTopics: [ ...remainingDeclined, { target_domain: "occupation", status: "declined", intent: "collect_method_evidence" }, ], closedCollectFocuses: [{ questionId: "collect:occupation:collect_method_evidence", target_domain: "occupation", status: "declined", intent: "collect_method_evidence", }], }); assert.equal(occupationClosed.next_followup?.domain, "other"); assert.equal( exhaustionSpokenCollectFollowup({ evidence: TWO_SCOREABLE, declinedTopics: remainingDeclined, })?.domain, "occupation", ); }); test("persistNextInterviewAfterChoice after family denial asks education, not occupation", async () => { const source = readFileSync( new URL("../src/lib/rectification-agentic/v9/answer-choice.ts", import.meta.url), "utf8", ); const persistFn = source.slice( source.indexOf("export async function persistNextInterviewAfterChoice"), source.indexOf("async function persistFocusAfterChoice"), ); assert.doesNotMatch(persistFn, /decideFromDossier\(/); const dossier = { evidence: TWO_SCOREABLE, conversationSummary: { activeFocus: null, declinedSkippedTopics: FAMILY_DECLINED, }, latestResult: { resultId: "55555555-5555-4555-8555-555555555555", candidates: [ { time: "04:52", relativeSupport: 40, rank: 1 }, { time: "05:04", relativeSupport: 38, rank: 2 }, ], representativeTime: "04:52", decisionReceipt: { oos_blind_prompts: OOS_PROMPTS, gates: { event_quality: { passed: false, scoreable_event_count: 2, minimum: 3 }, }, }, }, case: { acceptedTime: null }, }; 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-09-04T00:00:00.000Z", resolved_at: null, asked_turn_id: args.p_asked_turn_id ?? null, }, idempotent: false, }), }); const persisted = await persistNextInterviewAfterChoice({ accounting: accounting.client, userId: USER_ID, caseId: CASE_ID, dossier, decisionState: null, askedTurnId: TURN_ID, nextAction: { type: "ask_fact_collection", session_outcome: "collect_evidence", completion_status: null, validated: false, can_offer_range: false, can_adopt: false, can_confirm_exact_minute: false, selection_allowed: false, propose_allowed: false, precision_stage: "collect_events", representative_time: null, credible_range: null, stop_reason: null, }, }); assert.equal(persisted.persisted, true); assert.equal(persisted.focus?.targetDomain, "education"); assert.equal(persisted.hostNarration, USER_COLLECT_QUESTION.education); const setFocus = accounting.calls.find((item) => item.fn === "set_agentic_rectification_conversation_focus"); assert.equal(setFocus?.args.p_target_domain, "education"); assert.equal( (setFocus?.args.p_expected_answer_schema as { prompt?: string } | undefined)?.prompt, USER_COLLECT_QUESTION.education, ); }); test("four scoreable events do not take the dated-collect branch", () => { const plan = collectPlan(FOUR_SCOREABLE, { holdoutValidation: "passed", oosBlindPrompts: [], candidatesSeparated: true, }); assert.notEqual(plan.next_followup?.domain, "education"); assert.equal(plan.next_followup?.domain, "occupation"); }); test("set-focus twice with a domainless collect prompt falls back to USER_COLLECT_QUESTION.education", async () => { const raw = dossierFixture({ evidence: TWO_SCOREABLE.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.occurredFrom} ${item.eventKind ?? "event"}`, status: item.status, supersedes_evidence_id: null, created_at: "2026-09-04T00:00:00.000Z", })), latestResult: candidateSnapshotFixture({ decisionReceipt: { oos_blind_prompts: OOS_PROMPTS, gates: { event_quality: { passed: false, scoreable_event_count: 2, minimum: 3 }, }, }, }), conversationSummary: { confirmed_evidence_summary: [], pending_revisions: [], active_focus: null, declined_skipped_topics: FAMILY_DECLINED, candidate_divergence_summary: null, missing_evidence_categories: [], last_result_policy: null, summary_version: 1, updated_at: "2026-09-04T00:00:00.000Z", }, }); const store: { prompt: string | null } = { prompt: null }; const accounting = fakeAccounting({ ...receiptHandlers, get_agentic_rectification_case_dossier: () => raw, get_agentic_rectification_case_compute: () => computeFixture(), set_agentic_rectification_conversation_focus: (_fn, args) => { const schema = args.p_expected_answer_schema as { prompt?: string }; store.prompt = typeof schema?.prompt === "string" ? schema.prompt : null; return { 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-09-04T00:00:00.000Z", resolved_at: null, asked_turn_id: args.p_asked_turn_id ?? null, }, idempotent: false, }; }, }); const tools = createRectificationV9Tools({ userId: USER_ID, caseId: CASE_ID, turnId: TURN_ID, accounting: accounting.client as never, }); const generic = { caseId: CASE_ID, questionId: "collect:education:collect_method_evidence", intent: "collect_method_evidence", spokenPrompt: "除了工作,还有哪件事记得年份?", targetDomain: "education", }; const first = await (tools["rectification-set-focus"] as unknown as ExecutableTool>).execute(generic); assert.equal((first as { error?: string }).error, "invalid_spoken_prompt"); assert.equal((first as { reason?: string }).reason, "domain_missing"); const second = await (tools["rectification-set-focus"] as unknown as ExecutableTool>).execute(generic); assert.equal((second as { error?: string }).error, undefined); assert.equal((second as { target_domain?: string }).target_domain, "education"); assert.equal( ((second as { expected_answer_schema?: { prompt?: string } }).expected_answer_schema)?.prompt, USER_COLLECT_QUESTION.education, ); assert.equal(store.prompt, USER_COLLECT_QUESTION.education); }); test("turn decision and GET interview expose collection_progress 2/3/1 or null", () => { const withGate = parseV9CaseDossier(dossierFixture({ latestResult: candidateSnapshotFixture({ decisionReceipt: { gates: { event_quality: { passed: false, scoreable_event_count: 2, minimum: 3 }, }, }, }), })); assert.ok(withGate); assert.deepEqual(projectTurnDecision(withGate).collection_progress, { scoreable: 2, minimum: 3, missing: 1, }); assert.deepEqual(collectionProgressFromReceipt({ gates: { event_quality: { passed: false, scoreable_event_count: 2, minimum: 3 } }, }), { scoreable: 2, minimum: 3, missing: 1 }); const withoutGate = parseV9CaseDossier(dossierFixture({ latestResult: candidateSnapshotFixture({ decisionReceipt: {} }), })); assert.ok(withoutGate); assert.equal(projectTurnDecision(withoutGate).collection_progress, null); const route = readFileSync( new URL("../src/app/api/rectification/cases/[caseId]/route.ts", import.meta.url), "utf8", ); assert.match(route, /collection_progress: collectionProgressFromReceipt/); const tools = readFileSync( new URL("../src/mastra/rectification-v9-tools.ts", import.meta.url), "utf8", ); assert.match(tools, /采集题必须写出服务端给你的领域/); });