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

122 lines
5.1 KiB
TypeScript

/**
* Opening scan for the nearest chart change. Failures keep the original
* two-sentence opening. Question order stays on its own switch.
*/
import { resolveAyanamsa } from "../ayanamsa.ts";
import { requestCandidateIntervals, type DatedCandidateRange } from "./core/candidate-window.ts";
import {
CHART_LAYERS,
NEAREST_BOUNDARY_PROBE_THRESHOLD_MINUTES,
nearestBoundaryProbesEnabled,
topicsForLayers,
} from "./nearest-boundary.ts";
import type { OpeningBoundaryFacts } from "./user-copy.ts";
const BOUNDARY_CHARTS = ["D1", "D2", "D4", "D5", "D7", "D9", "D10", "D11", "D12", "D24", "D30"] as const;
const LAYERS = new Set<string>(CHART_LAYERS);
export function openingFactsFromBoundaryPayload(
payload: unknown,
askFirst: boolean,
): OpeningBoundaryFacts & { at?: string | null } {
if (!payload || typeof payload !== "object") return { kind: "unavailable" };
const row = payload as { found?: unknown; reason?: unknown; distance_minutes?: unknown; layers?: unknown };
if (row.found !== true) return row.reason === "no_transition" ? { kind: "stable" } : { kind: "unavailable" };
const distance = row.distance_minutes;
if (typeof distance !== "number" || !Number.isInteger(distance) || distance < 0 || distance > 720) {
return { kind: "unavailable" };
}
const rawLayers = Array.isArray(row.layers) ? row.layers : [];
const layers = rawLayers.flatMap((item) => {
const layer = item && typeof item === "object" ? (item as { layer?: unknown }).layer : null;
return typeof layer === "string" && LAYERS.has(layer) ? [layer] : [];
});
const d1Changed = rawLayers.some((item) => {
const layer = item && typeof item === "object" ? (item as { layer?: unknown }).layer : null;
return layer === "d1";
});
const topics = topicsForLayers(layers);
const at = (payload as { at?: unknown }).at;
return {
kind: "near",
distanceMinutes: distance,
topics,
d1Changed,
...(typeof at === "string" ? { at: at.slice(0, 5) } : {}),
askFirst: askFirst && topics.length > 0 && distance <= NEAREST_BOUNDARY_PROBE_THRESHOLD_MINUTES,
};
}
function preferBoundary(
left: OpeningBoundaryFacts & { at?: string | null },
right: OpeningBoundaryFacts & { at?: string | null },
): OpeningBoundaryFacts & { at?: string | null } {
if (left.kind !== "near") return right.kind === "near" ? right : left;
if (right.kind !== "near") return left;
const distance = (left.distanceMinutes ?? 9999) - (right.distanceMinutes ?? 9999);
if (distance !== 0) return distance < 0 ? left : right;
return (left.at ?? "99:99") <= (right.at ?? "99:99") ? left : right;
}
export async function fetchOpeningBoundary(input: {
snapshot: Readonly<Record<string, unknown>>;
range: DatedCandidateRange;
recordedTime: string;
}): Promise<OpeningBoundaryFacts> {
const recorded = input.recordedTime.trim().slice(0, 5);
if (!/^(?:[01]\d|2[0-3]):[0-5]\d$/.test(recorded)) return { kind: "unavailable" };
const latitude = input.snapshot.latitude;
const longitude = input.snapshot.longitude;
const timezone = input.snapshot.timezone_offset;
if (typeof latitude !== "number" || typeof longitude !== "number" || typeof timezone !== "number") {
return { kind: "unavailable" };
}
const intervals = requestCandidateIntervals(input.snapshot, input.range);
const askFirst = nearestBoundaryProbesEnabled();
let best: OpeningBoundaryFacts = { kind: "unavailable" };
for (const interval of intervals) {
const response = await fetch(`${process.env.JYOTISH_API_BASE?.trim() || "http://127.0.0.1:5200"}/api/rectification/v5/varga-segments`, {
method: "POST",
headers: { "Content-Type": "application/json" },
signal: AbortSignal.timeout(60000),
body: JSON.stringify({
start_at: interval.start_at,
end_at: interval.end_at,
latitude,
longitude,
timezone_offset: timezone,
target_charts: ["D1"],
boundary_charts: BOUNDARY_CHARTS,
recorded_time: recorded,
ayanamsa: resolveAyanamsa(input.snapshot),
}),
});
if (!response.ok) return { kind: "unavailable" };
const data = await response.json() as { nearest_boundary?: unknown };
best = preferBoundary(best, openingFactsFromBoundaryPayload(data.nearest_boundary, askFirst));
}
return best;
}
export async function resolveOpeningBoundaryFacts(input: {
birthTimeSource?: string | null;
reportedBirthTime?: string | null;
candidateRange?: { start_time?: string | null; end_time?: string | null } | null;
loadCompute: () => Promise<{ baselineBirthSnapshot: Readonly<Record<string, unknown>>; candidateRange: DatedCandidateRange }>;
}): Promise<OpeningBoundaryFacts> {
if (input.birthTimeSource === "period_only") return { kind: "period_only" };
const reported = input.reportedBirthTime?.trim() ?? "";
const range = input.candidateRange;
if (!input.birthTimeSource || !reported || !range?.start_time || !range.end_time) return { kind: "unavailable" };
try {
const compute = await input.loadCompute();
return await fetchOpeningBoundary({
snapshot: compute.baselineBirthSnapshot,
range: compute.candidateRange,
recordedTime: reported,
});
} catch {
return { kind: "unavailable" };
}
}