85b14b13ae
When coverage still blocks adopt and no renderable discriminator remains, narrate the numeric range and persist a spoken collect instead of promising a time card that cannot be clicked. Recompute probe information gain on active candidates and drop zero-split probes explicitly. Co-authored-by: Cursor <cursoragent@cursor.com>
484 lines
19 KiB
TypeScript
484 lines
19 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import { readFileSync } from "node:fs";
|
|
import test from "node:test";
|
|
|
|
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 } from "../src/lib/rectification-agentic/v9/method-followup.ts";
|
|
import {
|
|
parseRectificationCandidateResult,
|
|
workingRectificationHouseTable,
|
|
} from "../src/lib/rectification-candidate-result.ts";
|
|
import { decideFromDossier } from "../src/lib/rectification-agentic/v9/decision-from-dossier.ts";
|
|
import { latestResultToolProjection } from "../src/mastra/rectification-v9-tools.ts";
|
|
import { isRenderableChoiceOpenQuestion } from "../src/lib/rectification-agentic/v9/server-focus.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 },
|
|
];
|
|
|
|
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.13 for the occupation-coverage exit fix", () => {
|
|
assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.13");
|
|
});
|
|
|
|
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");
|
|
assert.equal(plan.next_followup?.method_id, "occupation");
|
|
});
|
|
|
|
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);
|
|
assert.equal(plan.next_followup?.method_id, "occupation");
|
|
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 allows offering a range without adopt", () => {
|
|
const blocked = decideRectification({
|
|
methodCoverageAll: false,
|
|
trainingGateOpen: true,
|
|
candidateScores: TIED,
|
|
discriminatorProbe: null,
|
|
engineAcceptAllowed: true,
|
|
engineProposeAllowed: true,
|
|
});
|
|
assert.equal(blocked.canOfferRange, true);
|
|
assert.equal(blocked.canAdopt, false);
|
|
assert.equal(blocked.canConfirmExactMinute, false);
|
|
assert.equal(blocked.nextAction, "offer_provisional_range");
|
|
assert.equal(blocked.sessionOutcome, "provisional_range");
|
|
const stillClosed = decideRectification({
|
|
methodCoverageAll: false,
|
|
trainingGateOpen: false,
|
|
candidateScores: TIED,
|
|
discriminatorProbe: null,
|
|
engineAcceptAllowed: true,
|
|
engineProposeAllowed: true,
|
|
});
|
|
assert.equal(stillClosed.canOfferRange, false);
|
|
assert.equal(stillClosed.canAdopt, false);
|
|
assert.equal(stillClosed.nextAction, "ask_fact_collection");
|
|
});
|
|
|
|
test("decideFromDossier offers a range when training is complete and the ask layer has no card", () => {
|
|
const decision = decideFromDossier({
|
|
evidence: CASE_EVIDENCE,
|
|
conversationSummary: { activeFocus: null, declinedSkippedTopics: [] },
|
|
latestResult: {
|
|
resultId: "55555555-5555-4555-8555-555555555555",
|
|
selectionAllowed: true,
|
|
confirmationAllowed: false,
|
|
candidates: TIED.map((item, index) => ({
|
|
candidateId: `88888888-8888-4888-8888-88888888888${index}`,
|
|
time: item.time,
|
|
rank: index + 1,
|
|
relativeSupport: item.score,
|
|
})),
|
|
representativeTime: "04:47",
|
|
decisionReceipt: {
|
|
accept_allowed: true,
|
|
propose_allowed: true,
|
|
selection_allowed: true,
|
|
},
|
|
},
|
|
case: { acceptedTime: null },
|
|
});
|
|
assert.equal(decision.canOfferRange, true);
|
|
assert.equal(decision.canAdopt, false);
|
|
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({
|
|
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);
|
|
});
|