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:
jesse-ux
2026-10-27 03:52:12 +08:00
co-authored by Grok (agent loop)
parent c3f69ef724
commit ccfe49e78f
6 changed files with 279 additions and 2 deletions
+16
View File
@@ -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,
+8 -1
View File
@@ -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) {
+52 -1
View File
@@ -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,
+13
View File
@@ -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,
+40
View File
@@ -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;
}
}
+150
View File
@@ -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);
});