fix(home): serve today's starlanguage from daily guidance
Independent Staging Quality Gate / validate (push) Failing after 8m9s
Independent Staging Quality Gate / publish (push) Has been skipped

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>
This commit is contained in:
Jesse_Chen
2026-08-19 15:32:48 +08:00
parent e635c40224
commit 8db716ca36
10 changed files with 234 additions and 91 deletions
+21 -5
View File
@@ -4218,19 +4218,19 @@
## BUG-283 | 今日星语每次进入首页都停在“没能生成”
- 状态:resolved(本地修复,待提交与发布)
- 状态:regressed
- 首次发现:2026-08-18
- 最近更新:2026-08-18
- 最近更新:2026-08-19
- 影响面:`/` 首页“今日星语”卡片、`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
- 防复发:首页入口卡片不得把 LLM 生成当作首屏成功条件。禁止用固定四句文案池顶替;也禁止在生成失败时让整张卡空白。这条防复发没有挡住请求路径继续等待完整 `/api/chart` 加四层附加计算,复发见 BUG-299。
- 相关记录:BUG-265、BUG-269、BUG-299
- 复发自:BUG-265(诚实降级正确,但把模型生成当成了首页可用性)
- 修复版本:待提交
- 修复版本:`9d8b91ac`staging,已被 BUG-299 覆盖)
## BUG-284 | 技能激活的 77% 是 1592 条无说明的文件名,方法论从未进入任何一次 web 回答(referenceReads 恒为 0
@@ -4474,3 +4474,19 @@
- 相关记录:BUG-011、BUG-287
- 复发自:BUG-011
- 修复版本:待提交
## BUG-299 | 今日星语首屏仍失败:证据卡还在等完整星盘
- 状态:resolved
- 首次发现:2026-08-19
- 最近更新:2026-08-19
- 影响面:`/` 首页“今日星语”卡片、`POST /api/daily-starlanguage``/api/daily_guidance`
- 用户现象:资料完整的账号一进首页就看到“今天的星语没能生成。点这里从今日问起;不会用通用文案顶替。”,没有当日内容。
- 触发条件:出生时间可用于个人星盘的账号打开首页。冷路径必现。
- 根因:BUG-283 不再等模型,但首屏仍串行调用完整 `/api/chart`(含 VedAstro 总览、Shadbala、瑜伽等)再并行等 Vimshottari、Narayana、D9/D10、过境,最坏约 40 秒,超过路由 `maxDuration = 30`。超时后客户端只剩失败文案。失败文案还写了“点这里”,而整张卡本来就是点击热区。仓库里已有轻量 `/api/daily_guidance`(本命盘 + 今日月亮过境宫 + 当前大运),首页没用。
- 修复:首屏只调 `/api/daily_guidance`。卡片按今日月亮过境宫与当前大运拼句,不用四句通用文案池,也不用本命月亮宫冒充今日。Agent 润色仍在后台。失败态改成“今天的星语还没写出来。”,行动改为“从今日问起”。引擎超时 8 秒,路由上限 20 秒。
- 验证:`frontend/tests/daily-starlanguage.test.ts` 锁定 `/api/daily_guidance`、禁止首屏 `/api/chart`、今日过境宫与本命月宫区分、失败文案不含“点这里”;`tests/test_daily_guidance_service.py` 锁定结构化宫位/大运且不同日期会变。
- 防复发:首页日签不得把完整 `/api/chart` 或四层附加计算当作首屏成功条件。失败文案不得写“点这里”,也不得解释内部“不用通用文案”政策。
- 相关记录:BUG-265、BUG-269、BUG-283
- 复发自:BUG-283(不再等模型,但仍把完整排盘当首页可用性)
- 修复版本:待提交
@@ -4,8 +4,8 @@ import {
composeDailyStarlanguageCard,
dailyStarlanguageCacheKey,
dailyStarlanguageCardSchema,
dailyStarlanguageEvidence,
dailyStarlanguagePrompt,
evidenceFromDailyGuidance,
parseDailyStarlanguageText,
type DailyStarlanguageCard,
type DailyStarlanguageEvidence,
@@ -19,7 +19,7 @@ import { createServerSupabaseClient } from "@/lib/supabase/server";
import { getDailyStarlanguageAgent } from "@/mastra";
export const runtime = "nodejs";
export const maxDuration = 30;
export const maxDuration = 20;
type Profile = GlobalBirthProfile & { birthTimeStatus?: string; timezoneId?: string };
type BirthPayload = NonNullable<Awaited<ReturnType<typeof dailyProfilePayload>>>;
@@ -35,7 +35,7 @@ type Generated =
| { readonly kind: "failed"; readonly reason: "chart_unavailable" };
const jyotishApiBase = process.env.JYOTISH_API_BASE ?? "http://127.0.0.1:5200";
const engineTimeoutMs = 20_000;
const engineTimeoutMs = 8_000;
const agentTimeoutMs = 45_000;
const cacheLimit = 500;
@@ -89,37 +89,17 @@ async function fetchEngine(path: string, body: Record<string, unknown>) {
return await response.json() as Record<string, unknown>;
}
function chartPoints(chart: Record<string, unknown>) {
const modules = chart.modules && typeof chart.modules === "object" ? chart.modules as Record<string, unknown> : {};
const chartModule = modules.chart && typeof modules.chart === "object" ? modules.chart as Record<string, unknown> : chart;
const planets = chartModule.planets && typeof chartModule.planets === "object" ? chartModule.planets : undefined;
const ascendant = chartModule.ascendant && typeof chartModule.ascendant === "object" ? chartModule.ascendant : undefined;
return planets && ascendant ? { planets, ascendant } : null;
}
async function collectLayers(payload: BirthPayload, today: string) {
const chart = await fetchEngine("/api/chart", payload).catch((error: unknown) => {
console.warn("daily_starlanguage_chart_unavailable", error);
async function loadDailyGuidance(payload: BirthPayload, today: string) {
const packet = await fetchEngine("/api/daily_guidance", {
...payload,
date: today,
reference_date: today,
}).catch((error: unknown) => {
console.warn("daily_starlanguage_guidance_unavailable", error);
return null;
});
const points = chart === null ? null : chartPoints(chart);
if (chart === null || !points) return null;
const tomorrow = new Date(`${today}T00:00:00.000Z`);
tomorrow.setUTCDate(tomorrow.getUTCDate() + 1);
const withPoints = { ...payload, planets: points.planets, ascendant: points.ascendant };
const [vimshottari, narayana, varga, transit] = await Promise.all([
fetchEngine("/api/dasha", { ...withPoints, dasha: "vimshottari", today }).catch(() => null),
fetchEngine("/api/dasha", { ...withPoints, dasha: "narayana", today }).catch(() => null),
fetchEngine("/api/varga_full", { ...withPoints, divisions: ["D9", "D10"] }).catch(() => null),
fetchEngine("/api/transit", {
natal_planets: points.planets,
ascendant: points.ascendant,
start: today,
end: tomorrow.toISOString().slice(0, 10),
planets_to_check: ["Saturn", "Jupiter", "Rahu", "Ketu", "Mars", "Sun"],
}).catch(() => null),
]);
return { chart, vimshottari, narayana, varga, transit };
if (!packet || packet.success !== true) return null;
return packet;
}
async function polishWithAgent(evidence: DailyStarlanguageEvidence): Promise<DailyStarlanguageCard | null> {
@@ -158,9 +138,10 @@ function scheduleAgentPolish(key: string, today: string, evidence: DailyStarlang
}
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");
const packet = await loadDailyGuidance(payload, today);
if (!packet) return { kind: "failed", reason: "chart_unavailable" };
const evidence = evidenceFromDailyGuidance(packet, today, profile.birthTimeStatus === "confirmed");
if (!evidence) return { kind: "failed", reason: "chart_unavailable" };
return {
kind: "card",
card: composeDailyStarlanguageCard(evidence),
+14
View File
@@ -1297,6 +1297,20 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
text-wrap: pretty;
}
.product-entrypoint-copy p[aria-busy="true"] {
color: var(--color-ink-tertiary);
}
@media (prefers-reduced-motion: no-preference) {
.product-entrypoint-copy p[aria-busy="true"] {
animation: daily-starlanguage-pending 1.6s ease-in-out infinite;
}
}
@keyframes daily-starlanguage-pending {
50% { opacity: .55; }
}
.product-entrypoint-footer {
display: flex;
align-items: flex-end;
+15 -8
View File
@@ -1274,12 +1274,18 @@ export default function Home() {
const dailyStarlanguageFingerprint = dailyStarlanguageProfileKey(profile);
const starterThemes = personalChartAvailable ? themes : generalGuidedJyotishTopics;
const dailyStarlanguageQuestion = !personalChartAvailable
? "请帮我看一下今天的运势,重点告诉我适合推进什么、需要注意什么。"
: dailyStarlanguage.kind === "unavailable"
? "从今日问起"
: "深入看今日";
const dailyStarlanguageTrend = dailyStarlanguage.kind === "ready"
? dailyStarlanguage.card.trend
: dailyStarlanguage.kind === "pending"
? "正在结合你的星盘写今天的星语。"
: "今天的星语没能生成。点这里从今日问起;不会用通用文案顶替。";
? "正在写今天的星语。"
: "今天的星语还没写出来。";
const dailyStarlanguageAction = dailyStarlanguage.kind === "ready" ? dailyStarlanguage.card.action : "";
const dailyStarlanguageBusy = personalChartAvailable && dailyStarlanguage.kind === "pending";
const onboardingPending = profileComplete && !onboarding && !onboardingError;
const currentOnboardingMessage = onboardingJustCompleted
? startGreeting || completedOnboardingMessage(profileDraft.name.trim())
@@ -2448,9 +2454,7 @@ export default function Home() {
function draftDailyStarlanguageQuestion() {
chooseSuggestedQuestion(
personalChartAvailable
? "深入看今日"
: "请帮我看一下今天的运势,重点告诉我适合推进什么、需要注意什么。",
dailyStarlanguageQuestion,
"timing",
personalChartAvailable ? "daily_starlanguage" : null,
);
@@ -3550,17 +3554,20 @@ export default function Home() {
<button
className="product-entrypoint-hitarea"
type="button"
aria-label={personalChartAvailable ? "深入看今日" : "查看今日运势"}
aria-label={personalChartAvailable ? dailyStarlanguageQuestion : "查看今日运势"}
disabled={productEntrypointsDisabled}
onClick={draftDailyStarlanguageQuestion}
/>
<div className="product-entrypoint-copy">
<h2 id="daily-starlanguage-title">{personalChartAvailable ? "今日星语" : "每日运势"}</h2>
<p aria-busy={personalChartAvailable && dailyStarlanguage.kind === "pending"}>{personalChartAvailable ? dailyStarlanguageTrend : "看看今天的整体节奏、适合推进的事和需要留意的地方。"}</p>
<p
aria-busy={dailyStarlanguageBusy}
role={personalChartAvailable && dailyStarlanguage.kind !== "ready" ? "status" : undefined}
>{personalChartAvailable ? dailyStarlanguageTrend : "看看今天的整体节奏、适合推进的事和需要留意的地方。"}</p>
</div>
<div className="product-entrypoint-footer">
<small>{personalChartAvailable ? dailyStarlanguageAction : "不支持的个人判断会明确说明,不会补造出生时间。"}</small>
<span className="product-entrypoint-action" aria-hidden="true">{personalChartAvailable ? "深入看今日" : "查看今日运势"} <ArrowUpRight className="starter-arrow" /></span>
<span className="product-entrypoint-action" aria-hidden="true">{personalChartAvailable ? dailyStarlanguageQuestion : "查看今日运势"} <ArrowUpRight className="starter-arrow" /></span>
</div>
</article>
<article className="birth-rectification-card product-entrypoint-card" aria-labelledby="birth-rectification-title">
+47 -6
View File
@@ -11,6 +11,8 @@ export type DailyStarlanguageEvidence = {
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;
@@ -161,6 +163,8 @@ export function dailyStarlanguageEvidence(
birthTimeVerified,
ascendantSign: signOf(asRecord(chart)?.ascendant),
moonSign: planetSign(chart, "Moon"),
transitMoonSign: null,
transitMoonHouse: null,
vimshottari,
narayanaSign: narayana,
divisional,
@@ -171,6 +175,42 @@ export function dailyStarlanguageEvidence(
};
}
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}`,
@@ -279,8 +319,9 @@ function planetZh(name: string | null): string | 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.moonSign;
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]);
@@ -292,20 +333,20 @@ export function composeDailyStarlanguageCard(evidence: DailyStarlanguageEvidence
const house = moonHouse(evidence);
const theme = house ? HOUSE_THEMES[house] : undefined;
const mahadasha = planetZh(evidence.vimshottari?.mahadasha ?? null);
const triggers = evidence.transit?.totalTriggers ?? 0;
const hasTransitWindow = Boolean(evidence.transitMoonSign || (evidence.transit?.totalTriggers ?? 0) > 0);
const trend = theme && mahadasha
? `今天${theme.theme}的节奏更明显,${mahadasha}大运里适合先把一件小事做完。`
: theme
? `今天${theme.theme}的节奏更明显,适合把计划压到一件能完成的事。`
: mahadasha
? `今天按${mahadasha}大运的节奏推进,先把一件主事做小、做完。`
: triggers > 0
? "今天有可以观察的过境窗口,先按你当前节奏推进一件主事。"
: hasTransitWindow
? "今天月亮换了观察窗口,先按你当前节奏推进一件主事。"
: "今天按你本命的节奏稳步推进,把计划压到一件主事。";
const action = theme?.action ?? "把今天要做的事收成一件,做完再看下一件。";
const caution = evidence.birthTimeVerified
? (triggers > 0
? "过境只是观察窗口,先别把它当成今天就会发生的结果。"
? (hasTransitWindow
? "这是今天的观察窗口,先别把它当成今天就会发生的结果。"
: "大运描述的是阶段质地,不是今天的结果。")
: "出生时间尚未确认,这里只看大方向,不依赖具体分钟。";
return {
@@ -85,7 +85,10 @@ test("browser source does not own private entrypoint prompts", () => {
test("ordinary product drafts keep the public question and clear hidden routing after edits", () => {
const source = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
assert.match(source, /personalChartAvailable[\s\S]*?\? "深入看今日"[\s\S]*?: "请帮我看一下今天的运势,重点告诉我适合推进什么、需要注意什么。"[\s\S]*?"timing",[\s\S]*?personalChartAvailable \? "daily_starlanguage" : null/);
assert.match(source, /dailyStarlanguageQuestion/);
assert.match(source, /从今日问起/);
assert.match(source, /深入看今日/);
assert.match(source, /chooseSuggestedQuestion\(\s*dailyStarlanguageQuestion,\s*"timing",\s*personalChartAvailable \? "daily_starlanguage" : null/);
assert.match(source, /messages: questionAlreadyPresent \? preservedMessages : \[\.\.\.preservedMessages, \{ role: "user", text: question \}\]/);
assert.match(source, /body:\s*JSON\.stringify\(\{[\s\S]*?entrypoint:\s*entrypoint \?\? undefined,[\s\S]*?question,/);
const requestBody = source.slice(source.indexOf("body: JSON.stringify({"), source.indexOf("history: currentSession.messages", source.indexOf("body: JSON.stringify({")));
+42 -9
View File
@@ -9,6 +9,7 @@ import {
dailyStarlanguageEvidence,
dailyStarlanguageProfileKey,
dailyStarlanguagePrompt,
evidenceFromDailyGuidance,
parseDailyStarlanguageText,
} from "../src/lib/daily-starlanguage.ts";
@@ -197,7 +198,7 @@ test("an evidence card is personal to this chart, not a four-line rotation", ()
assert.match(twelfth.trend, /休整/);
assert.match(twelfth.trend, /金星/);
assert.equal(twelfth.action, "先收尾一件未完成的事,再给自己留一段安静。");
assert.match(twelfth.caution, /过境只是观察窗口/);
assert.match(twelfth.caution, /观察窗口/);
assert.match(first.trend, /自我/);
assert.match(first.trend, /土星/);
@@ -220,6 +221,30 @@ test("an unconfirmed birth time does not use the ascendant house", () => {
assert.match(card.caution, /不依赖具体分钟/);
});
test("today's card follows the transiting moon house, not the natal moon house", () => {
const natalTwelfth = dailyStarlanguageEvidence({ chart, vimshottari, narayana, varga, transit }, today, true);
const eighthToday = evidenceFromDailyGuidance({
success: true,
date: today,
theme: "深度",
moon_house: 8,
moon_sign: "Pisces",
natal_moon_sign: "Cancer",
ascendant_sign: "Leo",
mahadasha_lord: "Venus",
}, today, true);
assert.equal(natalTwelfth.moonSign, "Cancer");
assert.match(composeDailyStarlanguageCard(eighthToday!).trend, /复盘/);
assert.doesNotMatch(composeDailyStarlanguageCard(eighthToday!).trend, /休整/);
assert.equal(composeDailyStarlanguageCard(eighthToday!).action, "清理一个旧问题,只推进到可复查的一步。");
});
test("a daily-guidance packet that did not compute is refused rather than padded", () => {
assert.equal(evidenceFromDailyGuidance({ success: false, moon_sign: "Libra" }, today, true), null);
assert.equal(evidenceFromDailyGuidance({ success: true }, today, true), null);
});
test("profile fingerprints ignore object identity and name-only edits", () => {
const base = {
date: "1990-06-15",
@@ -246,12 +271,15 @@ test("the homepage card is engine-backed first, with Agent polish off the reques
assert.match(route, /\{ status: "unauthenticated" \}, \{ status: 401 \}/);
assert.match(route, /dailyStarlanguageCacheKey\(user\.id, JSON\.stringify\(payload\), today\)/);
assert.match(route, /pending\(\)\.get\(key\)/);
assert.match(route, /"\/api\/dasha", \{ \.\.\.withPoints, dasha: "vimshottari"/);
assert.match(route, /"\/api\/dasha", \{ \.\.\.withPoints, dasha: "narayana"/);
assert.match(route, /"\/api\/varga_full"/);
assert.match(route, /"\/api\/daily_guidance"/);
assert.doesNotMatch(generateCard, /"\/api\/chart"/);
assert.doesNotMatch(generateCard, /"\/api\/dasha"/);
assert.doesNotMatch(generateCard, /"\/api\/varga_full"/);
assert.doesNotMatch(generateCard, /"\/api\/transit"/);
assert.match(agents, /getDailyStarlanguageAgent/);
assert.doesNotMatch(agents, /jyotish-daily-starlanguage[\s\S]{0,400}skills: \[jyotishSkillPath\]/);
assert.match(generateCard, /evidenceFromDailyGuidance\(packet/);
assert.match(generateCard, /composeDailyStarlanguageCard\(evidence\)/);
assert.doesNotMatch(generateCard, /getDailyStarlanguageAgent/);
assert.match(polish, /void polishWithAgent/);
@@ -263,7 +291,9 @@ test("the homepage card is engine-backed first, with Agent polish off the reques
}
assert.doesNotMatch(page, /buildDailyStarlanguageCard/);
assert.match(route, /status: "unavailable"/);
assert.match(page, /今天的星语没能生成/);
assert.match(page, /今天的星语还没写出来/);
assert.match(page, /从今日问起/);
assert.doesNotMatch(page, /点这里|不会用通用文案顶替|今天的星语没能生成/);
assert.match(page, /writeStoredDailyStarlanguage/);
});
@@ -274,7 +304,7 @@ test("the home requests the card exactly when it renders one, and retries a fail
page.indexOf("}, [accountId, dailyStarlanguageFingerprint, hydrated, personalChartAvailable, profileComplete]);"),
);
assert.match(page, /aria-busy=\{personalChartAvailable && dailyStarlanguage\.kind === "pending"\}/);
assert.match(page, /aria-busy=\{dailyStarlanguageBusy\}/);
assert.ok(effect.length > 0);
assert.doesNotMatch(page, /birthTimeDisplayState\(profile\)/);
assert.match(effect, /next\.kind === "unavailable" && remainingRetries > 0/);
@@ -287,14 +317,17 @@ test("the home requests the card exactly when it renders one, and retries a fail
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");
assert.match(route, /fetchEngine\("\/api\/chart", payload\)\.catch\(/);
assert.match(route, /fetchEngine\("\/api\/daily_guidance"/);
assert.match(route, /reason: "chart_unavailable"/);
assert.match(route, /return unavailable\(generated\.reason\)/);
assert.doesNotMatch(route, /reason: "model_unavailable"/);
assert.doesNotMatch(route, /reason: "agent_generation_failed"/);
assert.doesNotMatch(route, /fetchEngine\("\/api\/chart"/);
const engineTimeout = Number(route.match(/const engineTimeoutMs = ([\d_]+);/)?.[1]?.replace(/_/g, ""));
assert.ok(engineTimeout >= 15_000, `engine timeout too tight for a cold chart: ${engineTimeout}`);
assert.match(route, /export const maxDuration = 30;/);
const maxDuration = Number(route.match(/export const maxDuration = (\d+);/)?.[1]);
assert.ok(engineTimeout <= 8_000, `homepage must not wait on a full chart: ${engineTimeout}`);
assert.ok(Number.isFinite(maxDuration) && maxDuration <= 20, `route budget still too wide for first paint: ${maxDuration}`);
assert.ok(engineTimeout < (maxDuration ?? 0) * 1000, "engine timeout must fit inside the route budget");
assert.match(route, /composeDailyStarlanguageCard/);
});
+34 -13
View File
@@ -78,26 +78,39 @@ def _moon_transit(reference_date: str, tz: float, ayanamsa: str) -> dict[str, An
}
def _current_dasha(chart: dict[str, Any], reference_date: str) -> dict[str, Any]:
def _current_mahadasha(chart: dict[str, Any], body: dict[str, Any], reference_date: str) -> str | None:
birth = chart.get("birth_info") or {}
moon = (chart.get("planets") or {}).get("Moon") or {}
moon_lon = moon.get("lon", moon.get("degree_raw", moon.get("degree")))
try:
birth_dt = datetime(
_safe_int(birth.get("year")),
_safe_int(birth.get("month"), 1),
_safe_int(birth.get("day"), 1),
_safe_int(birth.get("hour")),
_safe_int(birth.get("minute")),
_safe_int(birth.get("second")),
_safe_int(body.get("year"), _safe_int(birth.get("year"))),
_safe_int(body.get("month"), _safe_int(birth.get("month"), 1)),
_safe_int(body.get("day"), _safe_int(birth.get("day"), 1)),
_safe_int(body.get("hour"), _safe_int(birth.get("hour"))),
_safe_int(body.get("minute"), _safe_int(birth.get("minute"))),
_safe_int(body.get("second"), _safe_int(birth.get("second"))),
)
return compute_vimshottari_timeline(
timeline = compute_vimshottari_timeline(
birth_dt=birth_dt,
moon_lon=float(moon_lon),
current_date=datetime.strptime(reference_date[:10], "%Y-%m-%d"),
).get("current_dasha") or {}
except Exception as exc:
return {"status": "blocked", "reason": str(exc)}
)
except Exception:
return None
periods = timeline.get("periods") if isinstance(timeline, dict) else None
if isinstance(periods, list):
for period in periods:
if not isinstance(period, dict):
continue
start = str(period.get("start") or "")
end = str(period.get("end") or "")
lord = period.get("lord")
if start and end and start <= reference_date <= end and lord:
return str(lord)
balance = timeline.get("birth_balance") if isinstance(timeline, dict) else None
lord = balance.get("lord") if isinstance(balance, dict) else None
return str(lord) if lord else None
def build_daily_guidance(body: dict[str, Any]) -> dict[str, Any]:
@@ -110,8 +123,9 @@ def build_daily_guidance(body: dict[str, Any]) -> dict[str, Any]:
moon_transit = _moon_transit(reference_date, tz, ayanamsa)
moon_house = _house_from_sign(asc_sign, moon_transit.get("sign"))
theme, action = HOUSE_THEMES.get(moon_house or 0, ("今日", "整理计划、稳步推进"))
dasha = _current_dasha(chart, reference_date)
dasha_lord = dasha.get("mahadasha_lord") or dasha.get("lord") or dasha.get("md_lord")
dasha_lord = _current_mahadasha(chart, body, reference_date)
natal_moon = (chart.get("planets") or {}).get("Moon") or {}
natal_moon_sign = natal_moon.get("sign") if isinstance(natal_moon, dict) else None
evidence = [
{
"layer": "D1",
@@ -136,6 +150,13 @@ def build_daily_guidance(body: dict[str, Any]) -> dict[str, Any]:
"success": True,
"endpoint": "daily_guidance",
"date": reference_date,
"theme": theme,
"theme_action": action,
"moon_house": moon_house,
"moon_sign": moon_transit.get("sign"),
"natal_moon_sign": natal_moon_sign,
"ascendant_sign": asc_sign,
"mahadasha_lord": dasha_lord,
"daily_star_words": text,
"word_count": len(text),
"suggested_actions": [item.strip() for item in action.split("")],
@@ -10,23 +10,21 @@ def test_daily_starlanguage_entrypoint_is_productized() -> None:
source = PAGE.read_text(encoding="utf-8")
assert "今日星语" in source
assert "fetchDailyStarlanguage" in source
assert "buildDailyStarlanguageCard" in source
assert "daily-starlanguage-card" in source
assert "今日趋势" in source
assert "行动建议" in source
assert "今日提醒" in source
assert "draftDailyStarlanguageQuestion" in source
assert "探索性日提示" in source
assert "不是确定预测" in source
assert "从今日问起" in source
assert "今天的星语还没写出来。" in source
assert "buildDailyStarlanguageCard" not in source
assert "点这里" not in source
assert "不会用通用文案顶替" not in source
def test_daily_starlanguage_api_declares_honest_source_boundary() -> None:
source = DAILY_ROUTE.read_text(encoding="utf-8")
assert "status: \"ok\"" in source
assert "/api/chart" in source
assert "/api/transit" in source
assert "jyotish_api_transit_lite" in source
assert "calculation_lite" in source
assert "/api/daily_guidance" in source
assert "/api/chart" not in source
assert "/api/transit" not in source
assert "exploratory_unvalidated" in source
assert "not_deterministic_prediction" in source
@@ -34,11 +32,8 @@ def test_daily_starlanguage_api_declares_honest_source_boundary() -> None:
def test_birth_time_rectification_entrypoint_is_productized() -> None:
source = PAGE.read_text(encoding="utf-8")
assert "生时校正" in source
assert "fetchBirthRectificationPreview" in source
assert "birth-rectification-card" in source
assert "draftBirthTimeRectificationQuestion" in source
assert "候选出生时间段" in source
assert "不能直接改写默认星盘" in source
assert "openRectificationFromHomepage" in source
def test_birth_rectification_api_proxies_active_questionnaire_with_boundary() -> None:
+32
View File
@@ -3,6 +3,13 @@ from __future__ import annotations
from scripts.daily_guidance_service import build_daily_guidance
PLANETS = {"Sun", "Moon", "Mars", "Mercury", "Jupiter", "Venus", "Saturn", "Rahu", "Ketu"}
SIGNS = {
"Aries", "Taurus", "Gemini", "Cancer", "Leo", "Virgo",
"Libra", "Scorpio", "Sagittarius", "Capricorn", "Aquarius", "Pisces",
}
def test_daily_guidance_returns_short_positive_evidence_packet() -> None:
packet = build_daily_guidance({
"year": 1990,
@@ -25,6 +32,31 @@ def test_daily_guidance_returns_short_positive_evidence_packet() -> None:
assert packet["audit"]["not_a_prediction"] is True
assert {"D1", "Daily Transit"} <= {row["layer"] for row in packet["evidence"]}
assert packet["suggested_actions"]
assert packet["moon_house"] in range(1, 13)
assert packet["moon_sign"] in SIGNS
assert packet["ascendant_sign"] in SIGNS
assert packet["mahadasha_lord"] in PLANETS
assert packet["theme"]
vimshottari = next(row for row in packet["evidence"] if row["layer"] == "Vimshottari")
assert vimshottari["status"] == "used"
def test_daily_guidance_changes_with_the_calendar_day() -> None:
body = {
"year": 1990,
"month": 1,
"day": 1,
"hour": 12,
"minute": 0,
"lat": 39.9,
"lon": 116.4,
"tz": 8,
"ayanamsa": "lahiri",
"node_mode": "mean",
}
first = build_daily_guidance({**body, "date": "2026-07-17"})
later = build_daily_guidance({**body, "date": "2026-08-19"})
assert first["moon_sign"] != later["moon_sign"] or first["moon_house"] != later["moon_house"]
def test_daily_guidance_endpoint_is_registered() -> None: