feat(home): write the daily card with the Agent and vary the starter heading
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:
@@ -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 同批,均为首页可见问题。
|
||||
- 修复版本:本地未提交候选
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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)];
|
||||
}
|
||||
@@ -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] ?? "";
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user