Files
Jyotisha/frontend/tests/rectification-collect-stall.test.ts
T
Jesse_ChenandCursor 02d4c91626
Independent Staging Quality Gate / validate (push) Successful in 11m40s
Independent Staging Quality Gate / publish (push) Successful in 9m36s
fix(rectification): stop occupation coverage from locking questions and the range exit
Occupation answers were stored as career, so coverage never closed, yearless
cards never fired, and the decision layer suppressed an engine-allowed range.
Normalize occupation-collect writes, allow yearless cards once training is
open, offer a range when no distinguish card remains, and align the public
house table to the representative minute.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-30 03:05:28 +08:00

949 lines
35 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 { applyChoiceWithoutEvidence } from "../src/lib/rectification-agentic/v9/inference-adapter.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 { serverOwnedChoiceCopy } from "../src/lib/rectification-agentic/v9/choice-card.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: ["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.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 yearless D12 distinguish card, not a family spoken collect", () => {
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, "distinguish_candidates");
assert.match(plan.next_followup?.semantic_key ?? "", /^varga\.d12/);
assert.ok(plan.next_followup?.choice_frame);
assert.notEqual(plan.next_followup?.source, "method_coverage");
const copy = serverOwnedChoiceCopy(plan.next_followup!.choice_frame!);
assert.ok(copy?.options.some((item) => item.label === "明确没有发生" && item.answer_class === "no"));
assert.equal(copy?.options.find((item) => item.key === "C")?.answer_class, "no");
});
test("answering C on the D12 card declines the family focus and scores the no branch", () => {
const state = revision5State();
const plan = planFrom(revision5Dossier(state));
assert.match(plan.next_followup?.semantic_key ?? "", /^varga\.d12/);
assert.equal(plan.next_followup?.domain, "family");
const copy = serverOwnedChoiceCopy(plan.next_followup!.choice_frame!);
assert.equal(copy?.options.find((item) => item.key === "C")?.answer_class, "no");
assert.equal(focusStatusForAnswer("no", "C"), "declined");
const applied = applyChoiceWithoutEvidence(state, {
choiceKey: "C",
schema: {
choice: copy,
probe_id: D12.id,
semantic_key: D12.semantic_key,
candidate_split_hash: D12.candidate_split_hash,
},
});
assert.equal(applied.applied, true);
assert.equal(applied.answerClass, "no");
assert.equal(applied.probeId, D12.id);
const recorded = applied.state.answered_probes.find((item) => item.probe_id === D12.id);
assert.equal(recorded?.answer_class, "no");
const noBranch = D12.expected_outcomes.find((item) => item.answer_class === "no");
assert.deepEqual(noBranch?.supports, ["05:00", "05:07", "05:12"]);
assert.deepEqual(noBranch?.conflicts, ["04:47", "04:51", "04:53", "04:59"]);
const next = planFrom(revision5Dossier(applied.state, {
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.match(next.next_followup.semantic_key ?? "", /^varga\.d(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, "distinguish_candidates");
});
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);
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);
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");
});