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 { decideFromDossier } from "../src/lib/rectification-agentic/v9/decision-from-dossier.ts"; import { buildMethodFollowupPlan, buildNextUserAction, exhaustionSpokenCollectFollowup, holdoutFollowupFor, nextDatedCollectFollowup, spokenFollowupForUser, type MethodFollowupEvidence, } from "../src/lib/rectification-agentic/v9/method-followup.ts"; import type { DiscriminatingEventProbe } from "../src/lib/rectification-agentic/v9/refinement-packet.ts"; import { persistNextInterviewAfterChoice, persistNextInterviewIfIdle, } from "../src/lib/rectification-agentic/v9/answer-choice.ts"; import { publicDecisionFields } from "../src/lib/rectification-agentic/core/rectification-decision.ts"; import { projectTurnDecision } from "../src/lib/rectification-agentic/v9/turn-decision.ts"; import { evidenceLedgerFingerprint, parseV9CaseDossier, RectificationToolServiceError, } from "../src/lib/rectification-agentic/v9/tool-service.ts"; import { GENERIC_COLLECT_QUESTION, USER_COLLECT_QUESTION, withFirstDatedCollectInvite } from "../src/lib/rectification-agentic/user-copy.ts"; import { turnQuestionKind } from "../src/lib/rectification-agentic/v9/turn-question.ts"; import { createRectificationV9Tools } from "../src/mastra/rectification-v9-tools.ts"; import { CANDIDATE_ID, CASE_ID, FOCUS_ID, SECOND_CANDIDATE_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("family collect spoken stem has no year prefix while probe_year stays dated", () => { const plan = buildMethodFollowupPlan({ evidence: TWO_SCOREABLE, evidenceCollectionProbes: [{ year: 2021, year_label: "2021 年前后", domain: "family", event_family: "家人结婚、添丁或住院", source: "age_band", tracks: ["vimshottari", "narayana"], tracks_agree: false, unique_minute_claim: false, user_meaning: "时间范围锁定 2021 年前后;领域锁定 family。", role: "collect", phase: "evidence_collection", information_gain: 0, semantic_key: "family.2021", }], }); assert.equal(plan.next_followup?.domain, "family"); assert.equal(plan.next_followup?.probe_year, 2021); // 旧:口语等于 USER_COLLECT_QUESTION.family // 新:第一道逐领域采集题干末尾加一次「想到别的也可以一起说」 // 原因:BUG-604 只报一件也走既有采集,不插「还有吗」追问轮 assert.equal(spokenFollowupForUser(plan.next_followup), withFirstDatedCollectInvite(USER_COLLECT_QUESTION.family)); }); test("four scoreable events skip holdout for domains already in the ledger", () => { assert.equal(meetsAcceptanceEventQuality(FOUR_SCOREABLE), true); const declined = new Set(["family"]); assert.equal(holdoutFollowupFor({ evidence: FOUR_SCOREABLE, oosBlindPrompts: OOS_PROMPTS, }, declined), null); const plan = collectPlan(FOUR_SCOREABLE, { candidatesSeparated: true, eventProbes: [], contrastPacket: { candidateSetVersion: "04:50-05:10", vargaDifferences: [], probes: [] }, }); assert.notEqual(plan.next_followup?.intent, "out_of_sample_check"); assert.notEqual(plan.next_followup?.source, "oos_blind"); assert.notEqual(plan.next_followup?.domain, "education"); assert.notEqual(plan.next_followup?.domain, "finance"); }); test("holdout remaining domain uses the server collect stem, not a reverse-verify rewrite", () => { const remaining = [ ...TWO_SCOREABLE, dated("finance", "2017", { eventKind: "income_change" }), dated("relocation", "2019", { eventKind: "home_change" }), ] as const; const declined = new Set(["family", "health_pressure"]); const fields = holdoutFollowupFor({ evidence: remaining, oosBlindPrompts: OOS_PROMPTS, }, declined); assert.equal(fields?.domain, "education"); assert.equal(fields?.intent, "collect_method_evidence"); assert.equal(fields?.source, "method_coverage"); assert.equal(fields?.user_prompt_hint, USER_COLLECT_QUESTION.education); const plan = collectPlan(remaining, { candidatesSeparated: true, eventProbes: [], contrastPacket: { candidateSetVersion: "04:50-05:10", vargaDifferences: [], probes: [] }, declinedTopics: [ ...FAMILY_DECLINED, { target_domain: "health_pressure", status: "declined", intent: "collect_method_evidence" }, ], }); assert.equal(plan.next_followup?.domain, "education"); assert.equal(plan.next_followup?.intent, "collect_method_evidence"); assert.equal(plan.next_followup?.choice_frame, null); assert.equal(spokenFollowupForUser(plan.next_followup), USER_COLLECT_QUESTION.education); assert.equal(turnQuestionKind({ intent: plan.next_followup?.intent, expectedAnswerSchema: { prompt: spokenFollowupForUser(plan.next_followup), collect: true }, }), "collect_spoken"); }); 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, null); // 原值: other // 新值: null // 原因: BUG-558 方法覆盖完成且七个带年份领域都问过/拒过后不得再落到开放式补问兜底句 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 still collect remaining dated domains after family denial", () => { const plan = collectPlan(FOUR_SCOREABLE, { holdoutValidation: "passed", oosBlindPrompts: [], candidatesSeparated: true, }); // 旧:训练门已开 → occupation。新:家人拒答后仍收搬家,用来区分剩余分钟。 assert.notEqual(plan.next_followup?.domain, "education"); assert.notEqual(plan.next_followup?.domain, "occupation"); assert.equal(plan.next_followup?.domain, "relocation"); assert.equal(plan.next_followup?.intent, "collect_method_evidence"); }); 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, /采集题必须写出服务端给你的领域/); }); const CAREER_EXISTENCE_PROBE: DiscriminatingEventProbe = { year: 2011, year_label: "2011 年前后", domain: "career", event_family: "入职、换工作或职责加重", source: "dasha_activation", tracks: ["vimshottari", "narayana"], tracks_agree: true, unique_minute_claim: false, user_meaning: "年份锁定 2011 年前后。事件家族:入职、换工作或职责加重。", role: "distinguish", information_gain: 0.4, candidate_ids: ["05:00", "05:20"], expected_outcomes: [ { answer_class: "yes", supports: ["05:00"], conflicts: ["05:20"] }, { answer_class: "no", supports: ["05:20"], conflicts: ["05:00"] }, ], semantic_key: "career.2011", candidate_split_hash: "career.2011:05:00|05:20", choice_kind: "existence", }; const RELATIONSHIP_AND_FAMILY = [ dated("relationship", "2014", { eventKind: "relationship_start" }), dated("family", "2016", { eventKind: "family_event" }), ] as const; function careerAnswerPlan(answerClass: string, extra: Partial[0]> = {}) { return buildMethodFollowupPlan({ evidence: RELATIONSHIP_AND_FAMILY, eventProbes: [CAREER_EXISTENCE_PROBE], askedProbeKeys: ["career.2011", "probe:career.2011"], answeredProbes: [{ semantic_key: "career.2011", probe_id: "probe:career.2011", answer_class: answerClass, classified_from: "choice", }], holdoutValidation: "not_started", ...extra, }); } test("career discriminator yes covers career collect without ledger career evidence", () => { const unanswered = buildMethodFollowupPlan({ evidence: RELATIONSHIP_AND_FAMILY, eventProbes: [CAREER_EXISTENCE_PROBE], askedProbeKeys: ["career.2011", "probe:career.2011"], holdoutValidation: "not_started", }); assert.equal(unanswered.next_followup?.domain, "career"); assert.equal(unanswered.next_followup?.intent, "collect_method_evidence"); assert.equal(spokenFollowupForUser(unanswered.next_followup), withFirstDatedCollectInvite(USER_COLLECT_QUESTION.career)); const yes = careerAnswerPlan("yes"); assert.notEqual(yes.next_followup?.domain, "career"); assert.equal(yes.next_followup?.domain, "education"); assert.equal(yes.next_followup?.intent, "collect_method_evidence"); assert.equal(spokenFollowupForUser(yes.next_followup), withFirstDatedCollectInvite(USER_COLLECT_QUESTION.education)); assert.equal( yes.methods.find((item) => item.method_id === "d10_career")?.status, "covered", ); const weakYes = careerAnswerPlan("weak_yes"); assert.notEqual(weakYes.next_followup?.domain, "career"); assert.equal(weakYes.next_followup?.domain, "education"); const no = careerAnswerPlan("no"); assert.equal(no.next_followup?.domain, "career"); assert.equal(spokenFollowupForUser(no.next_followup), withFirstDatedCollectInvite(USER_COLLECT_QUESTION.career)); const unsure = careerAnswerPlan("unsure"); assert.equal(unsure.next_followup?.domain, "career"); assert.equal(spokenFollowupForUser(unsure.next_followup), withFirstDatedCollectInvite(USER_COLLECT_QUESTION.career)); }); test("career discriminator yes does not invent coverage from a semantic_key prefix", () => { const plan = careerAnswerPlan("yes", { eventProbes: [] }); assert.equal(plan.next_followup?.domain, "career"); assert.equal(spokenFollowupForUser(plan.next_followup), withFirstDatedCollectInvite(USER_COLLECT_QUESTION.career)); }); test("ledger-classified career probe answers do not cover career collect", () => { const plan = careerAnswerPlan("yes", { answeredProbes: [{ semantic_key: "career.2011", probe_id: "probe:career.2011", answer_class: "yes", classified_from: "evidence", }], }); assert.equal(plan.next_followup?.domain, "career"); assert.equal(spokenFollowupForUser(plan.next_followup), withFirstDatedCollectInvite(USER_COLLECT_QUESTION.career)); }); const THIRD_CANDIDATE_ID = "88888888-8888-4888-8888-888888888883"; const CORE_DATED_FOR_OFFER = [ dated("education", "2008", { eventKind: "education_start" }), dated("education", "2012", { eventKind: "education_completion" }), dated("relationship", "2014", { eventKind: "relationship_start" }), dated("career", "2011", { eventKind: "career_entry" }), ] as const; function rpcEvidenceFromDated( item: MethodFollowupEvidence & { id: string }, ) { return { 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 ?? "undated"} ${item.eventKind ?? "event"}`, status: item.status, supersedes_evidence_id: null, created_at: "2026-09-04T00:00:00.000Z", }; } function declinedTopics( domains: readonly string[], ): Array<{ target_domain: string; status: "declined"; intent: "collect_method_evidence" }> { return domains.map((domain) => ({ target_domain: domain, status: "declined" as const, intent: "collect_method_evidence" as const, })); } function conversationWithDeclines(domains: readonly string[]) { return { confirmed_evidence_summary: [], pending_revisions: [], active_focus: null, declined_skipped_topics: declinedTopics(domains), candidate_divergence_summary: null, missing_evidence_categories: [], last_result_policy: null, summary_version: 1, updated_at: "2026-09-04T00:00:00.000Z", }; } function separatedOfferSnapshot(rawEvidence: unknown[]) { const parsed = parseV9CaseDossier(dossierFixture({ evidence: rawEvidence })); assert.ok(parsed); return candidateSnapshotFixture({ selectionAllowed: true, representativeTime: "05:00", evidenceLedgerFingerprint: evidenceLedgerFingerprint(parsed.evidence), candidates: [ { candidate_id: CANDIDATE_ID, rank: 1, time: "05:00", relative_support: 62, tied_minute_count: 1 }, { candidate_id: SECOND_CANDIDATE_ID, rank: 2, time: "05:01", relative_support: 22, tied_minute_count: 1 }, { candidate_id: THIRD_CANDIDATE_ID, rank: 3, time: "05:02", relative_support: 16, tied_minute_count: 1 }, ], }); } function collectVsOfferDossier(input: { extraEvidence?: readonly (MethodFollowupEvidence & { id: string })[]; declined: readonly string[]; }) { const evidence = [...CORE_DATED_FOR_OFFER, ...(input.extraEvidence ?? [])]; const raw = evidence.map(rpcEvidenceFromDated); return dossierFixture({ evidence: raw, evidenceCount: raw.length, latestResult: separatedOfferSnapshot(raw), conversationSummary: conversationWithDeclines(input.declined), }); } function nextActionFromDecision( dossier: NonNullable>, decision: ReturnType, ) { const catalog = { evidence: dossier.evidence, declinedTopics: dossier.conversationSummary.declinedSkippedTopics, closedCollectFocuses: dossier.conversationSummary.declinedSkippedTopics, sessionOutcome: decision.sessionOutcome, }; const plan = buildMethodFollowupPlan(catalog); return { plan, userAction: buildNextUserAction({ scorableCount: dossier.evidence.length, evidenceCount: dossier.evidence.length, hasLatestResult: true, selectionAllowed: true, sessionOutcome: decision.sessionOutcome, nextFollowup: plan.next_followup, workingTime: decision.representativeTime, }), }; } async function persistIdleFocus(rawDossier: ReturnType) { const accounting = fakeAccounting({ ...receiptHandlers, get_agentic_rectification_case_dossier: () => rawDossier, 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-09-04T00:00:00.000Z", resolved_at: null, asked_turn_id: args.p_asked_turn_id ?? null, }, idempotent: false, }), }); const persisted = await persistNextInterviewIfIdle({ accounting: accounting.client, userId: USER_ID, caseId: CASE_ID, }); const setFocus = accounting.calls.find((item) => item.fn === "set_agentic_rectification_conversation_focus"); return { persisted, setFocus }; } test("separated candidates keep collecting finance before offering time cards", async () => { const raw = collectVsOfferDossier({ declined: ["family"] }); const dossier = parseV9CaseDossier(raw); assert.ok(dossier); const decision = decideFromDossier(dossier); assert.equal(decision.sessionOutcome, "collect_evidence"); assert.equal(decision.nextAction, "ask_fact_collection"); assert.equal(publicDecisionFields(decision).can_adopt, false); const { plan, userAction } = nextActionFromDecision(dossier, decision); assert.equal(plan.next_followup?.domain, "finance"); assert.equal(plan.next_followup?.intent, "collect_method_evidence"); assert.equal(userAction.id, "ask_method_followup"); assert.notEqual(userAction.id, "adopt_representative"); const idle = await persistIdleFocus(raw); assert.equal(idle.persisted.persisted, true); assert.equal(idle.setFocus?.args.p_target_domain, "finance"); }); test("dated collect plus occupation close lets one round offer without another question", async () => { const extra = [ dated("finance", "2017", { eventKind: "income_change" }), dated("relocation", "2016", { eventKind: "home_change" }), dated("health_pressure", "2015", { eventKind: "self_health_event" }), dated("occupation", "2011", { eventKind: "occupation_note" }), ]; const raw = collectVsOfferDossier({ extraEvidence: extra, declined: ["family"], }); const dossier = parseV9CaseDossier(raw); assert.ok(dossier); const decision = decideFromDossier(dossier); assert.equal(decision.sessionOutcome, "adopt_representative"); assert.equal(publicDecisionFields(decision).can_adopt, true); const { plan, userAction } = nextActionFromDecision(dossier, decision); assert.equal(plan.next_followup, null); assert.equal(userAction.id, "adopt_representative"); const idle = await persistIdleFocus(raw); assert.equal(idle.persisted.persisted, false); assert.equal(idle.setFocus, undefined); }); test("offer-candidates refuses while remaining dated collect is still open", async () => { const accounting = fakeAccounting({ ...receiptHandlers, get_agentic_rectification_case_dossier: () => collectVsOfferDossier({ declined: ["family"] }), }); const tools = createRectificationV9Tools({ userId: USER_ID, caseId: CASE_ID, turnId: TURN_ID, accounting: accounting.client as never, }); await assert.rejects( () => (tools["rectification-offer-candidates"] as unknown as { execute(input: unknown): Promise; }).execute({ caseId: CASE_ID }), (error: unknown) => error instanceof RectificationToolServiceError && error.code === "offer_not_allowed", ); assert.equal( accounting.calls.some((call) => call.fn === "transition_agentic_rectification_case_status" && call.args.p_status === "candidate_ready" ), false, ); }); const SEVEN_EVENTS: MethodFollowupEvidence[] = [ { id: "e-edu", status: "confirmed", domain: "education", datePrecision: "month", occurredFrom: "2016-09-01", occurredTo: null, eventKind: "education_start" }, { id: "e-career", status: "confirmed", domain: "career", datePrecision: "month", occurredFrom: "2018-07-01", occurredTo: null, eventKind: "career_entry" }, { id: "e-rel", status: "confirmed", domain: "relationship", datePrecision: "month", occurredFrom: "2021-05-01", occurredTo: null, eventKind: "relationship_start" }, { id: "e-fam", status: "confirmed", domain: "family", datePrecision: "month", occurredFrom: "2023-03-01", occurredTo: null, eventKind: "family_event" }, { id: "e-fin", status: "confirmed", domain: "finance", datePrecision: "month", occurredFrom: "2017-04-01", occurredTo: null, eventKind: "income_change" }, { id: "e-health", status: "confirmed", domain: "health_pressure", datePrecision: "month", occurredFrom: "2019-11-01", occurredTo: null, eventKind: "self_health_event" }, { id: "e-career-2", status: "confirmed", domain: "career", datePrecision: "month", occurredFrom: "2020-04-01", occurredTo: null, eventKind: "career_change" }, ]; function declinedCollect(domain: string) { return { target_domain: domain, status: "declined" as const, intent: "collect_method_evidence" as const }; } test("lagna_frame with seven events asks relocation or occupation, not GENERIC", () => { assert.equal(meetsAcceptanceEventQuality(SEVEN_EVENTS), true); const plan = buildMethodFollowupPlan({ evidence: SEVEN_EVENTS, declinedTopics: [declinedCollect("family"), declinedCollect("health_pressure")], precisionStage: "lagna_frame", holdoutValidation: "not_started", oosBlindPrompts: [{ domain: "health_pressure", user_meaning: "身体这条线还没用过。", used_for_scoring: false, }], }); const next = plan.next_followup; assert.ok(next); assert.ok(next.domain === "relocation" || next.domain === "occupation", next.domain ?? "null"); assert.notEqual(spokenFollowupForUser(next, SEVEN_EVENTS), GENERIC_COLLECT_QUESTION); assert.equal( plan.dropped_probes.some((item) => item.reason === "frameless_distinguish"), true, ); }); test("holdout waits until dated collect and occupation are asked", () => { const evidence: MethodFollowupEvidence[] = [ { id: "e-edu", status: "confirmed", domain: "education", datePrecision: "month", occurredFrom: "2016-09-01", occurredTo: null, eventKind: "education_start" }, { id: "e-career", status: "confirmed", domain: "career", datePrecision: "month", occurredFrom: "2018-07-01", occurredTo: null, eventKind: "career_entry" }, { id: "e-rel", status: "confirmed", domain: "relationship", datePrecision: "month", occurredFrom: "2021-05-01", occurredTo: null, eventKind: "relationship_start" }, { id: "e-fam", status: "confirmed", domain: "family", datePrecision: "month", occurredFrom: "2023-03-01", occurredTo: null, eventKind: "family_event" }, { id: "e-fin", status: "confirmed", domain: "finance", datePrecision: "month", occurredFrom: "2017-04-01", occurredTo: null, eventKind: "income_change" }, { id: "e-occ", status: "confirmed", domain: "occupation", datePrecision: "unknown", occurredFrom: null, occurredTo: null, eventKind: "occupation_note" }, ]; const oos = [{ domain: "health_pressure", user_meaning: "身体这条线还没用过。", used_for_scoring: false as const, }]; assert.equal(holdoutFollowupFor({ evidence, oosBlindPrompts: oos }, new Set())?.domain, "health_pressure"); const plan = buildMethodFollowupPlan({ evidence, precisionStage: "lagna_frame", holdoutValidation: "not_started", oosBlindPrompts: oos, }); assert.equal(plan.next_followup?.domain, "relocation"); assert.notEqual(plan.next_followup?.domain, "health_pressure"); });