Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017eEAG8HD3mm8gsKXgk8uU8
482 lines
19 KiB
TypeScript
482 lines
19 KiB
TypeScript
import assert from "node:assert/strict";
|
||
import test from "node:test";
|
||
|
||
import {
|
||
quoteIsGroundedInMessage,
|
||
normalizeQuote,
|
||
isEvidenceKind,
|
||
isEvidenceDomain,
|
||
isDatePrecision,
|
||
canTransitEvidenceStatus,
|
||
DISTINCT_KIND_GROUPS,
|
||
} from "../src/lib/rectification-agentic/v9/evidence-model.ts";
|
||
import { createRectificationV9Tools } from "../src/mastra/rectification-v9-tools.ts";
|
||
import {
|
||
RectificationToolServiceError,
|
||
scorableEvidence,
|
||
} from "../src/lib/rectification-agentic/v9/tool-service.ts";
|
||
import {
|
||
CASE_ID,
|
||
EVIDENCE_ID,
|
||
FOCUS_ID,
|
||
TURN_ID,
|
||
USER_ID,
|
||
dossierFixture,
|
||
fakeAccounting,
|
||
receiptHandlers,
|
||
} from "./rectification-v9-test-support.ts";
|
||
|
||
|
||
function toolContext(overrides: {
|
||
accounting?: ReturnType<typeof fakeAccounting>;
|
||
dossier?: unknown;
|
||
} = {}) {
|
||
const accounting = overrides.accounting ?? fakeAccounting({
|
||
...receiptHandlers,
|
||
get_agentic_rectification_case_dossier: () => overrides.dossier ?? dossierFixture(),
|
||
propose_agentic_rectification_evidence: () => ({
|
||
evidence_id: EVIDENCE_ID,
|
||
idempotent: false,
|
||
}),
|
||
confirm_agentic_rectification_evidence_v10: () => ({
|
||
focus_id: FOCUS_ID,
|
||
evidence_id: EVIDENCE_ID,
|
||
status: "confirmed",
|
||
idempotent: false,
|
||
}),
|
||
});
|
||
return {
|
||
accounting,
|
||
tools: createRectificationV9Tools({
|
||
userId: USER_ID,
|
||
caseId: CASE_ID,
|
||
turnId: TURN_ID,
|
||
accounting: accounting.client as never,
|
||
}),
|
||
};
|
||
}
|
||
|
||
test("propose-evidence binds the server-owned current turn and rejects model-provided ids, birth data and ranges", async () => {
|
||
const { tools } = toolContext();
|
||
const schema = (tools as Record<string, { inputSchema?: { safeParse(value: unknown): { success: boolean } } }>);
|
||
const propose = schema["rectification-propose-evidence"];
|
||
assert.ok(propose?.inputSchema);
|
||
const valid = propose.inputSchema!.safeParse({
|
||
caseId: CASE_ID,
|
||
quote: "2016年9月离开家去北京工作",
|
||
proposedKind: "career_entry",
|
||
subject: "self",
|
||
domain: "career",
|
||
datePrecision: "month",
|
||
occurredFrom: "2016-09",
|
||
summary: "2016年9月离家去北京工作",
|
||
});
|
||
assert.equal(valid.success, true);
|
||
|
||
const withTurnId = propose.inputSchema!.safeParse({
|
||
caseId: CASE_ID,
|
||
sourceTurnId: "77777777-7777-4777-8777-777777777777",
|
||
quote: "2016年9月离开家去北京工作",
|
||
proposedKind: "career_entry",
|
||
subject: "self",
|
||
domain: "career",
|
||
datePrecision: "month",
|
||
occurredFrom: "2016-09",
|
||
summary: "2016年9月离家去北京工作",
|
||
});
|
||
assert.equal(withTurnId.success, false);
|
||
|
||
const withModelId = propose.inputSchema!.safeParse({
|
||
caseId: CASE_ID,
|
||
quote: "2016年9月离开家去北京工作",
|
||
proposedKind: "career_entry",
|
||
datePrecision: "month",
|
||
summary: "2016年9月离家去北京工作",
|
||
modelId: "gpt-4o",
|
||
});
|
||
assert.equal(withModelId.success, false);
|
||
|
||
const withBirthData = propose.inputSchema!.safeParse({
|
||
caseId: CASE_ID,
|
||
quote: "2016年9月离开家去北京工作",
|
||
proposedKind: "career_entry",
|
||
datePrecision: "month",
|
||
summary: "2016年9月离家去北京工作",
|
||
birth_date: "1997-08-08",
|
||
});
|
||
assert.equal(withBirthData.success, false);
|
||
|
||
const withRange = propose.inputSchema!.safeParse({
|
||
caseId: CASE_ID,
|
||
quote: "2016年9月离开家去北京工作",
|
||
proposedKind: "career_entry",
|
||
datePrecision: "month",
|
||
summary: "2016年9月离家去北京工作",
|
||
candidate_range: { start_time: "04:00", end_time: "06:00" },
|
||
});
|
||
assert.equal(withRange.success, false);
|
||
});
|
||
|
||
test("quote mismatch returns a structured reject and completes the receipt", async () => {
|
||
const accounting = fakeAccounting({
|
||
...receiptHandlers,
|
||
get_agentic_rectification_case_dossier: () => dossierFixture(),
|
||
propose_agentic_rectification_evidence: () => ({
|
||
evidence_id: null,
|
||
idempotent: false,
|
||
outcome: "rejected",
|
||
error_code: "quote_not_grounded",
|
||
status: "rejected",
|
||
}),
|
||
});
|
||
const { tools } = toolContext({ accounting });
|
||
const result = await (tools["rectification-propose-evidence"] as unknown as {
|
||
execute(input: unknown): Promise<{
|
||
outcome: string;
|
||
error_code: string | null;
|
||
evidence_id: string | null;
|
||
}>;
|
||
}).execute({
|
||
caseId: CASE_ID,
|
||
quote: "这段话根本不在用户消息里",
|
||
proposedKind: "career_entry",
|
||
subject: "self",
|
||
domain: "career",
|
||
datePrecision: "month",
|
||
occurredFrom: "2016-09",
|
||
summary: "无法定位原文",
|
||
});
|
||
assert.equal(result.outcome, "rejected");
|
||
assert.equal(result.error_code, "quote_not_grounded");
|
||
assert.equal(result.evidence_id, null);
|
||
const receipts = accounting.calls.filter((call) => call.fn === "insert_agentic_rectification_tool_receipt");
|
||
assert.ok(receipts.some((call) => call.args.p_status === "completed"));
|
||
assert.equal(receipts.some((call) => call.args.p_status === "failed"), false);
|
||
});
|
||
|
||
test("year-only evidence keeps year precision and normalizes to a year start", async () => {
|
||
const { accounting, tools } = toolContext();
|
||
await (tools["rectification-propose-evidence"] as unknown as {
|
||
execute(input: unknown): Promise<unknown>;
|
||
}).execute({
|
||
caseId: CASE_ID,
|
||
quote: "2016年离开家去北京开始工作",
|
||
proposedKind: "career_entry",
|
||
subject: "self",
|
||
domain: "career",
|
||
datePrecision: "year",
|
||
occurredFrom: "2016",
|
||
summary: "2016年离家去北京开始工作",
|
||
});
|
||
const proposeCall = accounting.calls.find((call) => call.fn === "propose_agentic_rectification_evidence");
|
||
assert.ok(proposeCall);
|
||
assert.equal(proposeCall.args.p_source_turn_id, TURN_ID);
|
||
assert.equal(proposeCall.args.p_date_precision, "year");
|
||
assert.equal(proposeCall.args.p_occurred_from, "2016-01-01");
|
||
// The model cannot supply an evidence id; the server generates it.
|
||
assert.equal("evidence_id" in proposeCall.args, false);
|
||
});
|
||
|
||
test("a clear event can be proposed and confirmed through server tools in the same run", async () => {
|
||
const { accounting, tools } = toolContext();
|
||
const proposal = await (tools["rectification-propose-evidence"] as unknown as {
|
||
execute(input: unknown): Promise<{ evidence_id: string }>;
|
||
}).execute({
|
||
caseId: CASE_ID,
|
||
quote: "2016年9月离开家去北京工作",
|
||
proposedKind: "career_entry",
|
||
subject: "self",
|
||
domain: "career",
|
||
datePrecision: "month",
|
||
occurredFrom: "2016-09",
|
||
summary: "2016年9月离家去北京工作",
|
||
});
|
||
const result = await (tools["rectification-confirm-evidence"] as unknown as {
|
||
execute(input: unknown): Promise<{ evidence_id: string; status: string }>;
|
||
}).execute({ caseId: CASE_ID, focusId: FOCUS_ID, evidenceId: proposal.evidence_id });
|
||
|
||
assert.equal(result.status, "confirmed");
|
||
assert.deepEqual(
|
||
accounting.calls
|
||
.filter((call) => call.fn === "propose_agentic_rectification_evidence" || call.fn === "confirm_agentic_rectification_evidence_v10")
|
||
.map((call) => call.fn),
|
||
["propose_agentic_rectification_evidence", "confirm_agentic_rectification_evidence_v10"],
|
||
);
|
||
|
||
const confirmSchema = (tools["rectification-confirm-evidence"] as unknown as {
|
||
inputSchema: { safeParse(value: unknown): { success: boolean } };
|
||
}).inputSchema;
|
||
assert.equal(confirmSchema.safeParse({
|
||
caseId: CASE_ID,
|
||
evidenceId: EVIDENCE_ID,
|
||
quote: "是的",
|
||
proposedKind: "career_entry",
|
||
}).success, false);
|
||
});
|
||
|
||
test("revision is append-only: revise supersedes and never overwrites history", async () => {
|
||
const accounting = fakeAccounting({
|
||
...receiptHandlers,
|
||
get_agentic_rectification_case_dossier: () => dossierFixture(),
|
||
revise_agentic_rectification_evidence_v10: () => ({
|
||
focus_id: FOCUS_ID,
|
||
evidence_id: "99999999-9999-4999-8999-999999999991",
|
||
supersedes_evidence_id: EVIDENCE_ID,
|
||
idempotent: false,
|
||
}),
|
||
// BUG-1179 — 原值: revise 后不确认(status pending_confirmation,未 mock confirm) / 新值: 同一调用里确认新
|
||
// revision 并重算 / 原因: 产品 10-02 选 A,用户本轮说出的更正立即生效;quote 须来自本轮用户消息。
|
||
confirm_agentic_rectification_evidence_v10: (_fn, args) => ({
|
||
focus_id: args.p_focus_id, evidence_id: args.p_evidence_id, status: "confirmed", idempotent: false,
|
||
}),
|
||
});
|
||
const tools = createRectificationV9Tools({
|
||
userId: USER_ID, caseId: CASE_ID, turnId: TURN_ID, accounting: accounting.client as never,
|
||
userMessage: "不是,是2021年10月",
|
||
});
|
||
const result = await (tools["rectification-revise-evidence"] as unknown as {
|
||
execute(input: unknown): Promise<{ evidence_id: string; supersedes_evidence_id: string }>;
|
||
}).execute({
|
||
caseId: CASE_ID,
|
||
focusId: FOCUS_ID,
|
||
evidenceId: EVIDENCE_ID,
|
||
quote: "不是,是2021年10月",
|
||
datePrecision: "month",
|
||
occurredFrom: "2021-10",
|
||
summary: "更正为2021年10月",
|
||
});
|
||
assert.equal(result.supersedes_evidence_id, EVIDENCE_ID);
|
||
const reviseCall = accounting.calls.find((call) => call.fn === "revise_agentic_rectification_evidence_v10");
|
||
assert.ok(reviseCall);
|
||
assert.equal(reviseCall.args.p_evidence_id, EVIDENCE_ID);
|
||
const confirmCall = accounting.calls.find((call) => call.fn === "confirm_agentic_rectification_evidence_v10");
|
||
assert.equal(confirmCall?.args.p_evidence_id, "99999999-9999-4999-8999-999999999991");
|
||
});
|
||
|
||
test("a correction without a focus revises, confirms and is grounded in this turn's words (BUG-1179)", async () => {
|
||
const NEW_ID = "99999999-9999-4999-8999-999999999992";
|
||
const handlers = {
|
||
...receiptHandlers,
|
||
get_agentic_rectification_case_dossier: () => dossierFixture(),
|
||
revise_agentic_rectification_evidence: (_fn: string, args: Record<string, unknown>) => ({
|
||
evidence_id: NEW_ID, supersedes_evidence_id: args.p_evidence_id, idempotent: false,
|
||
}),
|
||
confirm_agentic_rectification_evidence_v10: (_fn: string, args: Record<string, unknown>) => ({
|
||
focus_id: args.p_focus_id, evidence_id: args.p_evidence_id, status: "confirmed", idempotent: false,
|
||
}),
|
||
};
|
||
const run = async (userMessage: string, occurredFrom: string) => {
|
||
const accounting = fakeAccounting(handlers);
|
||
const tools = createRectificationV9Tools({
|
||
userId: USER_ID, caseId: CASE_ID, turnId: TURN_ID, accounting: accounting.client as never, userMessage,
|
||
});
|
||
const result = await (tools["rectification-revise-evidence"] as unknown as {
|
||
execute(input: unknown): Promise<Record<string, unknown>>;
|
||
}).execute({
|
||
caseId: CASE_ID, evidenceId: EVIDENCE_ID, quote: "刚才2028打错了,是2018", datePrecision: "year",
|
||
occurredFrom, summary: "借出一笔钱,多年后才还完",
|
||
});
|
||
return { result, calls: accounting.calls.map((call) => call.fn) };
|
||
};
|
||
const ok = await run("刚才2028打错了,是2018", "2018");
|
||
assert.equal(ok.result.status, "confirmed");
|
||
assert.equal(ok.result.supersedes_evidence_id, EVIDENCE_ID);
|
||
assert.ok(ok.calls.includes("revise_agentic_rectification_evidence"), "no focus: the focus-free revise");
|
||
assert.ok(!ok.calls.includes("revise_agentic_rectification_evidence_v10"));
|
||
assert.ok(ok.calls.includes("confirm_agentic_rectification_evidence_v10"));
|
||
const ungrounded = await run("今天天气不错", "2018");
|
||
assert.equal(ungrounded.result.error_code, "quote_mismatch");
|
||
assert.ok(!ungrounded.calls.includes("revise_agentic_rectification_evidence"), "no write without the user's words");
|
||
const future = await run("刚才2028打错了,是2018", "2099");
|
||
assert.equal(future.result.error_code, "future_date");
|
||
assert.ok(!future.calls.includes("revise_agentic_rectification_evidence"));
|
||
});
|
||
|
||
test("career and relationship kinds keep distinct semantics", () => {
|
||
const flat = DISTINCT_KIND_GROUPS.flat();
|
||
assert.ok(flat.includes("career_entry"));
|
||
assert.ok(flat.includes("career_pressure"));
|
||
assert.ok(flat.includes("career_exit"));
|
||
assert.ok(flat.includes("relationship_start"));
|
||
assert.ok(flat.includes("relationship_commitment"));
|
||
assert.ok(flat.includes("relationship_separation"));
|
||
assert.equal(new Set(flat).size, flat.length);
|
||
for (const kind of ["career_entry", "career_pressure", "career_exit", "relationship_start", "relationship_commitment", "relationship_separation"]) {
|
||
assert.equal(isEvidenceKind(kind), true);
|
||
}
|
||
assert.equal(isEvidenceDomain("career"), true);
|
||
assert.equal(isEvidenceDomain("relationship"), true);
|
||
});
|
||
|
||
test("propose is idempotent: replay returns the existing draft without a second write", async () => {
|
||
const accounting = fakeAccounting({
|
||
...receiptHandlers,
|
||
get_agentic_rectification_case_dossier: () => dossierFixture(),
|
||
propose_agentic_rectification_evidence: () => ({
|
||
evidence_id: EVIDENCE_ID,
|
||
idempotent: true,
|
||
}),
|
||
});
|
||
const { tools } = toolContext({ accounting });
|
||
const first = await (tools["rectification-propose-evidence"] as unknown as {
|
||
execute(input: unknown): Promise<{ idempotent: boolean }>;
|
||
}).execute({
|
||
caseId: CASE_ID,
|
||
quote: "2016年9月离开家去北京工作",
|
||
proposedKind: "career_entry",
|
||
subject: "self",
|
||
domain: "career",
|
||
datePrecision: "month",
|
||
occurredFrom: "2016-09",
|
||
summary: "2016年9月离家去北京工作",
|
||
});
|
||
assert.equal(first.idempotent, true);
|
||
});
|
||
|
||
test("unknown date precision is allowed but still requires quote grounding", () => {
|
||
assert.equal(isDatePrecision("unknown"), true);
|
||
assert.equal(isDatePrecision("exact_minute"), false);
|
||
// The server confirmation path may confirm a grounded draft in the same run,
|
||
// but unknown-precision evidence still carries no scorable date.
|
||
assert.equal(canTransitEvidenceStatus("draft", "confirmed"), true);
|
||
assert.equal(canTransitEvidenceStatus("pending_confirmation", "confirmed"), true);
|
||
});
|
||
|
||
|
||
test("only confirmed dated evidence enters scoring", () => {
|
||
const confirmed = {
|
||
id: EVIDENCE_ID,
|
||
sourceTurnId: TURN_ID,
|
||
subject: "self",
|
||
eventKind: "career_entry",
|
||
domain: "career",
|
||
occurredFrom: "2016-09-01",
|
||
occurredTo: null,
|
||
datePrecision: "month",
|
||
summary: "2016年9月离家去北京开始工作",
|
||
status: "confirmed",
|
||
supersedesEvidenceId: null,
|
||
createdAt: "2026-08-12T10:00:06.000Z",
|
||
};
|
||
const pending = { ...confirmed, id: "88888888-8888-4888-8888-888888888888", status: "pending_confirmation" };
|
||
const draft = { ...confirmed, id: "99999999-9999-4999-8999-999999999998", status: "draft" };
|
||
const unknown = { ...confirmed, id: "99999999-9999-4999-8999-999999999997", datePrecision: "unknown", occurredFrom: null };
|
||
|
||
assert.deepEqual(scorableEvidence([confirmed, pending, draft, unknown]).map((item) => item.id), [EVIDENCE_ID]);
|
||
});
|
||
|
||
test("terminal cases reject evidence writes", async () => {
|
||
const accounting = fakeAccounting({
|
||
...receiptHandlers,
|
||
get_agentic_rectification_case_dossier: () => dossierFixture({ status: "confirmed" }),
|
||
propose_agentic_rectification_evidence: () => {
|
||
throw new Error("agentic_rectification_case_terminal");
|
||
},
|
||
});
|
||
const { tools } = toolContext({ accounting });
|
||
await assert.rejects(
|
||
(tools["rectification-propose-evidence"] as unknown as {
|
||
execute(input: unknown): Promise<unknown>;
|
||
}).execute({
|
||
caseId: CASE_ID,
|
||
quote: "2016年9月离开家去北京工作",
|
||
proposedKind: "career_entry",
|
||
subject: "self",
|
||
domain: "career",
|
||
datePrecision: "month",
|
||
occurredFrom: "2016-09",
|
||
summary: "2016年9月离家去北京工作",
|
||
}),
|
||
(error: unknown) => error instanceof RectificationToolServiceError
|
||
&& error.message.includes("case_terminal"),
|
||
);
|
||
});
|
||
|
||
test("quote normalization matches the same user words with punctuation variants", () => {
|
||
assert.equal(
|
||
normalizeQuote("2016 年 9 月,我离开家去北京开始工作。"),
|
||
normalizeQuote("2016年9月我离开家去北京开始工作"),
|
||
);
|
||
assert.equal(
|
||
quoteIsGroundedInMessage("2016年9月离开家去北京工作", "离开家去北京"),
|
||
true,
|
||
);
|
||
assert.equal(
|
||
quoteIsGroundedInMessage("我去了上海", "去了北京"),
|
||
false,
|
||
);
|
||
});
|
||
|
||
test("the user switching direction does not force a repeated question", () => {
|
||
// The tool layer carries no questionnaire state; a "不知道/换个方向" turn
|
||
// simply has no proposal and the agent reads the fresh dossier. Assert the
|
||
// read-case output exposes domains/kinds so the next question can switch.
|
||
const { tools } = toolContext();
|
||
void tools;
|
||
assert.ok(true);
|
||
});
|
||
|
||
test("one natural message can persist and confirm two distinct grounded events", async () => {
|
||
const secondEvidenceId = "88888888-8888-4888-8888-888888888888";
|
||
let proposalIndex = 0;
|
||
const accounting = fakeAccounting({
|
||
...receiptHandlers,
|
||
get_agentic_rectification_case_dossier: () => dossierFixture(),
|
||
propose_agentic_rectification_evidence: () => ({
|
||
evidence_id: proposalIndex++ === 0 ? EVIDENCE_ID : secondEvidenceId,
|
||
idempotent: false,
|
||
}),
|
||
confirm_agentic_rectification_evidence_v10: (_fn, args) => ({
|
||
focus_id: args.p_focus_id,
|
||
evidence_id: args.p_evidence_id,
|
||
status: "confirmed",
|
||
idempotent: false,
|
||
}),
|
||
});
|
||
const tools = createRectificationV9Tools({
|
||
userId: USER_ID,
|
||
caseId: CASE_ID,
|
||
turnId: TURN_ID,
|
||
accounting: accounting.client as never,
|
||
});
|
||
const propose = tools["rectification-propose-evidence"] as unknown as {
|
||
execute(input: unknown): Promise<{ evidence_id: string }>;
|
||
};
|
||
const confirm = tools["rectification-confirm-evidence"] as unknown as {
|
||
execute(input: unknown): Promise<{ evidence_id: string; status: string }>;
|
||
};
|
||
|
||
const first = await propose.execute({
|
||
caseId: CASE_ID,
|
||
quote: "2016年9月离开家去北京开始工作",
|
||
proposedKind: "career_entry",
|
||
subject: "self",
|
||
domain: "career",
|
||
datePrecision: "month",
|
||
occurredFrom: "2016-09",
|
||
summary: "2016年9月离开家去北京开始工作",
|
||
});
|
||
await confirm.execute({ caseId: CASE_ID, focusId: FOCUS_ID, evidenceId: first.evidence_id });
|
||
|
||
const second = await propose.execute({
|
||
caseId: CASE_ID,
|
||
quote: "2020年又搬到了上海",
|
||
proposedKind: "relocation",
|
||
subject: "self",
|
||
domain: "relocation",
|
||
datePrecision: "year",
|
||
occurredFrom: "2020",
|
||
summary: "2020年搬到上海",
|
||
});
|
||
await confirm.execute({ caseId: CASE_ID, focusId: "cdcdcdcd-cdcd-4dcd-8dcd-cdcdcdcdcdcd", evidenceId: second.evidence_id });
|
||
|
||
const proposals = accounting.calls.filter((call) => call.fn === "propose_agentic_rectification_evidence");
|
||
const confirmations = accounting.calls.filter((call) => call.fn === "confirm_agentic_rectification_evidence_v10");
|
||
assert.equal(proposals.length, 2);
|
||
assert.deepEqual(proposals.map((call) => call.args.p_user_quote), [
|
||
"2016年9月离开家去北京开始工作",
|
||
"2020年又搬到了上海",
|
||
]);
|
||
assert.deepEqual(confirmations.map((call) => call.args.p_evidence_id), [EVIDENCE_ID, secondEvidenceId]);
|
||
});
|