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:
@@ -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