Stop falling through to "say another event" after dated and occupation collect are done. Deliver an adopt path or a gate sentence, and add a spoken-collect stop control. Co-authored-by: Cursor <cursoragent@cursor.com>
793 lines
28 KiB
TypeScript
793 lines
28 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import { readFileSync } from "node:fs";
|
|
import test from "node:test";
|
|
|
|
import {
|
|
collectionProgressFromReceipt,
|
|
meetsAcceptanceEventQuality,
|
|
} from "../src/lib/rectification-agentic/v9/evidence-model.ts";
|
|
import { decideFromDossier } from "../src/lib/rectification-agentic/v9/decision-from-dossier.ts";
|
|
import {
|
|
buildMethodFollowupPlan,
|
|
buildNextUserAction,
|
|
exhaustionSpokenCollectFollowup,
|
|
holdoutFollowupFor,
|
|
nextDatedCollectFollowup,
|
|
spokenFollowupForUser,
|
|
type MethodFollowupEvidence,
|
|
} from "../src/lib/rectification-agentic/v9/method-followup.ts";
|
|
import type { DiscriminatingEventProbe } from "../src/lib/rectification-agentic/v9/refinement-packet.ts";
|
|
import {
|
|
persistNextInterviewAfterChoice,
|
|
persistNextInterviewIfIdle,
|
|
} from "../src/lib/rectification-agentic/v9/answer-choice.ts";
|
|
import { publicDecisionFields } from "../src/lib/rectification-agentic/core/rectification-decision.ts";
|
|
import { projectTurnDecision } from "../src/lib/rectification-agentic/v9/turn-decision.ts";
|
|
import {
|
|
evidenceLedgerFingerprint,
|
|
parseV9CaseDossier,
|
|
RectificationToolServiceError,
|
|
} from "../src/lib/rectification-agentic/v9/tool-service.ts";
|
|
import { USER_COLLECT_QUESTION } from "../src/lib/rectification-agentic/user-copy.ts";
|
|
import { createRectificationV9Tools } from "../src/mastra/rectification-v9-tools.ts";
|
|
import {
|
|
CANDIDATE_ID,
|
|
CASE_ID,
|
|
FOCUS_ID,
|
|
SECOND_CANDIDATE_ID,
|
|
TURN_ID,
|
|
USER_ID,
|
|
candidateSnapshotFixture,
|
|
computeFixture,
|
|
dossierFixture,
|
|
fakeAccounting,
|
|
receiptHandlers,
|
|
} from "./rectification-v9-test-support.ts";
|
|
|
|
type ExecutableTool<T = unknown> = {
|
|
execute(input: unknown): Promise<T>;
|
|
};
|
|
|
|
const OOS_PROMPTS = [
|
|
{
|
|
domain: "family",
|
|
user_meaning: "校时还没用过家人这条线。有没有一件没提过、但记得大概时间的家人变化?",
|
|
used_for_scoring: false as const,
|
|
},
|
|
{
|
|
domain: "education",
|
|
user_meaning: "学业这条线还没单独核对。有没有记得大概时间的升学或考试?",
|
|
used_for_scoring: false as const,
|
|
},
|
|
{
|
|
domain: "finance",
|
|
user_meaning: "钱的方面还没单独核对。有没有记得大概时间的收入变化?",
|
|
used_for_scoring: false as const,
|
|
},
|
|
];
|
|
|
|
function dated(
|
|
domain: string,
|
|
year: string,
|
|
extra: { id?: string; eventKind?: string } = {},
|
|
): MethodFollowupEvidence & { id: string; eventKind?: string } {
|
|
const yearless = extra.eventKind === "occupation_note";
|
|
return {
|
|
id: extra.id ?? `e-${domain}-${year}`,
|
|
status: "confirmed",
|
|
domain,
|
|
datePrecision: yearless ? "unknown" : "year",
|
|
occurredFrom: yearless ? null : `${year}-03-01`,
|
|
occurredTo: null,
|
|
eventKind: extra.eventKind,
|
|
};
|
|
}
|
|
|
|
const TWO_SCOREABLE = [
|
|
dated("career", "2011", { eventKind: "career_entry" }),
|
|
dated("relationship", "2014", { eventKind: "relationship_start" }),
|
|
] as const;
|
|
|
|
const FOUR_SCOREABLE = [
|
|
...TWO_SCOREABLE,
|
|
dated("education", "2008", { eventKind: "education_start" }),
|
|
dated("finance", "2017", { eventKind: "income_change" }),
|
|
] as const;
|
|
|
|
const FAMILY_DECLINED = [{
|
|
target_domain: "family",
|
|
status: "declined",
|
|
intent: "collect_method_evidence",
|
|
}];
|
|
|
|
function collectPlan(
|
|
evidence: readonly MethodFollowupEvidence[],
|
|
extra: Partial<Parameters<typeof buildMethodFollowupPlan>[0]> = {},
|
|
) {
|
|
return buildMethodFollowupPlan({
|
|
evidence,
|
|
declinedTopics: FAMILY_DECLINED,
|
|
holdoutValidation: "not_started",
|
|
oosBlindPrompts: OOS_PROMPTS,
|
|
...extra,
|
|
});
|
|
}
|
|
|
|
test("method rotation asks relationship and career before dated collect", () => {
|
|
const careerOnly = buildMethodFollowupPlan({
|
|
evidence: [dated("career", "2011", { eventKind: "career_entry" })],
|
|
holdoutValidation: "not_started",
|
|
});
|
|
assert.equal(careerOnly.next_followup?.method_id, "d9_relationship");
|
|
assert.equal(careerOnly.next_followup?.domain, "relationship");
|
|
|
|
const relationshipOnly = buildMethodFollowupPlan({
|
|
evidence: [dated("relationship", "2014", { eventKind: "relationship_start" })],
|
|
holdoutValidation: "not_started",
|
|
});
|
|
assert.equal(relationshipOnly.next_followup?.method_id, "d10_career");
|
|
assert.equal(relationshipOnly.next_followup?.domain, "career");
|
|
|
|
const both = buildMethodFollowupPlan({
|
|
evidence: TWO_SCOREABLE,
|
|
holdoutValidation: "not_started",
|
|
});
|
|
assert.equal(both.next_followup?.method_id, "relatives");
|
|
assert.equal(both.next_followup?.domain, "family");
|
|
|
|
const familyDeclined = collectPlan(TWO_SCOREABLE);
|
|
assert.equal(familyDeclined.next_followup?.method_id, "d5_education");
|
|
assert.equal(familyDeclined.next_followup?.domain, "education");
|
|
assert.notEqual(familyDeclined.next_followup?.domain, "occupation");
|
|
|
|
const educationDeclined = collectPlan(TWO_SCOREABLE, {
|
|
declinedTopics: [
|
|
...FAMILY_DECLINED,
|
|
{ target_domain: "education", status: "declined", intent: "collect_method_evidence" },
|
|
],
|
|
});
|
|
assert.equal(educationDeclined.next_followup?.method_id, "d2_finance");
|
|
assert.equal(educationDeclined.next_followup?.domain, "finance");
|
|
});
|
|
|
|
test("two scoreable events do not open OOS even when holdout prompts exist", () => {
|
|
assert.equal(meetsAcceptanceEventQuality(TWO_SCOREABLE), false);
|
|
const declined = new Set(["family"]);
|
|
assert.equal(holdoutFollowupFor({
|
|
evidence: TWO_SCOREABLE,
|
|
oosBlindPrompts: OOS_PROMPTS,
|
|
}, declined), null);
|
|
const plan = collectPlan(TWO_SCOREABLE);
|
|
assert.notEqual(plan.next_followup?.intent, "out_of_sample_check");
|
|
assert.equal(plan.next_followup?.intent, "collect_method_evidence");
|
|
assert.equal(plan.next_followup?.domain, "education");
|
|
assert.match(spokenFollowupForUser(plan.next_followup) ?? "", /上学|升学/);
|
|
});
|
|
|
|
test("family collect spoken stem has no year prefix while probe_year stays dated", () => {
|
|
const plan = buildMethodFollowupPlan({
|
|
evidence: TWO_SCOREABLE,
|
|
evidenceCollectionProbes: [{
|
|
year: 2021,
|
|
year_label: "2021 年前后",
|
|
domain: "family",
|
|
event_family: "家人结婚、添丁或住院",
|
|
source: "age_band",
|
|
tracks: ["vimshottari", "narayana"],
|
|
tracks_agree: false,
|
|
unique_minute_claim: false,
|
|
user_meaning: "时间范围锁定 2021 年前后;领域锁定 family。",
|
|
role: "collect",
|
|
phase: "evidence_collection",
|
|
information_gain: 0,
|
|
semantic_key: "family.2021",
|
|
}],
|
|
});
|
|
assert.equal(plan.next_followup?.domain, "family");
|
|
assert.equal(plan.next_followup?.probe_year, 2021);
|
|
assert.equal(spokenFollowupForUser(plan.next_followup), USER_COLLECT_QUESTION.family);
|
|
});
|
|
|
|
test("four scoreable events skip declined OOS domain and ask education holdout", () => {
|
|
assert.equal(meetsAcceptanceEventQuality(FOUR_SCOREABLE), true);
|
|
const plan = collectPlan(FOUR_SCOREABLE, {
|
|
candidatesSeparated: true,
|
|
eventProbes: [],
|
|
contrastPacket: { candidateSetVersion: "04:50-05:10", vargaDifferences: [], probes: [] },
|
|
});
|
|
assert.equal(plan.next_followup?.intent, "out_of_sample_check");
|
|
assert.equal(plan.next_followup?.source, "oos_blind");
|
|
assert.equal(plan.next_followup?.domain, "education");
|
|
});
|
|
|
|
test("validate_holdout with every OOS domain declined and no dated holdout asks nothing", () => {
|
|
const declinedAll = [
|
|
{ target_domain: "family", status: "declined", intent: "collect_method_evidence" },
|
|
{ target_domain: "education", status: "declined", intent: "collect_method_evidence" },
|
|
{ target_domain: "finance", status: "declined", intent: "collect_method_evidence" },
|
|
];
|
|
const plan = collectPlan(FOUR_SCOREABLE, {
|
|
sessionOutcome: "validate_holdout",
|
|
declinedTopics: declinedAll,
|
|
holdoutEvents: [],
|
|
candidatesSeparated: true,
|
|
eventProbes: [],
|
|
});
|
|
assert.equal(plan.next_followup, null);
|
|
});
|
|
|
|
test("dated collect order walks remaining year-bearing domains then occupation", () => {
|
|
const declined = new Set(["family"]);
|
|
assert.equal(nextDatedCollectFollowup(TWO_SCOREABLE, declined)?.domain, "education");
|
|
const educationClosed = collectPlan(TWO_SCOREABLE, {
|
|
declinedTopics: [
|
|
...FAMILY_DECLINED,
|
|
{ target_domain: "education", status: "declined", intent: "collect_method_evidence" },
|
|
],
|
|
});
|
|
assert.equal(educationClosed.next_followup?.domain, "finance");
|
|
const remainingDeclined = [
|
|
"family",
|
|
"education",
|
|
"finance",
|
|
"relocation",
|
|
"health_pressure",
|
|
].map((domain) => ({
|
|
target_domain: domain,
|
|
status: "declined",
|
|
intent: "collect_method_evidence",
|
|
}));
|
|
const occupation = collectPlan(TWO_SCOREABLE, { declinedTopics: remainingDeclined });
|
|
assert.equal(occupation.next_followup?.domain, "occupation");
|
|
const occupationClosed = collectPlan(TWO_SCOREABLE, {
|
|
declinedTopics: [
|
|
...remainingDeclined,
|
|
{ target_domain: "occupation", status: "declined", intent: "collect_method_evidence" },
|
|
],
|
|
closedCollectFocuses: [{
|
|
questionId: "collect:occupation:collect_method_evidence",
|
|
target_domain: "occupation",
|
|
status: "declined",
|
|
intent: "collect_method_evidence",
|
|
}],
|
|
});
|
|
assert.equal(occupationClosed.next_followup, null);
|
|
// 原值: other
|
|
// 新值: null
|
|
// 原因: BUG-558 方法覆盖完成且七个带年份领域都问过/拒过后不得再落到「也可以再说一件事」
|
|
assert.equal(
|
|
exhaustionSpokenCollectFollowup({
|
|
evidence: TWO_SCOREABLE,
|
|
declinedTopics: remainingDeclined,
|
|
})?.domain,
|
|
"occupation",
|
|
);
|
|
});
|
|
|
|
test("persistNextInterviewAfterChoice after family denial asks education, not occupation", async () => {
|
|
const source = readFileSync(
|
|
new URL("../src/lib/rectification-agentic/v9/answer-choice.ts", import.meta.url),
|
|
"utf8",
|
|
);
|
|
const persistFn = source.slice(
|
|
source.indexOf("export async function persistNextInterviewAfterChoice"),
|
|
source.indexOf("async function persistFocusAfterChoice"),
|
|
);
|
|
assert.doesNotMatch(persistFn, /decideFromDossier\(/);
|
|
|
|
const dossier = {
|
|
evidence: TWO_SCOREABLE,
|
|
conversationSummary: {
|
|
activeFocus: null,
|
|
declinedSkippedTopics: FAMILY_DECLINED,
|
|
},
|
|
latestResult: {
|
|
resultId: "55555555-5555-4555-8555-555555555555",
|
|
candidates: [
|
|
{ time: "04:52", relativeSupport: 40, rank: 1 },
|
|
{ time: "05:04", relativeSupport: 38, rank: 2 },
|
|
],
|
|
representativeTime: "04:52",
|
|
decisionReceipt: {
|
|
oos_blind_prompts: OOS_PROMPTS,
|
|
gates: {
|
|
event_quality: { passed: false, scoreable_event_count: 2, minimum: 3 },
|
|
},
|
|
},
|
|
},
|
|
case: { acceptedTime: null },
|
|
};
|
|
const accounting = fakeAccounting({
|
|
...receiptHandlers,
|
|
set_agentic_rectification_conversation_focus: (_fn, args) => ({
|
|
focus: {
|
|
id: FOCUS_ID,
|
|
case_id: CASE_ID,
|
|
question_id: args.p_question_id,
|
|
intent: args.p_intent,
|
|
target_evidence_id: args.p_target_evidence_id,
|
|
target_domain: args.p_target_domain,
|
|
target_kind: args.p_target_kind,
|
|
expected_answer_schema: args.p_expected_answer_schema,
|
|
status: "active",
|
|
asked_at: "2026-09-04T00:00:00.000Z",
|
|
resolved_at: null,
|
|
asked_turn_id: args.p_asked_turn_id ?? null,
|
|
},
|
|
idempotent: false,
|
|
}),
|
|
});
|
|
const persisted = await persistNextInterviewAfterChoice({
|
|
accounting: accounting.client,
|
|
userId: USER_ID,
|
|
caseId: CASE_ID,
|
|
dossier,
|
|
decisionState: null,
|
|
askedTurnId: TURN_ID,
|
|
nextAction: {
|
|
type: "ask_fact_collection",
|
|
session_outcome: "collect_evidence",
|
|
completion_status: null,
|
|
validated: false,
|
|
can_offer_range: false,
|
|
can_adopt: false,
|
|
can_confirm_exact_minute: false,
|
|
selection_allowed: false,
|
|
propose_allowed: false,
|
|
precision_stage: "collect_events",
|
|
representative_time: null,
|
|
credible_range: null,
|
|
stop_reason: null,
|
|
},
|
|
});
|
|
assert.equal(persisted.persisted, true);
|
|
assert.equal(persisted.focus?.targetDomain, "education");
|
|
assert.equal(persisted.hostNarration, USER_COLLECT_QUESTION.education);
|
|
const setFocus = accounting.calls.find((item) => item.fn === "set_agentic_rectification_conversation_focus");
|
|
assert.equal(setFocus?.args.p_target_domain, "education");
|
|
assert.equal(
|
|
(setFocus?.args.p_expected_answer_schema as { prompt?: string } | undefined)?.prompt,
|
|
USER_COLLECT_QUESTION.education,
|
|
);
|
|
});
|
|
|
|
test("four scoreable events still collect remaining dated domains after family denial", () => {
|
|
const plan = collectPlan(FOUR_SCOREABLE, {
|
|
holdoutValidation: "passed",
|
|
oosBlindPrompts: [],
|
|
candidatesSeparated: true,
|
|
});
|
|
// 旧:训练门已开 → occupation。新:家人拒答后仍收搬家,用来区分剩余分钟。
|
|
assert.notEqual(plan.next_followup?.domain, "education");
|
|
assert.notEqual(plan.next_followup?.domain, "occupation");
|
|
assert.equal(plan.next_followup?.domain, "relocation");
|
|
assert.equal(plan.next_followup?.intent, "collect_method_evidence");
|
|
});
|
|
|
|
test("set-focus twice with a domainless collect prompt falls back to USER_COLLECT_QUESTION.education", async () => {
|
|
const raw = dossierFixture({
|
|
evidence: TWO_SCOREABLE.map((item) => ({
|
|
id: item.id,
|
|
source_turn_id: TURN_ID,
|
|
subject: "self",
|
|
event_kind: item.eventKind ?? "event",
|
|
domain: item.domain,
|
|
occurred_from: item.occurredFrom,
|
|
occurred_to: item.occurredTo,
|
|
date_precision: item.datePrecision,
|
|
summary: `${item.occurredFrom} ${item.eventKind ?? "event"}`,
|
|
status: item.status,
|
|
supersedes_evidence_id: null,
|
|
created_at: "2026-09-04T00:00:00.000Z",
|
|
})),
|
|
latestResult: candidateSnapshotFixture({
|
|
decisionReceipt: {
|
|
oos_blind_prompts: OOS_PROMPTS,
|
|
gates: {
|
|
event_quality: { passed: false, scoreable_event_count: 2, minimum: 3 },
|
|
},
|
|
},
|
|
}),
|
|
conversationSummary: {
|
|
confirmed_evidence_summary: [],
|
|
pending_revisions: [],
|
|
active_focus: null,
|
|
declined_skipped_topics: FAMILY_DECLINED,
|
|
candidate_divergence_summary: null,
|
|
missing_evidence_categories: [],
|
|
last_result_policy: null,
|
|
summary_version: 1,
|
|
updated_at: "2026-09-04T00:00:00.000Z",
|
|
},
|
|
});
|
|
const store: { prompt: string | null } = { prompt: null };
|
|
const accounting = fakeAccounting({
|
|
...receiptHandlers,
|
|
get_agentic_rectification_case_dossier: () => raw,
|
|
get_agentic_rectification_case_compute: () => computeFixture(),
|
|
set_agentic_rectification_conversation_focus: (_fn, args) => {
|
|
const schema = args.p_expected_answer_schema as { prompt?: string };
|
|
store.prompt = typeof schema?.prompt === "string" ? schema.prompt : null;
|
|
return {
|
|
focus: {
|
|
id: FOCUS_ID,
|
|
case_id: CASE_ID,
|
|
question_id: args.p_question_id,
|
|
intent: args.p_intent,
|
|
target_evidence_id: args.p_target_evidence_id,
|
|
target_domain: args.p_target_domain,
|
|
target_kind: args.p_target_kind,
|
|
expected_answer_schema: args.p_expected_answer_schema,
|
|
status: "active",
|
|
asked_at: "2026-09-04T00:00:00.000Z",
|
|
resolved_at: null,
|
|
asked_turn_id: args.p_asked_turn_id ?? null,
|
|
},
|
|
idempotent: false,
|
|
};
|
|
},
|
|
});
|
|
const tools = createRectificationV9Tools({
|
|
userId: USER_ID,
|
|
caseId: CASE_ID,
|
|
turnId: TURN_ID,
|
|
accounting: accounting.client as never,
|
|
});
|
|
const generic = {
|
|
caseId: CASE_ID,
|
|
questionId: "collect:education:collect_method_evidence",
|
|
intent: "collect_method_evidence",
|
|
spokenPrompt: "除了工作,还有哪件事记得年份?",
|
|
targetDomain: "education",
|
|
};
|
|
const first = await (tools["rectification-set-focus"] as unknown as ExecutableTool<Record<string, unknown>>).execute(generic);
|
|
assert.equal((first as { error?: string }).error, "invalid_spoken_prompt");
|
|
assert.equal((first as { reason?: string }).reason, "domain_missing");
|
|
const second = await (tools["rectification-set-focus"] as unknown as ExecutableTool<Record<string, unknown>>).execute(generic);
|
|
assert.equal((second as { error?: string }).error, undefined);
|
|
assert.equal((second as { target_domain?: string }).target_domain, "education");
|
|
assert.equal(
|
|
((second as { expected_answer_schema?: { prompt?: string } }).expected_answer_schema)?.prompt,
|
|
USER_COLLECT_QUESTION.education,
|
|
);
|
|
assert.equal(store.prompt, USER_COLLECT_QUESTION.education);
|
|
});
|
|
|
|
test("turn decision and GET interview expose collection_progress 2/3/1 or null", () => {
|
|
const withGate = parseV9CaseDossier(dossierFixture({
|
|
latestResult: candidateSnapshotFixture({
|
|
decisionReceipt: {
|
|
gates: {
|
|
event_quality: { passed: false, scoreable_event_count: 2, minimum: 3 },
|
|
},
|
|
},
|
|
}),
|
|
}));
|
|
assert.ok(withGate);
|
|
assert.deepEqual(projectTurnDecision(withGate).collection_progress, {
|
|
scoreable: 2,
|
|
minimum: 3,
|
|
missing: 1,
|
|
});
|
|
assert.deepEqual(collectionProgressFromReceipt({
|
|
gates: { event_quality: { passed: false, scoreable_event_count: 2, minimum: 3 } },
|
|
}), { scoreable: 2, minimum: 3, missing: 1 });
|
|
const withoutGate = parseV9CaseDossier(dossierFixture({
|
|
latestResult: candidateSnapshotFixture({ decisionReceipt: {} }),
|
|
}));
|
|
assert.ok(withoutGate);
|
|
assert.equal(projectTurnDecision(withoutGate).collection_progress, null);
|
|
const route = readFileSync(
|
|
new URL("../src/app/api/rectification/cases/[caseId]/route.ts", import.meta.url),
|
|
"utf8",
|
|
);
|
|
assert.match(route, /collection_progress: collectionProgressFromReceipt/);
|
|
const tools = readFileSync(
|
|
new URL("../src/mastra/rectification-v9-tools.ts", import.meta.url),
|
|
"utf8",
|
|
);
|
|
assert.match(tools, /采集题必须写出服务端给你的领域/);
|
|
});
|
|
|
|
const CAREER_EXISTENCE_PROBE: DiscriminatingEventProbe = {
|
|
year: 2011,
|
|
year_label: "2011 年前后",
|
|
domain: "career",
|
|
event_family: "入职、换工作或职责加重",
|
|
source: "dasha_activation",
|
|
tracks: ["vimshottari", "narayana"],
|
|
tracks_agree: true,
|
|
unique_minute_claim: false,
|
|
user_meaning: "年份锁定 2011 年前后。事件家族:入职、换工作或职责加重。",
|
|
role: "distinguish",
|
|
information_gain: 0.4,
|
|
candidate_ids: ["05:00", "05:20"],
|
|
expected_outcomes: [
|
|
{ answer_class: "yes", supports: ["05:00"], conflicts: ["05:20"] },
|
|
{ answer_class: "no", supports: ["05:20"], conflicts: ["05:00"] },
|
|
],
|
|
semantic_key: "career.2011",
|
|
candidate_split_hash: "career.2011:05:00|05:20",
|
|
choice_kind: "existence",
|
|
};
|
|
|
|
const RELATIONSHIP_AND_FAMILY = [
|
|
dated("relationship", "2014", { eventKind: "relationship_start" }),
|
|
dated("family", "2016", { eventKind: "family_event" }),
|
|
] as const;
|
|
|
|
function careerAnswerPlan(answerClass: string, extra: Partial<Parameters<typeof buildMethodFollowupPlan>[0]> = {}) {
|
|
return buildMethodFollowupPlan({
|
|
evidence: RELATIONSHIP_AND_FAMILY,
|
|
eventProbes: [CAREER_EXISTENCE_PROBE],
|
|
askedProbeKeys: ["career.2011", "probe:career.2011"],
|
|
answeredProbes: [{
|
|
semantic_key: "career.2011",
|
|
probe_id: "probe:career.2011",
|
|
answer_class: answerClass,
|
|
classified_from: "choice",
|
|
}],
|
|
holdoutValidation: "not_started",
|
|
...extra,
|
|
});
|
|
}
|
|
|
|
test("career discriminator yes covers career collect without ledger career evidence", () => {
|
|
const unanswered = buildMethodFollowupPlan({
|
|
evidence: RELATIONSHIP_AND_FAMILY,
|
|
eventProbes: [CAREER_EXISTENCE_PROBE],
|
|
askedProbeKeys: ["career.2011", "probe:career.2011"],
|
|
holdoutValidation: "not_started",
|
|
});
|
|
assert.equal(unanswered.next_followup?.domain, "career");
|
|
assert.equal(unanswered.next_followup?.intent, "collect_method_evidence");
|
|
assert.equal(spokenFollowupForUser(unanswered.next_followup), USER_COLLECT_QUESTION.career);
|
|
|
|
const yes = careerAnswerPlan("yes");
|
|
assert.notEqual(yes.next_followup?.domain, "career");
|
|
assert.equal(yes.next_followup?.domain, "education");
|
|
assert.equal(yes.next_followup?.intent, "collect_method_evidence");
|
|
assert.equal(spokenFollowupForUser(yes.next_followup), USER_COLLECT_QUESTION.education);
|
|
assert.equal(
|
|
yes.methods.find((item) => item.method_id === "d10_career")?.status,
|
|
"covered",
|
|
);
|
|
|
|
const weakYes = careerAnswerPlan("weak_yes");
|
|
assert.notEqual(weakYes.next_followup?.domain, "career");
|
|
assert.equal(weakYes.next_followup?.domain, "education");
|
|
|
|
const no = careerAnswerPlan("no");
|
|
assert.equal(no.next_followup?.domain, "career");
|
|
assert.equal(spokenFollowupForUser(no.next_followup), USER_COLLECT_QUESTION.career);
|
|
|
|
const unsure = careerAnswerPlan("unsure");
|
|
assert.equal(unsure.next_followup?.domain, "career");
|
|
assert.equal(spokenFollowupForUser(unsure.next_followup), USER_COLLECT_QUESTION.career);
|
|
});
|
|
|
|
test("career discriminator yes does not invent coverage from a semantic_key prefix", () => {
|
|
const plan = careerAnswerPlan("yes", { eventProbes: [] });
|
|
assert.equal(plan.next_followup?.domain, "career");
|
|
assert.equal(spokenFollowupForUser(plan.next_followup), USER_COLLECT_QUESTION.career);
|
|
});
|
|
|
|
test("ledger-classified career probe answers do not cover career collect", () => {
|
|
const plan = careerAnswerPlan("yes", {
|
|
answeredProbes: [{
|
|
semantic_key: "career.2011",
|
|
probe_id: "probe:career.2011",
|
|
answer_class: "yes",
|
|
classified_from: "evidence",
|
|
}],
|
|
});
|
|
assert.equal(plan.next_followup?.domain, "career");
|
|
assert.equal(spokenFollowupForUser(plan.next_followup), USER_COLLECT_QUESTION.career);
|
|
});
|
|
|
|
const THIRD_CANDIDATE_ID = "88888888-8888-4888-8888-888888888883";
|
|
|
|
const CORE_DATED_FOR_OFFER = [
|
|
dated("education", "2008", { eventKind: "education_start" }),
|
|
dated("education", "2012", { eventKind: "education_completion" }),
|
|
dated("relationship", "2014", { eventKind: "relationship_start" }),
|
|
dated("career", "2011", { eventKind: "career_entry" }),
|
|
] as const;
|
|
|
|
function rpcEvidenceFromDated(
|
|
item: MethodFollowupEvidence & { id: string },
|
|
) {
|
|
return {
|
|
id: item.id,
|
|
source_turn_id: TURN_ID,
|
|
subject: "self",
|
|
event_kind: item.eventKind ?? "event",
|
|
domain: item.domain,
|
|
occurred_from: item.occurredFrom,
|
|
occurred_to: item.occurredTo,
|
|
date_precision: item.datePrecision,
|
|
summary: `${item.occurredFrom ?? "undated"} ${item.eventKind ?? "event"}`,
|
|
status: item.status,
|
|
supersedes_evidence_id: null,
|
|
created_at: "2026-09-04T00:00:00.000Z",
|
|
};
|
|
}
|
|
|
|
function declinedTopics(
|
|
domains: readonly string[],
|
|
): Array<{ target_domain: string; status: "declined"; intent: "collect_method_evidence" }> {
|
|
return domains.map((domain) => ({
|
|
target_domain: domain,
|
|
status: "declined" as const,
|
|
intent: "collect_method_evidence" as const,
|
|
}));
|
|
}
|
|
|
|
function conversationWithDeclines(domains: readonly string[]) {
|
|
return {
|
|
confirmed_evidence_summary: [],
|
|
pending_revisions: [],
|
|
active_focus: null,
|
|
declined_skipped_topics: declinedTopics(domains),
|
|
candidate_divergence_summary: null,
|
|
missing_evidence_categories: [],
|
|
last_result_policy: null,
|
|
summary_version: 1,
|
|
updated_at: "2026-09-04T00:00:00.000Z",
|
|
};
|
|
}
|
|
|
|
function separatedOfferSnapshot(rawEvidence: unknown[]) {
|
|
const parsed = parseV9CaseDossier(dossierFixture({ evidence: rawEvidence }));
|
|
assert.ok(parsed);
|
|
return candidateSnapshotFixture({
|
|
selectionAllowed: true,
|
|
representativeTime: "05:00",
|
|
evidenceLedgerFingerprint: evidenceLedgerFingerprint(parsed.evidence),
|
|
candidates: [
|
|
{ candidate_id: CANDIDATE_ID, rank: 1, time: "05:00", relative_support: 62, tied_minute_count: 1 },
|
|
{ candidate_id: SECOND_CANDIDATE_ID, rank: 2, time: "05:01", relative_support: 22, tied_minute_count: 1 },
|
|
{ candidate_id: THIRD_CANDIDATE_ID, rank: 3, time: "05:02", relative_support: 16, tied_minute_count: 1 },
|
|
],
|
|
});
|
|
}
|
|
|
|
function collectVsOfferDossier(input: {
|
|
extraEvidence?: readonly (MethodFollowupEvidence & { id: string })[];
|
|
declined: readonly string[];
|
|
}) {
|
|
const evidence = [...CORE_DATED_FOR_OFFER, ...(input.extraEvidence ?? [])];
|
|
const raw = evidence.map(rpcEvidenceFromDated);
|
|
return dossierFixture({
|
|
evidence: raw,
|
|
evidenceCount: raw.length,
|
|
latestResult: separatedOfferSnapshot(raw),
|
|
conversationSummary: conversationWithDeclines(input.declined),
|
|
});
|
|
}
|
|
|
|
function nextActionFromDecision(
|
|
dossier: NonNullable<ReturnType<typeof parseV9CaseDossier>>,
|
|
decision: ReturnType<typeof decideFromDossier>,
|
|
) {
|
|
const catalog = {
|
|
evidence: dossier.evidence,
|
|
declinedTopics: dossier.conversationSummary.declinedSkippedTopics,
|
|
closedCollectFocuses: dossier.conversationSummary.declinedSkippedTopics,
|
|
sessionOutcome: decision.sessionOutcome,
|
|
};
|
|
const plan = buildMethodFollowupPlan(catalog);
|
|
return {
|
|
plan,
|
|
userAction: buildNextUserAction({
|
|
scorableCount: dossier.evidence.length,
|
|
evidenceCount: dossier.evidence.length,
|
|
hasLatestResult: true,
|
|
selectionAllowed: true,
|
|
sessionOutcome: decision.sessionOutcome,
|
|
nextFollowup: plan.next_followup,
|
|
workingTime: decision.representativeTime,
|
|
}),
|
|
};
|
|
}
|
|
|
|
async function persistIdleFocus(rawDossier: ReturnType<typeof dossierFixture>) {
|
|
const accounting = fakeAccounting({
|
|
...receiptHandlers,
|
|
get_agentic_rectification_case_dossier: () => rawDossier,
|
|
get_agentic_rectification_case_compute: () => computeFixture(),
|
|
set_agentic_rectification_conversation_focus: (_fn, args) => ({
|
|
focus: {
|
|
id: FOCUS_ID,
|
|
case_id: CASE_ID,
|
|
question_id: args.p_question_id,
|
|
intent: args.p_intent,
|
|
target_evidence_id: args.p_target_evidence_id,
|
|
target_domain: args.p_target_domain,
|
|
target_kind: args.p_target_kind,
|
|
expected_answer_schema: args.p_expected_answer_schema,
|
|
status: "active",
|
|
asked_at: "2026-09-04T00:00:00.000Z",
|
|
resolved_at: null,
|
|
asked_turn_id: args.p_asked_turn_id ?? null,
|
|
},
|
|
idempotent: false,
|
|
}),
|
|
});
|
|
const persisted = await persistNextInterviewIfIdle({
|
|
accounting: accounting.client,
|
|
userId: USER_ID,
|
|
caseId: CASE_ID,
|
|
});
|
|
const setFocus = accounting.calls.find((item) => item.fn === "set_agentic_rectification_conversation_focus");
|
|
return { persisted, setFocus };
|
|
}
|
|
|
|
test("separated candidates keep collecting finance before offering time cards", async () => {
|
|
const raw = collectVsOfferDossier({ declined: ["family"] });
|
|
const dossier = parseV9CaseDossier(raw);
|
|
assert.ok(dossier);
|
|
const decision = decideFromDossier(dossier);
|
|
assert.equal(decision.sessionOutcome, "collect_evidence");
|
|
assert.equal(decision.nextAction, "ask_fact_collection");
|
|
assert.equal(publicDecisionFields(decision).can_adopt, false);
|
|
const { plan, userAction } = nextActionFromDecision(dossier, decision);
|
|
assert.equal(plan.next_followup?.domain, "finance");
|
|
assert.equal(plan.next_followup?.intent, "collect_method_evidence");
|
|
assert.equal(userAction.id, "ask_method_followup");
|
|
assert.notEqual(userAction.id, "adopt_representative");
|
|
const idle = await persistIdleFocus(raw);
|
|
assert.equal(idle.persisted.persisted, true);
|
|
assert.equal(idle.setFocus?.args.p_target_domain, "finance");
|
|
});
|
|
|
|
test("dated collect plus occupation close lets one round offer without another question", async () => {
|
|
const extra = [
|
|
dated("finance", "2017", { eventKind: "income_change" }),
|
|
dated("relocation", "2016", { eventKind: "home_change" }),
|
|
dated("health_pressure", "2015", { eventKind: "self_health_event" }),
|
|
dated("occupation", "2011", { eventKind: "occupation_note" }),
|
|
];
|
|
const raw = collectVsOfferDossier({
|
|
extraEvidence: extra,
|
|
declined: ["family"],
|
|
});
|
|
const dossier = parseV9CaseDossier(raw);
|
|
assert.ok(dossier);
|
|
const decision = decideFromDossier(dossier);
|
|
assert.equal(decision.sessionOutcome, "adopt_representative");
|
|
assert.equal(publicDecisionFields(decision).can_adopt, true);
|
|
const { plan, userAction } = nextActionFromDecision(dossier, decision);
|
|
assert.equal(plan.next_followup, null);
|
|
assert.equal(userAction.id, "adopt_representative");
|
|
const idle = await persistIdleFocus(raw);
|
|
assert.equal(idle.persisted.persisted, false);
|
|
assert.equal(idle.setFocus, undefined);
|
|
});
|
|
|
|
test("offer-candidates refuses while remaining dated collect is still open", async () => {
|
|
const accounting = fakeAccounting({
|
|
...receiptHandlers,
|
|
get_agentic_rectification_case_dossier: () => collectVsOfferDossier({ declined: ["family"] }),
|
|
});
|
|
const tools = createRectificationV9Tools({
|
|
userId: USER_ID,
|
|
caseId: CASE_ID,
|
|
turnId: TURN_ID,
|
|
accounting: accounting.client as never,
|
|
});
|
|
await assert.rejects(
|
|
() => (tools["rectification-offer-candidates"] as unknown as {
|
|
execute(input: unknown): Promise<unknown>;
|
|
}).execute({ caseId: CASE_ID }),
|
|
(error: unknown) => error instanceof RectificationToolServiceError && error.code === "offer_not_allowed",
|
|
);
|
|
assert.equal(
|
|
accounting.calls.some((call) =>
|
|
call.fn === "transition_agentic_rectification_case_status"
|
|
&& call.args.p_status === "candidate_ready"
|
|
),
|
|
false,
|
|
);
|
|
});
|