335 lines
12 KiB
TypeScript
335 lines
12 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import test from "node:test";
|
|
|
|
import {
|
|
createAgenticRectificationTools,
|
|
type AgenticRectificationContext,
|
|
type AgenticRectificationTools,
|
|
} from "../src/mastra/rectification-tools.ts";
|
|
|
|
type EngineRoute = {
|
|
path: string;
|
|
respond: (body: unknown) => { status: number; body: unknown };
|
|
};
|
|
|
|
function installEngine(routes: readonly EngineRoute[]) {
|
|
const calls: Array<{ path: string; body: unknown }> = [];
|
|
const original = globalThis.fetch;
|
|
(globalThis as { fetch: unknown }).fetch = async (
|
|
input: RequestInfo | URL,
|
|
init?: RequestInit,
|
|
) => {
|
|
const path = new URL(String(input)).pathname;
|
|
const body = init?.body ? JSON.parse(String(init.body)) : null;
|
|
calls.push({ path, body });
|
|
const route = routes.find((candidate) => candidate.path === path);
|
|
const respond = route?.respond ?? (() => ({ status: 404, body: { error: "unexpected_route" } }));
|
|
const { status, body: responseBody } = respond(body);
|
|
return {
|
|
ok: status >= 200 && status < 300,
|
|
status,
|
|
json: async () => responseBody,
|
|
} as Response;
|
|
};
|
|
return {
|
|
calls,
|
|
restore: () => {
|
|
(globalThis as { fetch: unknown }).fetch = original;
|
|
},
|
|
};
|
|
}
|
|
|
|
const birth = {
|
|
birth_date: "1990-05-12",
|
|
reported_time: "14:30",
|
|
lat: 31.23,
|
|
lon: 121.47,
|
|
tz: 8,
|
|
};
|
|
|
|
function makeCtx(applyConfirmedBirthTime?: AgenticRectificationContext["applyConfirmedBirthTime"]): AgenticRectificationContext {
|
|
return {
|
|
userId: "user-1",
|
|
engineBase: "http://engine.test",
|
|
birth,
|
|
candidateRange: { start_time: "14:00", end_time: "15:00" },
|
|
declaredAccuracy: "15min",
|
|
timeSource: "family_clear",
|
|
applyConfirmedBirthTime: applyConfirmedBirthTime ?? (async (time) => ({ ok: true as const, saved_time: time })),
|
|
};
|
|
}
|
|
|
|
type AnyTool = { execute: (input: unknown) => Promise<unknown> };
|
|
async function runTool(
|
|
tools: AgenticRectificationTools,
|
|
key: keyof AgenticRectificationTools,
|
|
input: unknown,
|
|
): Promise<Record<string, unknown>> {
|
|
const tool = tools[key] as unknown as AnyTool;
|
|
return (await tool.execute(input)) as Record<string, unknown>;
|
|
}
|
|
|
|
function requestBody(engine: ReturnType<typeof installEngine>): Record<string, unknown> {
|
|
return (engine.calls[0]?.body ?? {}) as Record<string, unknown>;
|
|
}
|
|
|
|
const confirmedEngineResponse = () => ({
|
|
status: 200,
|
|
body: {
|
|
success: true,
|
|
endpoint: "active_rectification_events",
|
|
result_id: "r1",
|
|
confidence: "high",
|
|
event_count: 4,
|
|
domain_count: 3,
|
|
can_apply: true,
|
|
winning_segment: { start_time: "14:28", end_time: "14:32", representative_time: "14:30", width_minutes: 4 },
|
|
technique_contract: { confirmation_allowed: true, decision: "confirm_minute" },
|
|
reasons: [],
|
|
missing_layers: [],
|
|
candidate_ranking_summary: [],
|
|
boundary: "test",
|
|
},
|
|
});
|
|
|
|
const sampleEvents = [
|
|
{ id: "e1", domain: "career" as const, date: "2015", precision: "year" as const },
|
|
{ id: "e2", domain: "relationship" as const, date: "2018-06", precision: "month" as const },
|
|
{ id: "e3", domain: "relocation" as const, date: "2020", precision: "year" as const },
|
|
{ id: "e4", domain: "finance" as const, date: "2012-03-05", precision: "day" as const },
|
|
];
|
|
|
|
test("gate tool posts the birth profile to /api/rectification_gate", async () => {
|
|
const engine = installEngine([
|
|
{
|
|
path: "/api/rectification_gate",
|
|
respond: () => ({
|
|
status: 200,
|
|
body: {
|
|
success: true,
|
|
endpoint: "rectification_gate",
|
|
effective_accuracy: "15min",
|
|
lagna_boundary: { is_sensitive: true, note: "test" },
|
|
enabled_vargas: { D1: "enabled", D9: "enabled" },
|
|
summary: {
|
|
headline: "test",
|
|
enabled: ["D1", "D9"],
|
|
warned: [],
|
|
disabled: [],
|
|
confidence_floor: "medium",
|
|
recommended_events: ["marriage", "relocation"],
|
|
next_action: "collect dated events",
|
|
},
|
|
},
|
|
}),
|
|
},
|
|
]);
|
|
const tools = createAgenticRectificationTools(makeCtx());
|
|
const result = await runTool(tools, "rectification-gate", {});
|
|
assert.equal(engine.calls[0]?.path, "/api/rectification_gate");
|
|
const sent = requestBody(engine);
|
|
assert.equal(sent.year, 1990);
|
|
assert.equal(sent.month, 5);
|
|
assert.equal(sent.day, 12);
|
|
assert.equal(sent.hour, 14);
|
|
assert.equal(sent.minute, 30);
|
|
assert.equal(sent.lat, 31.23);
|
|
assert.equal(sent.tz, 8);
|
|
assert.equal(result.effective_accuracy, "15min");
|
|
engine.restore();
|
|
});
|
|
|
|
test("score tool normalizes year-precision events into the V5 date range contract", async () => {
|
|
const engine = installEngine([
|
|
{
|
|
path: "/api/rectification/v5/score",
|
|
respond: () => ({
|
|
status: 200,
|
|
body: {
|
|
success: true,
|
|
endpoint: "rectification_v5_score",
|
|
result_id: "r1",
|
|
algorithm_version: "rectification-v5-matrix-scoring-1",
|
|
calculation_spec_hash: "hash",
|
|
candidate_scores: [{ time: "14:30", score: 1.2 }],
|
|
robustness: {
|
|
neighbor_support_minutes: 5,
|
|
leave_one_out_retention_rate: 1,
|
|
leave_one_domain_out_retention_rate: 1,
|
|
date_sensitivity_retention_rate: 1,
|
|
},
|
|
diagnostics: { primary_cluster_retention_rate: 1 },
|
|
missing_layers: [],
|
|
can_confirm_exact_minute: false,
|
|
},
|
|
}),
|
|
},
|
|
]);
|
|
const tools = createAgenticRectificationTools(makeCtx());
|
|
const result = await runTool(tools, "rectification-score", {
|
|
candidate_range: { start_time: "14:00", end_time: "15:00" },
|
|
events: [
|
|
{ id: "marriage-2015", domain: "relationship", date: "2015", precision: "year", summary: "结婚" },
|
|
{ id: "moved", domain: "relocation", date: "2015-06", precision: "month", summary: "搬家" },
|
|
],
|
|
});
|
|
|
|
const sent = requestBody(engine);
|
|
const events = (sent.events ?? []) as Array<Record<string, unknown>>;
|
|
assert.equal(sent.start_time, "14:00");
|
|
assert.equal(sent.end_time, "15:00");
|
|
assert.equal(events.length, 2);
|
|
|
|
const marriage = events[0]!;
|
|
assert.equal(marriage.date_start, "2015-01-01");
|
|
assert.equal(marriage.date_end, "2015-12-31");
|
|
assert.equal(marriage.event_kind, "relationship_change");
|
|
assert.equal(marriage.precision, "year");
|
|
assert.equal(String(marriage.id).length, 36, "event id is normalized to a stable UUID");
|
|
|
|
const moved = events[1]!;
|
|
assert.equal(moved.date_start, "2015-06-01");
|
|
assert.equal(moved.date_end, "2015-06-30");
|
|
|
|
assert.equal(result.candidate_count, 1);
|
|
const top = (result.top_candidates as Array<{ time: string }>)[0];
|
|
assert.equal(top?.time, "14:30");
|
|
engine.restore();
|
|
});
|
|
|
|
test("scan tool derives its center and uncertainty from the server-owned range", async () => {
|
|
const engine = installEngine([
|
|
{
|
|
path: "/api/rectification/sensitivity_scan",
|
|
respond: () => ({
|
|
status: 200,
|
|
body: {
|
|
scope: "candidate_time_sensitivity_scan",
|
|
status: "local_computed",
|
|
center_time: "1990-05-12 14:30",
|
|
uncertainty_minutes: 30,
|
|
step_minutes: 5,
|
|
candidate_count: 13,
|
|
rows: [
|
|
{ time: "1990-05-12 14:20", sensitive_layers: ["D9", "D10"] },
|
|
{ time: "1990-05-12 14:25", sensitive_layers: ["D9"] },
|
|
],
|
|
supported_vargas: ["D4", "D9", "D10"],
|
|
unavailable_vargas: [],
|
|
pending_layers: ["KP_cusp"],
|
|
transitions: [{ between: ["14:20", "14:25"], changed: ["d1_ascendant"] }],
|
|
boundary: "test",
|
|
},
|
|
}),
|
|
},
|
|
]);
|
|
const tools = createAgenticRectificationTools(makeCtx());
|
|
const result = await runTool(tools, "rectification-scan", {});
|
|
const sent = requestBody(engine);
|
|
assert.equal(sent.hour, 14);
|
|
assert.equal(sent.minute, 30);
|
|
assert.equal(sent.time_uncertainty_minutes, 30);
|
|
assert.equal(result.candidate_count, 13);
|
|
assert.deepEqual(result.supported_vargas, ["D4", "D9", "D10"]);
|
|
engine.restore();
|
|
});
|
|
|
|
test("gate keeps period-only profiles on the server-owned range without inventing a minute", async () => {
|
|
const engine = installEngine([]);
|
|
const tools = createAgenticRectificationTools({
|
|
...makeCtx(),
|
|
birth: { ...birth, reported_time: null },
|
|
candidateRange: { start_time: "23:00", end_time: "03:59" },
|
|
declaredAccuracy: "unknown",
|
|
});
|
|
const result = await runTool(tools, "rectification-gate", {});
|
|
assert.equal(engine.calls.length, 0);
|
|
assert.equal(result.endpoint, "server_owned_rectification_preflight");
|
|
assert.deepEqual(result.candidate_range, { start_time: "23:00", end_time: "03:59" });
|
|
engine.restore();
|
|
});
|
|
|
|
test("scan defers an unknown full-day range instead of using a fake noon birth time", async () => {
|
|
const engine = installEngine([]);
|
|
const tools = createAgenticRectificationTools({
|
|
...makeCtx(),
|
|
birth: { ...birth, reported_time: null },
|
|
candidateRange: { start_time: "00:00", end_time: "23:59" },
|
|
});
|
|
const result = await runTool(tools, "rectification-scan", {});
|
|
assert.equal(engine.calls.length, 0);
|
|
assert.equal(result.status, "deferred_wide_range");
|
|
engine.restore();
|
|
});
|
|
|
|
test("score rejects an Agent-invented range", async () => {
|
|
const engine = installEngine([]);
|
|
const tools = createAgenticRectificationTools(makeCtx());
|
|
await assert.rejects(
|
|
() => runTool(tools, "rectification-score", {
|
|
candidate_range: { start_time: "14:15", end_time: "14:45" },
|
|
events: sampleEvents,
|
|
}),
|
|
/candidate_range_mismatch/,
|
|
);
|
|
assert.equal(engine.calls.length, 0);
|
|
engine.restore();
|
|
});
|
|
|
|
test("save tool rejects before a confirmation gate exists", async () => {
|
|
const engine = installEngine([]);
|
|
const applied: string[] = [];
|
|
const tools = createAgenticRectificationTools(makeCtx(async (time) => {
|
|
applied.push(time);
|
|
return { ok: true as const, saved_time: time };
|
|
}));
|
|
const result = await runTool(tools, "rectification-save-birth-time", { time: "14:30" });
|
|
assert.equal(result.ok, false);
|
|
assert.match(String(result.reason), /no_confirmed_gate/);
|
|
assert.equal(applied.length, 0);
|
|
engine.restore();
|
|
});
|
|
|
|
test("save tool rejects a time that does not equal the confirmed minute", async () => {
|
|
const engine = installEngine([
|
|
{ path: "/api/active_rectification_events", respond: confirmedEngineResponse },
|
|
]);
|
|
const applied: string[] = [];
|
|
const tools = createAgenticRectificationTools(makeCtx(async (time) => {
|
|
applied.push(time);
|
|
return { ok: true as const, saved_time: time };
|
|
}));
|
|
await runTool(tools, "rectification-confirm", {
|
|
candidate_range: { start_time: "14:00", end_time: "15:00" },
|
|
events: sampleEvents,
|
|
});
|
|
const rejected = await runTool(tools, "rectification-save-birth-time", { time: "14:29" });
|
|
assert.equal(rejected.ok, false);
|
|
assert.match(String(rejected.reason), /time_mismatch/);
|
|
assert.equal(applied.length, 0);
|
|
engine.restore();
|
|
});
|
|
|
|
test("confirm then save with the matching minute applies the write", async () => {
|
|
const engine = installEngine([
|
|
{ path: "/api/active_rectification_events", respond: confirmedEngineResponse },
|
|
]);
|
|
const applied: string[] = [];
|
|
const tools = createAgenticRectificationTools(makeCtx(async (time) => {
|
|
applied.push(time);
|
|
return { ok: true as const, saved_time: time };
|
|
}));
|
|
const confirm = await runTool(tools, "rectification-confirm", {
|
|
candidate_range: { start_time: "14:00", end_time: "15:00" },
|
|
events: sampleEvents,
|
|
});
|
|
assert.equal(confirm.confirmation_allowed, true);
|
|
assert.equal(confirm.representative_time, "14:30");
|
|
|
|
const saved = await runTool(tools, "rectification-save-birth-time", { time: "14:30" });
|
|
assert.equal(saved.ok, true);
|
|
assert.equal(saved.saved_time, "14:30");
|
|
assert.deepEqual(applied, ["14:30"]);
|
|
engine.restore();
|
|
});
|