Attach the ascendant precheck to the chart request.
The D1 segment scan runs beside the natal chart with a 3 second timeout. A failed scan omits the field and still returns the chart. Ran tests/lagna-precheck-chart.test.ts: 3 pass, and tests/chart-view-route.test.ts passed. Co-Authored-By: Grok (agent loop)
This commit is contained in:
co-authored by
Grok (agent loop)
parent
c3f69ef724
commit
ccfe49e78f
@@ -276,6 +276,21 @@ export const chartViewFailureStatusSchema = z.enum([
|
||||
]);
|
||||
export type ChartViewFailureStatus = z.infer<typeof chartViewFailureStatusSchema>;
|
||||
|
||||
const clockText = z.string().regex(/^(?:[01]\d|2[0-3]):[0-5]\d$/);
|
||||
|
||||
export const chartViewLagnaPrecheckSchema = z.object({
|
||||
state: z.enum(["fixed", "may_change", "unknown_time"]),
|
||||
signs: z.array(z.string().min(1)).min(1).max(12),
|
||||
changeAt: z.array(clockText).max(11).optional(),
|
||||
windowMinutes: z.number().int().min(1).max(1441),
|
||||
precisionKind: z.enum(["minutes", "period", "unknown"]),
|
||||
beforeMinutes: z.number().int().min(0).max(24 * 60).optional(),
|
||||
afterMinutes: z.number().int().min(0).max(24 * 60).optional(),
|
||||
periodStart: clockText.optional(),
|
||||
periodEnd: clockText.optional(),
|
||||
});
|
||||
export type ChartViewLagnaPrecheck = z.infer<typeof chartViewLagnaPrecheckSchema>;
|
||||
|
||||
export const chartViewOkSchema = z.object({
|
||||
layerFailures: z.object({
|
||||
varga: chartViewFailureStatusSchema.optional(),
|
||||
@@ -285,6 +300,7 @@ export const chartViewOkSchema = z.object({
|
||||
bhava: chartViewFailureStatusSchema.optional(),
|
||||
transit: chartViewFailureStatusSchema.optional(),
|
||||
}).optional(),
|
||||
lagnaPrecheck: chartViewLagnaPrecheckSchema.optional(),
|
||||
status: z.literal("ok"),
|
||||
billed: z.literal(false),
|
||||
profile: chartViewProfileSchema,
|
||||
|
||||
@@ -4,6 +4,7 @@ import { resolveAyanamsa } from "./ayanamsa.ts";
|
||||
import type { ChartBirthFailureReason } from "./chart-birth-truth.ts";
|
||||
import {
|
||||
chartViewMessageSchema,
|
||||
type ChartViewLagnaPrecheck,
|
||||
type ChartViewResponse,
|
||||
} from "./chart-view-contract.ts";
|
||||
import {
|
||||
@@ -22,7 +23,6 @@ import {
|
||||
engineChartHasPlanets,
|
||||
type ChartViewProfileInput,
|
||||
} from "./chart-view-mapper.ts";
|
||||
|
||||
/**
|
||||
* `cacheScope` separates calls to one path whose body is not the birth moment
|
||||
* (the transit chart posts `/api/chart` for today): the server cache is keyed
|
||||
@@ -44,6 +44,8 @@ export type ChartViewLoadInput = {
|
||||
layers?: readonly ChartViewLayer[];
|
||||
accountId?: string | null;
|
||||
profileFingerprint?: string | null;
|
||||
/** Started beside `/api/chart`. Failure resolves to null and never fails the chart. */
|
||||
lagnaPrecheck?: Promise<ChartViewLagnaPrecheck | null>;
|
||||
};
|
||||
|
||||
function message(
|
||||
@@ -131,6 +133,9 @@ export async function assembleChartView(input: ChartViewLoadInput): Promise<{
|
||||
httpStatus: number;
|
||||
body: ChartViewResponse;
|
||||
}> {
|
||||
// Attach the catch immediately. A chart failure returns before this is awaited,
|
||||
// and a rejected precheck must not fail the chart or surface as unhandled.
|
||||
const lagnaReady = input.lagnaPrecheck?.catch(() => null) ?? Promise.resolve(null);
|
||||
if (!input.userId) {
|
||||
return {
|
||||
httpStatus: 401,
|
||||
@@ -235,6 +240,7 @@ export async function assembleChartView(input: ChartViewLoadInput): Promise<{
|
||||
: Promise.resolve(null),
|
||||
]);
|
||||
await dashaReady;
|
||||
const lagnaPrecheck = await lagnaReady;
|
||||
|
||||
try {
|
||||
const body = buildChartView({
|
||||
@@ -247,6 +253,7 @@ export async function assembleChartView(input: ChartViewLoadInput): Promise<{
|
||||
transit,
|
||||
profile,
|
||||
asOf: input.asOf,
|
||||
lagnaPrecheck,
|
||||
});
|
||||
return { httpStatus: 200, body: stamp({ ...body, layerFailures }, input) };
|
||||
} catch (error) {
|
||||
|
||||
@@ -21,15 +21,19 @@ import {
|
||||
WESTERN_BODY_ZH,
|
||||
isSignName,
|
||||
planetZh,
|
||||
SIGN_ZH,
|
||||
signZh,
|
||||
} from "./chart-view-labels.ts";
|
||||
import { traditionalSymbol } from "./chart-view-symbols.ts";
|
||||
import { vargaDignityLevel } from "./chart-view-varga.ts";
|
||||
import { transitsFromEngine } from "./ephemeris-view.ts";
|
||||
import { TRANSIT_LOCAL_TIME } from "./transit-chart-body.ts";
|
||||
import type { LagnaPrecheckWindow } from "./lagna-precheck.ts";
|
||||
import {
|
||||
chartViewLagnaPrecheckSchema,
|
||||
chartViewOkSchema,
|
||||
type ChartViewBhavaOk,
|
||||
type ChartViewLagnaPrecheck,
|
||||
type ChartViewMoonChart,
|
||||
type ChartViewOk,
|
||||
type ChartViewTransitOk,
|
||||
@@ -60,6 +64,7 @@ export type ChartViewEngineBundle = {
|
||||
varga: Record<string, unknown> | null;
|
||||
chara: Record<string, unknown> | null;
|
||||
yogini?: Record<string, unknown> | null;
|
||||
lagnaPrecheck?: ChartViewLagnaPrecheck | null;
|
||||
western: Record<string, unknown> | null;
|
||||
bhava?: Record<string, unknown> | null;
|
||||
transit?: Record<string, unknown> | null;
|
||||
@@ -852,8 +857,53 @@ function mapWestern(raw: Record<string, unknown> | null, ayanamsaDisplay: string
|
||||
};
|
||||
}
|
||||
|
||||
function clockFromStamp(value: unknown): string | null {
|
||||
if (typeof value !== "string") return null;
|
||||
const matched = /(?:T|\s)(\d{2}:\d{2})/.exec(value) ?? /^(\d{2}:\d{2})$/.exec(value);
|
||||
const clock = matched?.[1];
|
||||
return clock && /^(?:[01]\d|2[0-3]):[0-5]\d$/.test(clock) ? clock : null;
|
||||
}
|
||||
|
||||
/** One D1 segment → fixed. Two or more → may_change. Unknown birth time stays unknown_time. */
|
||||
export function mapLagnaPrecheck(window: LagnaPrecheckWindow, payload: unknown): ChartViewLagnaPrecheck | null {
|
||||
const segments = record(record(payload)?.segments);
|
||||
const rows = segments?.D1;
|
||||
if (!Array.isArray(rows) || rows.length === 0) return null;
|
||||
const signs: string[] = [];
|
||||
const changeAt: string[] = [];
|
||||
for (const row of rows) {
|
||||
const item = record(row);
|
||||
const signIndex = finite(item?.sign);
|
||||
if (signIndex === null || !Number.isInteger(signIndex) || signIndex < 0 || signIndex > 11) return null;
|
||||
signs.push(SIGN_ZH[SIGN_ORDER[signIndex]!]);
|
||||
if (signs.length > 1) {
|
||||
const clock = clockFromStamp(item?.start_at);
|
||||
if (clock) changeAt.push(clock);
|
||||
}
|
||||
}
|
||||
const state = window.source === "unknown"
|
||||
? "unknown_time" as const
|
||||
: signs.length === 1
|
||||
? "fixed" as const
|
||||
: "may_change" as const;
|
||||
const parsed = chartViewLagnaPrecheckSchema.safeParse({
|
||||
state,
|
||||
signs,
|
||||
...(changeAt.length > 0 ? { changeAt } : {}),
|
||||
windowMinutes: window.minutes,
|
||||
precisionKind: window.precisionKind,
|
||||
...(window.precisionKind === "minutes"
|
||||
? { beforeMinutes: window.beforeMinutes, afterMinutes: window.afterMinutes }
|
||||
: {}),
|
||||
...(window.precisionKind === "period"
|
||||
? { periodStart: window.periodStart, periodEnd: window.periodEnd }
|
||||
: {}),
|
||||
});
|
||||
return parsed.success ? parsed.data : null;
|
||||
}
|
||||
|
||||
export function buildChartView(bundle: ChartViewEngineBundle): ChartViewOk {
|
||||
const { chart, varga, chara, yogini = null, western, bhava, transit, profile, asOf } = bundle;
|
||||
const { chart, varga, chara, yogini = null, western, bhava, transit, profile, asOf, lagnaPrecheck = null } = bundle;
|
||||
const planets = asPlanetMap(chart.planets);
|
||||
const moon = planets.Moon ?? {};
|
||||
const ascendant = record(chart.ascendant);
|
||||
@@ -871,6 +921,7 @@ export function buildChartView(bundle: ChartViewEngineBundle): ChartViewOk {
|
||||
const view = {
|
||||
status: "ok" as const,
|
||||
billed: false as const,
|
||||
...(lagnaPrecheck ? { lagnaPrecheck } : {}),
|
||||
profile: {
|
||||
name: profile.name?.trim() || "未命名",
|
||||
date: profile.date,
|
||||
|
||||
@@ -5,6 +5,8 @@ import { consumeRequestRateLimit } from "./request-rate-limit.ts";
|
||||
import { createServerSupabaseClient } from "./supabase/server.ts";
|
||||
import { ConsultationSubjectError, loadSubjectBirth, subjectIdFromQuery } from "./subject-birth.ts";
|
||||
import { resolveAyanamsa } from "./ayanamsa.ts";
|
||||
import { fetchLagnaPrecheck } from "./lagna-precheck-fetch.ts";
|
||||
import { lagnaPrecheckWindow } from "./lagna-precheck.ts";
|
||||
import { assembleChartView, type ChartViewEnginePost } from "./chart-view-load.ts";
|
||||
import type { ChartViewProfileInput } from "./chart-view-mapper.ts";
|
||||
import type { ChartViewResponse } from "./chart-view-contract.ts";
|
||||
@@ -195,6 +197,16 @@ export async function loadChartView(input: {
|
||||
body: { ...failureBody(prepared.failure, user.id, prepared.birth.fingerprint), subjectId: resolved.subjectId },
|
||||
};
|
||||
}
|
||||
const birthWindow = lagnaPrecheckWindow(resolved);
|
||||
const lagnaPrecheck = birthWindow
|
||||
? fetchLagnaPrecheck({
|
||||
window: birthWindow,
|
||||
latitude: prepared.profile.latitude,
|
||||
longitude: prepared.profile.longitude,
|
||||
timezoneOffset: prepared.profile.timezoneOffset,
|
||||
ayanamsa: prepared.profile.ayanamsa,
|
||||
})
|
||||
: undefined;
|
||||
const assembled = await assembleChartView({
|
||||
userId: user.id,
|
||||
accountId: user.id,
|
||||
@@ -203,6 +215,7 @@ export async function loadChartView(input: {
|
||||
postEngine: cachedPostEngine(user.id, resolved.subjectId, prepared.profile),
|
||||
asOf: now.toISOString().slice(0, 10),
|
||||
layers: input.layers,
|
||||
lagnaPrecheck,
|
||||
});
|
||||
return {
|
||||
...assembled,
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { resolveAyanamsa } from "./ayanamsa.ts";
|
||||
import { mapLagnaPrecheck } from "./chart-view-mapper.ts";
|
||||
import type { ChartViewLagnaPrecheck } from "./chart-view-contract.ts";
|
||||
import type { LagnaPrecheckWindow } from "./lagna-precheck.ts";
|
||||
|
||||
export const LAGNA_PRECHECK_TIMEOUT_MS = 3000;
|
||||
|
||||
const apiBase = () => process.env.JYOTISH_API_BASE?.trim() || "http://127.0.0.1:5200";
|
||||
|
||||
export async function fetchLagnaPrecheck(input: {
|
||||
readonly window: LagnaPrecheckWindow;
|
||||
readonly latitude: number;
|
||||
readonly longitude: number;
|
||||
readonly timezoneOffset: number;
|
||||
readonly ayanamsa?: string;
|
||||
readonly fetchImpl?: typeof fetch;
|
||||
readonly timeoutMs?: number;
|
||||
}): Promise<ChartViewLagnaPrecheck | null> {
|
||||
const fetchImpl = input.fetchImpl ?? fetch;
|
||||
try {
|
||||
const response = await fetchImpl(`${apiBase()}/api/rectification/v5/varga-segments`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
signal: AbortSignal.timeout(input.timeoutMs ?? LAGNA_PRECHECK_TIMEOUT_MS),
|
||||
body: JSON.stringify({
|
||||
start_at: input.window.start_at,
|
||||
end_at: input.window.end_at,
|
||||
latitude: input.latitude,
|
||||
longitude: input.longitude,
|
||||
timezone_offset: input.timezoneOffset,
|
||||
target_charts: ["D1"],
|
||||
ayanamsa: resolveAyanamsa({ ayanamsa: input.ayanamsa }),
|
||||
}),
|
||||
});
|
||||
if (!response.ok) return null;
|
||||
return mapLagnaPrecheck(input.window, await response.json());
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* Chart view keeps a D1 ascendant precheck beside the natal chart.
|
||||
* A failed scan leaves the field off and the chart at 200. Fictional fixture only.
|
||||
*/
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
import { assembleChartView, type ChartViewEnginePost } from "../src/lib/chart-view-load.ts";
|
||||
import { fetchLagnaPrecheck } from "../src/lib/lagna-precheck-fetch.ts";
|
||||
import { mapLagnaPrecheck, type ChartViewProfileInput } from "../src/lib/chart-view-mapper.ts";
|
||||
import type { LagnaPrecheckWindow } from "../src/lib/lagna-precheck.ts";
|
||||
const golden = JSON.parse(
|
||||
readFileSync(new URL("./fixtures/chart-view-golden.json", import.meta.url), "utf8"),
|
||||
) as { chart: Record<string, unknown> };
|
||||
|
||||
const profile: ChartViewProfileInput = {
|
||||
name: "示例",
|
||||
date: "1990-06-15",
|
||||
time: "12:00",
|
||||
placeLabel: "北京",
|
||||
latitude: 39.9,
|
||||
longitude: 116.4,
|
||||
timezoneOffset: 8,
|
||||
ayanamsa: "raman",
|
||||
};
|
||||
|
||||
const birthWindow: LagnaPrecheckWindow = {
|
||||
start_at: "1990-06-15T11:58",
|
||||
end_at: "1990-06-15T12:02",
|
||||
minutes: 5,
|
||||
source: "hospital_record",
|
||||
precisionKind: "minutes",
|
||||
beforeMinutes: 2,
|
||||
afterMinutes: 2,
|
||||
};
|
||||
|
||||
function postEngine(fail = false): ChartViewEnginePost {
|
||||
return async (path) => {
|
||||
if (fail || path !== "/api/chart") return { status: "http_error", path, elapsedMs: 1, httpStatus: 500 };
|
||||
return { status: "ok", payload: golden.chart };
|
||||
};
|
||||
}
|
||||
|
||||
test("one D1 segment is fixed and two segments name the change clock", () => {
|
||||
const fixed = mapLagnaPrecheck(birthWindow, {
|
||||
segments: { D1: [{ sign: 0, start_at: "1990-06-15T11:58", end_at: "1990-06-15T12:02" }] },
|
||||
});
|
||||
assert.equal(fixed?.state, "fixed");
|
||||
assert.deepEqual(fixed?.signs, ["白羊"]);
|
||||
assert.equal(fixed?.changeAt, undefined);
|
||||
|
||||
const changed = mapLagnaPrecheck(birthWindow, {
|
||||
segments: { D1: [
|
||||
{ sign: 0, start_at: "1990-06-15T11:58", end_at: "1990-06-15T12:00" },
|
||||
{ sign: 1, start_at: "1990-06-15 12:01", end_at: "1990-06-15T12:02" },
|
||||
] },
|
||||
});
|
||||
assert.equal(changed?.state, "may_change");
|
||||
assert.deepEqual(changed?.signs, ["白羊", "金牛"]);
|
||||
assert.deepEqual(changed?.changeAt, ["12:01"]);
|
||||
|
||||
const unknown = mapLagnaPrecheck({ ...birthWindow, source: "unknown", precisionKind: "unknown" }, {
|
||||
segments: { D1: [{ sign: 2, start_at: "1990-06-15T00:00", end_at: "1990-06-15T23:59" }] },
|
||||
});
|
||||
assert.equal(unknown?.state, "unknown_time");
|
||||
assert.equal(mapLagnaPrecheck(birthWindow, { segments: { D1: [] } }), null);
|
||||
assert.equal(mapLagnaPrecheck(birthWindow, { segments: { D1: [{ sign: 12, start_at: "1990-06-15T11:58" }] } }), null);
|
||||
});
|
||||
|
||||
test("a thrown or non-ok segment scan returns null and does not call boundary charts", async () => {
|
||||
const thrown = await fetchLagnaPrecheck({
|
||||
window: birthWindow,
|
||||
latitude: 39.9,
|
||||
longitude: 116.4,
|
||||
timezoneOffset: 8,
|
||||
fetchImpl: async () => { throw new Error("down"); },
|
||||
});
|
||||
assert.equal(thrown, null);
|
||||
|
||||
const failed = await fetchLagnaPrecheck({
|
||||
window: birthWindow,
|
||||
latitude: 39.9,
|
||||
longitude: 116.4,
|
||||
timezoneOffset: 8,
|
||||
fetchImpl: async () => new Response("no", { status: 500 }),
|
||||
});
|
||||
assert.equal(failed, null);
|
||||
|
||||
const seen: { body: Record<string, unknown> | null } = { body: null };
|
||||
const ok = await fetchLagnaPrecheck({
|
||||
window: birthWindow,
|
||||
latitude: 39.9,
|
||||
longitude: 116.4,
|
||||
timezoneOffset: 8,
|
||||
ayanamsa: "raman",
|
||||
fetchImpl: async (_url, init) => {
|
||||
seen.body = JSON.parse(String(init?.body)) as Record<string, unknown>;
|
||||
assert.equal(init?.signal instanceof AbortSignal, true);
|
||||
return new Response(JSON.stringify({
|
||||
segments: { D1: [{ sign: 0, start_at: "1990-06-15T11:58", end_at: "1990-06-15T12:02" }] },
|
||||
}), { status: 200 });
|
||||
},
|
||||
});
|
||||
assert.equal(ok?.state, "fixed");
|
||||
assert.ok(seen.body);
|
||||
assert.deepEqual(seen.body.target_charts, ["D1"]);
|
||||
assert.equal("boundary_charts" in seen.body, false);
|
||||
assert.equal(seen.body.start_at, "1990-06-15T11:58");
|
||||
});
|
||||
|
||||
test("a rejected precheck is omitted and a failed chart does not wait for it", async () => {
|
||||
const ok = await assembleChartView({
|
||||
userId: "user-1",
|
||||
profile,
|
||||
postEngine: postEngine(),
|
||||
asOf: "2026-09-15",
|
||||
lagnaPrecheck: Promise.resolve(mapLagnaPrecheck(birthWindow, {
|
||||
segments: { D1: [
|
||||
{ sign: 0, start_at: "1990-06-15T11:58", end_at: "1990-06-15T12:00" },
|
||||
{ sign: 1, start_at: "1990-06-15T12:01", end_at: "1990-06-15T12:02" },
|
||||
] },
|
||||
})),
|
||||
});
|
||||
assert.equal(ok.httpStatus, 200);
|
||||
assert.equal(ok.body.status, "ok");
|
||||
assert.equal(ok.body.status === "ok" ? ok.body.lagnaPrecheck?.state : null, "may_change");
|
||||
|
||||
const rejected = await assembleChartView({
|
||||
userId: "user-1",
|
||||
profile,
|
||||
postEngine: postEngine(),
|
||||
asOf: "2026-09-15",
|
||||
lagnaPrecheck: Promise.reject(new Error("timeout")),
|
||||
});
|
||||
assert.equal(rejected.httpStatus, 200);
|
||||
assert.equal(rejected.body.status, "ok");
|
||||
assert.equal(rejected.body.status === "ok" && rejected.body.lagnaPrecheck, undefined);
|
||||
|
||||
const failedChart = await assembleChartView({
|
||||
userId: "user-1",
|
||||
profile,
|
||||
postEngine: postEngine(true),
|
||||
asOf: "2026-09-15",
|
||||
lagnaPrecheck: new Promise(() => {}),
|
||||
});
|
||||
assert.equal(failedChart.httpStatus, 200);
|
||||
assert.notEqual(failedChart.body.status, "ok");
|
||||
assert.equal("lagnaPrecheck" in failedChart.body, false);
|
||||
});
|
||||
Reference in New Issue
Block a user