0be51e65f5
Move nextDatedCollectFollowup after family coverage so a new case still asks D9 then D10. Dated third-event collect stays education → finance → relocation → health. Admin input padding contract follows --space-3. BUG-531 and BUG-532. Co-authored-by: Cursor <cursoragent@cursor.com>
448 lines
16 KiB
TypeScript
448 lines
16 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 {
|
|
buildMethodFollowupPlan,
|
|
exhaustionSpokenCollectFollowup,
|
|
holdoutFollowupFor,
|
|
nextDatedCollectFollowup,
|
|
spokenFollowupForUser,
|
|
type MethodFollowupEvidence,
|
|
} from "../src/lib/rectification-agentic/v9/method-followup.ts";
|
|
import { persistNextInterviewAfterChoice } from "../src/lib/rectification-agentic/v9/answer-choice.ts";
|
|
import { projectTurnDecision } from "../src/lib/rectification-agentic/v9/turn-decision.ts";
|
|
import { parseV9CaseDossier } 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 {
|
|
CASE_ID,
|
|
FOCUS_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("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?.domain, "other");
|
|
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 do not take the dated-collect branch", () => {
|
|
const plan = collectPlan(FOUR_SCOREABLE, {
|
|
holdoutValidation: "passed",
|
|
oosBlindPrompts: [],
|
|
candidatesSeparated: true,
|
|
});
|
|
assert.notEqual(plan.next_followup?.domain, "education");
|
|
assert.equal(plan.next_followup?.domain, "occupation");
|
|
});
|
|
|
|
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, /采集题必须写出服务端给你的领域/);
|
|
});
|