Files
Jyotisha/frontend/tests/rectification-collect-direction-20260904.test.ts
T
Jesse_ChenandCursor dd8f35f7ba fix(rectification): invite-first collect, holdout at 4 events, Skill 10.0.23 (BUG-646–648)
Stop domain-wheel collecting and age-band years in prompts. Ask until the training gate, then discriminate until convergence, then deliver a range plus a concrete follow-up. Reserve holdout only with four dated events.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-11 01:43:02 +08:00

928 lines
36 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import {
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 { GENERIC_COLLECT_QUESTION, USER_COLLECT_QUESTION } from "../src/lib/rectification-agentic/user-copy.ts";
import { turnQuestionKind } from "../src/lib/rectification-agentic/v9/turn-question.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("after one or two dated events the next collect is an invite, not method rotation", () => {
// 原值: career → d9_relationshiprelationship → d10_career;两件 → relatives/family
// 新值: 训练门关时收集池首条是「还有吗」邀请
// 原因: 用户先说完,再从已说的事追问(BUG-648)
const careerOnly = buildMethodFollowupPlan({
evidence: [dated("career", "2011", { eventKind: "career_entry" })],
holdoutValidation: "not_started",
});
assert.equal(careerOnly.next_followup?.collection_key, "collect:invite:more");
assert.equal(careerOnly.next_followup?.domain, "other");
assert.match(spokenFollowupForUser(careerOnly.next_followup) ?? "", /^还有吗?/);
const relationshipOnly = buildMethodFollowupPlan({
evidence: [dated("relationship", "2014", { eventKind: "relationship_start" })],
holdoutValidation: "not_started",
});
assert.equal(relationshipOnly.next_followup?.collection_key, "collect:invite:more");
const both = buildMethodFollowupPlan({
evidence: TWO_SCOREABLE,
holdoutValidation: "not_started",
});
assert.equal(both.next_followup?.collection_key, "collect:invite:more");
assert.doesNotMatch(spokenFollowupForUser(both.next_followup) ?? "", /年前后/);
const familyDeclined = collectPlan(TWO_SCOREABLE);
assert.equal(familyDeclined.next_followup?.collection_key, "collect:invite:more");
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?.collection_key, "collect:invite:more");
});
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");
// 原值: education 通用采集
// 新值: 邀请「还有吗」
// 原因: 收集池邀请在产出期间排第一(BUG-648)
assert.equal(plan.next_followup?.domain, "other");
assert.match(spokenFollowupForUser(plan.next_followup) ?? "", /^还有吗?/);
});
test("age-band collection probes do not write birthday years into the spoken stem", () => {
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",
}],
});
// 原值: 家人采集题干含「2021 年前后」
// 新值: 邀请「还有吗」,不含年龄段年份
// 原因: 撤回 BUG-642;题干年份只能来自用户说过的事
const spoken = spokenFollowupForUser(plan.next_followup);
assert.equal(plan.next_followup?.collection_key, "collect:invite:more");
assert.match(spoken ?? "", /^还有吗?/);
assert.doesNotMatch(spoken ?? "", /2021 年前后/);
assert.doesNotMatch(spoken ?? "", /年前后/);
});
test("four scoreable events skip holdout for domains already in the ledger", () => {
assert.equal(meetsAcceptanceEventQuality(FOUR_SCOREABLE), true);
const declined = new Set(["family"]);
assert.equal(holdoutFollowupFor({
evidence: FOUR_SCOREABLE,
oosBlindPrompts: OOS_PROMPTS,
}, declined), null);
const plan = collectPlan(FOUR_SCOREABLE, {
candidatesSeparated: true,
eventProbes: [],
contrastPacket: { candidateSetVersion: "04:50-05:10", vargaDifferences: [], probes: [] },
});
assert.notEqual(plan.next_followup?.intent, "out_of_sample_check");
assert.notEqual(plan.next_followup?.source, "oos_blind");
assert.notEqual(plan.next_followup?.domain, "education");
assert.notEqual(plan.next_followup?.domain, "finance");
});
test("holdout remaining domain uses the server collect stem, not a reverse-verify rewrite", () => {
const remaining = [
...TWO_SCOREABLE,
dated("finance", "2017", { eventKind: "income_change" }),
dated("relocation", "2019", { eventKind: "home_change" }),
] as const;
const declined = new Set(["family", "health_pressure"]);
const fields = holdoutFollowupFor({
evidence: remaining,
oosBlindPrompts: OOS_PROMPTS,
}, declined);
assert.equal(fields?.domain, "education");
assert.equal(fields?.intent, "collect_method_evidence");
assert.equal(fields?.source, "method_coverage");
assert.equal(fields?.user_prompt_hint, USER_COLLECT_QUESTION.education);
const plan = collectPlan(remaining, {
candidatesSeparated: true,
eventProbes: [],
contrastPacket: { candidateSetVersion: "04:50-05:10", vargaDifferences: [], probes: [] },
declinedTopics: [
...FAMILY_DECLINED,
{ target_domain: "health_pressure", status: "declined", intent: "collect_method_evidence" },
],
});
assert.equal(plan.next_followup?.domain, "education");
assert.equal(plan.next_followup?.intent, "collect_method_evidence");
assert.equal(plan.next_followup?.choice_frame, null);
assert.equal(spokenFollowupForUser(plan.next_followup), USER_COLLECT_QUESTION.education);
assert.equal(turnQuestionKind({
intent: plan.next_followup?.intent,
expectedAnswerSchema: { prompt: spokenFollowupForUser(plan.next_followup), collect: true },
}), "collect_spoken");
});
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 pool is invite then user-year anchors, not a domain wheel", () => {
const declined = new Set(["family"]);
const invite = nextDatedCollectFollowup(TWO_SCOREABLE, declined);
// 原值: education → finance → occupation
// 新值: 邀请「还有吗」;拒答后再用用户年份锚定
// 原因: 撤回按领域轮转盘问(BUG-648)
assert.equal(invite?.domain, "other");
assert.equal(invite?.collection_key, "collect:invite:more");
const inviteClosed = collectPlan(TWO_SCOREABLE, {
declinedTopics: [
...FAMILY_DECLINED,
{
target_domain: "other",
status: "declined",
intent: "collect_method_evidence",
questionId: "collect:invite:more",
target_kind: "invite_more",
},
],
});
assert.notEqual(inviteClosed.next_followup?.collection_key, "collect:invite:more");
assert.doesNotMatch(spokenFollowupForUser(inviteClosed.next_followup) ?? "", /年前后/);
assert.match(
spokenFollowupForUser(inviteClosed.next_followup) ?? "",
/2011|2014|之后|分开了|结婚了/,
);
const remainingDeclined = [
"family",
"education",
"finance",
"relocation",
"health_pressure",
].map((domain) => ({
target_domain: domain,
status: "declined",
intent: "collect_method_evidence",
}));
const stillInvite = collectPlan(TWO_SCOREABLE, { declinedTopics: remainingDeclined });
assert.equal(stillInvite.next_followup?.collection_key, "collect:invite:more");
assert.notEqual(stillInvite.next_followup?.domain, "occupation");
assert.equal(
exhaustionSpokenCollectFollowup({
evidence: TWO_SCOREABLE,
declinedTopics: remainingDeclined,
})?.domain,
"other",
);
assert.notEqual(
exhaustionSpokenCollectFollowup({
evidence: TWO_SCOREABLE,
declinedTopics: remainingDeclined,
})?.domain,
"occupation",
);
});
test("persistNextInterviewAfterChoice after family denial asks the invite, 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,
},
});
// 原值: education 通用采集
// 新值: collect:invite:more
// 原因: 拒答家人不得跳过邀请(BUG-648)
assert.equal(persisted.persisted, true);
assert.equal(persisted.focus?.targetDomain, "other");
assert.match(persisted.hostNarration ?? "", /^还有吗?/);
const setFocus = accounting.calls.find((item) => item.fn === "set_agentic_rectification_conversation_focus");
assert.equal(setFocus?.args.p_target_domain, "other");
assert.equal(setFocus?.args.p_question_id, "collect:invite:more");
assert.equal(setFocus?.args.p_target_kind, "invite_more");
assert.match(
String((setFocus?.args.p_expected_answer_schema as { prompt?: string } | undefined)?.prompt ?? ""),
/^还有吗?/,
);
});
test("four scoreable events stop dated collect and leave remaining domains to S2", () => {
const plan = collectPlan(FOUR_SCOREABLE, {
holdoutValidation: "passed",
oosBlindPrompts: [],
candidatesSeparated: true,
});
// 原值: 训练门开后仍收 relocation
// 新值: 训练门开后不再按领域轮转采集
// 原因: S1 只问到训练门开;S2 走选择题或交付(BUG-648)
assert.notEqual(plan.next_followup?.intent, "collect_method_evidence");
});
test("set-focus with a domainless collect prompt does not invent an education stem", 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.ok(
(first as { reason?: string }).reason === "domain_missing"
|| (first as { reason?: string }).reason === "domain_mismatch",
);
const second = await (tools["rectification-set-focus"] as unknown as ExecutableTool<Record<string, unknown>>).execute(generic);
// 原值: 第二次回退到 USER_COLLECT_QUESTION.education
// 新值: 仍判定题干对不上,不发明学业采集
// 原因: 收集池当前首条是邀请,不得用领域轮转兜底(BUG-648)
assert.equal((second as { error?: string }).error, "invalid_spoken_prompt");
});
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 method without skipping the invite", () => {
const unanswered = buildMethodFollowupPlan({
evidence: RELATIONSHIP_AND_FAMILY,
eventProbes: [CAREER_EXISTENCE_PROBE],
askedProbeKeys: ["career.2011", "probe:career.2011"],
holdoutValidation: "not_started",
});
// 原值: 训练门关仍问 career / education 通用采集(带「想到别的也可以一起说」)
// 新值: 训练门关时下一问是邀请;选择题 yes 仍可盖住 d10_career
// 原因: 收集池在训练门开之前优先邀请(BUG-648)
assert.equal(unanswered.next_followup?.collection_key, "collect:invite:more");
assert.equal(unanswered.next_followup?.intent, "collect_method_evidence");
assert.match(spokenFollowupForUser(unanswered.next_followup) ?? "", /^还有吗?/);
const yes = careerAnswerPlan("yes");
assert.notEqual(yes.next_followup?.domain, "career");
assert.equal(yes.next_followup?.collection_key, "collect:invite:more");
assert.equal(yes.next_followup?.intent, "collect_method_evidence");
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?.collection_key, "collect:invite:more");
const no = careerAnswerPlan("no");
assert.equal(no.next_followup?.collection_key, "collect:invite:more");
assert.match(spokenFollowupForUser(no.next_followup) ?? "", /^还有吗?/);
const unsure = careerAnswerPlan("unsure");
assert.equal(unsure.next_followup?.collection_key, "collect:invite:more");
});
test("career discriminator yes does not invent coverage from a semantic_key prefix", () => {
const plan = careerAnswerPlan("yes", { eventProbes: [] });
assert.equal(plan.next_followup?.collection_key, "collect:invite:more");
assert.match(spokenFollowupForUser(plan.next_followup) ?? "", /^还有吗?/);
});
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?.collection_key, "collect:invite:more");
assert.match(spokenFollowupForUser(plan.next_followup) ?? "", /^还有吗?/);
});
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 stop leftover dated collect once the training gate is open", async () => {
const raw = collectVsOfferDossier({ declined: ["family"] });
const dossier = parseV9CaseDossier(raw);
assert.ok(dossier);
const decision = decideFromDossier(dossier);
// 原值: 仍采集 financesessionOutcome=collect_evidence
// 新值: 四件三类已开训练门,可交付
// 原因: S1 只问到训练门开(BUG-648
assert.notEqual(decision.sessionOutcome, "collect_evidence");
assert.notEqual(decision.nextAction, "ask_fact_collection");
const { plan, userAction } = nextActionFromDecision(dossier, decision);
assert.notEqual(plan.next_followup?.intent, "collect_method_evidence");
assert.notEqual(userAction.id, "ask_method_followup");
});
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 is allowed once remaining dated collect is closed by the training gate", 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,
});
// 原值: remaining dated collect 仍开 → offer_not_allowed
// 新值: 训练门开后不再挡 offer
// 原因: S1 只问到训练门开(BUG-648
const result = await (tools["rectification-offer-candidates"] as unknown as {
execute(input: unknown): Promise<unknown>;
}).execute({ caseId: CASE_ID });
assert.ok(result);
});
const SEVEN_EVENTS: MethodFollowupEvidence[] = [
{ id: "e-edu", status: "confirmed", domain: "education", datePrecision: "month", occurredFrom: "2016-09-01", occurredTo: null, eventKind: "education_start" },
{ id: "e-career", status: "confirmed", domain: "career", datePrecision: "month", occurredFrom: "2018-07-01", occurredTo: null, eventKind: "career_entry" },
{ id: "e-rel", status: "confirmed", domain: "relationship", datePrecision: "month", occurredFrom: "2021-05-01", occurredTo: null, eventKind: "relationship_start" },
{ id: "e-fam", status: "confirmed", domain: "family", datePrecision: "month", occurredFrom: "2023-03-01", occurredTo: null, eventKind: "family_event" },
{ id: "e-fin", status: "confirmed", domain: "finance", datePrecision: "month", occurredFrom: "2017-04-01", occurredTo: null, eventKind: "income_change" },
{ id: "e-health", status: "confirmed", domain: "health_pressure", datePrecision: "month", occurredFrom: "2019-11-01", occurredTo: null, eventKind: "self_health_event" },
{ id: "e-career-2", status: "confirmed", domain: "career", datePrecision: "month", occurredFrom: "2020-04-01", occurredTo: null, eventKind: "career_change" },
];
function declinedCollect(domain: string) {
return { target_domain: domain, status: "declined" as const, intent: "collect_method_evidence" as const };
}
test("lagna_frame with seven events does not fall back to GENERIC collect", () => {
assert.equal(meetsAcceptanceEventQuality(SEVEN_EVENTS), true);
const plan = buildMethodFollowupPlan({
evidence: SEVEN_EVENTS,
declinedTopics: [declinedCollect("family"), declinedCollect("health_pressure")],
precisionStage: "lagna_frame",
holdoutValidation: "not_started",
oosBlindPrompts: [{
domain: "health_pressure",
user_meaning: "身体这条线还没用过。",
used_for_scoring: false,
}],
});
const next = plan.next_followup;
// 原值: relocation 或 occupation 采集
// 新值: 训练门开后不再轮转采集;holdout 或精度层可以出题,但不能是 GENERIC
// 原因: BUG-648
assert.notEqual(spokenFollowupForUser(next, SEVEN_EVENTS), GENERIC_COLLECT_QUESTION);
assert.notEqual(next?.collection_key, "collect:invite:more");
});
test("holdout waits until dated collect and occupation are asked", () => {
const evidence: MethodFollowupEvidence[] = [
{ id: "e-edu", status: "confirmed", domain: "education", datePrecision: "month", occurredFrom: "2016-09-01", occurredTo: null, eventKind: "education_start" },
{ id: "e-career", status: "confirmed", domain: "career", datePrecision: "month", occurredFrom: "2018-07-01", occurredTo: null, eventKind: "career_entry" },
{ id: "e-rel", status: "confirmed", domain: "relationship", datePrecision: "month", occurredFrom: "2021-05-01", occurredTo: null, eventKind: "relationship_start" },
{ id: "e-fam", status: "confirmed", domain: "family", datePrecision: "month", occurredFrom: "2023-03-01", occurredTo: null, eventKind: "family_event" },
{ id: "e-fin", status: "confirmed", domain: "finance", datePrecision: "month", occurredFrom: "2017-04-01", occurredTo: null, eventKind: "income_change" },
{ id: "e-occ", status: "confirmed", domain: "occupation", datePrecision: "unknown", occurredFrom: null, occurredTo: null, eventKind: "occupation_note" },
];
const oos = [{
domain: "health_pressure",
user_meaning: "身体这条线还没用过。",
used_for_scoring: false as const,
}];
assert.equal(holdoutFollowupFor({ evidence, oosBlindPrompts: oos }, new Set())?.domain, "health_pressure");
const plan = buildMethodFollowupPlan({
evidence,
precisionStage: "lagna_frame",
holdoutValidation: "not_started",
oosBlindPrompts: oos,
});
assert.equal(plan.next_followup?.domain, "health_pressure");
assert.notEqual(plan.next_followup?.domain, "relocation");
});
test("skipped ledger health blocks health_pressure holdout", () => {
const evidence: MethodFollowupEvidence[] = [
{ id: "e-edu", status: "confirmed", domain: "education", datePrecision: "month", occurredFrom: "2016-09-01", occurredTo: null, eventKind: "education_start" },
{ id: "e-career", status: "confirmed", domain: "career", datePrecision: "month", occurredFrom: "2018-07-01", occurredTo: null, eventKind: "career_entry" },
{ id: "e-rel", status: "confirmed", domain: "relationship", datePrecision: "month", occurredFrom: "2021-05-01", occurredTo: null, eventKind: "relationship_start" },
{ id: "e-fam", status: "confirmed", domain: "family", datePrecision: "month", occurredFrom: "2023-03-01", occurredTo: null, eventKind: "family_event" },
{ id: "e-fin", status: "confirmed", domain: "finance", datePrecision: "month", occurredFrom: "2017-04-01", occurredTo: null, eventKind: "income_change" },
{ id: "e-reloc", status: "confirmed", domain: "relocation", datePrecision: "month", occurredFrom: "2024-08-01", occurredTo: null, eventKind: "relocation" },
{ id: "e-occ", status: "confirmed", domain: "occupation", datePrecision: "unknown", occurredFrom: null, occurredTo: null, eventKind: "occupation_note" },
];
const oos = [{
domain: "health_pressure",
user_meaning: "身体这条线还没用过。",
used_for_scoring: false as const,
}];
assert.equal(holdoutFollowupFor({
evidence,
oosBlindPrompts: oos,
}, new Set(["family", "health"])), null);
});