diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index c55f9948..c951a482 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -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(诚实降级正确,但把模型生成当成了首页可用性) +- 修复版本:待提交 diff --git a/frontend/src/app/api/daily-starlanguage/route.ts b/frontend/src/app/api/daily-starlanguage/route.ts index 86c0b947..3f28b370 100644 --- a/frontend/src/app/api/daily-starlanguage/route.ts +++ b/frontend/src/app/api/daily-starlanguage/route.ts @@ -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>>; -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) { @@ -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 { - const layers = await collectLayers(payload, today); - if (!layers) return { kind: "failed", reason: "chart_unavailable" }; +async function polishWithAgent(evidence: DailyStarlanguageEvidence): Promise { 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 { + 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); } diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index 7b570e99..e3adb6b4 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -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 { +async function fetchDailyStarlanguage(profile: Profile, today: string, signal: AbortSignal): Promise { 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 | 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) { diff --git a/frontend/src/lib/daily-starlanguage.ts b/frontend/src/lib/daily-starlanguage.ts index bd999ef9..466b8d32 100644 --- a/frontend/src/lib/daily-starlanguage.ts +++ b/frontend/src/lib/daily-starlanguage.ts @@ -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 = { + Sun: "太阳", + Moon: "月亮", + Mars: "火星", + Mercury: "水星", + Jupiter: "木星", + Venus: "金星", + Saturn: "土星", + Rahu: "罗睺", + Ketu: "计都", +}; + +const HOUSE_THEMES: Record = { + 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), +}); diff --git a/frontend/src/mastra/index.ts b/frontend/src/mastra/index.ts index 57cf362c..21d2f8d7 100644 --- a/frontend/src/mastra/index.ts +++ b/frontend/src/mastra/index.ts @@ -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; diff --git a/frontend/tests/daily-starlanguage.test.ts b/frontend/tests/daily-starlanguage.test.ts index 591f319d..bfab4570 100644 --- a/frontend/tests/daily-starlanguage.test.ts +++ b/frontend/tests/daily-starlanguage.test.ts @@ -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/); });