Files
Jyotisha/frontend/tests/rectification-collect-direction-20260904.test.ts
T
Jesse_ChenandClaude Fable 5.1 dc732825d8 fix(rectification): 有题就接着问,题问完才出卡;引导窗口不再硬贴领域
出卡时机只看题源有没有空:撤回「门槛达标就短路采集线」的写法,同时
按 D2 保住「题源全空就按现行规则出卡」——门槛只在还有题可问时挡住
出卡,precision_gate_met 改成只上报(新挂在决策与公开投影上),不再
单独决定时机。引导窗口题在无领域轨道上改问开放题,一个时间窗只问一
次;录入卡提交的是「YYYY 年 M 月,<领域>方面有一件事」,不再是题干
的三选一列表。记忆化 golden 只补一个新键并冻结墙钟。离线回放改成注
入真值方向的边界事件,另跑一组反方向对照。Skill 10.0.28。

BUG-747~752

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JUei7K13cYxLHE3Axe4A45
2026-09-16 12:16:55 +00:00

954 lines
38 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 { TARGETED_EXISTENCE_PROMPT } from "../src/lib/rectification-agentic/v9/collection-question-pool.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");
// 原值: choice_frame === null(纯口述采集题)
// 新值: 定向存在性题的 A/B/C/D frame
// 原因: D5/T4b——七条线统一走「先问有没有」的点选题;题名要的
// 「用服务器的采集题干、不改写成反向核对」由下面两条仍然保证(BUG-751)
assert.equal(plan.next_followup?.choice_frame?.choice_kind, "existence");
// 原值: USER_COLLECT_QUESTION.education(「上学这边,还记得哪年…」)
// 新值: TARGETED_EXISTENCE_PROMPT.education(「学业上有没有过…哪一年都算?」)
// 原因: T4b——存在性题问整个领域、任何时间;仍是服务器题干,不是反向核对
assert.equal(spokenFollowupForUser(plan.next_followup), TARGETED_EXISTENCE_PROMPT.education);
assert.doesNotMatch(String(spokenFollowupForUser(plan.next_followup)), /对不对|是不是这样/);
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("export async function requestTieBreakPersonality"),
);
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,
// 新增字段:D1 现在随每个出口一起上报(见 publicNextAction
precision_gate_met: 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");
// 原值: invite_more
// 新值: nullcollect_kind=invite_more
// 原因: target_kind CHECK 不允许 invite_more,原 hint 只进 schemaBUG-656
assert.equal(setFocus?.args.p_target_kind, null);
assert.equal(
(setFocus?.args.p_expected_answer_schema as { collect_kind?: string } | undefined)?.collect_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,
});
// 原值: next_followup.intent != collect_method_evidence
// 新值: 仍是 collect_method_evidence,但走的是定向七条线(不是旧的领域轮盘)
// 原因: D5——七条线按 COLLECT_KIND_ORDER 全部轮到;D6——线问完才交付(BUG-751)
assert.equal(plan.next_followup?.intent, "collect_method_evidence");
assert.match(String(plan.next_followup?.collection_key ?? ""), /^collect:(targeted|guided):/);
});
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/lib/rectification-agentic/v9/case-dossier-response.ts", import.meta.url),
"utf8",
);
// 原值: 断言读 cases/[caseId]/route.ts
// 新值: 断言读 case-dossier-response.ts
// 原因: Next App Router 不允许 route.ts 导出非 HTTP handler。
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);
// 原值: sessionOutcome != collect_evidence、nextAction != ask_fact_collection
// 新值: collect_evidence / ask_fact_collection,且下一问是定向线
// 原因: D5 + D6——只拒答了家人,其余六条线仍要问(BUG-751)
assert.equal(decision.sessionOutcome, "collect_evidence");
assert.equal(decision.nextAction, "ask_fact_collection");
const { plan, userAction } = nextActionFromDecision(dossier, decision);
assert.equal(plan.next_followup?.intent, "collect_method_evidence");
assert.match(String(plan.next_followup?.collection_key ?? ""), /^collect:(targeted|guided):/);
assert.equal(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,
});
// 原值: offer 允许(BUG-648 训练门开即可)
// 新值: offer_not_allowed
// 原因: D6——定向线没问完,offer 仍被挡;题名的「closed by the training
// gate」在 D5 之后不再成立(BUG-751)
await assert.rejects(
() => (tools["rectification-offer-candidates"] as unknown as {
execute(input: unknown): Promise<unknown>;
}).execute({ caseId: CASE_ID }),
/offer_not_allowed/,
);
});
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);
});