a0ce55f066
Showing a choice card is no longer treated as completion. Distinguish probes require real candidate groups, holdout stays out of scoring, and ordinary sessions can finish with a credible range instead of an exact-minute gate. Co-authored-by: Cursor <cursoragent@cursor.com>
727 lines
32 KiB
TypeScript
727 lines
32 KiB
TypeScript
import { createTool } from "@mastra/core/tools";
|
||
import { createHash } from "node:crypto";
|
||
import { z } from "zod";
|
||
import { dateRangeFromDeclared } from "../lib/rectification-agentic/date-range.ts";
|
||
|
||
/**
|
||
* Agentic birth-time rectification tool layer.
|
||
*
|
||
* These tools let an LLM agent drive the full local Jyotish rectification
|
||
* methodology the same way Claude Code drives `scripts/` locally: the agent
|
||
* requests engine computations on demand instead of inventing results. Every
|
||
* tool wraps one Python-engine HTTP endpoint (or a server-owned write).
|
||
*
|
||
* Hard boundary: the agent never writes a birth minute directly. Server-owned
|
||
* candidate identity, session ownership, profile baseline, and candidate membership
|
||
* decide whether a user-selected minute is accepted or engine-confirmed.
|
||
*/
|
||
|
||
const engineBase = process.env.JYOTISH_API_BASE ?? "http://127.0.0.1:5200";
|
||
|
||
const timePattern = /^(?:[01]\d|2[0-3]):[0-5]\d$/;
|
||
|
||
/** Birth fields supplied by the server from the user profile (never by the LLM). */
|
||
export type AgenticRectificationBirth = Readonly<{
|
||
birth_date: string;
|
||
reported_time: string | null;
|
||
lat: number;
|
||
lon: number;
|
||
tz: number;
|
||
}>;
|
||
|
||
export type AgenticRectificationCandidateRange = Readonly<{
|
||
start_time: string;
|
||
end_time: string;
|
||
}>;
|
||
|
||
export type AgenticRectificationCandidate = Readonly<{
|
||
rank: number;
|
||
time: string;
|
||
relative_support: number;
|
||
tied_minute_count: number;
|
||
}>;
|
||
|
||
export type AgenticRectificationCandidateResult = Readonly<{
|
||
engineResultId: string;
|
||
canonicalInputHash: string;
|
||
algorithmVersion: string;
|
||
candidateRange: AgenticRectificationCandidateRange;
|
||
candidates: readonly AgenticRectificationCandidate[];
|
||
overallConfidence: "low" | "medium" | "high";
|
||
marginPercent: number | null;
|
||
selectionAllowed: boolean;
|
||
confirmationAllowed: boolean;
|
||
representativeTime: string | null;
|
||
}>;
|
||
|
||
export type AgenticRectificationContext = Readonly<{
|
||
userId: string;
|
||
sessionId: string;
|
||
engineBase?: string;
|
||
birth: AgenticRectificationBirth;
|
||
candidateRange: AgenticRectificationCandidateRange;
|
||
declaredAccuracy?: "minute" | "15min" | "1hour" | "unknown";
|
||
timeSource?: "hospital" | "family_clear" | "family_vague" | "unknown";
|
||
persistCandidateResult: (result: AgenticRectificationCandidateResult) => Promise<Readonly<{
|
||
ok: true;
|
||
result_id: string;
|
||
} | { ok: false; reason: string }>>;
|
||
acceptCandidate: (time: string, resultId?: string) => Promise<Readonly<{
|
||
ok: true;
|
||
saved_time: string;
|
||
status: "accepted" | "confirmed";
|
||
result_id: string;
|
||
} | { ok: false; reason: string }>>;
|
||
applyConfirmedBirthTime: (time: string) => Promise<Readonly<{
|
||
ok: true;
|
||
saved_time: string;
|
||
} | { ok: false; reason: string }>>;
|
||
}>;
|
||
|
||
export const rectificationDomainSchema = z.enum([
|
||
"education", "relocation", "relationship", "career", "finance", "health_pressure",
|
||
]);
|
||
export type RectificationDomain = z.infer<typeof rectificationDomainSchema>;
|
||
|
||
/** One dated life event as the LLM supplies it (same shape for every tool). */
|
||
export const agenticRectificationEventSchema = z.object({
|
||
id: z.string().min(1).max(64),
|
||
domain: rectificationDomainSchema,
|
||
date: z.string().min(4).max(23).describe("YYYY, YYYY-MM, YYYY-MM-DD, or start..end for a range"),
|
||
precision: z.enum(["year", "month", "day", "range"]),
|
||
summary: z.string().max(1000).optional(),
|
||
});
|
||
export type AgenticRectificationEvent = z.infer<typeof agenticRectificationEventSchema>;
|
||
|
||
export const candidateRangeSchema = z.object({
|
||
start_time: z.string().regex(timePattern),
|
||
end_time: z.string().regex(timePattern),
|
||
});
|
||
|
||
function clockMinute(value: string): number {
|
||
const [hour = 0, minute = 0] = value.split(":").map(Number);
|
||
return hour * 60 + minute;
|
||
}
|
||
|
||
function rangeScanWindow(range: AgenticRectificationCandidateRange) {
|
||
const start = clockMinute(range.start_time);
|
||
let end = clockMinute(range.end_time);
|
||
if (end < start) end += 1_440;
|
||
const width = end - start;
|
||
const center = Math.round((start + end) / 2) % 1_440;
|
||
return {
|
||
width,
|
||
centerTime: `${String(Math.floor(center / 60)).padStart(2, "0")}:${String(center % 60).padStart(2, "0")}`,
|
||
uncertaintyMinutes: Math.max(1, Math.ceil(width / 2)),
|
||
};
|
||
}
|
||
|
||
function requireServerCandidateRange(
|
||
requested: AgenticRectificationCandidateRange,
|
||
serverOwned: AgenticRectificationCandidateRange,
|
||
) {
|
||
if (requested.start_time !== serverOwned.start_time || requested.end_time !== serverOwned.end_time) {
|
||
throw new Error(`candidate_range_mismatch: use ${serverOwned.start_time}-${serverOwned.end_time}`);
|
||
}
|
||
}
|
||
|
||
const defaultEventKind: Record<RectificationDomain, string> = {
|
||
education: "education_milestone",
|
||
relocation: "relocation",
|
||
relationship: "relationship_change",
|
||
career: "career_change",
|
||
finance: "finance_change",
|
||
health_pressure: "self_health_event",
|
||
};
|
||
|
||
/** Stable UUID derived from the agent-supplied event id so ids stay reusable. */
|
||
function stableEventId(rawId: string): string {
|
||
const digest = createHash("sha256").update(`agentic-rectification:${rawId}`).digest();
|
||
digest[6] = (digest[6]! & 0x0f) | 0x40;
|
||
digest[8] = (digest[8]! & 0x3f) | 0x80;
|
||
const hex = digest.toString("hex");
|
||
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`;
|
||
}
|
||
|
||
/** Normalize the LLM's simple event into the V5 (`date_start`/`date_end`) shape. */
|
||
function toV5Event(event: AgenticRectificationEvent): Readonly<{
|
||
id: string;
|
||
domain: RectificationDomain;
|
||
event_kind: string;
|
||
date_start: string;
|
||
date_end: string;
|
||
precision: "day" | "month" | "quarter" | "year" | "range";
|
||
summary?: string;
|
||
}> {
|
||
const range = normalizedEventDateRange(event);
|
||
return {
|
||
id: stableEventId(event.id),
|
||
domain: event.domain,
|
||
event_kind: defaultEventKind[event.domain],
|
||
date_start: range.start,
|
||
date_end: range.end,
|
||
precision: range.precision,
|
||
summary: event.summary,
|
||
};
|
||
}
|
||
|
||
function normalizedDeclaredDate(value: string, precision: "year" | "month" | "day") {
|
||
const matched = precision === "year"
|
||
? /^(\d{4})$/.exec(value.trim())
|
||
: precision === "month"
|
||
? /^(\d{4})-(\d{1,2})$/.exec(value.trim())
|
||
: /^(\d{4})-(\d{1,2})-(\d{1,2})$/.exec(value.trim());
|
||
if (!matched) throw new Error(`event_date_precision_mismatch: ${value} is not ${precision}`);
|
||
return matched.slice(1).map((part, index) => index === 0 ? part : part!.padStart(2, "0")).join("-");
|
||
}
|
||
|
||
function declaredDateRange(value: string) {
|
||
const precision = /^\d{4}$/.test(value.trim())
|
||
? "year"
|
||
: /^\d{4}-\d{1,2}$/.test(value.trim())
|
||
? "month"
|
||
: "day";
|
||
return dateRangeFromDeclared(normalizedDeclaredDate(value, precision), precision);
|
||
}
|
||
|
||
function splitRangeDate(value: string): readonly [string, string] {
|
||
const normalized = value.trim();
|
||
const compactDayRange = /^(\d{4}-\d{1,2}-\d{1,2})-(\d{4}-\d{1,2}-\d{1,2})$/.exec(normalized);
|
||
if (compactDayRange) return [compactDayRange[1]!, compactDayRange[2]!];
|
||
const compactMonthRange = /^(\d{4}-\d{1,2})-(\d{4}-\d{1,2})$/.exec(normalized);
|
||
if (compactMonthRange) return [compactMonthRange[1]!, compactMonthRange[2]!];
|
||
const parts = normalized.split(/\s*(?:\.\.|\/|\bto\b|至|到|[–—])\s*/iu);
|
||
if (parts.length > 2 || !parts[0] || (parts.length === 2 && !parts[1])) {
|
||
throw new Error(`invalid_event_date_range: ${value}`);
|
||
}
|
||
return [parts[0], parts[1] ?? parts[0]];
|
||
}
|
||
|
||
function normalizedEventDateRange(event: AgenticRectificationEvent) {
|
||
if (event.precision !== "range") {
|
||
return dateRangeFromDeclared(normalizedDeclaredDate(event.date, event.precision), event.precision);
|
||
}
|
||
const [start, end] = splitRangeDate(event.date);
|
||
const startRange = declaredDateRange(start);
|
||
const endRange = declaredDateRange(end);
|
||
if (startRange.start > endRange.end) throw new Error(`invalid_event_date_range: ${event.date}`);
|
||
return { start: startRange.start, end: endRange.end, precision: "range" as const };
|
||
}
|
||
|
||
/** Convert a V5 event to the v3 events schema used by `/api/active_rectification_events`. */
|
||
function toV3Event(event: AgenticRectificationEvent): Readonly<{
|
||
id: string;
|
||
domain: RectificationDomain;
|
||
date: string;
|
||
precision: "day" | "month" | "year";
|
||
summary?: string;
|
||
}> {
|
||
const v5 = toV5Event(event);
|
||
const precision = v5.precision === "day" ? "day" : v5.precision === "month" ? "month" : "year";
|
||
const date = precision === "day" ? v5.date_start : precision === "month" ? v5.date_start.slice(0, 7) : v5.date_start.slice(0, 4);
|
||
return { id: v5.id, domain: v5.domain, date, precision, summary: v5.summary };
|
||
}
|
||
|
||
async function postEngine(base: string, path: string, body: unknown): Promise<Record<string, unknown>> {
|
||
const response = await fetch(`${base}${path}`, {
|
||
method: "POST",
|
||
headers: { "content-type": "application/json" },
|
||
body: JSON.stringify(body),
|
||
signal: AbortSignal.timeout(60_000),
|
||
});
|
||
const data = await response.json().catch(() => null);
|
||
if (!response.ok) {
|
||
const message = data?.error || data?.message || `Jyotish API ${path} returned ${response.status}`;
|
||
throw new Error(message);
|
||
}
|
||
if (!data || typeof data !== "object") throw new Error(`Jyotish API ${path} returned an invalid response`);
|
||
return data as Record<string, unknown>;
|
||
}
|
||
|
||
function v5Request(
|
||
ctx: AgenticRectificationContext,
|
||
candidateRange: z.infer<typeof candidateRangeSchema>,
|
||
events: readonly AgenticRectificationEvent[],
|
||
) {
|
||
return {
|
||
birth_date: ctx.birth.birth_date,
|
||
start_time: candidateRange.start_time,
|
||
end_time: candidateRange.end_time,
|
||
lat: ctx.birth.lat,
|
||
lon: ctx.birth.lon,
|
||
tz: ctx.birth.tz,
|
||
events: events.map(toV5Event),
|
||
};
|
||
}
|
||
|
||
function topCandidates(value: unknown, limit = 6): unknown {
|
||
const scores = Array.isArray(value)
|
||
? (value as Array<Record<string, unknown>>)
|
||
: [];
|
||
return scores.slice(0, limit);
|
||
}
|
||
|
||
function compactRobustness(value: unknown): Record<string, unknown> | null {
|
||
if (!value || typeof value !== "object") return null;
|
||
const robustness = value as Record<string, unknown>;
|
||
return {
|
||
neighbor_support_minutes: robustness.neighbor_support_minutes,
|
||
leave_one_out_retention_rate: robustness.leave_one_out_retention_rate,
|
||
leave_one_domain_out_retention_rate: robustness.leave_one_domain_out_retention_rate,
|
||
date_sensitivity_retention_rate: robustness.date_sensitivity_retention_rate,
|
||
};
|
||
}
|
||
|
||
function timeInRange(time: string, range: AgenticRectificationCandidateRange): boolean {
|
||
const value = clockMinute(time);
|
||
const start = clockMinute(range.start_time);
|
||
const end = clockMinute(range.end_time);
|
||
return end >= start ? value >= start && value <= end : value >= start || value <= end;
|
||
}
|
||
|
||
function normalizedCandidates(
|
||
value: unknown,
|
||
range: AgenticRectificationCandidateRange,
|
||
): AgenticRectificationCandidate[] {
|
||
if (!Array.isArray(value)) return [];
|
||
const rows = value.flatMap((item): Array<{ rank: number; time: string; score: number; tied: number }> => {
|
||
if (!item || typeof item !== "object") return [];
|
||
const row = item as Record<string, unknown>;
|
||
const time = typeof row.time === "string" ? row.time : "";
|
||
const rank = typeof row.rank === "number" ? Math.trunc(row.rank) : 0;
|
||
const score = typeof row.score === "number" && Number.isFinite(row.score) ? row.score : 0;
|
||
const tied = typeof row.tied_minute_count === "number" ? Math.max(1, Math.trunc(row.tied_minute_count)) : 1;
|
||
if (!timePattern.test(time) || rank < 1 || !timeInRange(time, range)) return [];
|
||
return [{ rank, time, score, tied }];
|
||
}).sort((left, right) => left.rank - right.rank).slice(0, 12);
|
||
if (rows.length === 0) return [];
|
||
const weights = rows.map((row) => Math.max(0, row.score));
|
||
const total = weights.reduce((sum, weight) => sum + weight, 0);
|
||
const supports = weights.map((weight) => total > 0 ? Math.round((weight / total) * 100) : Math.floor(100 / rows.length));
|
||
supports[0] += 100 - supports.reduce((sum, support) => sum + support, 0);
|
||
return rows.map((row, index) => ({
|
||
rank: row.rank,
|
||
time: row.time,
|
||
relative_support: supports[index] ?? 0,
|
||
tied_minute_count: row.tied,
|
||
}));
|
||
}
|
||
|
||
function compactScoreResult(data: Record<string, unknown>): Record<string, unknown> {
|
||
const diagnostics = (data.diagnostics && typeof data.diagnostics === "object")
|
||
? data.diagnostics as Record<string, unknown>
|
||
: {};
|
||
return {
|
||
endpoint: data.endpoint,
|
||
result_id: data.result_id,
|
||
algorithm_version: data.algorithm_version,
|
||
calculation_spec_hash: data.calculation_spec_hash,
|
||
candidate_count: Array.isArray(data.candidate_scores) ? (data.candidate_scores as unknown[]).length : 0,
|
||
top_candidates: topCandidates(data.candidate_scores),
|
||
robustness: compactRobustness(data.robustness),
|
||
diagnostics_summary: {
|
||
primary_cluster_retention_rate: diagnostics.primary_cluster_retention_rate,
|
||
primary_secondary_margin_percent: diagnostics.primary_secondary_margin_percent,
|
||
most_discriminating_layers: diagnostics.most_discriminating_layers,
|
||
candidate_splits: diagnostics.candidate_splits,
|
||
unstable_event_ids: diagnostics.unstable_event_ids,
|
||
},
|
||
missing_layers: data.missing_layers,
|
||
can_confirm_exact_minute: data.can_confirm_exact_minute,
|
||
};
|
||
}
|
||
|
||
export function createAgenticRectificationTools(ctx: AgenticRectificationContext) {
|
||
const base = ctx.engineBase ?? engineBase;
|
||
// Server-owned confirmation gate for this session: only a minute the engine
|
||
// produced through the high-rigor gate may ever be persisted.
|
||
let confirmedGate: Readonly<{ time: string; resultId: string }> | null = null;
|
||
|
||
const gateTool = createTool({
|
||
id: "rectification-gate",
|
||
description:
|
||
"Run the birth-time precision gate: computes effective accuracy, enabled divisional charts, lagna boundary sensitivity, and recommended dated-event types for the user's reported birth time. Call this first to understand the starting precision and which events are most valuable.",
|
||
inputSchema: z.object({
|
||
declared_accuracy: z.enum(["minute", "15min", "1hour", "unknown"]).optional(),
|
||
time_source: z.enum(["hospital", "family_clear", "family_vague", "unknown"]).optional(),
|
||
}).strict(),
|
||
execute: async (input) => {
|
||
if (!ctx.birth.reported_time) {
|
||
return {
|
||
endpoint: "server_owned_rectification_preflight",
|
||
effective_accuracy: ctx.declaredAccuracy ?? "unknown",
|
||
candidate_range: ctx.candidateRange,
|
||
lagna_boundary: { is_sensitive: null, note: "requires_dated_event_scoring_across_candidate_range" },
|
||
enabled_vargas: {},
|
||
summary: {
|
||
headline: "broad_candidate_range",
|
||
enabled: [],
|
||
warned: ["candidate_range_requires_event_scoring"],
|
||
disabled: ["single_minute_precision_claims"],
|
||
confidence_floor: "low",
|
||
recommended_events: ["education", "career", "relationship", "relocation"],
|
||
next_action: "collect one clearly dated life event",
|
||
},
|
||
};
|
||
}
|
||
const body = {
|
||
year: Number(ctx.birth.birth_date.slice(0, 4)),
|
||
month: Number(ctx.birth.birth_date.slice(5, 7)),
|
||
day: Number(ctx.birth.birth_date.slice(8, 10)),
|
||
hour: Number(ctx.birth.reported_time.slice(0, 2)),
|
||
minute: Number(ctx.birth.reported_time.slice(3, 5)),
|
||
lat: ctx.birth.lat,
|
||
lon: ctx.birth.lon,
|
||
tz: ctx.birth.tz,
|
||
declared_accuracy: input.declared_accuracy ?? ctx.declaredAccuracy ?? "unknown",
|
||
time_source: input.time_source ?? ctx.timeSource ?? "family_clear",
|
||
};
|
||
const data = await postEngine(base, "/api/rectification_gate", body);
|
||
const summary = (data.summary && typeof data.summary === "object")
|
||
? data.summary as Record<string, unknown>
|
||
: {};
|
||
return {
|
||
endpoint: data.endpoint,
|
||
effective_accuracy: data.effective_accuracy,
|
||
candidate_range: ctx.candidateRange,
|
||
lagna_boundary: data.lagna_boundary,
|
||
enabled_vargas: data.enabled_vargas,
|
||
summary: {
|
||
headline: summary.headline,
|
||
enabled: summary.enabled,
|
||
warned: summary.warned,
|
||
disabled: summary.disabled,
|
||
confidence_floor: summary.confidence_floor,
|
||
recommended_events: summary.recommended_events,
|
||
next_action: summary.next_action,
|
||
},
|
||
};
|
||
},
|
||
});
|
||
|
||
const scanTool = createTool({
|
||
id: "rectification-scan",
|
||
description:
|
||
"Scan how chart layers change minute-to-minute across the server-owned candidate range. Wide ranges are deferred until dated-event scoring narrows the evidence.",
|
||
inputSchema: z.object({
|
||
step_minutes: z.number().int().min(1).max(30).optional(),
|
||
}).strict(),
|
||
execute: async (input) => {
|
||
const scanWindow = rangeScanWindow(ctx.candidateRange);
|
||
if (scanWindow.width > 360) {
|
||
return {
|
||
scope: "candidate_time_sensitivity_scan",
|
||
status: "deferred_wide_range",
|
||
candidate_range: ctx.candidateRange,
|
||
boundary: "Collect dated events and score the server-owned full range before running a local sensitivity scan.",
|
||
};
|
||
}
|
||
const body = {
|
||
year: Number(ctx.birth.birth_date.slice(0, 4)),
|
||
month: Number(ctx.birth.birth_date.slice(5, 7)),
|
||
day: Number(ctx.birth.birth_date.slice(8, 10)),
|
||
hour: Number(scanWindow.centerTime.slice(0, 2)),
|
||
minute: Number(scanWindow.centerTime.slice(3, 5)),
|
||
lat: ctx.birth.lat,
|
||
lon: ctx.birth.lon,
|
||
tz: ctx.birth.tz,
|
||
time_uncertainty_minutes: scanWindow.uncertaintyMinutes,
|
||
step_minutes: input.step_minutes,
|
||
};
|
||
const data = await postEngine(base, "/api/rectification/sensitivity_scan", body);
|
||
const rows = Array.isArray(data.rows) ? (data.rows as unknown[]) : [];
|
||
return {
|
||
scope: data.scope,
|
||
status: data.status,
|
||
candidate_range: ctx.candidateRange,
|
||
center_time: data.center_time,
|
||
uncertainty_minutes: data.uncertainty_minutes,
|
||
step_minutes: data.step_minutes,
|
||
candidate_count: data.candidate_count,
|
||
sensitivity_summary: {
|
||
sensitive_layers: rows.reduce<Record<string, number>>((acc, row) => {
|
||
const sensitive = (row as Record<string, unknown>).sensitive_layers;
|
||
if (Array.isArray(sensitive)) {
|
||
for (const layer of sensitive as string[]) acc[layer] = (acc[layer] ?? 0) + 1;
|
||
}
|
||
return acc;
|
||
}, {}),
|
||
high_sensitivity_layers: Object.entries(
|
||
rows.reduce<Record<string, number>>((acc, row) => {
|
||
const sensitive = (row as Record<string, unknown>).sensitive_layers;
|
||
if (Array.isArray(sensitive)) {
|
||
for (const layer of sensitive as string[]) acc[layer] = (acc[layer] ?? 0) + 1;
|
||
}
|
||
return acc;
|
||
}, {}),
|
||
).filter(([, count]) => count >= Math.max(1, rows.length / 4)).map(([layer]) => layer),
|
||
},
|
||
supported_vargas: data.supported_vargas,
|
||
unavailable_vargas: data.unavailable_vargas,
|
||
pending_layers: data.pending_layers,
|
||
transitions: Array.isArray(data.transitions) ? (data.transitions as unknown[]).slice(0, 12) : [],
|
||
boundary: data.boundary,
|
||
};
|
||
},
|
||
});
|
||
|
||
const scoreTool = createTool({
|
||
id: "rectification-score",
|
||
description:
|
||
"Score candidate birth minutes against the user's dated life events using the V5 matrix engine (Vimshottari/Narayana/D2-D30/Arudha/Ashtakavarga/Shadbala). Returns the top candidate minutes, robustness, and missing layers. Supply dated events you have confirmed with the user. Keep event ids stable across calls.",
|
||
inputSchema: z.object({
|
||
candidate_range: candidateRangeSchema,
|
||
events: z.array(agenticRectificationEventSchema).min(1).max(40),
|
||
}).strict(),
|
||
execute: async (input) => {
|
||
requireServerCandidateRange(input.candidate_range, ctx.candidateRange);
|
||
const data = await postEngine(base, "/api/rectification/v5/score", v5Request(ctx, input.candidate_range, input.events));
|
||
return compactScoreResult(data);
|
||
},
|
||
});
|
||
|
||
const diagnosticsTool = createTool({
|
||
id: "rectification-diagnostics",
|
||
description:
|
||
"Run robustness diagnostics over the candidate range for the user's dated events: leave-one-event-out and leave-one-domain-out retention, date sensitivity, neighbor stability, candidate splits, and unstable events. Use this to decide which event to clarify next.",
|
||
inputSchema: z.object({
|
||
candidate_range: candidateRangeSchema,
|
||
events: z.array(agenticRectificationEventSchema).min(1).max(40),
|
||
}).strict(),
|
||
execute: async (input) => {
|
||
requireServerCandidateRange(input.candidate_range, ctx.candidateRange);
|
||
const data = await postEngine(base, "/api/rectification/v5/diagnostics", v5Request(ctx, input.candidate_range, input.events));
|
||
const diagnostics = (data.diagnostics && typeof data.diagnostics === "object")
|
||
? data.diagnostics as Record<string, unknown>
|
||
: {};
|
||
return {
|
||
endpoint: data.endpoint,
|
||
result_id: data.result_id,
|
||
algorithm_version: data.algorithm_version,
|
||
diagnostics: {
|
||
primary_cluster_retention_rate: diagnostics.primary_cluster_retention_rate,
|
||
leave_one_event_out_retention_rate: diagnostics.leave_one_event_out_retention_rate,
|
||
leave_one_domain_out_retention_rate: diagnostics.leave_one_domain_out_retention_rate,
|
||
date_sensitivity_retention_rate: diagnostics.date_sensitivity_retention_rate,
|
||
neighbor_support_minutes: diagnostics.neighbor_support_minutes,
|
||
primary_secondary_margin_percent: diagnostics.primary_secondary_margin_percent,
|
||
cluster_mass_ratio: diagnostics.cluster_mass_ratio,
|
||
unstable_event_ids: diagnostics.unstable_event_ids,
|
||
most_discriminating_layers: diagnostics.most_discriminating_layers,
|
||
event_date_sensitivity: diagnostics.event_date_sensitivity,
|
||
candidate_splits: diagnostics.candidate_splits,
|
||
},
|
||
missing_layers: data.missing_layers,
|
||
can_confirm_exact_minute: data.can_confirm_exact_minute,
|
||
};
|
||
},
|
||
});
|
||
|
||
const featuresTool = createTool({
|
||
id: "rectification-candidate-features",
|
||
description:
|
||
"Compute the static chart features (ascendant degree, divisional ascendants, arudha signs, available/blocked layers) for each candidate minute in a range, without event scoring. Use this to reason about which layers each candidate actually has when interpreting a split or a transition.",
|
||
inputSchema: z.object({
|
||
candidate_range: candidateRangeSchema,
|
||
}).strict(),
|
||
execute: async (input) => {
|
||
requireServerCandidateRange(input.candidate_range, ctx.candidateRange);
|
||
const data = await postEngine(base, "/api/rectification/v5/candidate-features", {
|
||
birth_date: ctx.birth.birth_date,
|
||
start_time: input.candidate_range.start_time,
|
||
end_time: input.candidate_range.end_time,
|
||
lat: ctx.birth.lat,
|
||
lon: ctx.birth.lon,
|
||
tz: ctx.birth.tz,
|
||
events: [],
|
||
});
|
||
const snapshot = (data.candidate_feature_snapshot && typeof data.candidate_feature_snapshot === "object")
|
||
? data.candidate_feature_snapshot as Record<string, unknown>
|
||
: {};
|
||
const features = Array.isArray(snapshot.features) ? (snapshot.features as unknown[]).slice(0, 24) : [];
|
||
return {
|
||
endpoint: data.endpoint,
|
||
algorithm_version: data.algorithm_version,
|
||
calculation_spec_hash: data.calculation_spec_hash,
|
||
candidate_count: snapshot.candidate_count,
|
||
features,
|
||
can_confirm_exact_minute: data.can_confirm_exact_minute,
|
||
};
|
||
},
|
||
});
|
||
|
||
const confirmTool = createTool({
|
||
id: "rectification-confirm",
|
||
description:
|
||
"Run the high-rigor confirmation gate for the candidate range and the user's dated events. Official VedAstro runs only after the local external-validation entry gate is ready; external_validation_status=not_evaluated means it was not invoked, not that it failed. Neighbor stability and leave-one-out are diagnostic confidence indicators, not hard blockers. Set offer_selection=true only when this turn is ready to offer working-time choices; keep it false while asking for more evidence. Returns whether a precise minute can be confirmed, the representative minute, and the reasons. This does NOT write anything.",
|
||
inputSchema: z.object({
|
||
candidate_range: candidateRangeSchema,
|
||
events: z.array(agenticRectificationEventSchema).min(1).max(40),
|
||
offer_selection: z.boolean().default(false),
|
||
}).strict(),
|
||
execute: async (input) => {
|
||
requireServerCandidateRange(input.candidate_range, ctx.candidateRange);
|
||
const body = {
|
||
birth_date: ctx.birth.birth_date,
|
||
start_time: input.candidate_range.start_time,
|
||
end_time: input.candidate_range.end_time,
|
||
lat: ctx.birth.lat,
|
||
lon: ctx.birth.lon,
|
||
tz: ctx.birth.tz,
|
||
events: input.events.map(toV3Event),
|
||
high_rigor: true,
|
||
};
|
||
const data = await postEngine(base, "/api/active_rectification_events", body);
|
||
const winning = (data.winning_segment && typeof data.winning_segment === "object")
|
||
? data.winning_segment as Record<string, unknown>
|
||
: null;
|
||
const technique = (data.technique_contract && typeof data.technique_contract === "object")
|
||
? data.technique_contract as Record<string, unknown>
|
||
: null;
|
||
const gates = (technique?.gates && typeof technique.gates === "object")
|
||
? technique.gates as Record<string, unknown>
|
||
: {};
|
||
const externalEngines = (technique?.external_engines && typeof technique.external_engines === "object")
|
||
? technique.external_engines as Record<string, unknown>
|
||
: {};
|
||
const externalValidation = (externalEngines.validation && typeof externalEngines.validation === "object")
|
||
? externalEngines.validation as Record<string, unknown>
|
||
: {};
|
||
const externalValidationStatus = typeof externalEngines.status === "string"
|
||
? externalEngines.status
|
||
: "not_evaluated";
|
||
const confirmationAllowed = technique?.confirmation_allowed === true
|
||
&& technique?.decision === "confirm_minute";
|
||
const representativeTime = winning && timePattern.test(String(winning.representative_time ?? ""))
|
||
? String(winning.representative_time)
|
||
: null;
|
||
const rankedCandidates = normalizedCandidates(data.candidate_ranking_summary, ctx.candidateRange);
|
||
const candidates = rankedCandidates.length > 0 || !representativeTime
|
||
? rankedCandidates
|
||
: [{ rank: 1, time: representativeTime, relative_support: 100, tied_minute_count: 1 }];
|
||
const eventCount = typeof data.event_count === "number" ? data.event_count : 0;
|
||
const domainCount = typeof data.domain_count === "number" ? data.domain_count : 0;
|
||
const candidateEligible = eventCount >= 3 && domainCount >= 2 && candidates.length > 0;
|
||
const selectionAllowed = confirmationAllowed || (input.offer_selection && candidateEligible);
|
||
const persisted = await ctx.persistCandidateResult({
|
||
engineResultId: String(data.result_id ?? ""),
|
||
canonicalInputHash: String(data.canonical_input_hash ?? ""),
|
||
algorithmVersion: String(data.algorithm_version ?? "unknown"),
|
||
candidateRange: ctx.candidateRange,
|
||
candidates,
|
||
overallConfidence: data.confidence === "high" || data.confidence === "medium" ? data.confidence : "low",
|
||
marginPercent: typeof data.margin_percent === "number" ? data.margin_percent : null,
|
||
selectionAllowed,
|
||
confirmationAllowed,
|
||
representativeTime,
|
||
});
|
||
if (confirmationAllowed && representativeTime && persisted.ok) {
|
||
confirmedGate = { time: representativeTime, resultId: persisted.result_id };
|
||
}
|
||
return {
|
||
endpoint: data.endpoint,
|
||
result_id: data.result_id,
|
||
candidate_result_id: persisted.ok ? persisted.result_id : null,
|
||
candidate_persistence_error: persisted.ok ? null : persisted.reason,
|
||
confidence: data.confidence,
|
||
event_count: eventCount,
|
||
domain_count: domainCount,
|
||
can_apply: data.can_apply === true,
|
||
selection_allowed: selectionAllowed && persisted.ok,
|
||
confirmation_allowed: confirmationAllowed,
|
||
representative_time: representativeTime,
|
||
winning_segment: winning ? {
|
||
start_time: winning.start_time,
|
||
end_time: winning.end_time,
|
||
width_minutes: winning.width_minutes,
|
||
} : null,
|
||
reasons: Array.isArray(data.reasons) ? data.reasons : [],
|
||
stability_diagnostics: data.stability_diagnostics,
|
||
external_validation_status: externalValidationStatus,
|
||
external_validation_invoked: externalValidationStatus !== "not_evaluated",
|
||
external_validation_reason: typeof externalValidation.reason === "string"
|
||
? externalValidation.reason
|
||
: typeof externalValidation.vedastro_reason === "string" ? externalValidation.vedastro_reason : null,
|
||
technique_contract: {
|
||
decision: technique?.decision,
|
||
confirmation_allowed: technique?.confirmation_allowed,
|
||
can_narrow_to_minute: technique?.can_narrow_to_minute,
|
||
external_engines: technique?.external_engines,
|
||
gates: {
|
||
event_quality: gates.event_quality,
|
||
cross_domain_coverage: gates.cross_domain_coverage,
|
||
local_candidate: gates.local_candidate,
|
||
required_layers: gates.required_layers,
|
||
neighbor_stability: gates.neighbor_stability,
|
||
leave_one_event_out: gates.leave_one_event_out,
|
||
three_engine_input_parity: gates.three_engine_input_parity,
|
||
vedastro_official_response: gates.vedastro_official_response,
|
||
vedastro_minute_sensitive_validation: gates.vedastro_minute_sensitive_validation,
|
||
},
|
||
hard_blockers: technique?.hard_blockers,
|
||
boundary: technique?.boundary,
|
||
},
|
||
missing_layers: data.missing_layers,
|
||
candidates,
|
||
boundary: data.boundary,
|
||
};
|
||
},
|
||
});
|
||
|
||
const saveTool = createTool({
|
||
id: "rectification-save-birth-time",
|
||
description:
|
||
"Persist a confirmed birth minute to the user's profile. REQUIRES that rectification-confirm returned confirmation_allowed=true in this same session, that you have the user's explicit consent to overwrite their birth time, and that the requested time exactly equals the confirmed representative minute. Any other time is rejected. Returns whether the profile was updated.",
|
||
inputSchema: z.object({
|
||
time: z.string().regex(timePattern),
|
||
}).strict(),
|
||
execute: async (input) => {
|
||
if (!confirmedGate) {
|
||
return {
|
||
ok: false,
|
||
reason: "no_confirmed_gate: run rectification-confirm first and require confirmation_allowed=true before saving.",
|
||
};
|
||
}
|
||
if (input.time !== confirmedGate.time) {
|
||
return {
|
||
ok: false,
|
||
reason: `time_mismatch: the engine confirmed ${confirmedGate.time}, not ${input.time}. Only the confirmed minute can be saved.`,
|
||
};
|
||
}
|
||
const applied = await ctx.acceptCandidate(input.time, confirmedGate.resultId);
|
||
if (!applied.ok) {
|
||
return { ok: false, reason: `profile_write_failed: ${applied.reason}` };
|
||
}
|
||
return { ok: true, saved_time: applied.saved_time, status: applied.status, result_id: applied.result_id };
|
||
},
|
||
});
|
||
|
||
const acceptCandidateTool = createTool({
|
||
id: "rectification-accept-candidate",
|
||
description:
|
||
"Apply a server-persisted candidate after the user explicitly chooses that exact time. The server validates ownership, session, expiry, profile baseline, and candidate membership. A non-confirmed choice is saved as accepted, not as an engine-confirmed unique minute.",
|
||
inputSchema: z.object({
|
||
time: z.string().regex(timePattern),
|
||
}).strict(),
|
||
execute: async (input) => {
|
||
const applied = await ctx.acceptCandidate(input.time);
|
||
return applied.ok
|
||
? { ok: true, saved_time: applied.saved_time, status: applied.status, result_id: applied.result_id }
|
||
: { ok: false, reason: applied.reason };
|
||
},
|
||
});
|
||
|
||
return {
|
||
"rectification-gate": gateTool,
|
||
"rectification-scan": scanTool,
|
||
"rectification-score": scoreTool,
|
||
"rectification-diagnostics": diagnosticsTool,
|
||
"rectification-candidate-features": featuresTool,
|
||
"rectification-confirm": confirmTool,
|
||
"rectification-accept-candidate": acceptCandidateTool,
|
||
"rectification-save-birth-time": saveTool,
|
||
};
|
||
}
|
||
|
||
export type AgenticRectificationTools = ReturnType<typeof createAgenticRectificationTools>;
|