Files
Jyotisha/frontend/tests/rectification-v9-evidence.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

482 lines
19 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 {
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]);
});