diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index f6cad7cd..92578a39 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -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 同批,均为首页可见问题。 +- 修复版本:本地未提交候选 diff --git a/frontend/src/app/api/daily-starlanguage/route.ts b/frontend/src/app/api/daily-starlanguage/route.ts index e0dc35a0..511eebdd 100644 --- a/frontend/src/app/api/daily-starlanguage/route.ts +++ b/frontend/src/app/api/daily-starlanguage/route.ts @@ -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>>; +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; + jyotishaDailyStarlanguagePending?: Map>; +}; -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) { + 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; } function chartPoints(chart: Record) { @@ -27,57 +84,92 @@ function chartPoints(chart: Record) { return planets && ascendant ? { planets, ascendant } : null; } -async function fetchJson(path: string, body: Record) { - 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; - } 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 : {}; - 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: "ok", - card: transitCard ?? pickCard(profile, today), - source: transitCard ? "jyotish_api_transit_lite" : "calculation_lite", + 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, + source: "agent", claim_status: "exploratory_unvalidated", boundary: "not_deterministic_prediction", }); diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index c2b234e1..3b4b3f77 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -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); diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index bf9c4ee8..be9fe129 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -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 { 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(emptyProfile); const [synastryReportCard, setSynastryReportCard] = useState(null); const [synastryHistory, setSynastryHistory] = useState([]); - const [dailyStarlanguageCard, setDailyStarlanguageCard] = useState(null); + const [dailyStarlanguage, setDailyStarlanguage] = useState({ 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(null); const [startGreeting, setStartGreeting] = useState(""); + const [starterPrompt, setStarterPrompt] = useState(() => createStarterPrompt()); const [presetMessageLength, setPresetMessageLength] = useState(0); const conversation = useRef(null); const accountTrigger = useRef(null); @@ -1050,6 +1043,7 @@ export default function Home() { const dialogReturnTarget = useRef(null); const closeButton = useRef(null); const onboardingPaywallShown = useRef(false); + const starterPromptUnseen = useRef(true); const composerInput = useRef(null); const pendingConsultation = useRef(null); const cancellationRequests = useRef(new Map>()); @@ -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() {

{daypartGreeting},{profile.name.trim()}。

-

今天想先理清什么?

+

{starterPrompt}

{personalChartAvailable ? "从此刻最在意的事开始,我会结合你的星盘证据,帮你把问题拆得更清楚。" : "从你现在最关心的事开始;出生时间不足以支持的部分,我会明确说明,不会补造具体分钟。"}

@@ -3399,10 +3406,10 @@ export default function Home() { />

{personalChartAvailable ? "今日星语" : "每日运势"}

-

{personalChartAvailable ? dailyStarlanguage?.trend : "看看今天的整体节奏、适合推进的事和需要留意的地方。"}

+

{personalChartAvailable ? dailyStarlanguageTrend : "看看今天的整体节奏、适合推进的事和需要留意的地方。"}

- {personalChartAvailable ? dailyStarlanguage?.action : "不支持的个人判断会明确说明,不会补造出生时间。"} + {personalChartAvailable ? dailyStarlanguageAction : "不支持的个人判断会明确说明,不会补造出生时间。"}
@@ -3423,7 +3430,6 @@ export default function Home() { : "不确定准确出生时间时,可通过已经发生的人生事件逐步缩小范围。"}

- 进度会自动保存;结果只作为候选范围,不会改写已填报出生时间。
diff --git a/frontend/src/lib/daily-starlanguage.ts b/frontend/src/lib/daily-starlanguage.ts new file mode 100644 index 00000000..bd999ef9 --- /dev/null +++ b/frontend/src/lib/daily-starlanguage.ts @@ -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 | null { + return value && typeof value === "object" && !Array.isArray(value) ? value as Record : 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((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]); +} diff --git a/frontend/src/lib/starter-prompt.ts b/frontend/src/lib/starter-prompt.ts new file mode 100644 index 00000000..838571c3 --- /dev/null +++ b/frontend/src/lib/starter-prompt.ts @@ -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)]; +} diff --git a/frontend/src/mastra/index.ts b/frontend/src/mastra/index.ts index 75057299..dbf10172 100644 --- a/frontend/src/mastra/index.ts +++ b/frontend/src/mastra/index.ts @@ -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(); + +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. diff --git a/frontend/tests/consultation-entrypoint.test.ts b/frontend/tests/consultation-entrypoint.test.ts index 196297ef..94d07023 100644 --- a/frontend/tests/consultation-entrypoint.test.ts +++ b/frontend/tests/consultation-entrypoint.test.ts @@ -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, /

\{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('
", source.indexOf('
{ const styles = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8"); const heroHeading = styles.match(/\.starter-hero h1\s*\{([^}]*)\}/)?.[1] ?? ""; diff --git a/frontend/tests/daily-starlanguage.test.ts b/frontend/tests/daily-starlanguage.test.ts new file mode 100644 index 00000000..1df984b5 --- /dev/null +++ b/frontend/tests/daily-starlanguage.test.ts @@ -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, /今天的星语没能生成/); +}); diff --git a/frontend/tests/onboarding-presentation.test.ts b/frontend/tests/onboarding-presentation.test.ts index cee1a3c7..950c09dd 100644 --- a/frontend/tests/onboarding-presentation.test.ts +++ b/frontend/tests/onboarding-presentation.test.ts @@ -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); +});