Files
Jyotisha/frontend/tests/rectification-v10-conversation-focus.test.ts
T
Jesse_ChenandClaude Opus 5.5 3bbe65a215
Independent Staging Quality Gate / validate (push) Successful in 13m37s
Independent Staging Quality Gate / publish (push) Successful in 3m37s
fix(rectification): out-of-window event dates no longer fail every rescore; future dates refused at intake; corrections revise without a focus (BUG-1178, BUG-1179)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017eEAG8HD3mm8gsKXgk8uU8
2026-10-02 09:03:22 +08:00

495 lines
18 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.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,
}),
});
// BUG-1179: the revise below quotes this turn's user message (grounding replaces the focus requirement).
const tools = createRectificationV9Tools({
userId: USER_ID, caseId: CASE_ID, turnId: TURN_ID, accounting: accounting.client as never,
userMessage: "不是9月,是10月",
});
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 },
// BUG-1179 — 原值: 两次写(confirm、revise) / 新值: revise 后再确认新 revision(第三次写,仍带同一 focus)
// / 原因: 产品 10-02 选 A,更正立即生效并重算。
{ fn: "confirm_agentic_rectification_evidence_v10", focusId: FOCUS_ID, evidenceId: SECOND_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,
);
});