Files
Jyotisha/frontend/tests/rectification-occupation-coverage-exit.test.ts
T
Jesse_Chen 06e4410431
Independent Staging Quality Gate / validate (push) Successful in 15m5s
Independent Staging Quality Gate / publish (push) Has been cancelled
fix(rectification): make delivery report use inference-window facts (BUG-593)
The verification template was still quoting the engine's pre-inference span and eliminated dasha tops, and D9/D10 signs were left for the model to guess from transition clocks.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-08 15:33:48 +08:00

862 lines
34 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<Parameters<typeof buildMethodFollowupPlan>[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.16 after the delivery-report-facts bump", () => {
assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.16");
});
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<Parameters<typeof buildMethodFollowupPlan>[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 = financeL2224 取 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,
});
// 原值: financeD11 仍排第一)
// 新值: 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);
});