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 } from "../src/lib/rectification-agentic/user-copy.ts"; import { turnQuestionKind } from "../src/lib/rectification-agentic/v9/turn-question.ts"; import { TARGETED_EXISTENCE_PROMPT } from "../src/lib/rectification-agentic/v9/collection-question-pool.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("after one or two dated events the next collect is an invite, not method rotation", () => { // 原值: career → d9_relationship;relationship → d10_career;两件 → relatives/family // 新值: 训练门关时收集池首条是「还有吗」邀请 // 原因: 用户先说完,再从已说的事追问(BUG-648) const careerOnly = buildMethodFollowupPlan({ evidence: [dated("career", "2011", { eventKind: "career_entry" })], holdoutValidation: "not_started", }); assert.equal(careerOnly.next_followup?.collection_key, "collect:invite:more"); assert.equal(careerOnly.next_followup?.domain, "other"); assert.match(spokenFollowupForUser(careerOnly.next_followup) ?? "", /^还有吗?/); const relationshipOnly = buildMethodFollowupPlan({ evidence: [dated("relationship", "2014", { eventKind: "relationship_start" })], holdoutValidation: "not_started", }); assert.equal(relationshipOnly.next_followup?.collection_key, "collect:invite:more"); const both = buildMethodFollowupPlan({ evidence: TWO_SCOREABLE, holdoutValidation: "not_started", }); assert.equal(both.next_followup?.collection_key, "collect:invite:more"); assert.doesNotMatch(spokenFollowupForUser(both.next_followup) ?? "", /年前后/); const familyDeclined = collectPlan(TWO_SCOREABLE); assert.equal(familyDeclined.next_followup?.collection_key, "collect:invite:more"); 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?.collection_key, "collect:invite:more"); }); 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"); // 原值: education 通用采集 // 新值: 邀请「还有吗」 // 原因: 收集池邀请在产出期间排第一(BUG-648) assert.equal(plan.next_followup?.domain, "other"); assert.match(spokenFollowupForUser(plan.next_followup) ?? "", /^还有吗?/); }); test("age-band collection probes do not write birthday years into the spoken stem", () => { 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", }], }); // 原值: 家人采集题干含「2021 年前后」 // 新值: 邀请「还有吗」,不含年龄段年份 // 原因: 撤回 BUG-642;题干年份只能来自用户说过的事 const spoken = spokenFollowupForUser(plan.next_followup); assert.equal(plan.next_followup?.collection_key, "collect:invite:more"); assert.match(spoken ?? "", /^还有吗?/); assert.doesNotMatch(spoken ?? "", /2021 年前后/); assert.doesNotMatch(spoken ?? "", /年前后/); }); 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"); // 原值: choice_frame === null(纯口述采集题) // 新值: 定向存在性题的 A/B/C/D frame // 原因: D5/T4b——七条线统一走「先问有没有」的点选题;题名要的 // 「用服务器的采集题干、不改写成反向核对」由下面两条仍然保证(BUG-751) assert.equal(plan.next_followup?.choice_frame?.choice_kind, "existence"); // 原值: USER_COLLECT_QUESTION.education(「上学这边,还记得哪年…」) // 新值: TARGETED_EXISTENCE_PROMPT.education(「学业上有没有过…哪一年都算?」) // 原因: T4b——存在性题问整个领域、任何时间;仍是服务器题干,不是反向核对 assert.equal(spokenFollowupForUser(plan.next_followup), TARGETED_EXISTENCE_PROMPT.education); assert.doesNotMatch(String(spokenFollowupForUser(plan.next_followup)), /对不对|是不是这样/); 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 pool is invite then user-year anchors, not a domain wheel", () => { const declined = new Set(["family"]); const invite = nextDatedCollectFollowup(TWO_SCOREABLE, declined); // 原值: education → finance → occupation // 新值: 邀请「还有吗」;拒答后再用用户年份锚定 // 原因: 撤回按领域轮转盘问(BUG-648) assert.equal(invite?.domain, "other"); assert.equal(invite?.collection_key, "collect:invite:more"); const inviteClosed = collectPlan(TWO_SCOREABLE, { declinedTopics: [ ...FAMILY_DECLINED, { target_domain: "other", status: "declined", intent: "collect_method_evidence", questionId: "collect:invite:more", target_kind: "invite_more", }, ], }); assert.notEqual(inviteClosed.next_followup?.collection_key, "collect:invite:more"); assert.doesNotMatch(spokenFollowupForUser(inviteClosed.next_followup) ?? "", /年前后/); assert.match( spokenFollowupForUser(inviteClosed.next_followup) ?? "", /2011|2014|之后|分开了|结婚了/, ); const remainingDeclined = [ "family", "education", "finance", "relocation", "health_pressure", ].map((domain) => ({ target_domain: domain, status: "declined", intent: "collect_method_evidence", })); const stillInvite = collectPlan(TWO_SCOREABLE, { declinedTopics: remainingDeclined }); assert.equal(stillInvite.next_followup?.collection_key, "collect:invite:more"); assert.notEqual(stillInvite.next_followup?.domain, "occupation"); assert.equal( exhaustionSpokenCollectFollowup({ evidence: TWO_SCOREABLE, declinedTopics: remainingDeclined, })?.domain, "other", ); assert.notEqual( exhaustionSpokenCollectFollowup({ evidence: TWO_SCOREABLE, declinedTopics: remainingDeclined, })?.domain, "occupation", ); }); test("persistNextInterviewAfterChoice after family denial asks the invite, 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("export async function requestTieBreakPersonality"), ); 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, // 新增字段:D1 现在随每个出口一起上报(见 publicNextAction) precision_gate_met: null, }, }); // 原值: education 通用采集 // 新值: collect:invite:more // 原因: 拒答家人不得跳过邀请(BUG-648) assert.equal(persisted.persisted, true); assert.equal(persisted.focus?.targetDomain, "other"); assert.match(persisted.hostNarration ?? "", /^还有吗?/); const setFocus = accounting.calls.find((item) => item.fn === "set_agentic_rectification_conversation_focus"); assert.equal(setFocus?.args.p_target_domain, "other"); assert.equal(setFocus?.args.p_question_id, "collect:invite:more"); // 原值: invite_more // 新值: null,collect_kind=invite_more // 原因: target_kind CHECK 不允许 invite_more,原 hint 只进 schema(BUG-656) assert.equal(setFocus?.args.p_target_kind, null); assert.equal( (setFocus?.args.p_expected_answer_schema as { collect_kind?: string } | undefined)?.collect_kind, "invite_more", ); assert.match( String((setFocus?.args.p_expected_answer_schema as { prompt?: string } | undefined)?.prompt ?? ""), /^还有吗?/, ); }); test("four scoreable events stop dated collect and leave remaining domains to S2", () => { const plan = collectPlan(FOUR_SCOREABLE, { holdoutValidation: "passed", oosBlindPrompts: [], candidatesSeparated: true, }); // 原值: next_followup.intent != collect_method_evidence // 新值: 仍是 collect_method_evidence,但走的是定向七条线(不是旧的领域轮盘) // 原因: D5——七条线按 COLLECT_KIND_ORDER 全部轮到;D6——线问完才交付(BUG-751) assert.equal(plan.next_followup?.intent, "collect_method_evidence"); assert.match(String(plan.next_followup?.collection_key ?? ""), /^collect:(targeted|guided):/); }); test("set-focus with a domainless collect prompt does not invent an education stem", 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.ok( (first as { reason?: string }).reason === "domain_missing" || (first as { reason?: string }).reason === "domain_mismatch", ); const second = await (tools["rectification-set-focus"] as unknown as ExecutableTool>).execute(generic); // 原值: 第二次回退到 USER_COLLECT_QUESTION.education // 新值: 仍判定题干对不上,不发明学业采集 // 原因: 收集池当前首条是邀请,不得用领域轮转兜底(BUG-648) assert.equal((second as { error?: string }).error, "invalid_spoken_prompt"); }); 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/lib/rectification-agentic/v9/case-dossier-response.ts", import.meta.url), "utf8", ); // 原值: 断言读 cases/[caseId]/route.ts // 新值: 断言读 case-dossier-response.ts // 原因: Next App Router 不允许 route.ts 导出非 HTTP handler。 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 method without skipping the invite", () => { const unanswered = buildMethodFollowupPlan({ evidence: RELATIONSHIP_AND_FAMILY, eventProbes: [CAREER_EXISTENCE_PROBE], askedProbeKeys: ["career.2011", "probe:career.2011"], holdoutValidation: "not_started", }); // 原值: 训练门关仍问 career / education 通用采集(带「想到别的也可以一起说」) // 新值: 训练门关时下一问是邀请;选择题 yes 仍可盖住 d10_career // 原因: 收集池在训练门开之前优先邀请(BUG-648) assert.equal(unanswered.next_followup?.collection_key, "collect:invite:more"); assert.equal(unanswered.next_followup?.intent, "collect_method_evidence"); assert.match(spokenFollowupForUser(unanswered.next_followup) ?? "", /^还有吗?/); const yes = careerAnswerPlan("yes"); assert.notEqual(yes.next_followup?.domain, "career"); assert.equal(yes.next_followup?.collection_key, "collect:invite:more"); assert.equal(yes.next_followup?.intent, "collect_method_evidence"); 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?.collection_key, "collect:invite:more"); const no = careerAnswerPlan("no"); assert.equal(no.next_followup?.collection_key, "collect:invite:more"); assert.match(spokenFollowupForUser(no.next_followup) ?? "", /^还有吗?/); const unsure = careerAnswerPlan("unsure"); assert.equal(unsure.next_followup?.collection_key, "collect:invite:more"); }); test("career discriminator yes does not invent coverage from a semantic_key prefix", () => { const plan = careerAnswerPlan("yes", { eventProbes: [] }); assert.equal(plan.next_followup?.collection_key, "collect:invite:more"); assert.match(spokenFollowupForUser(plan.next_followup) ?? "", /^还有吗?/); }); 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?.collection_key, "collect:invite:more"); assert.match(spokenFollowupForUser(plan.next_followup) ?? "", /^还有吗?/); }); 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 stop leftover dated collect once the training gate is open", async () => { const raw = collectVsOfferDossier({ declined: ["family"] }); const dossier = parseV9CaseDossier(raw); assert.ok(dossier); const decision = decideFromDossier(dossier); // 原值: sessionOutcome != collect_evidence、nextAction != ask_fact_collection // 新值: collect_evidence / ask_fact_collection,且下一问是定向线 // 原因: D5 + D6——只拒答了家人,其余六条线仍要问(BUG-751) assert.equal(decision.sessionOutcome, "collect_evidence"); assert.equal(decision.nextAction, "ask_fact_collection"); const { plan, userAction } = nextActionFromDecision(dossier, decision); assert.equal(plan.next_followup?.intent, "collect_method_evidence"); assert.match(String(plan.next_followup?.collection_key ?? ""), /^collect:(targeted|guided):/); assert.equal(userAction.id, "ask_method_followup"); }); 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 is allowed once remaining dated collect is closed by the training gate", 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, }); // 原值: offer 允许(BUG-648 训练门开即可) // 新值: offer_not_allowed // 原因: D6——定向线没问完,offer 仍被挡;题名的「closed by the training // gate」在 D5 之后不再成立(BUG-751) await assert.rejects( () => (tools["rectification-offer-candidates"] as unknown as { execute(input: unknown): Promise; }).execute({ caseId: CASE_ID }), /offer_not_allowed/, ); }); 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 does not fall back to GENERIC collect", () => { 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; // 原值: relocation 或 occupation 采集 // 新值: 训练门开后不再轮转采集;holdout 或精度层可以出题,但不能是 GENERIC // 原因: BUG-648 assert.notEqual(spokenFollowupForUser(next, SEVEN_EVENTS), GENERIC_COLLECT_QUESTION); assert.notEqual(next?.collection_key, "collect:invite:more"); }); 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, "health_pressure"); assert.notEqual(plan.next_followup?.domain, "relocation"); }); test("skipped ledger health blocks health_pressure holdout", () => { 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-reloc", status: "confirmed", domain: "relocation", datePrecision: "month", occurredFrom: "2024-08-01", occurredTo: null, eventKind: "relocation" }, { 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(["family", "health"])), null); });