Files
Jyotisha/frontend/tests/rectification-agentic-tools.test.ts
2026-08-05 00:58:53 +08:00

504 lines
18 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(
acceptCandidate?: AgenticRectificationContext["acceptCandidate"],
persistCandidateResult?: AgenticRectificationContext["persistCandidateResult"],
): AgenticRectificationContext {
return {
userId: "user-1",
sessionId: "session-1",
engineBase: "http://engine.test",
birth,
candidateRange: { start_time: "14:00", end_time: "15:00" },
declaredAccuracy: "15min",
timeSource: "family_clear",
persistCandidateResult: persistCandidateResult
?? (async () => ({ ok: true as const, result_id: "candidate-result-1" })),
acceptCandidate: acceptCandidate ?? (async (time) => ({
ok: true as const,
saved_time: time,
status: "confirmed" as const,
result_id: "candidate-result-1",
})),
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",
algorithm_version: "fixture",
canonical_input_hash: "fixture-hash",
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",
external_engines: { status: "pass", validation: { reason: "validated" } },
},
reasons: [],
missing_layers: [],
candidate_ranking_summary: [
{ rank: 1, time: "14:30", score: 30, tied_minute_count: 1 },
{ rank: 2, time: "14:31", score: 20, tied_minute_count: 1 },
],
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: "搬家" },
{ id: "salary-range", domain: "finance", date: "2026-01/2026-07", precision: "range", 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, 3);
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");
const salaryRange = events[2]!;
assert.equal(salaryRange.date_start, "2026-01-01");
assert.equal(salaryRange.date_end, "2026-07-31");
assert.equal(salaryRange.precision, "range");
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, status: "confirmed" as const, result_id: "candidate-result-1" };
}));
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, status: "confirmed" as const, result_id: "candidate-result-1" };
}));
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, status: "confirmed" as const, result_id: "candidate-result-1" };
}));
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();
});
test("confirm serializes event dates to the legacy precision contract", async () => {
const engine = installEngine([
{ path: "/api/active_rectification_events", respond: confirmedEngineResponse },
]);
const tools = createAgenticRectificationTools(makeCtx());
await runTool(tools, "rectification-confirm", {
candidate_range: { start_time: "14:00", end_time: "15:00" },
events: [
{ id: "year", domain: "education", date: "2016", precision: "year" },
{ id: "month", domain: "career", date: "2020-04", precision: "month" },
{ id: "day", domain: "relationship", date: "2024-08-08", precision: "day" },
{ id: "range", domain: "finance", date: "2026-01/2026-07", precision: "range" },
],
});
const events = requestBody(engine).events as Array<Record<string, unknown>>;
assert.deepEqual(events.map(({ date, precision }) => ({ date, precision })), [
{ date: "2016", precision: "year" },
{ date: "2020-04", precision: "month" },
{ date: "2024-08-08", precision: "day" },
{ date: "2026", precision: "year" },
]);
engine.restore();
});
test("confirm persists ranked candidates with relative support totaling 100", async () => {
const engine = installEngine([
{ path: "/api/active_rectification_events", respond: confirmedEngineResponse },
]);
const persisted: unknown[] = [];
const tools = createAgenticRectificationTools(makeCtx(undefined, async (result) => {
persisted.push(result);
return { ok: true as const, result_id: "candidate-result-1" };
}));
const result = await runTool(tools, "rectification-confirm", {
candidate_range: { start_time: "14:00", end_time: "15:00" },
events: sampleEvents,
});
assert.equal(result.selection_allowed, true);
assert.deepEqual(result.candidates, [
{ rank: 1, time: "14:30", relative_support: 60, tied_minute_count: 1 },
{ rank: 2, time: "14:31", relative_support: 40, tied_minute_count: 1 },
]);
assert.equal((result.candidates as Array<{ relative_support: number }>).reduce((sum, candidate) => sum + candidate.relative_support, 0), 100);
assert.equal(persisted.length, 1);
engine.restore();
});
test("confirm distinguishes skipped external validation from a failed VedAstro run", async () => {
const engine = installEngine([{
path: "/api/active_rectification_events",
respond: () => {
const response = confirmedEngineResponse();
return {
...response,
body: {
...response.body,
can_apply: false,
technique_contract: {
confirmation_allowed: false,
decision: "continue_rectification",
external_engines: {
status: "not_evaluated",
validation: { reason: "local_candidate_not_ready_for_external_validation" },
},
},
},
};
},
}]);
const tools = createAgenticRectificationTools(makeCtx());
const result = await runTool(tools, "rectification-confirm", {
candidate_range: { start_time: "14:00", end_time: "15:00" },
events: sampleEvents,
offer_selection: false,
});
assert.equal(result.selection_allowed, false);
assert.equal(result.external_validation_status, "not_evaluated");
assert.equal(result.external_validation_invoked, false);
assert.equal(result.external_validation_reason, "local_candidate_not_ready_for_external_validation");
engine.restore();
});
test("confirm offers non-unique candidates only when the Agent explicitly ends evidence collection", async () => {
const engine = installEngine([{
path: "/api/active_rectification_events",
respond: () => {
const response = confirmedEngineResponse();
return {
...response,
body: {
...response.body,
can_apply: false,
technique_contract: {
confirmation_allowed: false,
decision: "continue_rectification",
external_engines: { status: "not_evaluated" },
},
},
};
},
}]);
const tools = createAgenticRectificationTools(makeCtx());
const result = await runTool(tools, "rectification-confirm", {
candidate_range: { start_time: "14:00", end_time: "15:00" },
events: sampleEvents,
offer_selection: true,
});
assert.equal(result.selection_allowed, true);
engine.restore();
});
test("accept candidate tool delegates the exact persisted candidate and preserves accepted status", async () => {
const calls: Array<{ time: string; resultId?: string }> = [];
const tools = createAgenticRectificationTools(makeCtx(async (time, resultId) => {
calls.push({ time, resultId });
return {
ok: true as const,
saved_time: time,
status: "accepted" as const,
result_id: "candidate-result-1",
};
}));
const result = await runTool(tools, "rectification-accept-candidate", { time: "14:31" });
assert.deepEqual(calls, [{ time: "14:31", resultId: undefined }]);
assert.deepEqual(result, {
ok: true,
saved_time: "14:31",
status: "accepted",
result_id: "candidate-result-1",
});
});