0be51e65f5
Move nextDatedCollectFollowup after family coverage so a new case still asks D9 then D10. Dated third-event collect stays education → finance → relocation → health. Admin input padding contract follows --space-3. BUG-531 and BUG-532. Co-authored-by: Cursor <cursoragent@cursor.com>
488 lines
17 KiB
TypeScript
488 lines
17 KiB
TypeScript
import assert from "node:assert/strict";
|
||
import test from "node:test";
|
||
|
||
import {
|
||
confirmV10Evidence,
|
||
recordV10EvidenceBatch,
|
||
resolveV10ConversationFocus,
|
||
reviseV10Evidence,
|
||
setV10ConversationFocus,
|
||
type V10EvidenceBatchItem,
|
||
} from "../src/lib/rectification-agentic/v9/tool-service.ts";
|
||
import { createRectificationV9Tools } from "../src/mastra/rectification-v9-tools.ts";
|
||
import {
|
||
CASE_ID,
|
||
EVIDENCE_ID,
|
||
FOCUS_ID,
|
||
RESULT_ID,
|
||
TURN_ID,
|
||
USER_ID,
|
||
activeFocusFixture,
|
||
candidateSnapshotFixture,
|
||
conversationSummaryFixture,
|
||
dossierFixture,
|
||
fakeAccounting,
|
||
receiptHandlers,
|
||
} from "./rectification-v9-test-support.ts";
|
||
import { buildInferenceState } from "../src/lib/rectification-agentic/core/build-state.ts";
|
||
import { USER_COLLECT_QUESTION } from "../src/lib/rectification-agentic/user-copy.ts";
|
||
|
||
type ExecutableTool<T = unknown> = {
|
||
execute(input: unknown): Promise<T>;
|
||
};
|
||
|
||
const SECOND_EVIDENCE_ID = "45454545-4545-4454-8454-454545454545";
|
||
|
||
function toolSet(accounting: ReturnType<typeof fakeAccounting>) {
|
||
return createRectificationV9Tools({
|
||
userId: USER_ID,
|
||
caseId: CASE_ID,
|
||
turnId: TURN_ID,
|
||
accounting: accounting.client as never,
|
||
});
|
||
}
|
||
|
||
test("set/resolve focus services preserve the exact durable RPC contract", async () => {
|
||
const accounting = fakeAccounting({
|
||
set_agentic_rectification_conversation_focus: () => ({
|
||
focus: activeFocusFixture({
|
||
targetEvidenceId: EVIDENCE_ID,
|
||
expectedAnswerSchema: { required: ["month"] },
|
||
}),
|
||
idempotent: false,
|
||
}),
|
||
resolve_agentic_rectification_conversation_focus: () => ({
|
||
focus_id: FOCUS_ID,
|
||
status: "declined",
|
||
evidence_id: EVIDENCE_ID,
|
||
idempotent: true,
|
||
}),
|
||
});
|
||
|
||
const setResult = await setV10ConversationFocus(accounting.client, USER_ID, CASE_ID, {
|
||
questionId: "career-month-question",
|
||
intent: "clarify_event_date",
|
||
targetEvidenceId: EVIDENCE_ID,
|
||
targetDomain: "career",
|
||
targetKind: "career_entry",
|
||
expectedAnswerSchema: { required: ["month"] },
|
||
});
|
||
assert.equal(setResult.focus.id, FOCUS_ID);
|
||
assert.equal(setResult.focus.targetEvidenceId, EVIDENCE_ID);
|
||
assert.deepEqual(setResult.focus.expectedAnswerSchema, { required: ["month"] });
|
||
assert.equal(setResult.idempotent, false);
|
||
assert.deepEqual(accounting.calls[0], {
|
||
fn: "set_agentic_rectification_conversation_focus",
|
||
args: {
|
||
p_user_id: USER_ID,
|
||
p_case_id: CASE_ID,
|
||
p_question_id: "career-month-question",
|
||
p_intent: "clarify_event_date",
|
||
p_target_evidence_id: EVIDENCE_ID,
|
||
p_target_domain: "career",
|
||
p_target_kind: "career_entry",
|
||
p_expected_answer_schema: { required: ["month"] },
|
||
p_asked_turn_id: null,
|
||
},
|
||
});
|
||
|
||
const resolved = await resolveV10ConversationFocus(accounting.client, USER_ID, CASE_ID, {
|
||
focusId: FOCUS_ID,
|
||
status: "declined",
|
||
evidenceId: EVIDENCE_ID,
|
||
});
|
||
assert.deepEqual(resolved, {
|
||
focusId: FOCUS_ID,
|
||
status: "declined",
|
||
evidenceId: EVIDENCE_ID,
|
||
idempotent: true,
|
||
});
|
||
assert.deepEqual(accounting.calls[1], {
|
||
fn: "resolve_agentic_rectification_conversation_focus",
|
||
args: {
|
||
p_user_id: USER_ID,
|
||
p_case_id: CASE_ID,
|
||
p_focus_id: FOCUS_ID,
|
||
p_status: "declined",
|
||
p_evidence_id: EVIDENCE_ID,
|
||
p_answer_option: null,
|
||
},
|
||
});
|
||
});
|
||
|
||
test("set/resolve focus tools return safe projections and never infer the active focus", async () => {
|
||
const accounting = fakeAccounting({
|
||
...receiptHandlers,
|
||
get_agentic_rectification_case_dossier: () => dossierFixture({
|
||
latestResult: candidateSnapshotFixture(),
|
||
}),
|
||
set_agentic_rectification_conversation_focus: (_fn, args) => ({
|
||
focus: activeFocusFixture({
|
||
questionId: String(args.p_question_id),
|
||
intent: String(args.p_intent),
|
||
targetEvidenceId: args.p_target_evidence_id ? String(args.p_target_evidence_id) : null,
|
||
targetDomain: typeof args.p_target_domain === "string" ? args.p_target_domain : null,
|
||
targetKind: args.p_target_kind == null ? null : String(args.p_target_kind),
|
||
expectedAnswerSchema: (args.p_expected_answer_schema as Record<string, unknown>) ?? {},
|
||
}),
|
||
idempotent: false,
|
||
}),
|
||
resolve_agentic_rectification_conversation_focus: (_fn, args) => ({
|
||
focus_id: args.p_focus_id,
|
||
status: args.p_status,
|
||
evidence_id: args.p_evidence_id,
|
||
idempotent: false,
|
||
}),
|
||
});
|
||
const tools = toolSet(accounting);
|
||
|
||
const setResult = await (tools["rectification-set-focus"] as unknown as ExecutableTool<Record<string, unknown>>).execute({
|
||
caseId: CASE_ID,
|
||
questionId: "career-month-question",
|
||
intent: "clarify_event_date",
|
||
// 原值: USER_COLLECT_QUESTION.family(3847e9c9;45bdb63e 为「哪年上的大学」)
|
||
// 新值: USER_COLLECT_QUESTION.relationship
|
||
// 原因: dated 分支移到家人之后,前两问恢复感情;服务端下一问仍是采集,BUG-529 要求题干命中感情词。
|
||
spokenPrompt: USER_COLLECT_QUESTION.relationship,
|
||
}) as Record<string, unknown>;
|
||
assert.equal(setResult.ok, undefined);
|
||
assert.equal(setResult.focus_id, FOCUS_ID);
|
||
assert.equal(setResult.error, undefined);
|
||
const schema = setResult.expected_answer_schema as Record<string, unknown>;
|
||
assert.equal(schema.collect, true);
|
||
assert.equal(typeof schema.prompt, "string");
|
||
assert.equal(schema.choice, undefined);
|
||
|
||
const resolved = await (tools["rectification-resolve-focus"] as unknown as ExecutableTool<Record<string, unknown>>).execute({
|
||
caseId: CASE_ID,
|
||
focusId: FOCUS_ID,
|
||
status: "skipped",
|
||
evidenceId: EVIDENCE_ID,
|
||
});
|
||
assert.deepEqual(resolved, {
|
||
focus_id: FOCUS_ID,
|
||
evidence_id: EVIDENCE_ID,
|
||
status: "skipped",
|
||
idempotent: false,
|
||
});
|
||
const resolveCall = accounting.calls.find((call) => call.fn === "resolve_agentic_rectification_conversation_focus");
|
||
assert.equal(resolveCall?.args.p_focus_id, FOCUS_ID);
|
||
assert.equal(resolveCall?.args.p_evidence_id, EVIDENCE_ID);
|
||
});
|
||
|
||
test("confirm and revise services bind both focusId and evidenceId", async () => {
|
||
const accounting = fakeAccounting({
|
||
confirm_agentic_rectification_evidence_v10: () => ({
|
||
focus_id: FOCUS_ID,
|
||
evidence_id: EVIDENCE_ID,
|
||
status: "confirmed",
|
||
idempotent: false,
|
||
}),
|
||
revise_agentic_rectification_evidence_v10: () => ({
|
||
focus_id: FOCUS_ID,
|
||
evidence_id: SECOND_EVIDENCE_ID,
|
||
supersedes_evidence_id: EVIDENCE_ID,
|
||
idempotent: true,
|
||
}),
|
||
});
|
||
|
||
const confirmed = await confirmV10Evidence(accounting.client, USER_ID, CASE_ID, FOCUS_ID, EVIDENCE_ID);
|
||
assert.deepEqual(confirmed, {
|
||
focusId: FOCUS_ID,
|
||
evidenceId: EVIDENCE_ID,
|
||
status: "confirmed",
|
||
idempotent: false,
|
||
});
|
||
|
||
const revised = await reviseV10Evidence(accounting.client, USER_ID, CASE_ID, {
|
||
focusId: FOCUS_ID,
|
||
evidenceId: EVIDENCE_ID,
|
||
quote: "不是9月,是10月",
|
||
occurredFrom: "2016-10-01",
|
||
occurredTo: null,
|
||
datePrecision: "month",
|
||
summary: "更正为2016年10月去北京工作",
|
||
});
|
||
assert.deepEqual(revised, {
|
||
focusId: FOCUS_ID,
|
||
evidenceId: SECOND_EVIDENCE_ID,
|
||
supersedesEvidenceId: EVIDENCE_ID,
|
||
idempotent: true,
|
||
});
|
||
|
||
const confirmCall = accounting.calls.find((call) => call.fn === "confirm_agentic_rectification_evidence_v10");
|
||
assert.equal(confirmCall?.args.p_focus_id, FOCUS_ID);
|
||
assert.equal(confirmCall?.args.p_evidence_id, EVIDENCE_ID);
|
||
const reviseCall = accounting.calls.find((call) => call.fn === "revise_agentic_rectification_evidence_v10");
|
||
assert.equal(reviseCall?.args.p_focus_id, FOCUS_ID);
|
||
assert.equal(reviseCall?.args.p_evidence_id, EVIDENCE_ID);
|
||
});
|
||
|
||
test("confirm/revise tools forward the durable focus/evidence binding", async () => {
|
||
const accounting = fakeAccounting({
|
||
...receiptHandlers,
|
||
get_agentic_rectification_case_dossier: () => dossierFixture(),
|
||
confirm_agentic_rectification_evidence_v10: (_fn, args) => ({
|
||
focus_id: args.p_focus_id,
|
||
evidence_id: args.p_evidence_id,
|
||
status: "confirmed",
|
||
idempotent: false,
|
||
}),
|
||
revise_agentic_rectification_evidence_v10: (_fn, args) => ({
|
||
focus_id: args.p_focus_id,
|
||
evidence_id: SECOND_EVIDENCE_ID,
|
||
supersedes_evidence_id: args.p_evidence_id,
|
||
idempotent: false,
|
||
}),
|
||
});
|
||
const tools = toolSet(accounting);
|
||
|
||
await (tools["rectification-confirm-evidence"] as unknown as ExecutableTool).execute({
|
||
caseId: CASE_ID,
|
||
focusId: FOCUS_ID,
|
||
evidenceId: EVIDENCE_ID,
|
||
});
|
||
await (tools["rectification-revise-evidence"] as unknown as ExecutableTool).execute({
|
||
caseId: CASE_ID,
|
||
focusId: FOCUS_ID,
|
||
evidenceId: EVIDENCE_ID,
|
||
quote: "不是9月,是10月",
|
||
datePrecision: "month",
|
||
occurredFrom: "2016-10",
|
||
summary: "更正为2016年10月去北京工作",
|
||
});
|
||
|
||
const writes = accounting.calls.filter((call) =>
|
||
call.fn === "confirm_agentic_rectification_evidence_v10"
|
||
|| call.fn === "revise_agentic_rectification_evidence_v10");
|
||
assert.deepEqual(writes.map((call) => ({
|
||
fn: call.fn,
|
||
focusId: call.args.p_focus_id,
|
||
evidenceId: call.args.p_evidence_id,
|
||
})), [
|
||
{ fn: "confirm_agentic_rectification_evidence_v10", focusId: FOCUS_ID, evidenceId: EVIDENCE_ID },
|
||
{ fn: "revise_agentic_rectification_evidence_v10", focusId: FOCUS_ID, evidenceId: EVIDENCE_ID },
|
||
]);
|
||
});
|
||
|
||
test("batch preserves three independent items, parses all outcomes, and keeps item idempotency stable", async () => {
|
||
let invocation = 0;
|
||
const accounting = fakeAccounting({
|
||
record_agentic_rectification_evidence_batch: () => {
|
||
invocation += 1;
|
||
return {
|
||
items: [
|
||
{
|
||
index: 0,
|
||
outcome: "accepted",
|
||
evidence_id: EVIDENCE_ID,
|
||
status: "confirmed",
|
||
idempotent: invocation > 1,
|
||
clarification_fields: [],
|
||
error_code: null,
|
||
},
|
||
{
|
||
index: 1,
|
||
outcome: "needs_clarification",
|
||
evidence_id: null,
|
||
status: "pending_confirmation",
|
||
idempotent: invocation > 1,
|
||
clarification_fields: ["occurred_from"],
|
||
error_code: null,
|
||
},
|
||
{
|
||
index: 2,
|
||
outcome: "rejected",
|
||
evidence_id: null,
|
||
status: "rejected",
|
||
idempotent: invocation > 1,
|
||
clarification_fields: [],
|
||
error_code: "quote_not_grounded",
|
||
},
|
||
],
|
||
accepted_count: 1,
|
||
needs_clarification_count: 1,
|
||
rejected_count: 1,
|
||
focus_id: FOCUS_ID,
|
||
};
|
||
},
|
||
});
|
||
const items: V10EvidenceBatchItem[] = [
|
||
{
|
||
quote: "2016年9月去北京工作",
|
||
subject: "self" as const,
|
||
eventKind: "career_entry" as const,
|
||
domain: "career",
|
||
occurredFrom: "2016-09-01",
|
||
occurredTo: null,
|
||
datePrecision: "month",
|
||
summary: "2016年9月去北京工作",
|
||
},
|
||
{
|
||
quote: "后来搬过一次家",
|
||
subject: "self" as const,
|
||
eventKind: "relocation" as const,
|
||
domain: "relocation",
|
||
occurredFrom: null,
|
||
occurredTo: null,
|
||
datePrecision: "unknown",
|
||
summary: "后来搬过一次家",
|
||
},
|
||
{
|
||
quote: "助手猜测我2020年结婚",
|
||
subject: "self" as const,
|
||
eventKind: "relationship_commitment",
|
||
domain: "relationship",
|
||
occurredFrom: "2020-01-01",
|
||
occurredTo: null,
|
||
datePrecision: "year",
|
||
summary: "助手猜测的婚姻事件",
|
||
},
|
||
];
|
||
|
||
const first = await recordV10EvidenceBatch(accounting.client, USER_ID, CASE_ID, TURN_ID, FOCUS_ID, items);
|
||
const second = await recordV10EvidenceBatch(accounting.client, USER_ID, CASE_ID, TURN_ID, FOCUS_ID, items);
|
||
|
||
assert.deepEqual(first.items.map((item) => item.outcome), [
|
||
"accepted",
|
||
"needs_clarification",
|
||
"rejected",
|
||
]);
|
||
assert.deepEqual(first.items.map((item) => item.idempotent), [false, false, false]);
|
||
assert.deepEqual(second.items.map((item) => item.idempotent), [true, true, true]);
|
||
assert.deepEqual({
|
||
accepted: first.acceptedCount,
|
||
needsClarification: first.needsClarificationCount,
|
||
rejected: first.rejectedCount,
|
||
focusId: first.focusId,
|
||
}, {
|
||
accepted: 1,
|
||
needsClarification: 1,
|
||
rejected: 1,
|
||
focusId: FOCUS_ID,
|
||
});
|
||
assert.deepEqual(first.items[1]?.clarificationFields, ["occurred_from"]);
|
||
assert.equal(first.items[2]?.errorCode, "quote_not_grounded");
|
||
|
||
const calls = accounting.calls.filter((call) => call.fn === "record_agentic_rectification_evidence_batch");
|
||
assert.equal(calls.length, 2);
|
||
const firstItems = calls[0]!.args.p_items as Array<Record<string, unknown>>;
|
||
const secondItems = calls[1]!.args.p_items as Array<Record<string, unknown>>;
|
||
assert.deepEqual(firstItems.map((item) => item.quote), items.map((item) => item.quote));
|
||
assert.deepEqual(firstItems.map((item) => item.event_kind), items.map((item) => item.eventKind));
|
||
assert.equal(new Set(firstItems.map((item) => item.idempotency_key)).size, 3);
|
||
assert.deepEqual(
|
||
secondItems.map((item) => item.idempotency_key),
|
||
firstItems.map((item) => item.idempotency_key),
|
||
);
|
||
});
|
||
|
||
test("resolve-focus C without new evidence appends an inference transition", async () => {
|
||
const inference = buildInferenceState({
|
||
range_start: "04:50",
|
||
range_end: "05:10",
|
||
candidates: [
|
||
{ id: "05:00", time: "05:00", relative_support: 10 },
|
||
{ id: "05:10", time: "05:10", relative_support: 10 },
|
||
],
|
||
events: [
|
||
{ id: "e1", domain: "education", year: 2016, precision: "month" },
|
||
{ id: "e2", domain: "career", year: 2018, precision: "year" },
|
||
{ id: "e3", domain: "relationship", year: 2021, precision: "year" },
|
||
{ id: "e4", domain: "family", year: 2023, precision: "year" },
|
||
],
|
||
probes: [{
|
||
id: "p-cd",
|
||
semantic_key: "career.2019",
|
||
candidate_split_hash: "05:00|05:10",
|
||
domain: "career",
|
||
year: 2019,
|
||
question: "2019 年前后有没有入职或职责加重?",
|
||
candidate_ids: ["05:00", "05:10"],
|
||
expected_outcomes: [
|
||
{ answer_class: "yes", supports: ["05:00"], conflicts: ["05:10"] },
|
||
{ answer_class: "no", supports: ["05:10"], conflicts: ["05:00"] },
|
||
{ answer_class: "unsure", supports: [], conflicts: [] },
|
||
],
|
||
information_gain: 0.4,
|
||
source: "dasha_boundary",
|
||
}],
|
||
});
|
||
const snapshot = candidateSnapshotFixture();
|
||
Object.assign(snapshot.decision_receipt, { inference_state: inference });
|
||
let appendedState: unknown = null;
|
||
const accounting = fakeAccounting({
|
||
...receiptHandlers,
|
||
get_agentic_rectification_case_dossier: () => dossierFixture({
|
||
latestResult: snapshot,
|
||
conversationSummary: conversationSummaryFixture({
|
||
activeFocus: activeFocusFixture({
|
||
expectedAnswerSchema: {
|
||
choice: {
|
||
prompt: "2019 年前后有没有入职或职责加重?",
|
||
option_a: "是,大概就在那段时间",
|
||
option_b: "有类似,但年份不对或不够重大",
|
||
option_c: "没有明显发生",
|
||
option_d: "不记得 / 不确定",
|
||
options: [
|
||
{ key: "A", label: "是,大概就在那段时间", answer_class: "yes" },
|
||
{ key: "B", label: "有类似,但年份不对或不够重大", answer_class: "weak_yes" },
|
||
{ key: "C", label: "没有明显发生", answer_class: "no" },
|
||
{ key: "D", label: "不记得 / 不确定", answer_class: "unsure" },
|
||
],
|
||
},
|
||
probe_id: "p-cd",
|
||
semantic_key: "career.2019",
|
||
},
|
||
}),
|
||
}),
|
||
}),
|
||
append_agentic_rectification_inference_transition: (_fn, args) => {
|
||
appendedState = args.p_inference_state;
|
||
return {
|
||
transition_id: "99999999-9999-4999-8999-999999999999",
|
||
result_id: RESULT_ID,
|
||
revision: (args.p_expected_revision as number) + 1,
|
||
idempotent: false,
|
||
decision_state_fingerprint: args.p_decision_state_fingerprint,
|
||
decision_receipt: {
|
||
...snapshot.decision_receipt,
|
||
inference_state: args.p_inference_state,
|
||
decision_state_fingerprint: args.p_decision_state_fingerprint,
|
||
},
|
||
};
|
||
},
|
||
resolve_agentic_rectification_conversation_focus: (_fn, args) => ({
|
||
focus_id: args.p_focus_id,
|
||
status: args.p_status,
|
||
evidence_id: args.p_evidence_id,
|
||
idempotent: false,
|
||
}),
|
||
});
|
||
const tools = toolSet(accounting);
|
||
const resolved = await (tools["rectification-resolve-focus"] as unknown as ExecutableTool<Record<string, unknown>>).execute({
|
||
caseId: CASE_ID,
|
||
focusId: FOCUS_ID,
|
||
status: "declined",
|
||
choiceKey: "C",
|
||
});
|
||
assert.equal(resolved.status, "declined");
|
||
assert.equal(resolved.evidence_id, null);
|
||
assert.ok(resolved.inference_state);
|
||
assert.ok(appendedState);
|
||
const answers = (appendedState as { answered_probes?: Array<{ answer_class?: string; semantic_key?: string }> }).answered_probes ?? [];
|
||
assert.equal(answers.some((item) => item.semantic_key === "career.2019" && item.answer_class === "no"), true);
|
||
const appendCall = accounting.calls.find((call) => call.fn === "append_agentic_rectification_inference_transition");
|
||
assert.ok(appendCall);
|
||
assert.equal(appendCall.args.p_probe_id, "p-cd");
|
||
assert.equal(appendCall.args.p_reason, "choice");
|
||
assert.equal(
|
||
accounting.calls.some((call) => call.fn === "persist_agentic_rectification_candidate_v2"),
|
||
false,
|
||
);
|
||
assert.equal(
|
||
accounting.calls.some((call) => call.fn === "patch_agentic_rectification_inference_state"),
|
||
false,
|
||
);
|
||
});
|