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
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
8e978cabc4
commit
3bbe65a215
@@ -0,0 +1,125 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { boundEngineEventDates, engineRequestBody, type V9EngineEvent } from "../src/lib/rectification-agentic/v9/engine-client.ts";
|
||||
import { composeSpokenWithServerFacts } from "../src/lib/rectification-agentic/v9/agent-run-support.ts";
|
||||
import { batchHasFutureDatedItem } from "../src/lib/rectification-agentic/v9/host-fallback.ts";
|
||||
import { RECTIFICATION_USER_COPY } from "../src/lib/rectification-agentic/user-copy.ts";
|
||||
import { createRectificationV9Tools, evidenceStartsInFuture } from "../src/mastra/rectification-v9-tools.ts";
|
||||
import {
|
||||
CASE_ID,
|
||||
FOCUS_ID,
|
||||
TURN_ID,
|
||||
USER_ID,
|
||||
computeFixture,
|
||||
dossierFixture,
|
||||
fakeAccounting,
|
||||
receiptHandlers,
|
||||
} from "./rectification-v9-test-support.ts";
|
||||
|
||||
// Real staging case 2026-10-02 (BUG-1178): a typo'd 「2028 年」 event entered the ledger and the
|
||||
// engine (scripts/rectification/contracts.py) rejected every later rescore with
|
||||
// 「events[i] dates must be between birth_date and today」; the card never came. 「今年 / 这个月」
|
||||
// events (period end after today) failed the same way.
|
||||
const NOW = new Date("2026-10-02T03:00:00Z");
|
||||
const event = (id: string, start: string, end: string): V9EngineEvent => ({
|
||||
id, domain: "finance", event_kind: "finance_change", date_start: start, date_end: end, precision: "year",
|
||||
summary: "fictional", date_source: null, date_reliability: null, date_corroboration: null,
|
||||
date_conflict_status: null, source_turn_id: null, subject: "self",
|
||||
} as V9EngineEvent);
|
||||
|
||||
test("engine events stay inside [birth_date, yesterday]: future dropped, open periods clipped", () => {
|
||||
const bounded = boundEngineEventDates([
|
||||
event("past", "2014-01-01", "2014-12-31"),
|
||||
event("future", "2028-01-01", "2028-12-31"),
|
||||
event("this-year", "2026-01-01", "2026-12-31"),
|
||||
event("this-month", "2026-10-01", "2026-10-31"),
|
||||
event("before-birth", "1980-01-01", "1980-12-31"),
|
||||
event("straddles-birth", "1995-01-01", "1995-12-31"),
|
||||
], "1995-06-14", NOW);
|
||||
assert.deepEqual(bounded.map((row) => [row.id, row.date_start, row.date_end]), [
|
||||
["past", "2014-01-01", "2014-12-31"],
|
||||
["this-year", "2026-01-01", "2026-10-01"],
|
||||
["this-month", "2026-10-01", "2026-10-01"],
|
||||
["straddles-birth", "1995-06-14", "1995-12-31"],
|
||||
]);
|
||||
});
|
||||
|
||||
test("the engine request body carries only bounded events", () => {
|
||||
const body = engineRequestBody({
|
||||
baselineBirthSnapshot: { birth_date: "1995-06-14", latitude: 30, longitude: 120, timezone_offset: 8, timezone_id: "Asia/Shanghai" },
|
||||
candidateRange: { start_time: "14:34", end_time: "15:04" } as never,
|
||||
events: [event("past", "2014-01-01", "2014-12-31"), event("future", "2099-01-01", "2099-12-31")],
|
||||
});
|
||||
const events = body.events as V9EngineEvent[];
|
||||
assert.deepEqual(events.map((row) => row.id), ["past"]);
|
||||
assert.throws(() => engineRequestBody({
|
||||
baselineBirthSnapshot: { birth_date: "1995-06-14", latitude: 30, longitude: 120, timezone_offset: 8 },
|
||||
candidateRange: { start_time: "14:34", end_time: "15:04" } as never,
|
||||
events: [event("future", "2099-01-01", "2099-12-31")],
|
||||
}), /no scorable evidence/);
|
||||
});
|
||||
|
||||
test("intake: an item starting after today is future-dated; this year is not", () => {
|
||||
assert.equal(evidenceStartsInFuture({ occurredFrom: "2028" }, NOW), true);
|
||||
assert.equal(evidenceStartsInFuture({ occurredFrom: "2026-11" }, NOW), true);
|
||||
assert.equal(evidenceStartsInFuture({ occurredFrom: "2026" }, NOW), false);
|
||||
assert.equal(evidenceStartsInFuture({ occurredFrom: "2014-12" }, NOW), false);
|
||||
assert.equal(evidenceStartsInFuture({ occurredFrom: null, occurredTo: null }, NOW), false);
|
||||
});
|
||||
|
||||
test("batch: the future item is not written, the rest keep their own index and recap", async () => {
|
||||
const quote = "2099年借出一笔钱,2014年年底拔牙";
|
||||
const accounting = fakeAccounting({
|
||||
...receiptHandlers,
|
||||
get_agentic_rectification_case_dossier: () => dossierFixture({ latestResult: null }),
|
||||
get_agentic_rectification_case_compute: () => computeFixture(),
|
||||
record_agentic_rectification_evidence_batch: (_fn, args) => {
|
||||
const items = Array.isArray(args.p_items) ? args.p_items as Array<Record<string, unknown>> : [];
|
||||
return {
|
||||
items: items.map((_item, index) => ({
|
||||
index, outcome: "accepted", evidence_id: "77777777-7777-4777-8777-777777777777", status: "confirmed",
|
||||
idempotent: false, clarification_fields: [], error_code: null,
|
||||
})),
|
||||
accepted_count: items.length, needs_clarification_count: 0, rejected_count: 0, focus_id: FOCUS_ID,
|
||||
};
|
||||
},
|
||||
});
|
||||
const tools = createRectificationV9Tools({
|
||||
userId: USER_ID, caseId: CASE_ID, turnId: TURN_ID, userMessage: quote, accounting: accounting.client as never,
|
||||
});
|
||||
const result = await (tools["rectification-record-evidence-batch"] as unknown as {
|
||||
execute(input: unknown): Promise<{
|
||||
items: Array<{ index: number; outcome: string; error_code: string | null }>;
|
||||
accepted_recaps: Array<{ display_date_label?: string; event_phrase?: string }>;
|
||||
accepted_count: number;
|
||||
}>;
|
||||
}).execute({
|
||||
caseId: CASE_ID,
|
||||
items: [
|
||||
{ quote: "2099年借出一笔钱", proposedKind: "finance_change", subject: "self", domain: "finance",
|
||||
datePrecision: "year", occurredFrom: "2099", summary: "借出一笔钱" },
|
||||
{ quote: "2014年年底拔牙", proposedKind: "self_health_event", subject: "self", domain: "health",
|
||||
datePrecision: "month", occurredFrom: "2014-12", summary: "2014年年底拔牙" },
|
||||
],
|
||||
});
|
||||
const write = accounting.calls.find((call) => call.fn === "record_agentic_rectification_evidence_batch");
|
||||
const written = (write?.args.p_items ?? []) as Array<Record<string, unknown>>;
|
||||
assert.equal(written.length, 1, "the 2099 item is never written");
|
||||
assert.equal(written[0]?.occurred_from, "2014-12-01");
|
||||
assert.deepEqual(result.items.map((item) => [item.index, item.outcome, item.error_code]), [
|
||||
[0, "rejected", "future_date"],
|
||||
[1, "accepted", null],
|
||||
]);
|
||||
assert.equal(result.accepted_count, 1);
|
||||
assert.equal(result.accepted_recaps.length, 1, "the recap reads the recorded row, not the rejected slot");
|
||||
assert.equal(batchHasFutureDatedItem(result), true);
|
||||
});
|
||||
|
||||
test("the reply says the future-dated item was not counted, once", () => {
|
||||
const facts = { compareFailed: false, rescoreSkipped: false, futureDated: true, rangeBefore: null, rangeAfter: null };
|
||||
const spoken = composeSpokenWithServerFacts("记下了:2014年年底拔牙。", facts);
|
||||
assert.ok(spoken.endsWith(RECTIFICATION_USER_COPY.futureDatedEvent));
|
||||
assert.equal(spoken.split(RECTIFICATION_USER_COPY.futureDatedEvent).length, 2);
|
||||
assert.equal(composeSpokenWithServerFacts("记下了。", { ...facts, futureDated: false }), "记下了。");
|
||||
});
|
||||
@@ -235,7 +235,11 @@ test("confirm/revise tools forward the durable focus/evidence binding", async ()
|
||||
idempotent: false,
|
||||
}),
|
||||
});
|
||||
const tools = toolSet(accounting);
|
||||
// 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,
|
||||
@@ -262,6 +266,9 @@ test("confirm/revise tools forward the durable focus/evidence binding", async ()
|
||||
})), [
|
||||
{ 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 },
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -163,9 +163,16 @@ test("focus/evidence mutation schemas require durable focus and evidence referen
|
||||
|
||||
assert.equal(confirm.safeParse({ caseId: CASE_ID, evidenceId: EVIDENCE_ID }).success, true);
|
||||
assert.equal(confirm.safeParse({ caseId: CASE_ID, focusId: FOCUS_ID }).success, false);
|
||||
// BUG-1179 — 原值: revise 缺 focusId 时 schema 拒绝(false) / 新值: 允许省略 focusId(true),但仍必须带
|
||||
// evidenceId / 原因: 产品 10-02 选 A,用户随口更正打错的年份时没有对应的问题焦点;「改动必须来自用户
|
||||
// 原话」改由服务端校验 quote 落在本轮用户消息里来保证(见 rectification-v9-evidence 用例)。
|
||||
assert.equal(revise.safeParse({
|
||||
...validInputs["rectification-revise-evidence"],
|
||||
focusId: undefined,
|
||||
}).success, true);
|
||||
assert.equal(revise.safeParse({
|
||||
...validInputs["rectification-revise-evidence"],
|
||||
evidenceId: undefined,
|
||||
}).success, false);
|
||||
assert.equal(resolve.safeParse({ caseId: CASE_ID, status: "declined" }).success, false);
|
||||
});
|
||||
|
||||
@@ -224,8 +224,16 @@ test("revision is append-only: revise supersedes and never overwrites history",
|
||||
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 { tools } = toolContext({ accounting });
|
||||
const result = await (tools["rectification-revise-evidence"] as unknown as {
|
||||
execute(input: unknown): Promise<{ evidence_id: string; supersedes_evidence_id: string }>;
|
||||
}).execute({
|
||||
@@ -241,6 +249,47 @@ test("revision is append-only: revise supersedes and never overwrites history",
|
||||
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", () => {
|
||||
|
||||
Reference in New Issue
Block a user