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; 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); 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; }).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, }), }); 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({ 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); }); 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; }).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]); });