7ca6ade244
Yearless-to-collect now skips domains already confirmed, declined, or asked; adopt-exhausted turns skip leftover collect persist. Collect focus collisions only retry with :next when collect_retry is set. Co-authored-by: Cursor <cursoragent@cursor.com>
862 lines
34 KiB
TypeScript
862 lines
34 KiB
TypeScript
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.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<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 = 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);
|
||
});
|
||
|