feat(home): write the daily card with the Agent and vary the starter heading
Independent Staging Quality Gate / validate (push) Failing after 11m34s
Independent Staging Quality Gate / publish (push) Has been skipped

The daily starlanguage card claimed to be personal but was a four-card
rotation picked by hashing the date and birth place, with the same pool
duplicated as a client fallback. It now collects chart, Vimshottari and
Narayana dasha, D9/D10 and today's transits, hands that evidence to a
dedicated Agent, and keeps the result per account per day in process. A
failed generation says so instead of printing generic advice.

The starter heading is drawn from a pool on each visit, and the
rectification card drops its fine print.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jesse_Chen
2026-08-17 22:27:49 +08:00
co-authored by Cursor
parent fd415e1d11
commit f6715ee4bf
10 changed files with 670 additions and 89 deletions
+16
View File
@@ -3909,3 +3909,19 @@
- 相关记录:BUG-018(首次保存未写入档案状态,同一类真值不一致)、BUG-017(`mode_changed` 的另一入口)
- 复发自:无
- 修复版本:本地未提交候选
## BUG-265 | 首页“今日星语”号称个人化,实际是 4 张写死卡片轮换
- 状态:resolved(本地修复,待提交与发布)
- 首次发现:2026-08-17
- 最近更新:2026-08-17
- 影响面:`/` 首页在出生资料完整时展示的“今日星语”入口卡片。
- 用户现象:卡片标题是“今日星语”、位置紧挨生时校正入口,读起来像是基于本人星盘的当日解读,但同一个人换个日期、不同人同一天,看到的往往是同几句话。
- 触发条件:任何完成资料的账号打开首页。
- 根因:`/api/daily-starlanguage` 从来没有接过模型。顺利时走 `transitBackedCard`,它确实调了 `/api/chart` 与 `/api/transit`,但只把 `total_triggers` 填进一句固定模板,`action` 与 `caution` 是常量;引擎 2.5 秒超时或资料不全就走 `pickCard`,用「日期+生日+省市」的字符码之和对 4 取模选一张写死卡片。`page.tsx` 还把同样的 4 张卡抄了一份作为客户端兜底,因此即便接口整个挂掉,界面也照样显示得像有内容。
- 修复:改成 Agent 生成。新增 `getDailyStarlanguageAgent`(`frontend/src/mastra/index.ts`)与纯函数模块 `frontend/src/lib/daily-starlanguage.ts`(证据摘要、模型输出解析、缓存键)。路由先取 `/api/chart`,再并行取 `/api/dasha`(Vimshottari 与 Narayana 双轨)、`/api/varga_full`(D9/D10)、`/api/transit`,把结果压成一份紧凑证据交给 Agent,只接受完整的 `{trend, action, caution}` JSON。生成前要求已登录会话(避免匿名烧 token),按「账号+出生载荷+日期」在进程内缓存当天结果,并用 in-flight Map 合并并发请求。前端与路由里那 4 张写死卡片全部删除。
- 验证:新增 `frontend/tests/daily-starlanguage.test.ts` 7 条:证据抽取覆盖 Vimshottari 当前 MD/AD、按今天选中的 Narayana 期、D9/D10、过境触发与功能吉凶星;引擎层缺失时必须显式列进 `missingLayers`;`status: blocked` 的功能吉凶层不得进入证据;未确认的出生时间必须写成硬约束进提示词;模型输出缺字段或过短一律拒收;缓存键不得跨账号、跨资料、跨日期复用。另有源码合同断言鉴权、缓存键、双轨 Dasha 调用与「不留写死卡片」。`tsc`、`eslint` 清洁,`next build` 通过,非数据库套件 1630 条中仅 `rectification-pr4-database` 因本机 Docker 环境失败(与本次无关)。未做的验证:**没有真实跑通一次线上生成**——本机没有 Python 引擎与模型密钥,Agent 实际产出的措辞、耗时与失败率均未观测。
- 待跟进:内存缓存随进程重启失效、且多实例不共享;若上线后发现重复生成成本明显,需要改成落库(要新迁移,staging 迁移是手动工作流)。另外 `/api/daily-starlanguage` 现在最长可能跑满 60 秒,首页首屏会看到“正在结合你的星盘写今天的星语”的等待态,是否需要预生成尚未决定。
- 防复发:任何号称个人化的界面文案,其数据来源必须能追到当次计算或当次生成;用哈希在固定文案池里取模不是个人化,只是伪装成个人化。降级路径不得复制一份“看起来正常”的内容——生成失败时必须让用户看出没有生成成功,本次改成明确的一句话降级文案而不是通用建议。
- 相关记录:BUG-265 无前序同类记录。与 BUG-263 同批,均为首页可见问题。
- 修复版本:本地未提交候选
+146 -54
View File
@@ -1,22 +1,79 @@
import { NextResponse } from "next/server";
import {
dailyStarlanguageCacheKey,
dailyStarlanguageEvidence,
dailyStarlanguagePrompt,
parseDailyStarlanguageText,
type DailyStarlanguageCard,
} from "@/lib/daily-starlanguage";
import {
dailyProfilePayload,
type GlobalBirthProfile as Profile,
type GlobalBirthProfile,
} from "@/lib/global-birth-payloads";
import { loadLanguageModelCatalog } from "@/lib/model-catalog";
import { createServerSupabaseClient } from "@/lib/supabase/server";
import { getDailyStarlanguageAgent } from "@/mastra";
export const runtime = "nodejs";
export const maxDuration = 60;
type Profile = GlobalBirthProfile & { birthTimeStatus?: string };
type BirthPayload = NonNullable<Awaited<ReturnType<typeof dailyProfilePayload>>>;
type CacheEntry = { readonly day: string; readonly card: DailyStarlanguageCard };
const jyotishApiBase = process.env.JYOTISH_API_BASE ?? "http://127.0.0.1:5200";
const engineTimeoutMs = 8_000;
const agentTimeoutMs = 30_000;
const cacheLimit = 500;
const cards = [
{ trend: "先收束,再推进。适合把一个悬而未决的问题拆小。", action: "选一件最重要的事,给它留出 45 分钟不被打断的时间。", caution: "避免在情绪最满时做承诺。" },
{ trend: "适合整理关系与边界。越清楚,越不容易被外界节奏带走。", action: "把今天要回复的人和要推迟的事分开列出来。", caution: "不要把暂时的沉默误读成最终答案。" },
{ trend: "执行力比灵感更重要。小步完成会比大计划更有力量。", action: "先完成一个可交付版本,再考虑优化。", caution: "别让完美感拖慢开始。" },
{ trend: "适合观察资源流向:时间、注意力、金钱都算。", action: "检查一个正在消耗你的习惯,并给它设上限。", caution: "不要为了短期安心做长期成本高的选择。" },
];
const state = globalThis as typeof globalThis & {
jyotishaDailyStarlanguageCache?: Map<string, CacheEntry>;
jyotishaDailyStarlanguagePending?: Map<string, Promise<DailyStarlanguageCard | null>>;
};
function pickCard(profile: Profile, today: string) {
const seed = `${today}-${profile.date ?? ""}-${profile.time ?? ""}-${profile.provinceCode ?? ""}-${profile.cityCode ?? ""}`;
const index = Array.from(seed).reduce((sum, char) => sum + char.charCodeAt(0), 0) % cards.length;
return cards[index];
function cache() {
state.jyotishaDailyStarlanguageCache ??= new Map();
return state.jyotishaDailyStarlanguageCache;
}
function pending() {
state.jyotishaDailyStarlanguagePending ??= new Map();
return state.jyotishaDailyStarlanguagePending;
}
function readCache(key: string, today: string): DailyStarlanguageCard | null {
const entry = cache().get(key);
if (!entry) return null;
if (entry.day !== today) {
cache().delete(key);
return null;
}
return entry.card;
}
function writeCache(key: string, today: string, card: DailyStarlanguageCard) {
const store = cache();
for (const [storedKey, entry] of store) {
if (entry.day !== today) store.delete(storedKey);
}
while (store.size >= cacheLimit) {
const oldest = store.keys().next();
if (oldest.done) break;
store.delete(oldest.value);
}
store.set(key, { day: today, card });
}
async function fetchEngine(path: string, body: Record<string, unknown>) {
const response = await fetch(`${jyotishApiBase}${path}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
cache: "no-store",
signal: AbortSignal.timeout(engineTimeoutMs),
});
if (!response.ok) throw new Error(`jyotish_api_${response.status}`);
return await response.json() as Record<string, unknown>;
}
function chartPoints(chart: Record<string, unknown>) {
@@ -27,57 +84,92 @@ function chartPoints(chart: Record<string, unknown>) {
return planets && ascendant ? { planets, ascendant } : null;
}
async function fetchJson(path: string, body: Record<string, unknown>) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 2500);
try {
const response = await fetch(`${jyotishApiBase}${path}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
cache: "no-store",
signal: controller.signal,
});
if (!response.ok) throw new Error(`jyotish_api_${response.status}`);
return await response.json() as Record<string, unknown>;
} finally {
clearTimeout(timeout);
}
}
async function transitBackedCard(profile: Profile, today: string) {
const payload = await dailyProfilePayload(profile, today);
if (!payload) return null;
const chart = await fetchJson("/api/chart", payload);
async function collectLayers(payload: BirthPayload, today: string) {
const chart = await fetchEngine("/api/chart", payload);
const points = chartPoints(chart);
if (!points) return null;
const tomorrow = new Date(`${today}T00:00:00.000Z`);
tomorrow.setUTCDate(tomorrow.getUTCDate() + 1);
const transit = await fetchJson("/api/transit", {
natal_planets: points.planets,
ascendant: points.ascendant,
start: today,
end: tomorrow.toISOString().slice(0, 10),
planets_to_check: ["Saturn", "Jupiter", "Rahu", "Ketu"],
});
const summary = transit.summary && typeof transit.summary === "object" ? transit.summary as Record<string, unknown> : {};
const total = Number(summary.total_triggers ?? 0);
return {
trend: total > 0 ? `今日有 ${total} 个可观察过境触发点,适合把它当作时间窗口观察。` : "今日未发现强精确过境触发,适合按本命节奏稳步推进。",
action: "把今日计划压缩到一件主事,并记录实际发生的触发点。",
caution: "过境触发不能单独定事件,需与 Dasha、分盘和本命承诺交叉确认。",
};
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 };
}
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 transitCard = await transitBackedCard(profile, today).catch(() => null);
async function generateCard(payload: BirthPayload, profile: Profile, today: string) {
const layers = await collectLayers(payload, today);
if (!layers) return 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 null;
const evidence = dailyStarlanguageEvidence(layers, today, profile.birthTimeStatus === "confirmed");
const result = await getDailyStarlanguageAgent(model).generate(
[{ role: "user", content: dailyStarlanguagePrompt(evidence) }],
{ abortSignal: AbortSignal.timeout(agentTimeoutMs) },
);
return parseDailyStarlanguageText(result.text);
}
function unavailable(reason: string) {
return NextResponse.json({
status: "unavailable",
reason,
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();
if (!user) return NextResponse.json({ status: "unauthenticated" }, { status: 401 });
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 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",
});
}
const inFlight = pending().get(key) ?? generateCard(payload, profile, today)
.catch((error: unknown) => {
console.warn("daily_starlanguage_generation_failed", error);
return null;
})
.finally(() => pending().delete(key));
pending().set(key, inFlight);
const card = await inFlight;
if (!card) return unavailable("agent_generation_failed");
writeCache(key, today, card);
return NextResponse.json({
status: "ok",
card: transitCard ?? pickCard(profile, today),
source: transitCard ? "jyotish_api_transit_lite" : "calculation_lite",
card,
source: "agent",
claim_status: "exploratory_unvalidated",
boundary: "not_deterministic_prediction",
});
+1
View File
@@ -1211,6 +1211,7 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
flex: 0 0 auto;
align-items: center;
justify-content: flex-end;
margin-left: auto;
gap: var(--space-1);
color: var(--color-action);
font-size: var(--type-caption);
+41 -35
View File
@@ -115,6 +115,7 @@ import {
resolveSessionModelId,
type PublicLanguageModelCatalog,
} from "@/lib/public-models";
import { createStarterPrompt } from "@/lib/starter-prompt";
import { selfHostedOtpActions } from "@/modules/identity/client";
const BirthTimeRectification = dynamic(
@@ -227,12 +228,16 @@ type OnboardingStep = "name" | "birth" | "place" | "rectification";
type AccountDialog = "profile" | "logout";
type DailyStarlanguageCard = { trend: string; action: string; caution: string };
type DailyStarlanguageApiResponse = {
status?: "ok";
status?: "ok" | "unavailable" | "unauthenticated";
card?: DailyStarlanguageCard;
source?: "calculation_lite";
source?: "agent" | "agent_cache";
claim_status?: "exploratory_unvalidated";
boundary?: "not_deterministic_prediction";
};
type DailyStarlanguageState =
| { kind: "pending" }
| { kind: "ready"; card: DailyStarlanguageCard }
| { kind: "unavailable" };
type SessionReadResult = { readonly sessions: ChatSession[]; readonly fallbackSessionIds: string[] };
type ConsultationStatus = {
readonly requestId: string;
@@ -284,13 +289,6 @@ const previewModelCatalog = parsePublicModelCatalog({
const presetOnboardingMessage = "你好,我是 Jyotisha。\n开始前,我想先认识你。\n请问我该怎么称呼你?";
const dailyStarlanguageCards: DailyStarlanguageCard[] = [
{ trend: "先收束,再推进。适合把一个悬而未决的问题拆小。", action: "选一件最重要的事,给它留出 45 分钟不被打断的时间。", caution: "避免在情绪最满时做承诺。" },
{ trend: "适合整理关系与边界。越清楚,越不容易被外界节奏带走。", action: "把今天要回复的人和要推迟的事分开列出来。", caution: "不要把暂时的沉默误读成最终答案。" },
{ trend: "执行力比灵感更重要。小步完成会比大计划更有力量。", action: "先完成一个可交付版本,再考虑优化。", caution: "别让完美感拖慢开始。" },
{ trend: "适合观察资源流向:时间、注意力、金钱都算。", action: "检查一个正在消耗你的习惯,并给它设上限。", caution: "不要为了短期安心做长期成本高的选择。" },
];
function greetingForHour(hour: number): string {
if (hour < 11) return "早上好";
if (hour < 18) return "下午好";
@@ -512,23 +510,17 @@ function buildSynastryQuestion(selfProfile: Profile, partnerProfile: Profile, re
].join("\n");
}
function buildDailyStarlanguageCard(profile: Profile) {
const today = new Date().toISOString().slice(0, 10);
const seed = `${today}-${profile.date}-${profile.time}-${profile.provinceCode}-${profile.cityCode}`;
const index = Array.from(seed).reduce((sum, char) => sum + char.charCodeAt(0), 0) % dailyStarlanguageCards.length;
return dailyStarlanguageCards[index];
}
async function fetchDailyStarlanguage(profile: Profile) {
async function fetchDailyStarlanguage(profile: Profile, signal: AbortSignal): Promise<DailyStarlanguageState> {
const response = await fetch("/api/daily-starlanguage", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ profile }),
signal,
});
if (!response.ok) throw new Error("daily_starlanguage_unavailable");
if (!response.ok) return { kind: "unavailable" };
const payload = await response.json().catch(() => null) as DailyStarlanguageApiResponse | null;
if (payload?.status !== "ok" || !payload.card) throw new Error("daily_starlanguage_invalid");
return payload.card;
if (payload?.status !== "ok" || !payload.card) return { kind: "unavailable" };
return { kind: "ready", card: payload.card };
}
function missingProfileStep(profile: Profile): OnboardingStep | null {
@@ -994,7 +986,7 @@ export default function Home() {
const [otherProfileDraft, setOtherProfileDraft] = useState<Profile>(emptyProfile);
const [synastryReportCard, setSynastryReportCard] = useState<SynastryReportCard | null>(null);
const [synastryHistory, setSynastryHistory] = useState<SynastryReportCard[]>([]);
const [dailyStarlanguageCard, setDailyStarlanguageCard] = useState<DailyStarlanguageCard | null>(null);
const [dailyStarlanguage, setDailyStarlanguage] = useState<DailyStarlanguageState>({ kind: "pending" });
const [profileNotice, setProfileNotice] = useState("");
const [avatarNotice, setAvatarNotice] = useState("");
const [avatarSaving, setAvatarSaving] = useState(false);
@@ -1043,6 +1035,7 @@ export default function Home() {
const [birthTimeError, setBirthTimeError] = useState("");
const [birthTimeAssessmentPhase, setBirthTimeAssessmentPhase] = useState<BirthTimeAssessmentPhase | null>(null);
const [startGreeting, setStartGreeting] = useState("");
const [starterPrompt, setStarterPrompt] = useState(() => createStarterPrompt());
const [presetMessageLength, setPresetMessageLength] = useState(0);
const conversation = useRef<HTMLDivElement>(null);
const accountTrigger = useRef<HTMLButtonElement>(null);
@@ -1050,6 +1043,7 @@ export default function Home() {
const dialogReturnTarget = useRef<HTMLButtonElement | null>(null);
const closeButton = useRef<HTMLButtonElement>(null);
const onboardingPaywallShown = useRef(false);
const starterPromptUnseen = useRef(true);
const composerInput = useRef<HTMLTextAreaElement>(null);
const pendingConsultation = useRef<PendingConsultation | null>(null);
const cancellationRequests = useRef(new Map<string, Promise<void>>());
@@ -1230,7 +1224,12 @@ export default function Home() {
const personalChartAvailable = birthTimeRoute.kind === "consult" && birthTimeRoute.mode !== "general_no_birth_time";
const starterThemes = personalChartAvailable ? themes : generalGuidedJyotishTopics;
const dailyStarlanguage = dailyStarlanguageCard ?? (profileComplete ? buildDailyStarlanguageCard(profile) : null);
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
? startGreeting || completedOnboardingMessage(profileDraft.name.trim())
@@ -1724,20 +1723,28 @@ export default function Home() {
useEffect(() => {
if (!hydrated || !profileComplete || birthTimeDisplayState(profile)) return;
let cancelled = false;
setDailyStarlanguageCard(null);
void fetchDailyStarlanguage(profile)
.then((card) => {
if (!cancelled) setDailyStarlanguageCard(card);
const controller = new AbortController();
setDailyStarlanguage({ kind: "pending" });
void fetchDailyStarlanguage(profile, controller.signal)
.then((next) => {
if (!controller.signal.aborted) setDailyStarlanguage(next);
})
.catch(() => {
if (!cancelled) setDailyStarlanguageCard(buildDailyStarlanguageCard(profile));
if (!controller.signal.aborted) setDailyStarlanguage({ kind: "unavailable" });
});
return () => {
cancelled = true;
};
return () => controller.abort();
}, [hydrated, profile, profileComplete]);
useEffect(() => {
if (starterHomeVisible) {
starterPromptUnseen.current = false;
return;
}
if (starterPromptUnseen.current) return;
starterPromptUnseen.current = true;
setStarterPrompt(createStarterPrompt());
}, [starterHomeVisible]);
useEffect(() => {
if (starterHomeVisible) return;
const container = conversation.current;
@@ -3381,7 +3388,7 @@ export default function Home() {
<section className="starter-hero" aria-labelledby="starter-heading">
<div className="starter-hero-copy">
<p className="starter-greeting">{daypartGreeting},{profile.name.trim()}。</p>
<h1 id="starter-heading">今天想先理清什么?</h1>
<h1 id="starter-heading">{starterPrompt}</h1>
<p className="starter-hero-note">{personalChartAvailable
? "从此刻最在意的事开始,我会结合你的星盘证据,帮你把问题拆得更清楚。"
: "从你现在最关心的事开始;出生时间不足以支持的部分,我会明确说明,不会补造具体分钟。"}</p>
@@ -3399,10 +3406,10 @@ export default function Home() {
/>
<div className="product-entrypoint-copy">
<h2 id="daily-starlanguage-title">{personalChartAvailable ? "今日星语" : "每日运势"}</h2>
<p>{personalChartAvailable ? dailyStarlanguage?.trend : "看看今天的整体节奏、适合推进的事和需要留意的地方。"}</p>
<p aria-busy={personalChartAvailable && dailyStarlanguage.kind === "pending"}>{personalChartAvailable ? dailyStarlanguageTrend : "看看今天的整体节奏、适合推进的事和需要留意的地方。"}</p>
</div>
<div className="product-entrypoint-footer">
<small>{personalChartAvailable ? dailyStarlanguage?.action : "不支持的个人判断会明确说明,不会补造出生时间。"}</small>
<small>{personalChartAvailable ? dailyStarlanguageAction : "不支持的个人判断会明确说明,不会补造出生时间。"}</small>
<span className="product-entrypoint-action" aria-hidden="true">{personalChartAvailable ? "深入看今日" : "查看今日运势"} <ArrowUpRight className="starter-arrow" /></span>
</div>
</article>
@@ -3423,7 +3430,6 @@ export default function Home() {
: "不确定准确出生时间时,可通过已经发生的人生事件逐步缩小范围。"}</p>
</div>
<div className="product-entrypoint-footer">
<small>进度会自动保存;结果只作为候选范围,不会改写已填报出生时间。</small>
<span className="product-entrypoint-action" aria-hidden="true">{rectificationCardLabel} <ArrowUpRight className="starter-arrow" /></span>
</div>
</article>
+202
View File
@@ -0,0 +1,202 @@
import { z } from "zod";
export type DailyStarlanguageCard = {
readonly trend: string;
readonly action: string;
readonly caution: string;
};
export type DailyStarlanguageEvidence = {
readonly today: string;
readonly birthTimeVerified: boolean;
readonly ascendantSign: string | null;
readonly moonSign: string | null;
readonly vimshottari: {
readonly mahadasha: string | null;
readonly antardasha: string | null;
readonly remainingDays: number | null;
readonly nakshatra: string | null;
} | null;
readonly narayanaSign: string | null;
readonly divisional: readonly {
readonly chart: string;
readonly ascendantSign: string | null;
readonly moonSign: string | null;
}[];
readonly transit: {
readonly totalTriggers: number;
readonly top: readonly string[];
} | null;
readonly functionalBenefics: readonly string[];
readonly functionalMalefics: readonly string[];
readonly missingLayers: readonly string[];
};
export type DailyStarlanguageEngineLayers = {
readonly chart: unknown;
readonly vimshottari: unknown;
readonly narayana: unknown;
readonly varga: unknown;
readonly transit: unknown;
};
const cardSchema = z.object({
trend: z.string().transform(normalizeLine).pipe(z.string().min(8)),
action: z.string().transform(normalizeLine).pipe(z.string().min(4)),
caution: z.string().transform(normalizeLine).pipe(z.string().min(4)),
});
function normalizeLine(value: string): string {
return value.replace(/\s+/g, " ").trim().slice(0, 90);
}
function asRecord(value: unknown): Record<string, unknown> | null {
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : null;
}
function asArray(value: unknown): readonly unknown[] {
return Array.isArray(value) ? value : [];
}
function asText(value: unknown): string | null {
return typeof value === "string" && value.trim() ? value.trim().slice(0, 60) : null;
}
function asCount(value: unknown): number | null {
return typeof value === "number" && Number.isFinite(value) ? value : null;
}
function at(value: unknown, ...path: readonly string[]): unknown {
return path.reduce<unknown>((current, key) => asRecord(current)?.[key], value);
}
function chartModule(chart: unknown): unknown {
return at(chart, "modules", "chart") ?? chart;
}
function signOf(value: unknown): string | null {
return asText(asRecord(value)?.sign);
}
function planetSign(container: unknown, planet: string): string | null {
return signOf(at(container, "planets", planet));
}
function vimshottariLayer(response: unknown): DailyStarlanguageEvidence["vimshottari"] {
const current = at(response, "vimshottari_analysis", "current");
if (!asRecord(current)) return null;
return {
mahadasha: asText(at(current, "mahadasha", "lord")),
antardasha: asText(at(current, "antardasha", "lord")),
remainingDays: asCount(asRecord(current)?.remaining_days),
nakshatra: asText(at(response, "vimshottari_analysis", "nakshatra", "name")),
};
}
function narayanaSign(response: unknown, today: string): string | null {
const periods = asArray(asRecord(response)?.periods);
const running = periods.find((period) => {
const start = asText(asRecord(period)?.start);
const end = asText(asRecord(period)?.end);
return Boolean(start && end && start <= today && today <= end);
});
return asText(asRecord(running ?? periods[0])?.lord);
}
function divisionalLayer(response: unknown): DailyStarlanguageEvidence["divisional"] {
const result = asRecord(asRecord(response)?.result);
if (!result) return [];
return Object.entries(result)
.slice(0, 4)
.map(([chart, value]) => ({
chart,
ascendantSign: signOf(asRecord(value)?.ascendant),
moonSign: planetSign(value, "Moon"),
}))
.filter((entry) => entry.ascendantSign || entry.moonSign);
}
function transitLayer(response: unknown): DailyStarlanguageEvidence["transit"] {
const summary = asRecord(asRecord(response)?.summary);
if (!summary) return null;
const top = asArray(summary.top_triggers)
.slice(0, 3)
.map((trigger) => {
const planet = asText(asRecord(trigger)?.planet);
const target = asText(asRecord(trigger)?.target);
const event = asText(asRecord(trigger)?.event);
return [planet, target, event].filter(Boolean).join(" → ");
})
.filter((line) => line.length > 0);
return { totalTriggers: asCount(summary.total_triggers) ?? 0, top };
}
function functionalPlanets(chart: unknown, key: "functional_benefics" | "functional_malefics"): readonly string[] {
const snapshot = asRecord(at(chart, "ai_prompt_pack", "evidence_snapshot", "functional_benefic_malefic"));
if (!snapshot || snapshot.status !== "used") return [];
return asArray(snapshot[key])
.map((planet) => asText(planet))
.filter((planet): planet is string => Boolean(planet))
.slice(0, 5);
}
export function dailyStarlanguageEvidence(
layers: DailyStarlanguageEngineLayers,
today: string,
birthTimeVerified: boolean,
): DailyStarlanguageEvidence {
const chart = chartModule(layers.chart);
const vimshottari = vimshottariLayer(layers.vimshottari);
const divisional = divisionalLayer(layers.varga);
const transit = transitLayer(layers.transit);
const narayana = narayanaSign(layers.narayana, today);
const missingLayers = [
vimshottari ? "" : "Vimshottari",
narayana ? "" : "Narayana",
divisional.length ? "" : "分盘",
transit ? "" : "过境",
].filter((layer) => layer.length > 0);
return {
today,
birthTimeVerified,
ascendantSign: signOf(asRecord(chart)?.ascendant),
moonSign: planetSign(chart, "Moon"),
vimshottari,
narayanaSign: narayana,
divisional,
transit,
functionalBenefics: functionalPlanets(layers.chart, "functional_benefics"),
functionalMalefics: functionalPlanets(layers.chart, "functional_malefics"),
missingLayers,
};
}
export function dailyStarlanguagePrompt(evidence: DailyStarlanguageEvidence): string {
return [
`今天的日期:${evidence.today}`,
evidence.birthTimeVerified
? "出生时间状态:已确认。"
: "出生时间状态:未经校正确认,禁止任何依赖分钟精度的判断。",
JSON.stringify(evidence),
"只依据上面这份证据写今天的星语。缺失的层不要假装存在,也不要提到证据缺失本身。",
].join("\n");
}
export function parseDailyStarlanguageText(text: string): DailyStarlanguageCard | null {
const normalized = text.trim().replace(/^```(?:json)?\s*/i, "").replace(/\s*```$/, "");
const start = normalized.indexOf("{");
const end = normalized.lastIndexOf("}");
if (start < 0 || end <= start) return null;
let payload: unknown;
try {
payload = JSON.parse(normalized.slice(start, end + 1));
} catch {
return null;
}
const parsed = cardSchema.safeParse(payload);
return parsed.success ? parsed.data : null;
}
export function dailyStarlanguageCacheKey(userId: string, profileFingerprint: string, today: string): string {
return JSON.stringify([userId, profileFingerprint, today]);
}
+17
View File
@@ -0,0 +1,17 @@
const starterPrompts = [
"今天想先理清什么?",
"现在最想弄明白哪件事?",
"今天想从哪里说起?",
"此刻最挂心的是什么?",
"最近有什么反复出现的问题?",
"眼下最想要一个答案的是什么?",
"想先把哪件事想清楚?",
"有什么事正等着你决定?",
] as const;
export const starterPromptVariants: readonly string[] = starterPrompts;
export function createStarterPrompt(selection = Math.random()): string {
const index = Math.floor(selection * starterPrompts.length);
return starterPrompts[Math.min(Math.max(index, 0), starterPrompts.length - 1)];
}
+27
View File
@@ -145,6 +145,33 @@ 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.
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":"今天值得留意的一点"}
Write calm, plain Simplified Chinese, second person, no mysticism, no marketing, no emoji. Keep trend within 60 characters and action and caution within 40 characters each.
The trend must be grounded in the supplied evidence rather than generic life advice, but stay readable: name at most one technical layer in everyday words, and never dump technique names, degrees, or English terms.
Transit triggers are observation windows, not events. Dasha periods describe texture, not outcomes. Never promise an outcome, name a guaranteed date, or claim an event will happen.
When the server says the birth time is not confirmed, avoid anything that depends on minute-level precision, and never imply the time is verified.
No medical, legal, investment, or safety-critical instruction. No claims about death, diagnosis, pregnancy, or guaranteed money. The action must be low-risk and reversible.`;
const dailyStarlanguageAgents = new Map<string, Agent>();
export function getDailyStarlanguageAgent(model: ResolvedLanguageModel) {
const cached = dailyStarlanguageAgents.get(model.id);
if (cached) return cached;
const agent = new Agent({
id: `jyotish-daily-starlanguage-${model.id}`,
name: "Jyotisha Daily Starlanguage",
model: model.model,
instructions: dailyStarlanguageInstructions,
skills: [jyotishSkillPath],
});
dailyStarlanguageAgents.set(model.id, agent);
return agent;
}
const birthTimeGuideInstructions = `You are a constrained guide for birth-time rectification.
Return valid JSON only, without Markdown, commentary, metadata, or hidden fields.
The server supplies the only allowed domains and identifiers for each task. Never change a supplied domain, rank a candidate time, set confidence, choose a route, report progress, grant permission, or infer an active birth time.
@@ -283,6 +283,25 @@ test("starter cards use transient pressed feedback instead of sticky hover shadi
for (const rule of disabledProductRules) assert.match(rule[1], /opacity:\s*1\s*;/);
});
test("the starter heading is drawn per visit and the rectification card carries no fine print", () => {
const source = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
const styles = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
// Given: the heading reads from state seeded once per mount and redrawn when the home is left.
assert.match(source, /<h1 id="starter-heading">\{starterPrompt\}<\/h1>/);
assert.match(source, /useState\(\(\) => createStarterPrompt\(\)\)/);
assert.match(source, /starterPromptUnseen\.current = true;\s*\n\s*setStarterPrompt\(createStarterPrompt\(\)\)/);
// Then: the rectification card's footer is the action alone, still flush right.
const rectificationCard = source.slice(
source.indexOf('<article className="birth-rectification-card'),
source.indexOf("</article>", source.indexOf('<article className="birth-rectification-card')),
);
assert.doesNotMatch(rectificationCard, /进度会自动保存/);
assert.doesNotMatch(rectificationCard, /<small>/);
assert.match(styles, /\.product-entrypoint-action \{[^}]*margin-left: auto;/);
});
test("starter homepage keeps its editorial display typography", () => {
const styles = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
const heroHeading = styles.match(/\.starter-hero h1\s*\{([^}]*)\}/)?.[1] ?? "";
+182
View File
@@ -0,0 +1,182 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import {
dailyStarlanguageCacheKey,
dailyStarlanguageEvidence,
dailyStarlanguagePrompt,
parseDailyStarlanguageText,
} from "../src/lib/daily-starlanguage.ts";
const today = "2026-08-17";
const chart = {
modules: {
chart: {
ascendant: { sign: "Leo", lon: 132.5 },
planets: { Moon: { sign: "Cancer", lon: 98.2 }, Sun: { sign: "Leo", lon: 130.1 } },
},
},
ai_prompt_pack: {
evidence_snapshot: {
functional_benefic_malefic: {
status: "used",
functional_benefics: ["Mars", "Sun"],
functional_malefics: ["Mercury"],
},
},
},
};
const vimshottari = {
vimshottari_analysis: {
nakshatra: { name: "Pushya" },
current: {
mahadasha: { lord: "Venus" },
antardasha: { lord: "Mercury" },
remaining_days: 412,
},
},
};
const narayana = {
periods: [
{ lord: "Aries", start: "2019-01-01", end: "2026-01-01" },
{ lord: "Taurus", start: "2026-01-01", end: "2033-01-01" },
],
};
const varga = {
result: {
D9_Navamsa: { ascendant: { sign: "Sagittarius" }, planets: { Moon: { sign: "Pisces" } } },
D10_Dasamsa: { ascendant: { sign: "Gemini" }, planets: { Moon: { sign: "Virgo" } } },
},
};
const transit = {
summary: {
total_triggers: 2,
top_triggers: [
{ planet: "Saturn", target: "Moon", event: "接近精确合相" },
{ planet: "Jupiter", target: "Ascendant", event: "三分相进入容许度" },
],
},
};
test("the daily card reads every engine layer it claims to use", () => {
const evidence = dailyStarlanguageEvidence({ chart, vimshottari, narayana, varga, transit }, today, true);
// Given: the chart, both dashas, the divisional charts and today's transits all answered.
assert.equal(evidence.ascendantSign, "Leo");
assert.equal(evidence.moonSign, "Cancer");
assert.deepEqual(evidence.vimshottari, {
mahadasha: "Venus",
antardasha: "Mercury",
remainingDays: 412,
nakshatra: "Pushya",
});
// And: the Narayana period is the one actually running today, not simply the first.
assert.equal(evidence.narayanaSign, "Taurus");
assert.deepEqual(evidence.divisional.map((entry) => entry.chart), ["D9_Navamsa", "D10_Dasamsa"]);
assert.equal(evidence.divisional[0].moonSign, "Pisces");
assert.equal(evidence.transit?.totalTriggers, 2);
assert.deepEqual(evidence.transit?.top, ["Saturn → Moon → 接近精确合相", "Jupiter → Ascendant → 三分相进入容许度"]);
assert.deepEqual(evidence.functionalBenefics, ["Mars", "Sun"]);
assert.deepEqual(evidence.functionalMalefics, ["Mercury"]);
assert.deepEqual(evidence.missingLayers, []);
});
test("layers the engine could not return are named instead of quietly missing", () => {
// Given: only the chart answered; the rest timed out and came back null.
const evidence = dailyStarlanguageEvidence(
{ chart, vimshottari: null, narayana: null, varga: null, transit: null },
today,
true,
);
// Then: the model is told which layers are absent, so it cannot pass them off as consulted.
assert.deepEqual(evidence.missingLayers, ["Vimshottari", "Narayana", "分盘", "过境"]);
assert.equal(evidence.vimshottari, null);
assert.equal(evidence.narayanaSign, null);
assert.deepEqual(evidence.divisional, []);
assert.equal(evidence.transit, null);
});
test("a blocked functional-role layer contributes nothing", () => {
const blocked = {
ai_prompt_pack: {
evidence_snapshot: {
functional_benefic_malefic: { status: "blocked", functional_benefics: [], functional_malefics: [] },
},
},
modules: chart.modules,
};
const evidence = dailyStarlanguageEvidence(
{ chart: blocked, vimshottari, narayana, varga, transit },
today,
true,
);
assert.deepEqual(evidence.functionalBenefics, []);
assert.deepEqual(evidence.functionalMalefics, []);
});
test("an unconfirmed birth time is stated to the model as a hard limit", () => {
const evidence = dailyStarlanguageEvidence({ chart, vimshottari, narayana, varga, transit }, today, false);
const prompt = dailyStarlanguagePrompt(evidence);
assert.match(prompt, /出生时间状态:未经校正确认/);
assert.match(prompt, /禁止任何依赖分钟精度的判断/);
assert.match(prompt, /2026-08-17/);
});
test("model output is accepted only as a complete card", () => {
const fenced = "```json\n{\"trend\":\"今天适合把一件悬着的事收口。\",\"action\":\"给最重要的一件事留出不被打断的时间。\",\"caution\":\"先别在情绪最满时下承诺。\"}\n```";
const card = parseDailyStarlanguageText(fenced);
assert.equal(card?.trend, "今天适合把一件悬着的事收口。");
assert.equal(card?.action, "给最重要的一件事留出不被打断的时间。");
// Then: partial, empty or non-JSON output is rejected rather than half-rendered.
assert.equal(parseDailyStarlanguageText("{\"trend\":\"今天适合把一件悬着的事收口。\"}"), null);
assert.equal(parseDailyStarlanguageText("今天没什么特别的。"), null);
assert.equal(parseDailyStarlanguageText("{\"trend\":\"太短\",\"action\":\"做事\",\"caution\":\"小心\"}"), null);
});
test("cached cards cannot cross accounts, profiles or days", () => {
const base = dailyStarlanguageCacheKey("account-1", "payload-a", today);
assert.notEqual(base, dailyStarlanguageCacheKey("account-2", "payload-a", today));
assert.notEqual(base, dailyStarlanguageCacheKey("account-1", "payload-b", today));
assert.notEqual(base, dailyStarlanguageCacheKey("account-1", "payload-a", "2026-08-18"));
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", () => {
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");
// 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/);
// 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, /今天的星语没能生成/);
});
@@ -7,6 +7,7 @@ import {
onboardingProfileFingerprint,
onboardingRequestIdentity,
} from "../src/lib/onboarding-client.ts";
import { createStarterPrompt, starterPromptVariants } from "../src/lib/starter-prompt.ts";
const completeProfile = {
name: "林遥", date: "1990-06-15", time: "12:30", reportedTime: "12:30",
@@ -33,3 +34,21 @@ test("rejects stale A presentation and derives terminal and fallback greetings f
}
assert.equal(fallbackGreeting, "周宁,从你此刻最关心的问题开始吧。");
});
test("the starter heading varies per visit and stays inside the pool", () => {
// Given: a pool of distinct openings rather than one fixed line.
assert.ok(starterPromptVariants.length >= 5);
assert.equal(new Set(starterPromptVariants).size, starterPromptVariants.length);
// Then: every selection lands on a real variant, including the extremes.
assert.equal(createStarterPrompt(0), starterPromptVariants[0]);
assert.equal(createStarterPrompt(1), starterPromptVariants.at(-1));
assert.equal(createStarterPrompt(0.999999), starterPromptVariants.at(-1));
// And: the whole pool is reachable, so the heading is not one line in disguise.
const reached = new Set(
Array.from({ length: starterPromptVariants.length }, (_, index) =>
createStarterPrompt(index / starterPromptVariants.length)),
);
assert.equal(reached.size, starterPromptVariants.length);
});