Files
Jyotisha/frontend/src/lib/daily-starlanguage.ts
T
Jesse_ChenandCursor 8db716ca36
Independent Staging Quality Gate / validate (push) Failing after 8m9s
Independent Staging Quality Gate / publish (push) Has been skipped
fix(home): serve today's starlanguage from daily guidance
The homepage was waiting on a full chart plus four extra engines, so the card timed out and showed the failure copy on every visit.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-19 15:35:38 +08:00

364 lines
13 KiB
TypeScript

import { z } from "zod";
export type DailyStarlanguageCard = {
readonly trend: string;
readonly action: string;
readonly caution: string;
};
export type DailyStarlanguageEvidence = {
readonly today: string;
readonly birthTimeVerified: boolean;
readonly ascendantSign: string | null;
readonly moonSign: string | null;
readonly transitMoonSign: string | null;
readonly transitMoonHouse: number | null;
readonly vimshottari: {
readonly mahadasha: string | null;
readonly antardasha: string | null;
readonly remainingDays: number | null;
readonly nakshatra: string | null;
} | null;
readonly narayanaSign: string | null;
readonly divisional: readonly {
readonly chart: string;
readonly ascendantSign: string | null;
readonly moonSign: string | null;
}[];
readonly transit: {
readonly totalTriggers: number;
readonly top: readonly string[];
} | null;
readonly functionalBenefics: readonly string[];
readonly functionalMalefics: readonly string[];
readonly missingLayers: readonly string[];
};
export type DailyStarlanguageEngineLayers = {
readonly chart: unknown;
readonly vimshottari: unknown;
readonly narayana: unknown;
readonly varga: unknown;
readonly transit: unknown;
};
const cardSchema = z.object({
trend: z.string().transform(normalizeLine).pipe(z.string().min(8).max(90)),
action: z.string().transform(normalizeLine).pipe(z.string().min(4).max(90)),
caution: z.string().transform(normalizeLine).pipe(z.string().min(4).max(90)),
});
function normalizeLine(value: string): string {
return value.replace(/\s+/g, " ").trim().slice(0, 90);
}
function asRecord(value: unknown): Record<string, unknown> | null {
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : null;
}
function asArray(value: unknown): readonly unknown[] {
return Array.isArray(value) ? value : [];
}
function asText(value: unknown): string | null {
return typeof value === "string" && value.trim() ? value.trim().slice(0, 60) : null;
}
function asCount(value: unknown): number | null {
return typeof value === "number" && Number.isFinite(value) ? value : null;
}
function at(value: unknown, ...path: readonly string[]): unknown {
return path.reduce<unknown>((current, key) => asRecord(current)?.[key], value);
}
function chartModule(chart: unknown): unknown {
return at(chart, "modules", "chart") ?? chart;
}
function signOf(value: unknown): string | null {
return asText(asRecord(value)?.sign);
}
function planetSign(container: unknown, planet: string): string | null {
return signOf(at(container, "planets", planet));
}
function vimshottariLayer(response: unknown): DailyStarlanguageEvidence["vimshottari"] {
const current = at(response, "vimshottari_analysis", "current");
if (!asRecord(current)) return null;
return {
mahadasha: asText(at(current, "mahadasha", "lord")),
antardasha: asText(at(current, "antardasha", "lord")),
remainingDays: asCount(asRecord(current)?.remaining_days),
nakshatra: asText(at(response, "vimshottari_analysis", "nakshatra", "name")),
};
}
function narayanaSign(response: unknown, today: string): string | null {
const periods = asArray(asRecord(response)?.periods);
const running = periods.find((period) => {
const start = asText(asRecord(period)?.start);
const end = asText(asRecord(period)?.end);
return Boolean(start && end && start <= today && today <= end);
});
return asText(asRecord(running ?? periods[0])?.lord);
}
function divisionalLayer(response: unknown): DailyStarlanguageEvidence["divisional"] {
const result = asRecord(asRecord(response)?.result);
if (!result) return [];
return Object.entries(result)
.slice(0, 4)
.map(([chart, value]) => ({
chart,
ascendantSign: signOf(asRecord(value)?.ascendant),
moonSign: planetSign(value, "Moon"),
}))
.filter((entry) => entry.ascendantSign || entry.moonSign);
}
function transitLayer(response: unknown): DailyStarlanguageEvidence["transit"] {
const summary = asRecord(asRecord(response)?.summary);
if (!summary) return null;
const top = asArray(summary.top_triggers)
.slice(0, 3)
.map((trigger) => {
const planet = asText(asRecord(trigger)?.planet);
const target = asText(asRecord(trigger)?.target);
const event = asText(asRecord(trigger)?.event);
return [planet, target, event].filter(Boolean).join(" → ");
})
.filter((line) => line.length > 0);
return { totalTriggers: asCount(summary.total_triggers) ?? 0, top };
}
function functionalPlanets(chart: unknown, key: "functional_benefics" | "functional_malefics"): readonly string[] {
const snapshot = asRecord(at(chart, "ai_prompt_pack", "evidence_snapshot", "functional_benefic_malefic"));
if (!snapshot || snapshot.status !== "used") return [];
return asArray(snapshot[key])
.map((planet) => asText(planet))
.filter((planet): planet is string => Boolean(planet))
.slice(0, 5);
}
export function dailyStarlanguageEvidence(
layers: DailyStarlanguageEngineLayers,
today: string,
birthTimeVerified: boolean,
): DailyStarlanguageEvidence {
const chart = chartModule(layers.chart);
const vimshottari = vimshottariLayer(layers.vimshottari);
const divisional = divisionalLayer(layers.varga);
const transit = transitLayer(layers.transit);
const narayana = narayanaSign(layers.narayana, today);
const missingLayers = [
vimshottari ? "" : "Vimshottari",
narayana ? "" : "Narayana",
divisional.length ? "" : "分盘",
transit ? "" : "过境",
].filter((layer) => layer.length > 0);
return {
today,
birthTimeVerified,
ascendantSign: signOf(asRecord(chart)?.ascendant),
moonSign: planetSign(chart, "Moon"),
transitMoonSign: null,
transitMoonHouse: null,
vimshottari,
narayanaSign: narayana,
divisional,
transit,
functionalBenefics: functionalPlanets(layers.chart, "functional_benefics"),
functionalMalefics: functionalPlanets(layers.chart, "functional_malefics"),
missingLayers,
};
}
export function evidenceFromDailyGuidance(
packet: Record<string, unknown>,
today: string,
birthTimeVerified: boolean,
): DailyStarlanguageEvidence | null {
if (packet.success !== true) return null;
const moonHouse = asCount(packet.moon_house);
const transitMoonHouse = moonHouse !== null && moonHouse >= 1 && moonHouse <= 12 ? moonHouse : null;
const mahadasha = asText(packet.mahadasha_lord);
const transitMoonSign = asText(packet.moon_sign);
if (!transitMoonHouse && !mahadasha && !transitMoonSign) return null;
return {
today: asText(packet.date) ?? today,
birthTimeVerified,
ascendantSign: asText(packet.ascendant_sign),
moonSign: asText(packet.natal_moon_sign),
transitMoonSign,
transitMoonHouse,
vimshottari: mahadasha
? { mahadasha, antardasha: null, remainingDays: null, nakshatra: null }
: null,
narayanaSign: null,
divisional: [],
transit: transitMoonSign
? { totalTriggers: transitMoonHouse ? 1 : 0, top: [`Moon → ${transitMoonSign}`] }
: null,
functionalBenefics: [],
functionalMalefics: [],
missingLayers: [
mahadasha ? "" : "Vimshottari",
"Narayana",
"分盘",
].filter((layer) => layer.length > 0),
};
}
export function dailyStarlanguagePrompt(evidence: DailyStarlanguageEvidence): string {
return [
`今天的日期:${evidence.today}`,
evidence.birthTimeVerified
? "出生时间状态:已确认。"
: "出生时间状态:未经校正确认,禁止任何依赖分钟精度的判断。",
JSON.stringify(evidence),
"只依据上面这份证据写今天的星语。缺失的层不要假装存在,也不要提到证据缺失本身。",
].join("\n");
}
export function parseDailyStarlanguageText(text: string): DailyStarlanguageCard | null {
const normalized = text.trim().replace(/^```(?:json)?\s*/i, "").replace(/\s*```$/, "");
const start = normalized.indexOf("{");
const end = normalized.lastIndexOf("}");
if (start < 0 || end <= start) return null;
let payload: unknown;
try {
payload = JSON.parse(normalized.slice(start, end + 1));
} catch {
return null;
}
const parsed = cardSchema.safeParse(payload);
return parsed.success ? parsed.data : null;
}
export function dailyStarlanguageCacheKey(userId: string, profileFingerprint: string, today: string): string {
return JSON.stringify([userId, profileFingerprint, today]);
}
export function dailyStarlanguageProfileKey(profile: {
readonly date?: string;
readonly time?: string;
readonly timezoneId?: string;
readonly latitude?: number | null;
readonly longitude?: number | null;
readonly timezoneOffset?: number | null;
readonly birthTimeStatus?: string;
}): string {
return JSON.stringify([
profile.date ?? "",
profile.time ?? "",
profile.timezoneId ?? "",
profile.latitude ?? null,
profile.longitude ?? null,
profile.timezoneOffset ?? null,
profile.birthTimeStatus ?? "",
]);
}
export function calendarDateInTimeZone(now: Date, timeZone?: string | null): string {
const utc = now.toISOString().slice(0, 10);
const zone = timeZone?.trim();
if (!zone) return utc;
try {
const parts = new Intl.DateTimeFormat("en-CA", {
timeZone: zone,
year: "numeric",
month: "2-digit",
day: "2-digit",
}).formatToParts(now);
const year = parts.find((part) => part.type === "year")?.value;
const month = parts.find((part) => part.type === "month")?.value;
const day = parts.find((part) => part.type === "day")?.value;
return year && month && day ? `${year}-${month}-${day}` : utc;
} catch {
return utc;
}
}
const SIGNS = [
"Aries", "Taurus", "Gemini", "Cancer", "Leo", "Virgo",
"Libra", "Scorpio", "Sagittarius", "Capricorn", "Aquarius", "Pisces",
] as const;
const PLANET_ZH: Record<string, string> = {
Sun: "太阳",
Moon: "月亮",
Mars: "火星",
Mercury: "水星",
Jupiter: "木星",
Venus: "金星",
Saturn: "土星",
Rahu: "罗睺",
Ketu: "计都",
};
const HOUSE_THEMES: Record<number, { readonly theme: string; readonly action: string }> = {
1: { theme: "自我", action: "整理状态,把节奏重新起起来。" },
2: { theme: "财务", action: "核对一笔开支或定价,给资源设上限。" },
3: { theme: "沟通", action: "把一条卡住的消息写成可以直接发出去的版本。" },
4: { theme: "家庭", action: "处理一件家宅事务,或把一个角落收拾完。" },
5: { theme: "表达", action: "完成一小段创作或一次轻松的联系。" },
6: { theme: "执行", action: "从清单里勾掉一件能今天做完的事。" },
7: { theme: "合作", action: "主动联系一个人,把合作里未说清的一句补上。" },
8: { theme: "复盘", action: "清理一个旧问题,只推进到可复查的一步。" },
9: { theme: "学习", action: "记下一个观点,或把远程联络发出去。" },
10: { theme: "事业", action: "把一件成果推进一步,让别人看得见。" },
11: { theme: "人脉", action: "在一个群或一次交流里,完成一次具体的交换。" },
12: { theme: "休整", action: "先收尾一件未完成的事,再给自己留一段安静。" },
};
function planetZh(name: string | null): string | null {
return name ? PLANET_ZH[name] ?? null : null;
}
function moonHouse(evidence: DailyStarlanguageEvidence): number | null {
if (!evidence.birthTimeVerified) return null;
if (evidence.transitMoonHouse) return evidence.transitMoonHouse;
const asc = evidence.ascendantSign;
const moon = evidence.transitMoonSign ?? evidence.moonSign;
if (!asc || !moon) return null;
const ascIndex = SIGNS.indexOf(asc as typeof SIGNS[number]);
const moonIndex = SIGNS.indexOf(moon as typeof SIGNS[number]);
if (ascIndex < 0 || moonIndex < 0) return null;
return (moonIndex - ascIndex + 12) % 12 + 1;
}
export function composeDailyStarlanguageCard(evidence: DailyStarlanguageEvidence): DailyStarlanguageCard {
const house = moonHouse(evidence);
const theme = house ? HOUSE_THEMES[house] : undefined;
const mahadasha = planetZh(evidence.vimshottari?.mahadasha ?? null);
const hasTransitWindow = Boolean(evidence.transitMoonSign || (evidence.transit?.totalTriggers ?? 0) > 0);
const trend = theme && mahadasha
? `今天${theme.theme}的节奏更明显,${mahadasha}大运里适合先把一件小事做完。`
: theme
? `今天${theme.theme}的节奏更明显,适合把计划压到一件能完成的事。`
: mahadasha
? `今天按${mahadasha}大运的节奏推进,先把一件主事做小、做完。`
: hasTransitWindow
? "今天月亮换了观察窗口,先按你当前节奏推进一件主事。"
: "今天按你本命的节奏稳步推进,把计划压到一件主事。";
const action = theme?.action ?? "把今天要做的事收成一件,做完再看下一件。";
const caution = evidence.birthTimeVerified
? (hasTransitWindow
? "这是今天的观察窗口,先别把它当成今天就会发生的结果。"
: "大运描述的是阶段质地,不是今天的结果。")
: "出生时间尚未确认,这里只看大方向,不依赖具体分钟。";
return {
trend: normalizeLine(trend),
action: normalizeLine(action),
caution: normalizeLine(caution),
};
}
export const dailyStarlanguageCardSchema = z.object({
trend: z.string().min(8).max(90),
action: z.string().min(4).max(90),
caution: z.string().min(4).max(90),
});