import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import test from "node:test"; import { OPEN_ENGINE_CAPABILITY_CEILING } from "./rectification-v9-test-support.ts"; import { decideRectification } from "../src/lib/rectification-agentic/core/rectification-decision.ts"; import { trainingScoreableGate } from "../src/lib/rectification-agentic/v9/evidence-model.ts"; import { RECTIFICATION_SKILL_VERSION } from "../src/lib/rectification-agentic/v9/case-status.ts"; import { buildMethodFollowupPlan, holdoutFollowupFor } from "../src/lib/rectification-agentic/v9/method-followup.ts"; import { canShowRectificationSelectionCards, parseRectificationCandidateResult, workingRectificationHouseTable, } from "../src/lib/rectification-candidate-result.ts"; import { decideFromDossier } from "../src/lib/rectification-agentic/v9/decision-from-dossier.ts"; import { persistNextInterviewIfIdle } from "../src/lib/rectification-agentic/v9/answer-choice.ts"; import { evidenceLedgerFingerprint, parseV9CaseDossier } from "../src/lib/rectification-agentic/v9/tool-service.ts"; import { latestResultToolProjection } from "../src/mastra/rectification-v9-tools.ts"; import { isRenderableChoiceOpenQuestion } from "../src/lib/rectification-agentic/v9/server-focus.ts"; import { projectCurrentQuestion } from "../src/lib/rectification-agentic/v9/turn-decision.ts"; import { interviewQuestionBlocksAdoptOffer } from "../src/lib/rectification-agentic/v9/turn-question.ts"; import { candidateSetId } from "../src/lib/rectification-agentic/core/build-state.ts"; import { asInferenceState } from "../src/lib/rectification-agentic/core/compose-receipt.ts"; import { INFERENCE_ALGORITHM_VERSION } from "../src/lib/rectification-agentic/core/types.ts"; import { CASE_ID, USER_ID, candidateSnapshotFixture, computeFixture, dossierFixture, fakeAccounting, receiptHandlers, } from "./rectification-v9-test-support.ts"; const STYLE_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 TIED = [ { time: "04:47", score: 12 }, { time: "04:51", score: 12 }, { time: "04:53", score: 11 }, { time: "04:59", score: 11 }, { time: "05:00", score: 11 }, { time: "05:07", score: 11 }, { time: "05:12", score: 11 }, { time: "05:14", score: 8 }, { time: "05:15", score: 7 }, ]; const NARROW_LEAD = [ { time: "04:47", score: 34 }, { time: "04:51", score: 33 }, { time: "04:53", score: 33 }, ]; function dated( id: string, domain: string, eventKind: string, occurredFrom: string, extra: { occurredTo?: string | null; datePrecision?: "year" | "month" | "day" | "range" | "unknown"; status?: "confirmed" | "draft"; } = {}, ) { return { id, status: extra.status ?? ("confirmed" as const), domain, datePrecision: extra.datePrecision ?? ("month" as const), occurredFrom, occurredTo: extra.occurredTo ?? null, eventKind, }; } /** 19-row ledger from the stalled case: 15 scoreable, 4 domains; occupation never recorded. */ const CASE_EVIDENCE = [ dated("e-edu-start", "education", "education_start", "2016-09-01"), dated("e-edu-complete", "education", "education_completion", "2020-06-01"), dated("e-edu-interrupt", "education", "education_interruption", "2021-01-01", { occurredTo: "2023-07-01", datePrecision: "range", }), dated("e-edu-change", "education", "education_change", "2023-01-01", { datePrecision: "year" }), dated("e-career-entry-2020", "career", "career_entry", "2020-04-01"), dated("e-career-exit-2020", "career", "career_exit", "2020-10-01"), dated("e-career-entry-2024", "career", "career_entry", "2024-04-07", { datePrecision: "day" }), dated("e-career-exit-2026", "career", "career_exit", "2026-08-11", { datePrecision: "day" }), dated("e-career-change-2022", "career", "career_change", "2022-12-01"), dated("e-career-change-2024", "career", "career_change", "2024-01-01", { datePrecision: "year" }), dated("e-career-range", "career", "career_change", "2020-04-01", { occurredTo: "2020-10-01", datePrecision: "range", }), dated("e-career-draft-a", "career", "career_change", "", { datePrecision: "unknown", status: "draft", occurredTo: null, }), dated("e-career-draft-b", "career", "career_entry", "", { datePrecision: "unknown", status: "draft", occurredTo: null, }), dated("e-rel-2024-05", "relationship", "relationship_change", "2024-05-01"), dated("e-rel-start", "relationship", "relationship_start", "2024-06-01"), dated("e-rel-end", "relationship", "relationship_end", "2024-08-08", { datePrecision: "day" }), dated("e-reloc", "relocation", "home_change", "2022-10-01"), dated("e-family", "family", "family_event", "2016-05-01"), ].map((item) => ({ ...item, occurredFrom: item.occurredFrom || null, })); const OCCUPATION_FOCUS = { intent: "collect_method_evidence" as const, targetDomain: "occupation", targetKind: "occupation_note", questionId: "collect:occupation:collect_method_evidence", }; function yearlessProbe(layer: string, gain: number, domain: string) { return { probeId: `contrast:varga.${layer}.04:47/05:00`, candidateSetVersion: "04:47-05:15", question: `当前几个候选在 ${layer} 上还分得开。`, expectedOutcomes: [ { outcomeId: "yes" as const, supportsCandidateIds: ["04:47"], conflictsCandidateIds: ["05:00"] }, { outcomeId: "no" as const, supportsCandidateIds: ["05:00"], conflictsCandidateIds: ["04:47"] }, ], candidateSplitHash: `varga.${layer}.04:47/05:00`, informationGain: gain, sourceFeatures: [{ technique: layer.toUpperCase(), calculationResultId: null }], domain, year: null as number | null, semanticKey: `varga.${layer}.04:47/05:00`, choiceKind: "existence" as const, styleOptions: STYLE_OPTIONS.map((item) => ({ label: item.label, answerClass: item.answer_class, })), }; } const YEARLESS_PACKET = { candidateSetVersion: "04:47-05:15", vargaDifferences: [] as const, probes: [ yearlessProbe("d24", 2.5, "education"), yearlessProbe("d7", 1.22, "family"), yearlessProbe("d4", 0.99, "relocation"), yearlessProbe("d5", 0.5, "education"), ], }; function occupationPlan( extra: Partial[0]> = {}, ) { return buildMethodFollowupPlan({ evidence: CASE_EVIDENCE, contrastPacket: YEARLESS_PACKET, candidatesSeparated: false, ...extra, }); } function twelveHouses(sign: string, occupant?: string) { return Array.from({ length: 12 }, (_, index) => ({ house: index + 1, sign, occupants: index === 0 && occupant ? [occupant] : [], })); } const CANDIDATE_IDS = [ "88888888-8888-4888-8888-888888888881", "88888888-8888-4888-8888-888888888882", ] as const; test("skill version stays 10.0.15 after the range-delivery bump", () => { assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.15"); }); test("nineteen-row ledger opens the training gate with four scoreable domains", () => { const gate = trainingScoreableGate(CASE_EVIDENCE); assert.equal(gate.open, true); assert.ok(gate.trainingCount >= 3); assert.ok(gate.trainingDomainCount >= 2); assert.ok(CASE_EVIDENCE.length >= 15); }); test("career answers under an occupation collect focus do not cover occupation before ledger norm", () => { const plan = occupationPlan(); assert.equal(plan.methods.find((item) => item.method_id === "occupation")?.status, "uncovered"); assert.equal( CASE_EVIDENCE.some((item) => item.domain === "occupation" && item.eventKind === "occupation_note"), false, ); }); test("answering occupation collect remaps a career-domain job description onto occupation_note", async () => { const { applyOccupationCollectLedgerNorm } = await import( "../src/lib/rectification-agentic/v9/evidence-model.ts" ); const remapped = applyOccupationCollectLedgerNorm(OCCUPATION_FOCUS, [{ domain: "career", eventKind: "career_entry" as const, datePrecision: "unknown" as const, occurredFrom: null, occurredTo: null, summary: "长期做有机合成", quote: "有机合成", subject: "self" as const, }]); assert.equal(remapped[0]?.domain, "occupation"); assert.equal(remapped[0]?.eventKind, "occupation_note"); const plan = occupationPlan({ evidence: [ ...CASE_EVIDENCE, { status: "confirmed" as const, domain: remapped[0]!.domain, datePrecision: remapped[0]!.datePrecision, occurredFrom: remapped[0]!.occurredFrom, occurredTo: remapped[0]!.occurredTo, eventKind: remapped[0]!.eventKind, summary: remapped[0]!.summary, }, ], contrastPacket: { candidateSetVersion: "04:47-05:15", vargaDifferences: [], probes: [] }, }); assert.equal(plan.methods.find((item) => item.method_id === "occupation")?.status, "covered"); assert.notEqual(plan.next_followup?.method_id, "occupation"); assert.notEqual(plan.next_followup?.ask_theme, "occupation"); }); test("occupation coverage fallback uses a closed occupation collect focus plus career evidence", () => { const uncovered = occupationPlan({ contrastPacket: { candidateSetVersion: "04:47-05:15", vargaDifferences: [], probes: [] }, }); assert.equal(uncovered.methods.find((item) => item.method_id === "occupation")?.status, "uncovered"); const covered = occupationPlan({ contrastPacket: { candidateSetVersion: "04:47-05:15", vargaDifferences: [], probes: [] }, closedCollectFocuses: [{ questionId: OCCUPATION_FOCUS.questionId, targetDomain: "occupation", intent: "collect_method_evidence", status: "resolved", }], }); assert.equal(covered.methods.find((item) => item.method_id === "occupation")?.status, "covered"); assert.notEqual(covered.next_followup?.method_id, "occupation"); }); test("career evidence alone still does not cover occupation", () => { const plan = occupationPlan({ contrastPacket: { candidateSetVersion: "04:47-05:15", vargaDifferences: [], probes: [] }, }); assert.equal(plan.methods.find((item) => item.method_id === "occupation")?.status, "uncovered"); // 原值: occupation // 新值: d2_finance // 原因: 家人已覆盖且无区分卡时,BUG-546 继续收未用带年份域;职业仍未覆盖,只是还没轮到 assert.equal(plan.next_followup?.method_id, "d2_finance"); }); test("training gate open does not mint yearless existence or quality varga cards", () => { const plan = occupationPlan(); assert.equal(plan.methods.find((item) => item.method_id === "occupation")?.status, "uncovered"); assert.doesNotMatch(plan.next_followup?.semantic_key ?? "", /^varga\.d(24|7|4|5)\./); assert.equal(plan.dropped_probes.some((item) => item.reason === "yearless_ungrounded_contrast"), true); // 原值: occupation // 新值: d2_finance // 原因: yearless 存在/质量卡被丢掉后没有可渲染区分卡,BUG-546 先收财务采集 assert.equal(plan.next_followup?.method_id, "d2_finance"); assert.equal(plan.next_followup?.intent, "collect_method_evidence"); assert.equal(plan.next_followup?.choice_frame, null); }); test("training gate open still asks a signed yearless D10 style card", () => { const d10 = { probeId: "contrast:varga.d10.巨蟹座/狮子座", candidateSetVersion: "04:47-05:15", question: "平时做事,你更接近下面哪一种?", expectedOutcomes: [ { outcomeId: "yes" as const, supportsCandidateIds: ["04:47"], conflictsCandidateIds: ["05:00"] }, { outcomeId: "weak_yes" as const, supportsCandidateIds: ["05:00"], conflictsCandidateIds: ["04:47"] }, ], candidateSplitHash: "varga.d10.巨蟹座/狮子座", informationGain: 1.4, sourceFeatures: [{ technique: "D10", calculationResultId: null }], domain: "career", year: null as number | null, semanticKey: "varga.d10.巨蟹座/狮子座", choiceKind: "varga_style" as const, styleOptions: [ { label: "做事以照顾人为主,在意团队里的感受", answerClass: "yes" as const, sign: "巨蟹座" }, { label: "习惯带头,也不排斥站到台前", answerClass: "weak_yes" as const, sign: "狮子座" }, ], }; const plan = occupationPlan({ contrastPacket: { ...YEARLESS_PACKET, probes: [...YEARLESS_PACKET.probes, d10], }, }); assert.equal(plan.next_followup?.intent, "distinguish_candidates"); assert.ok(plan.next_followup?.choice_frame); assert.equal(plan.next_followup?.semantic_key, d10.semanticKey); assert.equal(plan.next_followup?.choice_kind, "varga_style"); }); test("training gate closed still withholds yearless varga cards", () => { const short = [ dated("e-edu", "education", "education_start", "2016-09-01"), dated("e-career", "career", "career_entry", "2020-04-01"), ]; assert.equal(trainingScoreableGate(short).open, false); const plan = buildMethodFollowupPlan({ evidence: short, contrastPacket: YEARLESS_PACKET, candidatesSeparated: false, }); assert.doesNotMatch(plan.next_followup?.semantic_key ?? "", /^varga\.d(24|7|4|5)\./); assert.notEqual(plan.next_followup?.intent, "distinguish_candidates"); }); test("training complete with no renderable distinguish card still allows adopt when the engine does", () => { const blocked = decideRectification({ engineCeiling: OPEN_ENGINE_CAPABILITY_CEILING, methodCoverageAll: false, trainingGateOpen: true, candidateScores: NARROW_LEAD, discriminatorProbe: null, }); // 原断言 canAdopt=false、sessionOutcome=provisional_range // → 新断言 canAdopt=true、sessionOutcome=adopt_representative。 // coverage(含 occupation)只做问询路由,不再挡采用;确认门仍 fail-closed。 assert.equal(blocked.canOfferRange, true); assert.equal(blocked.canAdopt, true); assert.equal(blocked.canConfirmExactMinute, false); assert.equal(blocked.nextAction, "offer_provisional_range"); assert.equal(blocked.sessionOutcome, "adopt_representative"); const stillClosed = decideRectification({ engineCeiling: OPEN_ENGINE_CAPABILITY_CEILING, methodCoverageAll: false, trainingGateOpen: false, candidateScores: NARROW_LEAD, discriminatorProbe: null, }); assert.equal(stillClosed.canOfferRange, false); assert.equal(stillClosed.canAdopt, false); assert.equal(stillClosed.nextAction, "ask_fact_collection"); }); test("decideFromDossier offers a range with adopt when training is complete and occupation is still uncovered", () => { const decision = decideFromDossier({ evidence: CASE_EVIDENCE, conversationSummary: { activeFocus: null, declinedSkippedTopics: [] }, latestResult: { resultId: "55555555-5555-4555-8555-555555555555", selectionAllowed: true, confirmationAllowed: false, candidates: NARROW_LEAD.map((item, index) => ({ candidateId: `88888888-8888-4888-8888-88888888888${index}`, time: item.time, rank: index + 1, relativeSupport: item.score, })), representativeTime: "04:47", evidenceLedgerFingerprint: evidenceLedgerFingerprint(CASE_EVIDENCE as never), decisionReceipt: { accept_allowed: true, acceptance_allowed: true, propose_allowed: true, selection_allowed: true, confirmation_allowed: false, }, }, case: { acceptedTime: null }, }); // 旧:训练门开、职业未覆盖即可出牌。新:财务/健康/职业口述未问完保持采集。 assert.equal(decision.canOfferRange, false); assert.equal(decision.sessionOutcome, "collect_evidence"); assert.equal(decision.nextAction, "ask_fact_collection"); assert.equal(decision.canConfirmExactMinute, false); }); test("agent route narrates a numeric range exit and persists a collect when no renderable distinguish card remains", () => { const route = readFileSync(new URL("../src/app/api/rectification/agent/route.ts", import.meta.url), "utf8"); assert.match(route, /nonConvergingRangeNarration/); assert.doesNotMatch(route, /可以先按当前区间看盘,也可以再补一件记得时间的经历/); const fastPath = route.slice( route.indexOf('if (action === "message")'), route.indexOf("const requestTime"), ); assert.match(fastPath, /persistNextInterviewIfIdle|isNonConvergingRangeOffer|canOfferRange/); assert.doesNotMatch(fastPath, /USER_STOP_PATTERN|parseChoiceKeyFromUserMessage/); }); test("public house_table and natal_recast follow the representative minute", () => { const matched = parseRectificationCandidateResult({ resultId: "11111111-1111-4111-8111-111111111111", candidates: [ { candidateId: CANDIDATE_IDS[0], rank: 1, time: "04:47", relativeSupport: 12, tiedMinuteCount: 1 }, { candidateId: CANDIDATE_IDS[1], rank: 2, time: "05:00", relativeSupport: 11, tiedMinuteCount: 1 }, ], overallConfidence: "low", selectionAllowed: true, confirmationAllowed: false, representativeTime: "04:47", selectedTime: null, decisionReceipt: { house_table: { time: "05:00", lagna: "双子座", houses: twelveHouses("双子座", "水星") }, house_tables_by_time: { "04:47": { time: "04:47", lagna: "金牛座", houses: twelveHouses("金牛座", "太阳") }, "05:00": { time: "05:00", lagna: "双子座", houses: twelveHouses("双子座", "水星") }, }, natal_recast: { time: "05:00", lagna: "双子座", user_meaning: "本命宫位已按 05:00 重算(上升 双子座)。下面是本轮实际执行的技法,不能当作唯一分钟确认。", unique_minute_claim: false, confirmation_allowed: false, }, }, }); assert.equal(matched?.representativeTime, "04:47"); assert.equal(matched?.houseTable?.time, "04:47"); assert.equal(matched?.houseTable?.lagna, "金牛座"); assert.equal(matched?.natalRecast?.time, "04:47"); assert.equal(workingRectificationHouseTable(matched!)?.time, "04:47"); }); test("a mismatched receipt house_table is not emitted with another representative minute", () => { const mismatched = parseRectificationCandidateResult({ resultId: "11111111-1111-4111-8111-111111111111", candidates: [ { candidateId: CANDIDATE_IDS[0], rank: 1, time: "04:47", relativeSupport: 12, tiedMinuteCount: 1 }, { candidateId: CANDIDATE_IDS[1], rank: 2, time: "05:00", relativeSupport: 11, tiedMinuteCount: 1 }, ], overallConfidence: "low", selectionAllowed: true, confirmationAllowed: false, representativeTime: "04:47", selectedTime: null, decisionReceipt: { house_table: { time: "05:00", lagna: "双子座", houses: twelveHouses("双子座", "水星") }, natal_recast: { time: "05:00", lagna: "双子座", user_meaning: "本命宫位已按 05:00 重算(上升 双子座)。下面是本轮实际执行的技法,不能当作唯一分钟确认。", unique_minute_claim: false, confirmation_allowed: false, }, }, }); assert.equal(mismatched?.representativeTime, "04:47"); assert.notEqual(mismatched?.houseTable?.time, "05:00"); assert.equal(mismatched?.houseTable, null); assert.notEqual(mismatched?.natalRecast?.time, "05:00"); }); test("tool projection does not pair a 05:00 house table with a 04:47 representative time", () => { const decision = decideRectification({ engineCeiling: OPEN_ENGINE_CAPABILITY_CEILING, methodCoverageAll: true, trainingGateOpen: true, candidateScores: TIED, discriminatorProbe: null, }); const projection = latestResultToolProjection({ resultId: "55555555-5555-4555-8555-555555555555", candidates: TIED.map((item, index) => ({ candidateId: `88888888-8888-4888-8888-88888888888${index}`, time: item.time, rank: index + 1, relativeSupport: item.score, tiedMinuteCount: 1, })), selectionAllowed: true, confirmationAllowed: false, representativeTime: "04:47", selectedTime: null, selectionKind: null, algorithmVersion: "test", decisionReceipt: { house_table: { time: "05:00", lagna: "双子座", houses: twelveHouses("双子座", "水星") }, natal_recast: { time: "05:00", lagna: "双子座", user_meaning: "本命宫位已按 05:00 重算(上升 双子座)。", unique_minute_claim: false, confirmation_allowed: false, }, }, }, decision); assert.notEqual(projection.house_table && (projection.house_table as { time?: string }).time, "05:00"); if (projection.representative_time === "04:47" && projection.house_table) { assert.equal((projection.house_table as { time?: string }).time, "04:47"); } }); test("collect focus is still not a renderable choice card", () => { assert.equal(isRenderableChoiceOpenQuestion({ question_id: OCCUPATION_FOCUS.questionId, prompt: "你平时主要做什么工作?", status: "already_open", kind: "collect_spoken", intent: "collect_method_evidence", domain: "occupation", focus_id: "11111111-1111-4111-8111-111111111111", probe_id: null, }), false); }); const ACCIDENT_EVIDENCE = [ dated("e-edu-1", "education", "education_start", "2016-09-01"), dated("e-edu-2", "education", "education_completion", "2020-06-01"), dated("e-rel-1", "relationship", "relationship_start", "2018-05-01"), dated("e-rel-2", "relationship", "relationship_end", "2021-08-01"), dated("e-fin-1", "finance", "finance_change", "2024-03-01"), dated("e-career-1", "career", "career_entry", "2020-04-01"), dated("e-career-2", "career", "career_change", "2023-07-01"), dated("e-reloc-1", "relocation", "home_change", "2022-10-01"), dated("e-health-1", "health", "self_health_event", "2021-11-01"), { id: "e-occ-1", status: "confirmed" as const, domain: "occupation", datePrecision: "unknown" as const, occurredFrom: null, occurredTo: null, eventKind: "occupation_note", }, ]; const FAMILY_DECLINED_TOPIC = [{ target_domain: "family", status: "declined", questionId: "collect:family:collect_method_evidence", intent: "collect_method_evidence", }]; const CLOSED_FINANCE_FOCUS = [{ questionId: "collect:finance:collect_method_evidence", targetDomain: "finance", intent: "collect_method_evidence", status: "resolved", }]; const ACCIDENT_YEARLESS = { candidateSetVersion: "04:51-04:53", vargaDifferences: [] as const, probes: [ yearlessProbe("d11", 2.5, "finance"), yearlessProbe("d2", 2.2, "finance"), yearlessProbe("d24", 1.2, "education"), yearlessProbe("d5", 0.5, "education"), yearlessProbe("d4", 0.99, "relocation"), yearlessProbe("d7", 1.22, "family"), yearlessProbe("d12", 1.1, "family"), ], }; const ANSWERED_SIX = [ { semantic_key: "varga.d9.天秤座|天蝎座", answer_class: "yes" }, { semantic_key: "varga.d10.巨蟹座|狮子座", answer_class: "yes" }, { semantic_key: "career.2020", answer_class: "yes" }, { semantic_key: "career.2023", answer_class: "no" }, { semantic_key: "relocation.2022", answer_class: "no" }, { semantic_key: "finance.2024", answer_class: "no" }, ]; function coveredDomainPlan( extra: Partial[0]> = {}, ) { return buildMethodFollowupPlan({ evidence: ACCIDENT_EVIDENCE, contrastPacket: ACCIDENT_YEARLESS, declinedTopics: FAMILY_DECLINED_TOPIC, closedCollectFocuses: CLOSED_FINANCE_FOCUS, answeredProbes: ANSWERED_SIX, sessionOutcome: "adopt_representative", ...extra, }); } test("yearless-to-collect does not re-ask a covered domain after occupation is done", () => { const plan = coveredDomainPlan(); // 原值: next_followup.domain = finance(L2224 取 yearless[0]=D11) // 新值: null // 原因: 财务已有带年月事件且采集焦点已关闭;守卫跳过已覆盖领域后交付 assert.equal(plan.next_followup, null); }); test("yearless-to-collect still asks an uncovered relocation domain", () => { const plan = coveredDomainPlan({ evidence: ACCIDENT_EVIDENCE.filter((item) => item.domain !== "relocation"), closedCollectFocuses: CLOSED_FINANCE_FOCUS, }); // 原值: finance(D11 仍排第一) // 新值: relocation // 原因: 守卫只跳过已覆盖领域,不是删 yearless→采集分支 assert.equal(plan.next_followup?.domain, "relocation"); assert.equal(plan.next_followup?.intent, "collect_method_evidence"); assert.equal(plan.next_followup?.choice_frame, null); }); function rpcAccidentEvidence(rows: readonly typeof ACCIDENT_EVIDENCE[number][]) { return rows.map((item, index) => ({ id: item.id ?? `e-${index}`, source_turn_id: "33333333-3333-4333-8333-333333333333", subject: "self", event_kind: item.eventKind ?? item.domain, domain: item.domain, occurred_from: item.occurredFrom, occurred_to: item.occurredTo, date_precision: item.datePrecision, summary: "", status: item.status, supersedes_evidence_id: null, created_at: "2026-09-08T00:00:00.000Z", })); } function accidentEvidenceFingerprint() { return evidenceLedgerFingerprint(rpcAccidentEvidence(ACCIDENT_EVIDENCE).map((item) => ({ id: item.id, eventKind: item.event_kind, domain: item.domain, occurredFrom: item.occurred_from, occurredTo: item.occurred_to, datePrecision: item.date_precision, summary: item.summary, status: item.status, })) as never); } function yearlessInferenceProbe(layer: string, domain: string, gain: number) { return { id: `contrast:varga.${layer}.unsigned`, semantic_key: `varga.${layer}.unsigned`, candidate_split_hash: `varga.${layer}.04:51/04:53`, domain, year: 0, question: `当前几个候选在 ${layer} 上还分得开。`, candidate_ids: ["04:51", "04:53"], expected_outcomes: [ { answer_class: "yes" as const, supports: ["04:51"], conflicts: ["04:53"] }, { answer_class: "no" as const, supports: ["04:53"], conflicts: ["04:51"] }, ], information_gain: gain, source: "varga_contrast", choice_kind: "existence" as const, style_options: STYLE_OPTIONS, }; } function accidentIdleState() { const times = ["04:51", "04:52", "04:53"] as const; const raw = { algorithm_version: INFERENCE_ALGORITHM_VERSION, candidate_set_id: candidateSetId("04:51", "04:53", times), revision: 6, phase: "discrimination" as const, result_status: "discriminating" as const, range_start: "04:51", range_end: "04:53", candidates: times.map((time, index) => ({ id: time, time, cluster_range: [time, time] as const, prior_score: 30 - index, posterior_score: 30 - index, probability: index === 0 ? 0.5 : 0.25, status: "active" as const, rank: index + 1, strong_conflict_count: 0, })), events: [ { id: "e-edu-1", domain: "education", year: 2016, precision: "month" as const, usage: "training" as const }, { id: "e-edu-2", domain: "education", year: 2020, precision: "month" as const, usage: "training" as const }, { id: "e-rel-1", domain: "relationship", year: 2018, precision: "month" as const, usage: "training" as const }, { id: "e-rel-2", domain: "relationship", year: 2021, precision: "month" as const, usage: "holdout" as const }, { id: "e-fin-1", domain: "finance", year: 2024, precision: "month" as const, usage: "holdout" as const }, { id: "e-career-1", domain: "career", year: 2020, precision: "month" as const, usage: "training" as const }, { id: "e-career-2", domain: "career", year: 2023, precision: "month" as const, usage: "training" as const }, { id: "e-reloc-1", domain: "relocation", year: 2022, precision: "month" as const, usage: "training" as const }, { id: "e-health-1", domain: "health", year: 2021, precision: "month" as const, usage: "training" as const }, ], probes: [ yearlessInferenceProbe("d11", "finance", 2.5), yearlessInferenceProbe("d24", "education", 1.2), yearlessInferenceProbe("d4", "relocation", 0.99), yearlessInferenceProbe("d7", "family", 1.22), ], answered_probes: ANSWERED_SIX.map((item) => ({ probe_id: `probe:${item.semantic_key}`, semantic_key: item.semantic_key, candidate_split_hash: item.semantic_key, answer_class: item.answer_class as "yes" | "no", classified_from: "choice" as const, })), rounds: ANSWERED_SIX.map((item, index) => ({ round: index + 1, phase: "discrimination" as const, probe_id: `probe:${item.semantic_key}`, scores_before: { "04:51": 30, "04:52": 29, "04:53": 28 }, scores_after: { "04:51": 32, "04:52": 29, "04:53": 28 }, entropy_before: 1.1, entropy_after: 1.0, eliminated_ids: [] as string[], winner_id: null, kind: "informative" as const, })), last_inference_round: null, entropy: 1.0, representative_time: "04:51", credible_range: ["04:51", "04:53"] as const, holdout_passed: null, }; const loaded = asInferenceState(raw); assert.ok(loaded, "covered-domain accident inference must parse"); return loaded; } function accidentIdleDossier() { const fingerprint = accidentEvidenceFingerprint(); const state = accidentIdleState(); return dossierFixture({ evidence: rpcAccidentEvidence(ACCIDENT_EVIDENCE), evidenceCount: ACCIDENT_EVIDENCE.length, latestResult: candidateSnapshotFixture({ selectionAllowed: true, representativeTime: "04:51", evidenceLedgerFingerprint: fingerprint, candidates: [ { candidate_id: "88888888-8888-4888-8888-888888888881", rank: 1, time: "04:51", relative_support: 40, tied_minute_count: 1 }, { candidate_id: "88888888-8888-4888-8888-888888888882", rank: 2, time: "04:52", relative_support: 35, tied_minute_count: 1 }, { candidate_id: "88888888-8888-4888-8888-888888888883", rank: 3, time: "04:53", relative_support: 25, tied_minute_count: 1 }, ], decisionReceipt: { accept_allowed: true, acceptance_allowed: true, propose_allowed: true, selection_allowed: true, confirmation_allowed: false, inference_state: state, }, }), conversationSummary: { confirmed_evidence_summary: [], pending_revisions: [], active_focus: null, declined_skipped_topics: FAMILY_DECLINED_TOPIC, candidate_divergence_summary: null, missing_evidence_categories: [], last_result_policy: null, summary_version: 1, updated_at: "2026-09-08T00:00:00.000Z", }, }); } test("idle persist on the covered-domain accident delivers adopt and writes no focus", async () => { const rpc = accidentIdleDossier(); const dossier = parseV9CaseDossier(rpc); assert.ok(dossier, "covered-domain accident RPC dossier must parse"); const decision = decideFromDossier(dossier, { birthDate: "1997-08-08" }); assert.equal(decision.sessionOutcome, "adopt_representative"); assert.equal(decision.canAdopt, true); assert.equal(decision.stopReason, "probe_pool_exhausted"); const accounting = fakeAccounting({ ...receiptHandlers, get_agentic_rectification_case_dossier: () => rpc, get_agentic_rectification_case_compute: () => computeFixture(), set_agentic_rectification_conversation_focus: (_fn, args) => { throw new Error(`must not persist ${String(args.p_question_id)}`); }, append_agentic_rectification_turn: () => ({ turn_id: "33333333-3333-4333-8333-333333333333", idempotent: false }), finalize_agentic_rectification_turn: () => ({ turn_id: "33333333-3333-4333-8333-333333333333", status: "completed", idempotent: false, }), get_agentic_rectification_turn_receipt: () => null, }); const idle = await persistNextInterviewIfIdle({ accounting: accounting.client, userId: USER_ID, caseId: CASE_ID, }); assert.equal( accounting.calls.some((item) => item.fn === "set_agentic_rectification_conversation_focus"), false, ); assert.equal(idle.terminalNote, true); assert.ok(idle.hostNarration); assert.equal(projectCurrentQuestion(null), null); assert.equal(interviewQuestionBlocksAdoptOffer(null, false), false); }); test("agent-run still writes the exhaustion gate when idle persist returns terminalNote", () => { const agent = readFileSync(new URL("../src/lib/rectification-agentic/v9/agent-run.ts", import.meta.url), "utf8"); const chat = readFileSync(new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url), "utf8"); assert.match(agent, /idle\.terminalNote && idle\.hostNarration/); assert.match(agent, /persistExhaustionGateTurn/); assert.match(chat, /canOfferCards = canShowRectificationSelectionCards/); const offer = canShowRectificationSelectionCards( parseRectificationCandidateResult({ resultId: "55555555-5555-4555-8555-555555555555", candidates: [ { candidateId: CANDIDATE_IDS[0], rank: 1, time: "04:51", relativeSupport: 40, tiedMinuteCount: 1 }, { candidateId: CANDIDATE_IDS[1], rank: 2, time: "04:53", relativeSupport: 35, tiedMinuteCount: 1 }, ], overallConfidence: "medium", selectionAllowed: true, canAdopt: true, confirmationAllowed: false, representativeTime: "04:51", selectedTime: null, sessionOutcome: "adopt_representative", decisionReceipt: { accept_allowed: true, acceptance_allowed: true, selection_allowed: true, propose_allowed: true, confirmation_allowed: false, }, }), ); assert.equal(offer, true); }); test("holdout occupied treats health and health_pressure as the same line", () => { const evidence = [ ...ACCIDENT_EVIDENCE.filter((item) => item.domain !== "health"), dated("e-health-ledger", "health", "self_health_event", "2021-11-01"), ]; const fields = holdoutFollowupFor({ evidence, oosBlindPrompts: [{ domain: "health_pressure", user_meaning: "身体这条线还没用过。", used_for_scoring: false, }], }, new Set()); // 原值: health_pressure // 新值: null // 原因: occupied 不认 health / health_pressure 同义时会再问一遍健康 assert.equal(fields, null); });