fix(home): serve today's starlanguage from chart evidence instead of waiting on the Agent
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:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user