Files
Jyotisha/frontend/src/lib/rectification-agentic/session.ts
T

217 lines
8.1 KiB
TypeScript

import type { SupabaseClient } from "@supabase/supabase-js";
import type { AgenticRectificationContext } from "@/mastra/rectification-tools";
/**
* Agentic rectification session support.
*
* The server owns everything the LLM is not allowed to decide: the user's
* birth profile, the baseline active birth time, and the only write path to
* `profiles.active_birth_time`. The LLM can never persist an arbitrary minute;
* the save tool re-validates against the engine's confirmation gate in the
* same session and only then calls this module's RPC-backed writer.
*/
type AccountingClient = SupabaseClient;
export class AgenticRectificationProfileError extends Error {
readonly code: string;
constructor(code: string) {
super(`Agentic rectification profile error: ${code}`);
this.name = "AgenticRectificationProfileError";
this.code = code;
}
}
export type AgenticRectificationProfile = Readonly<{
birth_date: string;
reported_time: string | null;
candidateRange: AgenticRectificationContext["candidateRange"];
lat: number;
lon: number;
tz: number;
declaredAccuracy: AgenticRectificationContext["declaredAccuracy"];
timeSource: AgenticRectificationContext["timeSource"];
baselineActiveTime: string | null;
}>;
const timeValue = (value: unknown): string | null => {
const time = typeof value === "string" ? value.slice(0, 5) : "";
return /^([01]\d|2[0-3]):[0-5]\d$/.test(time) ? time : null;
};
const periodRanges = {
early_morning: { start_time: "04:00", end_time: "07:59" },
morning: { start_time: "08:00", end_time: "11:59" },
afternoon: { start_time: "12:00", end_time: "17:59" },
evening: { start_time: "18:00", end_time: "22:59" },
late_night: { start_time: "23:00", end_time: "03:59" },
} as const;
function shiftedTime(time: string, offsetMinutes: number): string {
const [hour = 0, minute = 0] = time.split(":").map(Number);
const normalized = ((hour * 60 + minute + offsetMinutes) % 1_440 + 1_440) % 1_440;
return `${String(Math.floor(normalized / 60)).padStart(2, "0")}:${String(normalized % 60).padStart(2, "0")}`;
}
function candidateRangeFrom(input: {
activeTime: string | null;
reportedTime: string | null;
source: string;
period: unknown;
uncertaintyBefore: number | null;
uncertaintyAfter: number | null;
}): AgenticRectificationContext["candidateRange"] {
const referenceTime = input.activeTime ?? input.reportedTime;
if (referenceTime) {
const fallback = input.source === "hospital_record" || input.source === "hospital"
? 2
: input.source === "family_exact" || input.source === "family_clear"
? 15
: input.source === "approximate" || input.source === "family_vague"
? 60
: 2;
return {
start_time: shiftedTime(referenceTime, -(input.uncertaintyBefore ?? fallback)),
end_time: shiftedTime(referenceTime, input.uncertaintyAfter ?? fallback),
};
}
if (input.source === "period_only" || input.source === "legacy_import") {
const period = typeof input.period === "string"
? periodRanges[input.period as keyof typeof periodRanges]
: null;
if (period) return period;
if (input.source === "period_only") throw new AgenticRectificationProfileError("missing_birth_time_period");
}
if (input.source === "unknown" || input.source === "legacy_import") {
return { start_time: "00:00", end_time: "23:59" };
}
throw new AgenticRectificationProfileError("missing_birth_time");
}
function declaredAccuracyFrom(uncertaintyBefore: number | null, uncertaintyAfter: number | null, timeSource: string | null): AgenticRectificationContext["declaredAccuracy"] {
const before = uncertaintyBefore ?? 0;
const after = uncertaintyAfter ?? 0;
const total = Math.max(before, after);
if (total > 0) {
if (total <= 5) return "minute";
if (total <= 15) return "15min";
if (total <= 60) return "1hour";
return "unknown";
}
switch (timeSource) {
case "hospital":
case "hospital_record": return "minute";
case "family_clear":
case "family_exact": return "15min";
case "family_vague":
case "approximate": return "1hour";
default: return "unknown";
}
}
function timeSourceFrom(value: unknown): AgenticRectificationContext["timeSource"] {
const source = typeof value === "string" ? value.trim() : "";
if (source === "hospital" || source === "hospital_record") return "hospital";
if (source === "family_clear" || source === "family_exact") return "family_clear";
if (source === "family_vague" || source === "approximate" || source === "period_only") return "family_vague";
return "unknown";
}
function numberOrNull(value: unknown): number | null {
return typeof value === "number" && Number.isFinite(value) ? value : null;
}
export async function loadAgenticRectificationProfile(
accounting: AccountingClient,
userId: string,
): Promise<AgenticRectificationProfile> {
const { data, error } = await accounting
.from("profiles")
.select("birth_date,reported_birth_time,active_birth_time,birth_time_source,birth_time_period,uncertainty_before_minutes,uncertainty_after_minutes,latitude,longitude,timezone_offset")
.eq("id", userId)
.single();
if (error || !data) throw new AgenticRectificationProfileError("profile_unavailable");
const birthDate = typeof data.birth_date === "string" ? data.birth_date.trim() : "";
if (!/^\d{4}-\d{2}-\d{2}$/.test(birthDate)) {
throw new AgenticRectificationProfileError("missing_birth_date");
}
const activeTime = timeValue(data.active_birth_time);
const reportedTime = timeValue(data.reported_birth_time);
const lat = numberOrNull(data.latitude);
const lon = numberOrNull(data.longitude);
const tz = numberOrNull(data.timezone_offset);
if (lat === null || lon === null || tz === null) {
throw new AgenticRectificationProfileError("missing_birth_place");
}
const timeSource = timeSourceFrom(data.birth_time_source);
const uncertaintyBefore = numberOrNull(data.uncertainty_before_minutes);
const uncertaintyAfter = numberOrNull(data.uncertainty_after_minutes);
const candidateRange = candidateRangeFrom({
activeTime,
reportedTime,
source: typeof data.birth_time_source === "string" ? data.birth_time_source.trim() : "",
period: data.birth_time_period,
uncertaintyBefore,
uncertaintyAfter,
});
return {
birth_date: birthDate,
reported_time: activeTime ?? reportedTime,
candidateRange,
lat,
lon,
tz,
declaredAccuracy: declaredAccuracyFrom(uncertaintyBefore, uncertaintyAfter, data.birth_time_source),
timeSource,
baselineActiveTime: activeTime,
};
}
export function createAgenticRectificationContext(
accounting: AccountingClient,
userId: string,
profile: AgenticRectificationProfile,
): AgenticRectificationContext {
return {
userId,
birth: {
birth_date: profile.birth_date,
reported_time: profile.reported_time,
lat: profile.lat,
lon: profile.lon,
tz: profile.tz,
},
candidateRange: profile.candidateRange,
declaredAccuracy: profile.declaredAccuracy,
timeSource: profile.timeSource,
async applyConfirmedBirthTime(time) {
if (!/^\d{2}:\d{2}$/.test(time)) {
return { ok: false, reason: "invalid_time_format" };
}
try {
const { data, error } = await accounting.rpc("apply_agentic_rectification_birth_time", {
p_user_id: userId,
p_time: time,
p_baseline_time: profile.baselineActiveTime,
p_source: "agentic-rectification",
});
if (error) return { ok: false, reason: error.message };
const candidate = Array.isArray(data) ? data[0] : data;
if (candidate && typeof candidate === "object"
&& (candidate as { success?: boolean }).success === true) {
return { ok: true, saved_time: String((candidate as { saved_time?: unknown }).saved_time ?? time) };
}
const reason = candidate && typeof candidate === "object"
? String((candidate as { error?: unknown }).error ?? "rpc_rejected")
: "rpc_rejected";
return { ok: false, reason };
} catch (error) {
return { ok: false, reason: error instanceof Error ? error.message : "rpc_failed" };
}
},
};
}