122 lines
5.1 KiB
TypeScript
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" };
|
|
}
|
|
}
|