fix(home): serve today's starlanguage from chart evidence instead of waiting on the Agent
Independent Staging Quality Gate / validate (push) Has been cancelled
Independent Staging Quality Gate / publish (push) Has been cancelled

Homepage visits were failing closed whenever the 30s generation missed. Return an evidence-backed card immediately, polish in the background, and remember the day's card locally.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jesse_Chen
2026-08-18 20:46:36 +08:00
parent fa6019a303
commit 9d8b91ac02
6 changed files with 364 additions and 71 deletions
+16
View File
@@ -4215,3 +4215,19 @@
- 相关记录:BUG-261
- 复发自:无
- 修复版本:待提交
## BUG-283 | 今日星语每次进入首页都停在“没能生成”
- 状态:resolved(本地修复,待提交与发布)
- 首次发现:2026-08-18
- 最近更新:2026-08-18
- 影响面:`/` 首页“今日星语”卡片、`POST /api/daily-starlanguage`、进程内缓存与浏览器当日缓存
- 用户现象:资料完整的账号每次打开首页,卡片都显示“今天的星语没能生成,稍后再回来看看;这里不会用通用文案顶替。”,没有当日内容。
- 触发条件:出生时间可用于个人星盘的账号进入首页。冷路径或换实例后必现。
- 根因:BUG-265 把卡片改成同步等待 Agent,失败就不展示内容;BUG-269 修好了请求守卫,但首页仍把 30–45 秒的模型生成当作首屏依赖。进程内缓存不跨实例、不跨重启,JSON 解析或超时失败后客户端只剩失败文案。再进入等于再走一条冷路径。
- 修复:星盘、双轨 Dasha、分盘和过境算完后,立刻用这份证据拼一张个人卡片返回,不再等待模型。Agent 改为后台润色,成功才覆盖缓存。首页按账号+资料指纹把当天卡片写入 localStorage,刷新先显示已有内容;只有星盘算不出来才落到失败文案。日期改用出生时区日历日,不再用 UTC。
- 验证:`frontend/tests/daily-starlanguage.test.ts` 覆盖证据卡片因盘而异、未确认出生时间不用上升宫、请求路径不阻塞 Agent、失败不覆盖已缓存卡片。
- 防复发:首页入口卡片不得把 LLM 生成当作首屏成功条件。禁止用固定四句文案池顶替;也禁止在生成失败时让整张卡空白。
- 相关记录:BUG-265、BUG-269
- 复发自:BUG-265(诚实降级正确,但把模型生成当成了首页可用性)
- 修复版本:待提交
@@ -1,10 +1,14 @@
import { NextResponse } from "next/server";
import {
calendarDateInTimeZone,
composeDailyStarlanguageCard,
dailyStarlanguageCacheKey,
dailyStarlanguageCardSchema,
dailyStarlanguageEvidence,
dailyStarlanguagePrompt,
parseDailyStarlanguageText,
type DailyStarlanguageCard,
type DailyStarlanguageEvidence,
} from "@/lib/daily-starlanguage";
import {
dailyProfilePayload,
@@ -15,15 +19,20 @@ import { createServerSupabaseClient } from "@/lib/supabase/server";
import { getDailyStarlanguageAgent } from "@/mastra";
export const runtime = "nodejs";
export const maxDuration = 60;
export const maxDuration = 30;
type Profile = GlobalBirthProfile & { birthTimeStatus?: string };
type Profile = GlobalBirthProfile & { birthTimeStatus?: string; timezoneId?: string };
type BirthPayload = NonNullable<Awaited<ReturnType<typeof dailyProfilePayload>>>;
type CacheEntry = { readonly day: string; readonly card: DailyStarlanguageCard };
type GenerationFailure = "chart_unavailable" | "model_unavailable" | "agent_generation_failed";
type CardSource = "engine_evidence" | "agent";
type CacheEntry = { readonly day: string; readonly card: DailyStarlanguageCard; readonly source: CardSource };
type Generated =
| { readonly kind: "card"; readonly card: DailyStarlanguageCard }
| { readonly kind: "failed"; readonly reason: GenerationFailure };
| {
readonly kind: "card";
readonly card: DailyStarlanguageCard;
readonly source: CardSource;
readonly evidence: DailyStarlanguageEvidence;
}
| { readonly kind: "failed"; readonly reason: "chart_unavailable" };
const jyotishApiBase = process.env.JYOTISH_API_BASE ?? "http://127.0.0.1:5200";
const engineTimeoutMs = 20_000;
@@ -45,17 +54,17 @@ function pending() {
return state.jyotishaDailyStarlanguagePending;
}
function readCache(key: string, today: string): DailyStarlanguageCard | null {
function readCache(key: string, today: string): CacheEntry | null {
const entry = cache().get(key);
if (!entry) return null;
if (entry.day !== today) {
cache().delete(key);
return null;
}
return entry.card;
return entry;
}
function writeCache(key: string, today: string, card: DailyStarlanguageCard) {
function writeCache(key: string, today: string, card: DailyStarlanguageCard, source: CardSource) {
const store = cache();
for (const [storedKey, entry] of store) {
if (entry.day !== today) store.delete(storedKey);
@@ -65,7 +74,7 @@ function writeCache(key: string, today: string, card: DailyStarlanguageCard) {
if (oldest.done) break;
store.delete(oldest.value);
}
store.set(key, { day: today, card });
store.set(key, { day: today, card, source });
}
async function fetchEngine(path: string, body: Record<string, unknown>) {
@@ -113,22 +122,51 @@ async function collectLayers(payload: BirthPayload, today: string) {
return { chart, vimshottari, narayana, varga, transit };
}
async function generateCard(payload: BirthPayload, profile: Profile, today: string): Promise<Generated> {
const layers = await collectLayers(payload, today);
if (!layers) return { kind: "failed", reason: "chart_unavailable" };
async function polishWithAgent(evidence: DailyStarlanguageEvidence): Promise<DailyStarlanguageCard | null> {
const modelId = process.env.DAILY_STARLANGUAGE_MODEL_ID?.trim() || "deepseek-v4-flash";
const catalog = await loadLanguageModelCatalog();
const model = catalog.models.find((entry) => entry.id === modelId)
?? catalog.models.find((entry) => entry.id === catalog.defaultModelId)
?? null;
if (!model) return { kind: "failed", reason: "model_unavailable" };
const evidence = dailyStarlanguageEvidence(layers, today, profile.birthTimeStatus === "confirmed");
if (!model) return null;
const result = await getDailyStarlanguageAgent(model).generate(
[{ role: "user", content: dailyStarlanguagePrompt(evidence) }],
{ abortSignal: AbortSignal.timeout(agentTimeoutMs) },
{
abortSignal: AbortSignal.timeout(agentTimeoutMs),
structuredOutput: {
schema: dailyStarlanguageCardSchema,
jsonPromptInjection: "inline",
},
},
);
const card = parseDailyStarlanguageText(result.text);
return card ? { kind: "card", card } : { kind: "failed", reason: "agent_generation_failed" };
const structured = dailyStarlanguageCardSchema.safeParse(result.object);
if (structured.success) return structured.data;
return parseDailyStarlanguageText(result.text);
}
function scheduleAgentPolish(key: string, today: string, evidence: DailyStarlanguageEvidence) {
void polishWithAgent(evidence)
.then((card) => {
if (!card) return;
const current = readCache(key, today);
if (current?.source === "agent") return;
writeCache(key, today, card, "agent");
})
.catch((error: unknown) => {
console.warn("daily_starlanguage_agent_polish_failed", error);
});
}
async function generateCard(payload: BirthPayload, profile: Profile, today: string): Promise<Generated> {
const layers = await collectLayers(payload, today);
if (!layers) return { kind: "failed", reason: "chart_unavailable" };
const evidence = dailyStarlanguageEvidence(layers, today, profile.birthTimeStatus === "confirmed");
return {
kind: "card",
card: composeDailyStarlanguageCard(evidence),
source: "engine_evidence",
evidence,
};
}
function unavailable(reason: string) {
@@ -140,6 +178,16 @@ function unavailable(reason: string) {
});
}
function ok(card: DailyStarlanguageCard, source: string) {
return NextResponse.json({
status: "ok",
card,
source,
claim_status: "exploratory_unvalidated",
boundary: "not_deterministic_prediction",
});
}
export async function POST(request: Request) {
const supabase = await createServerSupabaseClient();
const { data: { user } } = await supabase.auth.getUser();
@@ -147,38 +195,25 @@ export async function POST(request: Request) {
const body = await request.json().catch(() => null) as { profile?: Profile; today?: string } | null;
const profile = body?.profile ?? {};
const today = body?.today || new Date().toISOString().slice(0, 10);
const today = body?.today || calendarDateInTimeZone(new Date(), profile.timezoneId);
const payload = await dailyProfilePayload(profile, today).catch(() => null);
if (!payload) return unavailable("birth_profile_incomplete");
const key = dailyStarlanguageCacheKey(user.id, JSON.stringify(payload), today);
const cached = readCache(key, today);
if (cached) {
return NextResponse.json({
status: "ok",
card: cached,
source: "agent_cache",
claim_status: "exploratory_unvalidated",
boundary: "not_deterministic_prediction",
});
}
if (cached) return ok(cached.card, `${cached.source}_cache`);
const inFlight = pending().get(key) ?? generateCard(payload, profile, today)
.catch((error: unknown): Generated => {
console.warn("daily_starlanguage_generation_failed", error);
return { kind: "failed", reason: "agent_generation_failed" };
return { kind: "failed", reason: "chart_unavailable" };
})
.finally(() => pending().delete(key));
pending().set(key, inFlight);
const generated = await inFlight;
if (generated.kind === "failed") return unavailable(generated.reason);
writeCache(key, today, generated.card);
return NextResponse.json({
status: "ok",
card: generated.card,
source: "agent",
claim_status: "exploratory_unvalidated",
boundary: "not_deterministic_prediction",
});
writeCache(key, today, generated.card, generated.source);
scheduleAgentPolish(key, today, generated.evidence);
return ok(generated.card, generated.source);
}
+52 -9
View File
@@ -105,6 +105,10 @@ import {
requestOnboardingWithRecovery,
} from "@/lib/onboarding-client";
import { protectOnboardingPhrases } from "@/lib/onboarding-copy";
import {
calendarDateInTimeZone,
dailyStarlanguageProfileKey,
} from "@/lib/daily-starlanguage";
import { preserveShallowEqual } from "@/lib/preserve-shallow-equal";
import {
SessionModelPersistenceQueue,
@@ -229,7 +233,7 @@ type DailyStarlanguageCard = { trend: string; action: string; caution: string };
type DailyStarlanguageApiResponse = {
status?: "ok" | "unavailable" | "unauthenticated";
card?: DailyStarlanguageCard;
source?: "agent" | "agent_cache";
source?: "engine_evidence" | "engine_evidence_cache" | "agent" | "agent_cache";
claim_status?: "exploratory_unvalidated";
boundary?: "not_deterministic_prediction";
};
@@ -423,6 +427,29 @@ function chartLibraryStorageKey(accountId: string) {
function synastryHistoryStorageKey(accountId: string) {
return `jyotisha_synastry_history:${accountId}`;
}
function dailyStarlanguageStorageKey(accountId: string) {
return `jyotisha_daily_starlanguage:${accountId}`;
}
type StoredDailyStarlanguage = {
readonly day: string;
readonly fingerprint: string;
readonly card: DailyStarlanguageCard;
};
function readStoredDailyStarlanguage(accountId: string): StoredDailyStarlanguage | null {
try {
const parsed = JSON.parse(localStorage.getItem(dailyStarlanguageStorageKey(accountId)) || "null") as StoredDailyStarlanguage | null;
if (!parsed?.day || !parsed.fingerprint || !parsed.card?.trend || !parsed.card?.action) return null;
return parsed;
} catch {
return null;
}
}
function writeStoredDailyStarlanguage(accountId: string, stored: StoredDailyStarlanguage) {
localStorage.setItem(dailyStarlanguageStorageKey(accountId), JSON.stringify(stored));
}
function profileReadyForLibrary(profile: Profile) {
return !missingProfileStep(profile);
@@ -543,11 +570,11 @@ function buildSynastryQuestion(selfProfile: Profile, partnerProfile: Profile, re
const dailyStarlanguageRetryDelayMs = 5_000;
async function fetchDailyStarlanguage(profile: Profile, signal: AbortSignal): Promise<DailyStarlanguageState> {
async function fetchDailyStarlanguage(profile: Profile, today: string, signal: AbortSignal): Promise<DailyStarlanguageState> {
const response = await fetch("/api/daily-starlanguage", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ profile }),
body: JSON.stringify({ profile, today }),
signal,
});
if (!response.ok) return { kind: "unavailable" };
@@ -1244,13 +1271,14 @@ export default function Home() {
const profileComplete = isProfileComplete(profile);
const birthTimeRoute = resolveBirthTimeConsultationRoute(profile, birthTimeConsultationConsent, activeSessionId);
const personalChartAvailable = birthTimeRoute.kind === "consult" && birthTimeRoute.mode !== "general_no_birth_time";
const dailyStarlanguageFingerprint = dailyStarlanguageProfileKey(profile);
const starterThemes = personalChartAvailable ? themes : generalGuidedJyotishTopics;
const dailyStarlanguageTrend = dailyStarlanguage.kind === "ready"
? dailyStarlanguage.card.trend
: dailyStarlanguage.kind === "pending"
? "正在结合你的星盘写今天的星语。"
: "今天的星语没能生成,稍后再回来看看;这里不会用通用文案顶替。";
: "今天的星语没能生成。点这里从今日问起;不会用通用文案顶替。";
const dailyStarlanguageAction = dailyStarlanguage.kind === "ready" ? dailyStarlanguage.card.action : "";
const onboardingPending = profileComplete && !onboarding && !onboardingError;
const currentOnboardingMessage = onboardingJustCompleted
@@ -1757,22 +1785,37 @@ export default function Home() {
}, [accountId, hydrated, onboardingFingerprint, profile.name, profileComplete]);
useEffect(() => {
if (!hydrated || !profileComplete || !personalChartAvailable) return;
if (!hydrated || !accountId || !profileComplete || !personalChartAvailable) return;
const today = calendarDateInTimeZone(new Date(), profile.timezoneId);
const fingerprint = dailyStarlanguageProfileKey(profile);
const stored = readStoredDailyStarlanguage(accountId);
const controller = new AbortController();
let retryTimer: ReturnType<typeof setTimeout> | undefined;
setDailyStarlanguage({ kind: "pending" });
if (stored && stored.day === today && stored.fingerprint === fingerprint) {
setDailyStarlanguage({ kind: "ready", card: stored.card });
} else {
setDailyStarlanguage({ kind: "pending" });
}
const attempt = (remainingRetries: number) => {
void fetchDailyStarlanguage(profile, controller.signal)
void fetchDailyStarlanguage(profile, today, controller.signal)
.then((next) => {
if (controller.signal.aborted) return;
if (next.kind === "unavailable" && remainingRetries > 0) {
retryTimer = setTimeout(() => attempt(remainingRetries - 1), dailyStarlanguageRetryDelayMs);
return;
}
if (next.kind === "ready") {
writeStoredDailyStarlanguage(accountId, { day: today, fingerprint, card: next.card });
setDailyStarlanguage(next);
return;
}
if (stored && stored.day === today && stored.fingerprint === fingerprint) return;
setDailyStarlanguage(next);
})
.catch(() => {
if (!controller.signal.aborted) setDailyStarlanguage({ kind: "unavailable" });
if (controller.signal.aborted) return;
if (stored && stored.day === today && stored.fingerprint === fingerprint) return;
setDailyStarlanguage({ kind: "unavailable" });
});
};
attempt(1);
@@ -1780,7 +1823,7 @@ export default function Home() {
controller.abort();
if (retryTimer !== undefined) clearTimeout(retryTimer);
};
}, [hydrated, personalChartAvailable, profile, profileComplete]);
}, [accountId, dailyStarlanguageFingerprint, hydrated, personalChartAvailable, profileComplete]);
useEffect(() => {
if (starterHomeVisible) {
+123 -3
View File
@@ -41,9 +41,9 @@ export type DailyStarlanguageEngineLayers = {
};
const cardSchema = z.object({
trend: z.string().transform(normalizeLine).pipe(z.string().min(8)),
action: z.string().transform(normalizeLine).pipe(z.string().min(4)),
caution: z.string().transform(normalizeLine).pipe(z.string().min(4)),
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 {
@@ -200,3 +200,123 @@ export function parseDailyStarlanguageText(text: string): DailyStarlanguageCard
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;
const asc = evidence.ascendantSign;
const moon = 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);
const triggers = evidence.transit?.totalTriggers ?? 0;
const trend = theme && mahadasha
? `今天${theme.theme}的节奏更明显,${mahadasha}大运里适合先把一件小事做完。`
: theme
? `今天${theme.theme}的节奏更明显,适合把计划压到一件能完成的事。`
: mahadasha
? `今天按${mahadasha}大运的节奏推进,先把一件主事做小、做完。`
: triggers > 0
? "今天有可以观察的过境窗口,先按你当前节奏推进一件主事。"
: "今天按你本命的节奏稳步推进,把计划压到一件主事。";
const action = theme?.action ?? "把今天要做的事收成一件,做完再看下一件。";
const caution = evidence.birthTimeVerified
? (triggers > 0
? "过境只是观察窗口,先别把它当成今天就会发生的结果。"
: "大运描述的是阶段质地,不是今天的结果。")
: "出生时间尚未确认,这里只看大方向,不依赖具体分钟。";
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),
});
+1 -3
View File
@@ -150,8 +150,7 @@ export function getOnboardingAgent(model: ResolvedLanguageModel) {
return agent;
}
const dailyStarlanguageInstructions = `You write one day's short reading card for Jyotisha, a Vedic astrology product.
Load and follow the jyotish-vedic-astrology skill for method and truth boundaries.
const dailyStarlanguageInstructions = `You rewrite one day's short reading card for Jyotisha, a Vedic astrology product.
The server supplies every piece of evidence: ascendant, Moon sign, Vimshottari mahadasha/antardasha, Narayana sign period, divisional charts, today's transit triggers, and functional benefics/malefics. Read only that evidence. Never calculate, infer, or invent a placement, dasha, transit, or degree the server did not send.
Return valid JSON only, with no Markdown fences, commentary, or extra fields:
{"trend":"今天的整体节奏","action":"今天可以做的一件具体小事","caution":"今天值得留意的一点"}
@@ -171,7 +170,6 @@ export function getDailyStarlanguageAgent(model: ResolvedLanguageModel) {
name: "Jyotisha Daily Starlanguage",
model: model.model,
instructions: dailyStarlanguageInstructions,
skills: [jyotishSkillPath],
});
dailyStarlanguageAgents.set(model.id, agent);
return agent;
+100 -19
View File
@@ -3,8 +3,11 @@ import { readFileSync } from "node:fs";
import test from "node:test";
import {
calendarDateInTimeZone,
composeDailyStarlanguageCard,
dailyStarlanguageCacheKey,
dailyStarlanguageEvidence,
dailyStarlanguageProfileKey,
dailyStarlanguagePrompt,
parseDailyStarlanguageText,
} from "../src/lib/daily-starlanguage.ts";
@@ -154,66 +157,144 @@ test("cached cards cannot cross accounts, profiles or days", () => {
assert.equal(base, dailyStarlanguageCacheKey("account-1", "payload-a", today));
});
test("the daily card is Agent-written per signed-in account, with no written-in card left anywhere", () => {
test("the calendar day follows the birth timezone, not UTC", () => {
const utcEvening = new Date("2026-08-18T16:30:00.000Z");
assert.equal(calendarDateInTimeZone(utcEvening, "Asia/Shanghai"), "2026-08-19");
assert.equal(calendarDateInTimeZone(utcEvening, "America/Los_Angeles"), "2026-08-18");
assert.equal(calendarDateInTimeZone(utcEvening), "2026-08-18");
});
test("an evidence card is personal to this chart, not a four-line rotation", () => {
const venusTwelfth = dailyStarlanguageEvidence({ chart, vimshottari, narayana, varga, transit }, today, true);
const saturnFirst = dailyStarlanguageEvidence({
chart: {
modules: {
chart: {
ascendant: { sign: "Aries" },
planets: { Moon: { sign: "Aries" }, Sun: { sign: "Taurus" } },
},
},
},
vimshottari: {
vimshottari_analysis: {
nakshatra: { name: "Ashwini" },
current: {
mahadasha: { lord: "Saturn" },
antardasha: { lord: "Jupiter" },
remaining_days: 80,
},
},
},
narayana,
varga,
transit: { summary: { total_triggers: 0, top_triggers: [] } },
}, today, true);
const twelfth = composeDailyStarlanguageCard(venusTwelfth);
const first = composeDailyStarlanguageCard(saturnFirst);
assert.match(twelfth.trend, /休整/);
assert.match(twelfth.trend, /金星/);
assert.equal(twelfth.action, "先收尾一件未完成的事,再给自己留一段安静。");
assert.match(twelfth.caution, /过境只是观察窗口/);
assert.match(first.trend, /自我/);
assert.match(first.trend, /土星/);
assert.equal(first.action, "整理状态,把节奏重新起起来。");
assert.notEqual(twelfth.trend, first.trend);
assert.notEqual(twelfth.action, first.action);
for (const card of [twelfth, first]) {
assert.doesNotMatch(card.trend, /先收束,再推进|执行力比灵感更重要|适合观察资源流向/);
assert.doesNotMatch(card.action, /选一件最重要的事,给它留出 45 分钟/);
}
});
test("an unconfirmed birth time does not use the ascendant house", () => {
const evidence = dailyStarlanguageEvidence({ chart, vimshottari, narayana, varga, transit }, today, false);
const card = composeDailyStarlanguageCard(evidence);
assert.doesNotMatch(card.trend, /休整|自我|财务|沟通/);
assert.match(card.trend, /金星/);
assert.match(card.caution, /不依赖具体分钟/);
});
test("profile fingerprints ignore object identity and name-only edits", () => {
const base = {
date: "1990-06-15",
time: "12:30",
timezoneId: "Asia/Shanghai",
latitude: 31.2,
longitude: 121.5,
timezoneOffset: 8,
birthTimeStatus: "confirmed",
};
assert.equal(dailyStarlanguageProfileKey(base), dailyStarlanguageProfileKey({ ...base }));
assert.notEqual(dailyStarlanguageProfileKey(base), dailyStarlanguageProfileKey({ ...base, time: "12:31" }));
});
test("the homepage card is engine-backed first, with Agent polish off the request path", () => {
const route = readFileSync(new URL("../src/app/api/daily-starlanguage/route.ts", import.meta.url), "utf8");
const page = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
const agents = readFileSync(new URL("../src/mastra/index.ts", import.meta.url), "utf8");
const generateCard = route.slice(route.indexOf("async function generateCard"), route.indexOf("function unavailable"));
const polish = route.slice(route.indexOf("function scheduleAgentPolish"), route.indexOf("async function generateCard"));
// Given: generation costs tokens, so it is gated on a session and reused per account per day.
assert.match(route, /supabase\.auth\.getUser\(\)/);
assert.match(route, /\{ status: "unauthenticated" \}, \{ status: 401 \}/);
assert.match(route, /dailyStarlanguageCacheKey\(user\.id, JSON\.stringify\(payload\), today\)/);
assert.match(route, /pending\(\)\.get\(key\)/);
// And: the answer comes from the Agent over engine evidence, never from a rotation of written cards.
assert.match(route, /getDailyStarlanguageAgent\(model\)\.generate\(/);
assert.match(route, /"\/api\/dasha", \{ \.\.\.withPoints, dasha: "vimshottari"/);
assert.match(route, /"\/api\/dasha", \{ \.\.\.withPoints, dasha: "narayana"/);
assert.match(route, /"\/api\/varga_full"/);
assert.match(agents, /getDailyStarlanguageAgent/);
assert.doesNotMatch(agents, /jyotish-daily-starlanguage[\s\S]{0,400}skills: \[jyotishSkillPath\]/);
assert.match(generateCard, /composeDailyStarlanguageCard\(evidence\)/);
assert.doesNotMatch(generateCard, /getDailyStarlanguageAgent/);
assert.match(polish, /void polishWithAgent/);
assert.match(route, /scheduleAgentPolish\(key, today, generated\.evidence\)/);
assert.match(route, /getDailyStarlanguageAgent\(model\)\.generate\(/);
// Then: a failed generation degrades honestly instead of printing a generic reading.
for (const source of [route, page]) {
assert.doesNotMatch(source, /先收束,再推进|执行力比灵感更重要|适合观察资源流向/);
}
assert.doesNotMatch(page, /buildDailyStarlanguageCard/);
assert.match(route, /status: "unavailable"/);
assert.match(page, /今天的星语没能生成/);
assert.match(page, /writeStoredDailyStarlanguage/);
});
test("the home requests the card exactly when it renders one, and retries a failed day once", () => {
const page = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
const effect = page.slice(
page.indexOf("if (!hydrated || !profileComplete || !personalChartAvailable) return;"),
page.indexOf("}, [hydrated, personalChartAvailable, profile, profileComplete]);"),
page.indexOf("if (!hydrated || !accountId || !profileComplete || !personalChartAvailable) return;"),
page.indexOf("}, [accountId, dailyStarlanguageFingerprint, hydrated, personalChartAvailable, profileComplete]);"),
);
// Given: the card only renders personal content behind personalChartAvailable, so the fetch is gated on the same fact.
assert.match(page, /aria-busy=\{personalChartAvailable && dailyStarlanguage\.kind === "pending"\}/);
assert.ok(effect.length > 0);
// Then: an accepted, candidate or confirmed birth time no longer suppresses the request.
assert.doesNotMatch(page, /birthTimeDisplayState\(profile\)/);
// And: one transient failure is retried before the card admits defeat, and the timer is cleared on teardown.
assert.match(effect, /next\.kind === "unavailable" && remainingRetries > 0/);
assert.match(effect, /attempt\(1\);/);
assert.match(effect, /clearTimeout\(retryTimer\)/);
assert.match(effect, /readStoredDailyStarlanguage\(accountId\)/);
assert.match(effect, /if \(stored && stored\.day === today && stored\.fingerprint === fingerprint\) return;/);
});
test("no single engine layer can take the whole card down without saying which one", () => {
const route = readFileSync(new URL("../src/app/api/daily-starlanguage/route.ts", import.meta.url), "utf8");
// Given: every engine call, the chart included, fails into a reason instead of throwing the request away.
assert.match(route, /fetchEngine\("\/api\/chart", payload\)\.catch\(/);
assert.match(route, /reason: "chart_unavailable"/);
assert.match(route, /reason: "model_unavailable"/);
assert.match(route, /return unavailable\(generated\.reason\)/);
assert.doesNotMatch(route, /reason: "model_unavailable"/);
assert.doesNotMatch(route, /reason: "agent_generation_failed"/);
// Then: the budgets leave room for a cold engine and a slow model inside the route's 60s ceiling.
const engineTimeout = Number(route.match(/const engineTimeoutMs = ([\d_]+);/)?.[1]?.replace(/_/g, ""));
const agentTimeout = Number(route.match(/const agentTimeoutMs = ([\d_]+);/)?.[1]?.replace(/_/g, ""));
assert.ok(engineTimeout >= 15_000, `engine timeout too tight for a cold chart: ${engineTimeout}`);
assert.ok(agentTimeout >= 45_000, `agent timeout too tight for observed generation: ${agentTimeout}`);
assert.match(route, /export const maxDuration = 60;/);
assert.match(route, /export const maxDuration = 30;/);
assert.match(route, /composeDailyStarlanguageCard/);
});