Files
Jyotisha/frontend/tests/rectification-v10-conversation-focus.test.ts
T
Jesse_Chen 0be51e65f5
Independent Staging Quality Gate / validate (push) Successful in 10m6s
Independent Staging Quality Gate / publish (push) Has been cancelled
fix(rectification): ask relationship and career before dated collect
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>
2026-09-04 19:06:54 +08:00

488 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import assert from "node:assert/strict";
import 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.family3847e9c945bdb63e 为「哪年上的大学」)
// 新值: 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,
);
});