Files
Jyotisha/frontend/tests/rectification-collect-stall.test.ts
T
Jesse_Chen 85b14b13ae
Independent Staging Quality Gate / validate (push) Successful in 11m3s
Independent Staging Quality Gate / publish (push) Successful in 8m48s
fix(rectification): close non-converging range offer without an exit
When coverage still blocks adopt and no renderable discriminator remains, narrate the numeric range and persist a spoken collect instead of promising a time card that cannot be clicked. Recompute probe information gain on active candidates and drop zero-split probes explicitly.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-30 18:35:16 +08:00

931 lines
34 KiB
TypeScript

import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import { applyAnswerToState, buildInferenceState } from "../src/lib/rectification-agentic/core/build-state.ts";
import type { ConflictProbe } from "../src/lib/rectification-agentic/core/types.ts";
import {
decideFromDossier,
rectificationFollowupCatalog,
type DecisionDossier,
} from "../src/lib/rectification-agentic/v9/decision-from-dossier.ts";
import { buildMethodFollowupPlan } from "../src/lib/rectification-agentic/v9/method-followup.ts";
import { focusStatusForAnswer } from "../src/lib/rectification-agentic/v9/choice-action.ts";
import { persistServerOwnedFocus } from "../src/lib/rectification-agentic/v9/server-focus.ts";
import { projectCurrentQuestion } from "../src/lib/rectification-agentic/v9/turn-decision.ts";
import {
shouldContinueAgentForDatedEvent,
shouldDeclineCollectFocus,
} from "../src/lib/rectification-agentic/v9/turn-intent-classifier.ts";
import {
applyCollectFocusDenial,
persistNextInterviewIfIdle,
} from "../src/lib/rectification-agentic/v9/answer-choice.ts";
import { evidenceLedgerFingerprint } from "../src/lib/rectification-agentic/v9/tool-service.ts";
import { RECTIFICATION_SKILL_VERSION } from "../src/lib/rectification-agentic/v9/case-status.ts";
import {
CASE_ID,
FOCUS_ID,
TURN_ID,
USER_ID,
candidateSnapshotFixture,
computeFixture,
dossierFixture,
fakeAccounting,
receiptHandlers,
} from "./rectification-v9-test-support.ts";
const EXISTENCE_OPTIONS = [
{ label: "明确发生且时间吻合", answer_class: "yes" as const },
{ label: "发生过但程度较弱", answer_class: "weak_yes" as const },
{ label: "明确没有发生", answer_class: "no" as const },
{ label: "这段记不清楚", answer_class: "unsure" as const },
];
const CANDIDATES = [
{ id: "04:47", time: "04:47", relative_support: 8 },
{ id: "04:51", time: "04:51", relative_support: 9 },
{ id: "04:53", time: "04:53", relative_support: 9 },
{ id: "04:59", time: "04:59", relative_support: 10 },
{ id: "05:00", time: "05:00", relative_support: 18 },
{ id: "05:07", time: "05:07", relative_support: 16 },
{ id: "05:12", time: "05:12", relative_support: 11 },
{ id: "05:14", time: "05:14", relative_support: 7 },
{ id: "05:15", time: "05:15", relative_support: 6 },
] as const;
const EVIDENCE = [
{
id: "e-career-entry",
status: "confirmed",
domain: "career",
datePrecision: "month",
occurredFrom: "2020-04-01",
occurredTo: null,
eventKind: "career_entry",
},
{
id: "e-career-exit",
status: "confirmed",
domain: "career",
datePrecision: "month",
occurredFrom: "2020-10-01",
occurredTo: null,
eventKind: "career_exit",
},
{
id: "e-rel-end",
status: "confirmed",
domain: "relationship",
datePrecision: "day",
occurredFrom: "2024-08-08",
occurredTo: null,
eventKind: "relationship_end",
},
{
id: "e-rel-start",
status: "confirmed",
domain: "relationship",
datePrecision: "month",
occurredFrom: "2024-05-01",
occurredTo: null,
eventKind: "relationship_start",
},
] as const;
const FAMILY_2021_COLLECT = {
year: 2021,
year_label: "2021 年前后",
domain: "family" as const,
event_family: "家人结婚、添丁或住院",
source: "age_band" as const,
tracks: ["vimshottari", "narayana"] as const,
tracks_agree: false,
unique_minute_claim: false as const,
user_meaning: "时间范围锁定 2021 年前后;领域锁定 family。",
role: "collect" as const,
phase: "evidence_collection" as const,
information_gain: 0,
semantic_key: "family.2021",
candidate_split_hash: "family:2021",
candidate_ids: [] as const,
expected_outcomes: [] as const,
choice_kind: "existence" as const,
};
function vargaExistence(input: {
layer: string;
gain: number;
domain: string;
question: string;
supports: readonly string[];
conflicts: readonly string[];
}): ConflictProbe {
const key = `varga.${input.layer}.${input.supports.join("/")}`;
return {
id: `contrast:${key}`,
semantic_key: key,
candidate_split_hash: key,
domain: input.domain,
year: 0,
question: input.question,
candidate_ids: [...new Set([...input.supports, ...input.conflicts])],
expected_outcomes: [
{ answer_class: "yes", supports: input.conflicts, conflicts: input.supports },
{ answer_class: "weak_yes", supports: input.conflicts, conflicts: [] },
{ answer_class: "no", supports: input.supports, conflicts: input.conflicts },
{ answer_class: "unsure", supports: [], conflicts: [] },
],
information_gain: input.gain,
source: "varga_contrast",
choice_kind: "existence",
style_options: EXISTENCE_OPTIONS,
};
}
const D9: ConflictProbe = {
id: "contrast:varga.d9.巨蟹座/狮子座",
semantic_key: "varga.d9.巨蟹座/狮子座",
candidate_split_hash: "varga.d9.巨蟹座/狮子座",
domain: "relationship",
year: 0,
question: "亲密关系里更接近下面哪一种相处方式?",
candidate_ids: ["05:00", "05:07"],
expected_outcomes: [
{ answer_class: "yes", supports: ["05:00"], conflicts: ["05:07"] },
{ answer_class: "weak_yes", supports: ["05:07"], conflicts: ["05:00"] },
{ answer_class: "no", supports: [], conflicts: [] },
{ answer_class: "unsure", supports: [], conflicts: [] },
],
information_gain: 1.1,
source: "varga_contrast",
choice_kind: "varga_style",
};
const D10: ConflictProbe = {
id: "contrast:varga.d10.天秤座/天蝎座",
semantic_key: "varga.d10.天秤座/天蝎座",
candidate_split_hash: "varga.d10.天秤座/天蝎座",
domain: "career",
year: 0,
question: "平时做事更接近下面哪一种职责风格?",
candidate_ids: ["05:00", "05:07"],
expected_outcomes: [
{ answer_class: "yes", supports: ["05:00"], conflicts: ["05:07"] },
{ answer_class: "weak_yes", supports: ["05:07"], conflicts: ["05:00"] },
{ answer_class: "no", supports: [], conflicts: [] },
{ answer_class: "unsure", supports: [], conflicts: [] },
],
information_gain: 1.05,
source: "varga_contrast",
choice_kind: "varga_style",
};
const CAREER_2023: ConflictProbe = {
id: "probe:career.2023.dasha_boundary",
semantic_key: "career.2023.dasha_boundary",
candidate_split_hash: "career.2023",
domain: "career",
year: 2023,
question: "2023 年前后有没有入职或换工作?",
candidate_ids: ["05:00", "05:07"],
expected_outcomes: [
{ answer_class: "yes", supports: ["05:00"], conflicts: ["05:07"] },
{ answer_class: "no", supports: ["05:07"], conflicts: ["05:00"] },
{ answer_class: "unsure", supports: [], conflicts: [] },
],
information_gain: 0.72,
source: "dasha_boundary",
choice_kind: "existence",
style_options: EXISTENCE_OPTIONS,
};
const RELOCATION_2015: ConflictProbe = {
id: "probe:relocation.2015.dasha_boundary",
semantic_key: "relocation.2015.dasha_boundary",
candidate_split_hash: "relocation.2015",
domain: "relocation",
year: 2015,
question: "2015 年前后有没有搬家或长期住到外地?",
candidate_ids: ["05:00", "05:07"],
expected_outcomes: [
{ answer_class: "yes", supports: ["05:00"], conflicts: ["05:07"] },
{ answer_class: "no", supports: ["05:07"], conflicts: ["05:00"] },
{ answer_class: "unsure", supports: [], conflicts: [] },
],
information_gain: 0.61,
source: "dasha_boundary",
choice_kind: "existence",
style_options: EXISTENCE_OPTIONS,
};
const D12 = vargaExistence({
layer: "d12",
gain: 1.89,
domain: "family",
question: "家里有没有结婚、添丁或住院这类事?",
supports: ["05:00", "05:07", "05:12"],
conflicts: ["04:47", "04:51", "04:53", "04:59"],
});
const D24 = vargaExistence({
layer: "d24",
gain: 2.5,
domain: "education",
question: "有没有学业或考试发挥明显失常、压力特别大的时候?",
supports: ["05:00"],
conflicts: ["05:07"],
});
const D7 = vargaExistence({
layer: "d7",
gain: 1.35,
domain: "family",
question: "有没有子女或子嗣相关的家里变化?",
supports: ["05:00", "05:07"],
conflicts: ["04:47"],
});
const D4 = vargaExistence({
layer: "d4",
gain: 0.99,
domain: "relocation",
question: "有没有搬家或长期住到外地?",
supports: ["05:00"],
conflicts: ["05:07"],
});
const D5 = vargaExistence({
layer: "d5",
gain: 0.5,
domain: "education",
question: "有没有记得住年份的升学或考试?",
supports: ["05:00"],
conflicts: ["05:07"],
});
const DATED_RELOCATION_2016: ConflictProbe = {
id: "probe:relocation.2016.dasha_boundary",
semantic_key: "relocation.2016.dasha_boundary",
candidate_split_hash: "relocation.2016",
domain: "relocation",
year: 2016,
question: "2016 年前后有没有搬家或长期住到外地?",
candidate_ids: [
"04:47", "04:51", "04:53", "04:59", "05:00", "05:07", "05:12", "05:14", "05:15",
],
expected_outcomes: [
{ answer_class: "yes", supports: ["04:47", "04:51", "04:53", "04:59"], conflicts: ["05:00", "05:07", "05:12", "05:14", "05:15"] },
{ answer_class: "no", supports: ["05:00", "05:07", "05:12", "05:14", "05:15"], conflicts: ["04:47", "04:51", "04:53", "04:59"] },
{ answer_class: "unsure", supports: [], conflicts: [] },
],
information_gain: 0.8,
source: "dasha_boundary",
choice_kind: "existence",
style_options: EXISTENCE_OPTIONS,
};
function revision5State(extraProbes: readonly ConflictProbe[] = []) {
const probes = [
D9,
D10,
CAREER_2023,
RELOCATION_2015,
D24,
D12,
D7,
D4,
D5,
...extraProbes,
];
let state = buildInferenceState({
range_start: "04:47",
range_end: "05:15",
candidates: CANDIDATES,
events: [
{ id: "e-career-entry", domain: "career", year: 2020, precision: "month" },
{ id: "e-career-exit", domain: "career", year: 2020, precision: "month" },
{ id: "e-rel-end", domain: "relationship", year: 2024, precision: "day" },
{ id: "e-rel-start", domain: "relationship", year: 2024, precision: "month" },
],
probes,
});
const answers: ReadonlyArray<{ id: string; answer: "yes" | "weak_yes" | "no" }> = [
{ id: D9.id, answer: "weak_yes" },
{ id: D10.id, answer: "weak_yes" },
{ id: CAREER_2023.id, answer: "no" },
{ id: RELOCATION_2015.id, answer: "no" },
];
for (const item of answers) {
state = applyAnswerToState(state, item.id, item.answer);
}
return state;
}
function revision5Dossier(
state = revision5State(),
extra?: { declinedTopics?: ReadonlyArray<Record<string, unknown>>; activeFocus?: DecisionDossier["conversationSummary"]["activeFocus"] },
): DecisionDossier {
return {
evidence: EVIDENCE,
conversationSummary: {
activeFocus: extra?.activeFocus ?? null,
declinedSkippedTopics: extra?.declinedTopics ?? [],
},
latestResult: {
resultId: "55555555-5555-4555-8555-555555555555",
candidates: state.candidates.map((item) => ({
candidateId: item.id,
time: item.time,
rank: item.rank,
relativeSupport: Math.round(item.posterior_score),
})),
representativeTime: state.representative_time,
evidenceLedgerFingerprint: evidenceLedgerFingerprint(EVIDENCE as never),
decisionReceipt: {
inference_state: state,
evidence_collection_probes: [FAMILY_2021_COLLECT],
},
},
case: { acceptedTime: null },
};
}
function planFrom(
dossier: DecisionDossier,
extra: Partial<Parameters<typeof buildMethodFollowupPlan>[0]> = {},
) {
const catalog = rectificationFollowupCatalog(dossier.latestResult, dossier.evidence);
return buildMethodFollowupPlan({
evidence: dossier.evidence,
declinedTopics: dossier.conversationSummary.declinedSkippedTopics,
closedCollectFocuses: dossier.conversationSummary.declinedSkippedTopics,
sessionOutcome: "discriminate_candidates",
...catalog,
candidatesSeparated: false,
...extra,
});
}
function rpcDossier(decision: DecisionDossier, activeFocus?: Record<string, unknown> | null) {
return dossierFixture({
evidence: EVIDENCE.map((item) => ({
id: item.id,
source_turn_id: TURN_ID,
subject: "self",
event_kind: item.eventKind,
domain: item.domain,
occurred_from: item.occurredFrom,
occurred_to: item.occurredTo,
date_precision: item.datePrecision,
summary: `${item.occurredFrom} ${item.eventKind}`,
status: item.status,
supersedes_evidence_id: null,
created_at: "2026-08-29T00:00:00.000Z",
})),
latestResult: candidateSnapshotFixture({
decisionReceipt: { ...(decision.latestResult?.decisionReceipt ?? {}) },
}),
conversationSummary: {
confirmed_evidence_summary: [],
pending_revisions: [],
active_focus: activeFocus ?? null,
declined_skipped_topics: decision.conversationSummary.declinedSkippedTopics,
candidate_divergence_summary: null,
missing_evidence_categories: [],
last_result_policy: null,
summary_version: 1,
updated_at: "2026-08-29T00:00:00.000Z",
},
});
}
test("skill version stays 10.0.13 for this collect-stall fix", () => {
assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.13");
});
test("revision 5 with uncovered relatives asks the dated family collect, not a yearless D12 card", () => {
const state = revision5State();
assert.equal(state.revision, 5);
assert.equal(state.answered_probes.length, 4);
assert.equal(state.events.find((item) => item.id === "e-rel-start")?.usage, "holdout");
const unanswered = state.probes.filter((probe) => (
!state.answered_probes.some((item) => item.probe_id === probe.id)
));
assert.equal(unanswered.some((item) => item.semantic_key.startsWith("varga.d12")), true);
assert.equal(unanswered.find((item) => item.semantic_key.startsWith("varga.d12"))?.information_gain, 1.89);
const plan = planFrom(revision5Dossier(state));
assert.equal(plan.methods.find((item) => item.method_id === "relatives")?.status, "uncovered");
assert.equal(plan.next_followup?.intent, "collect_method_evidence");
assert.equal(plan.next_followup?.domain, "family");
assert.equal(plan.next_followup?.choice_frame, null);
assert.equal(plan.next_followup?.probe_year, 2021);
assert.match(plan.next_followup?.year_label ?? "", /2021/);
assert.equal(plan.dropped_probes.some((item) => (
item.semantic_key.startsWith("varga.d12") && item.reason === "yearless_ungrounded_contrast"
)), true);
});
test("denying the dated family collect declines relatives and leaves the D12 card unasked", () => {
const plan = planFrom(revision5Dossier());
assert.equal(plan.next_followup?.domain, "family");
assert.equal(plan.next_followup?.choice_frame, null);
assert.equal(focusStatusForAnswer("no", "C"), "declined");
const next = planFrom(revision5Dossier(revision5State(), {
declinedTopics: [{ target_domain: "family", status: "declined" }],
}));
assert.equal(next.methods.find((item) => item.method_id === "relatives")?.status, "covered");
assert.notEqual(next.next_followup?.domain, "family");
if (next.next_followup?.choice_frame) {
assert.equal(next.next_followup.intent, "distinguish_candidates");
assert.doesNotMatch(next.next_followup.semantic_key ?? "", /^varga\.d(12|24|7|4|5)\./);
} else {
assert.equal(next.next_followup?.domain, "occupation");
assert.equal(next.next_followup?.intent, "collect_method_evidence");
}
});
test("persistServerOwnedFocus writes family as target_domain so a no answer becomes declined coverage", async () => {
const plan = planFrom(revision5Dossier());
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-08-29T00:00:00.000Z",
resolved_at: null,
},
idempotent: false,
}),
});
const persisted = await persistServerOwnedFocus({
accounting: accounting.client,
userId: USER_ID,
caseId: CASE_ID,
activeFocus: null,
decisionReceipt: revision5Dossier().latestResult?.decisionReceipt,
followup: plan.next_followup,
});
assert.equal(persisted.status, "created");
const call = accounting.calls.find((item) => item.fn === "set_agentic_rectification_conversation_focus");
assert.equal(call?.args.p_target_domain, "family");
assert.equal(call?.args.p_intent, "collect_method_evidence");
});
test("occupation collect denial declines the focus and advances coverage to horary", async () => {
assert.equal(shouldDeclineCollectFocus({
intent: "answer_current_focus",
answer_class: "no",
}), true);
assert.equal(shouldDeclineCollectFocus({
intent: "provide_new_evidence",
answer_class: null,
}), false);
assert.equal(shouldDeclineCollectFocus({
intent: "answer_current_focus",
answer_class: "unsure",
}), false);
const askedYearless = [D24, D12, D7, D4, D5].map((item) => item.semantic_key);
const occupationPlan = planFrom(revision5Dossier(revision5State(), {
declinedTopics: [{ target_domain: "family", status: "declined" }],
}), { askedProbeKeys: askedYearless });
assert.equal(occupationPlan.next_followup?.domain, "occupation");
assert.equal(occupationPlan.next_followup?.intent, "collect_method_evidence");
assert.equal(occupationPlan.next_followup?.choice_frame, null);
const occupationFocus = {
id: FOCUS_ID,
case_id: CASE_ID,
question_id: "occupation:occupation",
intent: "collect_method_evidence",
target_evidence_id: null,
target_domain: "occupation",
target_kind: null,
expected_answer_schema: {
prompt: "你长期做什么工作?",
collect: true,
},
status: "active",
asked_at: "2026-08-29T00:00:00.000Z",
resolved_at: null,
};
let loads = 0;
const accounting = fakeAccounting({
...receiptHandlers,
get_agentic_rectification_case_dossier: () => {
loads += 1;
if (loads === 1) {
return rpcDossier(revision5Dossier(revision5State(), {
declinedTopics: [{ target_domain: "family", status: "declined" }],
}), occupationFocus);
}
return rpcDossier(revision5Dossier(revision5State(), {
declinedTopics: [
{ target_domain: "family", status: "declined" },
{ target_domain: "occupation", status: "declined" },
],
}));
},
get_agentic_rectification_case_compute: () => computeFixture(),
resolve_agentic_rectification_conversation_focus: (_fn, args) => ({
focus_id: args.p_focus_id,
status: args.p_status,
evidence_id: null,
idempotent: false,
}),
set_agentic_rectification_conversation_focus: (_fn, args) => ({
focus: {
id: "acacacac-acac-4cac-8cac-acacacacacac",
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-08-29T00:00:00.000Z",
resolved_at: null,
},
idempotent: false,
}),
});
const applied = await applyCollectFocusDenial(accounting.client, {
userId: USER_ID,
caseId: CASE_ID,
focusId: FOCUS_ID,
});
const resolve = accounting.calls.find((item) => item.fn === "resolve_agentic_rectification_conversation_focus");
assert.equal(resolve?.args.p_status, "declined");
assert.notEqual(resolve?.args.p_status, "resolved");
const setFocus = accounting.calls.find((item) => item.fn === "set_agentic_rectification_conversation_focus");
assert.equal(setFocus?.args.p_target_domain, "horary");
assert.equal(setFocus?.args.p_intent, "collect_method_evidence");
assert.ok(applied.narration);
const horary = planFrom(revision5Dossier(revision5State(), {
declinedTopics: [
{ target_domain: "family", status: "declined" },
{ target_domain: "occupation", status: "declined" },
],
}), { askedProbeKeys: askedYearless });
assert.equal(horary.next_followup?.domain, "horary");
});
test("free-text turns persist the next followup so current_question is not null", async () => {
const route = readFileSync(new URL("../src/app/api/rectification/agent/route.ts", import.meta.url), "utf8");
const afterRun = route.slice(route.indexOf("const result = await runV9AgentTurn"));
assert.match(afterRun, /persistNextInterviewIfIdle/);
assert.ok(afterRun.indexOf("result.ok") < afterRun.indexOf("persistNextInterviewIfIdle"));
const occupationPlan = planFrom(revision5Dossier(revision5State(), {
declinedTopics: [{ target_domain: "family", status: "declined" }],
}));
assert.ok(occupationPlan.next_followup);
const accounting = fakeAccounting({
...receiptHandlers,
get_agentic_rectification_case_dossier: () => rpcDossier(revision5Dossier(revision5State(), {
declinedTopics: [{ target_domain: "family", status: "declined" }],
})),
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-08-29T00:00:00.000Z",
resolved_at: null,
},
idempotent: false,
}),
});
const persisted = await persistNextInterviewIfIdle({
accounting: accounting.client,
userId: USER_ID,
caseId: CASE_ID,
});
assert.equal(persisted.persisted, true);
assert.ok(persisted.hostNarration || persisted.choiceReady);
const setFocus = accounting.calls.find((item) => item.fn === "set_agentic_rectification_conversation_focus");
assert.ok(setFocus);
const question = projectCurrentQuestion({
id: FOCUS_ID,
questionId: String(setFocus?.args.p_question_id ?? ""),
intent: String(setFocus?.args.p_intent ?? ""),
targetDomain: typeof setFocus?.args.p_target_domain === "string" ? setFocus.args.p_target_domain : null,
expectedAnswerSchema: setFocus?.args.p_expected_answer_schema as Record<string, unknown>,
});
assert.notEqual(question, null);
assert.ok(question?.kind === "collect_spoken" || question?.kind === "choice");
});
test("coverage incomplete still prefers a dated discriminator over a same-turn yearless varga card", () => {
const state = revision5State([DATED_RELOCATION_2016]);
const plan = planFrom(revision5Dossier(state));
assert.equal(plan.next_followup?.semantic_key, DATED_RELOCATION_2016.semantic_key);
assert.ok(plan.next_followup?.choice_frame);
assert.notEqual(plan.next_followup?.source, "method_coverage");
assert.doesNotMatch(plan.next_followup?.semantic_key ?? "", /varga\.d12/);
});
test("yearless varga in another domain stays behind uncovered-method spoken collect", () => {
const evidence = [
{
id: "e-career",
status: "confirmed",
domain: "career",
datePrecision: "year" as const,
occurredFrom: "2020-01-01",
occurredTo: null,
eventKind: "career_entry",
},
{
id: "e-rel",
status: "confirmed",
domain: "relationship",
datePrecision: "year" as const,
occurredFrom: "2024-01-01",
occurredTo: null,
eventKind: "relationship_end",
},
{
id: "e-edu",
status: "confirmed",
domain: "education",
datePrecision: "year" as const,
occurredFrom: "2016-01-01",
occurredTo: null,
eventKind: "education_start",
},
{
id: "e-job",
status: "confirmed",
domain: "occupation",
datePrecision: "unknown" as const,
occurredFrom: null,
occurredTo: null,
eventKind: "occupation_note",
},
];
const plan = buildMethodFollowupPlan({
evidence,
contrastPacket: {
candidateSetVersion: "05:00-05:07",
vargaDifferences: [],
probes: [{
probeId: D24.id,
candidateSetVersion: "05:00-05:07",
question: D24.question,
expectedOutcomes: D24.expected_outcomes.map((row) => ({
outcomeId: row.answer_class,
supportsCandidateIds: [...row.supports],
conflictsCandidateIds: [...row.conflicts],
})),
candidateSplitHash: D24.candidate_split_hash,
informationGain: D24.information_gain,
sourceFeatures: [{ technique: "D24", calculationResultId: null }],
domain: "education",
year: null,
semanticKey: D24.semantic_key,
choiceKind: "existence",
styleOptions: EXISTENCE_OPTIONS.map((item) => ({
label: item.label,
answerClass: item.answer_class,
})),
}],
},
candidatesSeparated: false,
});
assert.equal(plan.next_followup?.domain, "family");
assert.doesNotMatch(plan.next_followup?.semantic_key ?? "", /varga\.d24/);
});
test("collect-focus classifier and resolve-focus copy do not treat explicit no as resolved", () => {
const route = readFileSync(new URL("../src/app/api/rectification/agent/route.ts", import.meta.url), "utf8");
const classifier = readFileSync(new URL("../src/lib/rectification-agentic/v9/turn-intent-classifier.ts", import.meta.url), "utf8");
const tools = readFileSync(new URL("../src/mastra/rectification-v9-tools.ts", import.meta.url), "utf8");
const fastPath = route.slice(
route.indexOf('if (action === "message")'),
route.indexOf("const requestTime"),
);
assert.match(fastPath, /isCollectFocusSchema/);
assert.match(fastPath, /shouldDeclineCollectFocus/);
assert.match(fastPath, /applyCollectFocusDenial/);
assert.match(classifier, /collect === true|isCollectFocusSchema/);
assert.doesNotMatch(classifier + fastPath, /USER_STOP_PATTERN|parseChoiceKeyFromUserMessage/);
const resolveTool = tools.slice(
tools.indexOf('id: "rectification-resolve-focus"'),
tools.indexOf("inputSchema: z.object({", tools.indexOf('id: "rectification-resolve-focus"')),
);
assert.match(resolveTool, /declined/);
assert.match(resolveTool, /采集题|确实没有/);
assert.match(resolveTool, /resolved 只用于已落证据|已落证据/);
assert.match(resolveTool, /没有 active focus|没有当前.*focus/);
});
test("has_new_dated_event continues into the agent after applying the answer", () => {
const route = readFileSync(new URL("../src/app/api/rectification/agent/route.ts", import.meta.url), "utf8");
const classifier = readFileSync(new URL("../src/lib/rectification-agentic/v9/turn-intent-classifier.ts", import.meta.url), "utf8");
const fastPath = route.slice(
route.indexOf('if (action === "message")'),
route.indexOf("const requestTime"),
);
assert.match(classifier, /has_new_dated_event/);
assert.match(classifier, /带大概时间的经历/);
assert.match(classifier, /intent 仍为 answer_current_focus/);
assert.match(classifier, /不要改成 provide_new_evidence/);
assert.doesNotMatch(classifier + fastPath, /USER_STOP_PATTERN|parseChoiceKeyFromUserMessage/);
assert.doesNotMatch(classifier + fastPath, /(?:userMessage|user_message|message)\.(?:match|search|includes|startsWith|endsWith)\(/);
assert.match(fastPath, /shouldContinueAgentForDatedEvent/);
assert.match(fastPath, /deferFollowup:\s*continueToAgent/);
const choiceApply = fastPath.slice(
fastPath.indexOf("if (classified.intent === \"answer_current_focus\")"),
fastPath.indexOf("if (classified.intent === \"stop_rectification\")"),
);
assert.ok(choiceApply.indexOf("applyRectificationChoice") < choiceApply.indexOf("if (!continueToAgent)"));
assert.match(choiceApply, /return completedMessageResponse\(applied\.narration/);
assert.ok(choiceApply.indexOf("if (!continueToAgent)") < choiceApply.indexOf("return completedMessageResponse(applied.narration"));
const collectApply = fastPath.slice(
fastPath.indexOf("if (shouldDeclineCollectFocus(classified))"),
fastPath.indexOf("} else {"),
);
assert.ok(collectApply.indexOf("applyCollectFocusDenial") < collectApply.indexOf("if (!continueToAgent)"));
assert.match(collectApply, /persistV9DeterministicTurn/);
assert.ok(collectApply.indexOf("if (!continueToAgent)") < collectApply.indexOf("persistV9DeterministicTurn"));
assert.ok(route.indexOf("if (action === \"message\")") < route.indexOf("runV9AgentTurn({"));
const answerChoice = readFileSync(new URL("../src/lib/rectification-agentic/v9/answer-choice.ts", import.meta.url), "utf8");
const persistApplied = answerChoice.slice(answerChoice.indexOf("async function persistApplied"));
assert.match(persistApplied, /command\.deferFollowup !== true/);
assert.equal(shouldContinueAgentForDatedEvent({
intent: "answer_current_focus",
answer_class: "no",
has_new_dated_event: true,
}), true);
assert.equal(shouldContinueAgentForDatedEvent({
intent: "answer_current_focus",
answer_class: "no",
}), false);
});
test("collect denial with a new dated event does not persist the next interview before the agent", async () => {
const occupationFocus = {
id: FOCUS_ID,
case_id: CASE_ID,
question_id: "occupation:occupation",
intent: "collect_method_evidence",
target_evidence_id: null,
target_domain: "occupation",
target_kind: null,
expected_answer_schema: {
prompt: "你长期做什么工作?",
collect: true,
},
status: "active",
asked_at: "2026-08-29T00:00:00.000Z",
resolved_at: null,
};
let loads = 0;
const accounting = fakeAccounting({
...receiptHandlers,
get_agentic_rectification_case_dossier: () => {
loads += 1;
if (loads === 1) {
return rpcDossier(revision5Dossier(revision5State(), {
declinedTopics: [{ target_domain: "family", status: "declined" }],
}), occupationFocus);
}
return rpcDossier(revision5Dossier(revision5State(), {
declinedTopics: [
{ target_domain: "family", status: "declined" },
{ target_domain: "occupation", status: "declined" },
],
}));
},
get_agentic_rectification_case_compute: () => computeFixture(),
resolve_agentic_rectification_conversation_focus: (_fn, args) => ({
focus_id: args.p_focus_id,
status: args.p_status,
evidence_id: null,
idempotent: false,
}),
set_agentic_rectification_conversation_focus: (_fn, args) => ({
focus: {
id: "acacacac-acac-4cac-8cac-acacacacacac",
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-08-29T00:00:00.000Z",
resolved_at: null,
},
idempotent: false,
}),
});
const applied = await applyCollectFocusDenial(accounting.client, {
userId: USER_ID,
caseId: CASE_ID,
focusId: FOCUS_ID,
deferFollowup: true,
});
const resolve = accounting.calls.find((item) => item.fn === "resolve_agentic_rectification_conversation_focus");
assert.equal(resolve?.args.p_status, "declined");
assert.equal(
accounting.calls.some((item) => item.fn === "set_agentic_rectification_conversation_focus"),
false,
);
assert.equal(applied.nextInterviewPersisted, false);
});
test("persistNextInterviewIfIdle uses the dossier decision sessionOutcome once", async () => {
const source = readFileSync(new URL("../src/lib/rectification-agentic/v9/answer-choice.ts", import.meta.url), "utf8");
const idle = source.slice(
source.indexOf("export async function persistNextInterviewIfIdle"),
source.indexOf("async function persistApplied"),
);
assert.equal(idle.split("decideFromDossier").length - 1, 1);
assert.match(idle, /sessionOutcome:\s*decision\.sessionOutcome/);
assert.doesNotMatch(idle, /sessionOutcome:\s*"collect_evidence"/);
assert.match(idle, /publicNextAction\(decision\)/);
const covered = revision5Dossier(revision5State([DATED_RELOCATION_2016]), {
declinedTopics: [
{ target_domain: "family", status: "declined" },
{ target_domain: "occupation", status: "declined" },
{ target_domain: "horary", status: "declined" },
],
});
const decision = decideFromDossier(covered);
assert.notEqual(decision.sessionOutcome, "collect_evidence");
const catalog = rectificationFollowupCatalog(covered.latestResult, covered.evidence);
const collectPlan = buildMethodFollowupPlan({
evidence: covered.evidence,
declinedTopics: covered.conversationSummary.declinedSkippedTopics,
sessionOutcome: "collect_evidence",
...catalog,
candidatesSeparated: false,
});
const decisionPlan = buildMethodFollowupPlan({
evidence: covered.evidence,
declinedTopics: covered.conversationSummary.declinedSkippedTopics,
sessionOutcome: decision.sessionOutcome,
...catalog,
candidatesSeparated: false,
});
assert.ok(decisionPlan.next_followup);
assert.notEqual(collectPlan.next_followup?.intent, decisionPlan.next_followup?.intent);
const accounting = fakeAccounting({
...receiptHandlers,
get_agentic_rectification_case_dossier: () => rpcDossier(covered),
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-08-29T00:00:00.000Z",
resolved_at: null,
},
idempotent: false,
}),
});
const persisted = await persistNextInterviewIfIdle({
accounting: accounting.client,
userId: USER_ID,
caseId: CASE_ID,
});
assert.equal(persisted.persisted, true);
assert.ok(persisted.hostNarration || persisted.choiceReady);
const setFocus = accounting.calls.find((item) => item.fn === "set_agentic_rectification_conversation_focus");
assert.ok(setFocus);
assert.notEqual(setFocus?.args.p_intent, "collect_method_evidence");
});