b43808b016
Only sign-bound varga_style questions may omit a concrete period. Remaining-layer existence and quality probes now drop as yearless_ungrounded_contrast instead of scoring by group order. Co-authored-by: Cursor <cursoragent@cursor.com>
927 lines
34 KiB
TypeScript
927 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: ["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 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);
|
|
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");
|
|
});
|