fix(web): keep unrectified birth data usable without inventing a minute

Rectification stays optional. Reported minutes can consult and generate reports; date-plus-period uses a declared window instead of a midpoint or 00:00. Updates BUG-341.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jesse_Chen
2026-08-21 15:29:58 +08:00
co-authored by Cursor
parent e7f4030e3f
commit 9958e00abc
34 changed files with 1441 additions and 169 deletions
@@ -111,20 +111,20 @@ test("period-only users enter minute-free consultation directly without a blocki
assert.deepEqual(resolveBirthTimeConsultationRoute(periodOnly, initial, "chat-a"), {
kind: "consult",
mode: "general_no_birth_time",
mode: "declared_birth_window",
time: null,
});
const general = grantBirthTimeConsultationConsent(initial, "chat-a", "general_no_birth_time");
assert.equal(consultationModeForSession(general, "chat-a"), "general_no_birth_time");
const general = grantBirthTimeConsultationConsent(initial, "chat-a", "declared_birth_window");
assert.equal(consultationModeForSession(general, "chat-a"), "declared_birth_window");
assert.equal(consultationModeForSession(general, "chat-b"), null);
assert.deepEqual(resolveBirthTimeConsultationRoute(periodOnly, general, "chat-a"), {
kind: "consult",
mode: "general_no_birth_time",
mode: "declared_birth_window",
time: null,
});
assert.deepEqual(resolveBirthTimeConsultationRoute(periodOnly, general, "chat-b"), {
kind: "consult",
mode: "general_no_birth_time",
mode: "declared_birth_window",
time: null,
});
});
@@ -7,6 +7,7 @@ import {
createBirthTimeModeOutputGuard,
consultationBirthTimeModeSchema,
shouldRunBirthChartWorkflow,
shouldRunDeclaredWindowWorkflow,
} from "../src/lib/consultation-birth-time-mode.ts";
import { getGeneralJyotishAgent } from "../src/mastra/index.ts";
import type { ResolvedLanguageModel } from "../src/mastra/model.ts";
@@ -14,9 +15,14 @@ import type { ResolvedLanguageModel } from "../src/mastra/model.ts";
test("general-no-birth-time is an explicit server mode that never runs a chart workflow", () => {
assert.equal(consultationBirthTimeModeSchema.safeParse("verified_chart").success, true);
assert.equal(consultationBirthTimeModeSchema.safeParse("unverified_birth_time").success, true);
assert.equal(consultationBirthTimeModeSchema.safeParse("declared_birth_window").success, true);
assert.equal(consultationBirthTimeModeSchema.safeParse("general_no_birth_time").success, true);
assert.equal(shouldRunBirthChartWorkflow("general_no_birth_time"), false);
assert.equal(shouldRunBirthChartWorkflow("declared_birth_window"), false);
assert.equal(shouldRunBirthChartWorkflow("verified_chart"), true);
assert.equal(shouldRunBirthChartWorkflow("unverified_birth_time"), true);
assert.equal(shouldRunDeclaredWindowWorkflow("declared_birth_window"), true);
assert.equal(shouldRunDeclaredWindowWorkflow("general_no_birth_time"), false);
});
test("unverified chart context preserves evidence-owned precise timing permission while retaining provenance", () => {
@@ -70,7 +76,8 @@ test("general mode deterministically rejects personal chart claims while preserv
].join("\n"));
assert.match(guarded, /D9 在印度占星中通常用于观察婚姻与法则层面的成熟/);
assert.match(guarded, /一般咨询模式不能生成个人星盘结论/);
assert.match(guarded, /这部分需要具体出生分钟才能判断,我不会补造时间/);
assert.doesNotMatch(guarded, /改问一般知识|先完成生时校正|一般咨询模式不能生成个人星盘结论/);
assert.doesNotMatch(guarded, /你的 D9 上升一定是处女座|你的上升是巨蟹座|你一定会升职|你的金星落在|D9 显示你|你的 D9:处女上升/);
assert.doesNotMatch(guarded, /。。/);
});
@@ -115,8 +122,14 @@ test("consult route validates mode before billing and general mode uses no chart
assert.match(route, /general_no_birth_time/);
assert.match(route, /shouldRunBirthChartWorkflow/);
assert.match(route, /getGeneralJyotishAgent/);
assert.match(route, /getWindowJyotishAgent/);
assert.match(route, /declared_birth_window/);
assert.match(route, /run-jyotish-window-consultation/);
assert.match(mastra, /getGeneralJyotishAgent/);
assert.match(mastra, /Never calculate, infer, or claim a personal birth chart/);
assert.doesNotMatch(mastra, /offer exactly two safe next steps/);
assert.match(mastra, /Do not refuse the whole turn/);
assert.match(mastra, /not a gate for continuing this session/);
const generalFactory = mastra.slice(
mastra.indexOf("export function getGeneralJyotishAgent"),
mastra.indexOf("const onboardingInstructions"),
@@ -132,7 +145,20 @@ test("homepage sends explicit modes and never routes an unverified minute throug
assert.match(page, /consultationMode:/);
assert.match(page, /general_no_birth_time/);
assert.match(page, /unverified_birth_time/);
assert.match(page, /declared_birth_window/);
assert.match(page, /natalMinuteAvailable/);
assert.match(page, /personalConsultationAvailable/);
assert.doesNotMatch(page, /birthTimeStatus === "confirmed" \? "direct_chart" : "rectification"/);
assert.match(route, /旧版生时校正入口已停用/);
assert.ok(route.indexOf("旧版生时校正入口已停用") < route.indexOf("reserveConsultationModel(", route.indexOf("chatRequestSchema.safeParse")));
});
test("window agent instructions forbid probe clocks as a birth minute", () => {
const mastra = readFileSync(new URL("../src/mastra/index.ts", import.meta.url), "utf8");
const windowFactory = mastra.slice(mastra.indexOf("const windowJyotishInstructions"));
assert.match(windowFactory, /getWindowJyotishAgent/);
assert.match(windowFactory, /createWindowConsultationTools/);
assert.match(windowFactory, /run-jyotish-window-consultation/);
assert.match(windowFactory, /Never invent 00:00, a period midpoint, noon, or any probe clock as the birth time/);
assert.doesNotMatch(windowFactory, /id: `jyotish-guide-/);
});
+53 -5
View File
@@ -2,8 +2,12 @@ import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import {
GENERAL_NO_MINUTE_DAILY_FORTUNE_QUESTION,
consultationEntrypointSchema,
isGeneralDailyFortuneQuestion,
isRectificationHandoffQuestion,
resolveConsultationQuestion,
shouldLoadGeneralDailyPanchanga,
} from "../src/lib/consultation-entrypoint.ts";
test("plain consultation questions remain user-authored", () => {
@@ -39,7 +43,7 @@ test("daily entrypoint selects a private server expansion", () => {
test("daily entrypoint without a birth minute selects a public-day expansion", () => {
const resolved = resolveConsultationQuestion({
visibleQuestion: "请帮我看一下今天的运势,重点告诉我适合推进什么、需要注意什么。",
visibleQuestion: GENERAL_NO_MINUTE_DAILY_FORTUNE_QUESTION,
entrypoint: "daily_starlanguage",
currentDate: "2026-08-15",
consultationMode: "general_no_birth_time",
@@ -51,6 +55,43 @@ test("daily entrypoint without a birth minute selects a public-day expansion", (
assert.doesNotMatch(resolved.modelQuestion, /已校验的星盘资料/);
});
test("ordinary-session daily fortune without an entrypoint still uses the public-day expansion", () => {
const resolved = resolveConsultationQuestion({
visibleQuestion: GENERAL_NO_MINUTE_DAILY_FORTUNE_QUESTION,
entrypoint: undefined,
currentDate: "2026-08-15",
consultationMode: "general_no_birth_time",
});
assert.equal(isGeneralDailyFortuneQuestion(GENERAL_NO_MINUTE_DAILY_FORTUNE_QUESTION), true);
assert.equal(shouldLoadGeneralDailyPanchanga({
consultationMode: "general_no_birth_time",
visibleQuestion: GENERAL_NO_MINUTE_DAILY_FORTUNE_QUESTION,
}), true);
assert.equal(shouldLoadGeneralDailyPanchanga({
consultationMode: "general_no_birth_time",
visibleQuestion: "未来半年是否适合换工作?",
}), false);
assert.equal(shouldLoadGeneralDailyPanchanga({
consultationMode: "declared_birth_window",
visibleQuestion: GENERAL_NO_MINUTE_DAILY_FORTUNE_QUESTION,
}), true);
assert.equal(shouldLoadGeneralDailyPanchanga({
consultationMode: "declared_birth_window",
visibleQuestion: "请帮我看事业方向",
}), false);
assert.equal(resolved.kind, "expanded");
assert.match(resolved.modelQuestion, /公共 Panchanga/);
});
test("rectification labels in ordinary sessions are handoffs, not consult questions", () => {
assert.equal(isRectificationHandoffQuestion("出生时间校正 (生时校正)"), true);
assert.equal(isRectificationHandoffQuestion("出生时间校正 (生时矫正)"), true);
assert.equal(isRectificationHandoffQuestion("先完成生时校正"), true);
assert.equal(isRectificationHandoffQuestion("生时校正是什么"), false);
assert.equal(isRectificationHandoffQuestion("未来半年是否适合换工作?"), false);
});
test("birth-time entrypoint selects a private server expansion", () => {
// Given: a completed profile starts another rectification from a public label.
const visibleQuestion = "再次校正";
@@ -88,12 +129,16 @@ test("ordinary product drafts keep the public question and clear hidden routing
assert.match(source, /dailyStarlanguageQuestion/);
assert.match(source, /从今日问起/);
assert.match(source, /深入看今日/);
assert.match(source, /chooseSuggestedQuestion\(\s*dailyStarlanguageQuestion,\s*"timing",\s*personalChartAvailable \? "daily_starlanguage" : null/);
assert.match(source, /chooseSuggestedQuestion\(\s*dailyStarlanguageQuestion,\s*"timing",\s*"daily_starlanguage"/);
assert.match(source, /consultEntrypoint === "birth_time_rectification"/);
assert.match(source, /isRectificationHandoffQuestion\(question\)/);
assert.match(source, /openRectificationFromHomepage\(pendingQuestion\)/);
assert.doesNotMatch(source, /personalChartAvailable \? "daily_starlanguage" : null/);
assert.match(source, /messages: questionAlreadyPresent \? preservedMessages : \[\.\.\.preservedMessages, \{ role: "user", text: question \}\]/);
assert.match(source, /body:\s*JSON\.stringify\(\{[\s\S]*?entrypoint:\s*entrypoint \?\? undefined,[\s\S]*?question,/);
assert.match(source, /body:\s*JSON\.stringify\(\{[\s\S]*?entrypoint:\s*consultEntrypoint \?\? undefined,[\s\S]*?question,/);
const requestBody = source.slice(source.indexOf("body: JSON.stringify({"), source.indexOf("history: currentSession.messages", source.indexOf("body: JSON.stringify({")));
assert.match(requestBody, /consultationMode:[\s\S]*?entrypoint: entrypoint \?\? undefined/);
assert.doesNotMatch(requestBody, /general_no_birth_time" \? \{\} : \{[\s\S]*?entrypoint/);
assert.match(requestBody, /consultationMode:[\s\S]*?entrypoint: consultEntrypoint \?\? undefined/);
assert.doesNotMatch(requestBody, /general_no_birth_time" \|\| consultationRoute\.mode === "declared_birth_window" \? \{\} : \{[\s\S]*?entrypoint/);
assert.match(source, /onChange=\{\(event\) => \{[\s\S]*?setDraft\(event\.target\.value\);[\s\S]*?setDraftTheme\(null\);[\s\S]*?setDraftEntrypoint\(null\);/);
assert.match(source, /setDraft\(pending\.question\);[\s\S]*?setDraftTheme\(pending\.theme\);[\s\S]*?setDraftEntrypoint\(pending\.entrypoint\);/);
});
@@ -259,6 +304,9 @@ test("consult route expands an optional entrypoint for both Agent and tool input
assert.match(source, /question:\s*resolvedQuestion\.modelQuestion/);
assert.match(source, /请按 skill Level 2 模板回答下面的问题[\s\S]*?resolvedQuestion\.modelQuestion/);
assert.doesNotMatch(source, /JSON\.stringify\(toolInput\)/);
assert.match(source, /shouldLoadGeneralDailyPanchanga\(\{/);
assert.doesNotMatch(source, /用户明确选择的无出生分钟一般咨询/);
assert.match(source, /不要把整轮对话改成「一般知识或生时校正」二选一/);
});
test("homepage entrypoints use two whole-card native actions", () => {
+5
View File
@@ -46,6 +46,11 @@ test("reported concrete birth time uses the same evidence-owned precision bounda
theme: "general",
consultationMode: "general_no_birth_time",
}).precisionBoundary, "precise_timing_blocked");
assert.equal(createConsultationPlan({
userIntent: "请帮我看事业方向",
theme: "career",
consultationMode: "declared_birth_window",
}).precisionBoundary, "precise_timing_blocked");
});
test("rejects mode or precision tampering", () => {
@@ -293,7 +293,7 @@ test("stale general mode is upgraded from persisted exact-minute profile truth",
assert.equal(prepared.reservation, "reserved");
});
test("general mode remains general when persisted profile has no concrete minute", async () => {
test("stale general mode upgrades period-only profiles to a declared birth window", async () => {
const prepared = await prepareConsultationRoute({
userId: "user-1",
mode: "general_no_birth_time",
@@ -302,13 +302,18 @@ test("general mode remains general when persisted profile has no concrete minute
reported_birth_time: null,
active_birth_time: null,
birth_time_source: "period_only",
birth_time_period: "evening",
birth_time_status: "reported",
}),
reserve: async () => "reserved",
});
assert.equal(prepared.consultationMode, "general_no_birth_time");
assert.equal(prepared.consultationMode, "declared_birth_window");
assert.equal(prepared.serverChart, null);
assert.equal(prepared.declaredWindow?.toolInput.rangeStart, "18:00");
assert.equal(prepared.declaredWindow?.toolInput.rangeEnd, "22:59");
assert.equal("hour" in (prepared.declaredWindow?.toolInput ?? {}), false);
assert.equal("minute" in (prepared.declaredWindow?.toolInput ?? {}), false);
assert.deepEqual(prepared.generalDailyReference, {
latitude: profile.latitude,
longitude: profile.longitude,
@@ -320,7 +325,7 @@ test("general mode remains general when persisted profile has no concrete minute
test("consult route constructs workflow input from the route service rather than client chart fields", () => {
const route = readFileSync(new URL("../src/app/api/consult/route.ts", import.meta.url), "utf8");
const select = "name,birth_date,reported_birth_time,active_birth_time,birth_time_source,birth_time_status,country_code,province_code,city_code,district_code,latitude,longitude,timezone_offset";
const select = "name,birth_date,reported_birth_time,active_birth_time,birth_time_source,birth_time_period,birth_time_status,country_code,province_code,city_code,district_code,latitude,longitude,timezone_offset";
assert.match(route, new RegExp(select));
assert.match(route, /prepareConsultationRoute/);
+2 -2
View File
@@ -308,8 +308,8 @@ test("the homepage card is engine-backed first, with Agent polish off the reques
test("the home requests the card exactly when it renders one, and retries a failed day once", () => {
const page = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
const effect = page.slice(
page.indexOf("if (!hydrated || !accountId || !profileComplete || !personalChartAvailable) return;"),
page.indexOf("}, [accountId, dailyStarlanguageFingerprint, hydrated, personalChartAvailable, profileComplete]);"),
page.indexOf("if (!hydrated || !accountId || !profileComplete || !natalMinuteAvailable) return;"),
page.indexOf("}, [accountId, dailyStarlanguageFingerprint, hydrated, natalMinuteAvailable, profileComplete]);"),
);
assert.match(page, /aria-busy=\{dailyStarlanguageBusy\}/);
@@ -0,0 +1,36 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
DECLARED_PERIOD_RANGES,
declaredClockRange,
declaredWindowProbeClocks,
} from "../src/lib/declared-birth-window.ts";
test("evening probes stay inside the declared range and are not the midpoint", () => {
const range = DECLARED_PERIOD_RANGES.evening;
const clocks = declaredWindowProbeClocks(range.startTime, range.endTime);
assert.deepEqual(clocks, ["18:00", "19:40", "21:19", "22:59"]);
assert.equal(clocks.includes("20:29"), false);
assert.equal(clocks.includes("20:30"), false);
assert.equal(clocks.includes("12:00"), false);
assert.equal(clocks.includes("00:00"), false);
});
test("unknown-day probes keep midnight as a sample, not as a birth minute", () => {
const range = declaredClockRange({ source: "unknown" });
assert.ok(range);
const clocks = declaredWindowProbeClocks(range.startTime, range.endTime);
assert.equal(clocks[0], "00:00");
assert.equal(clocks.at(-1), "23:59");
assert.equal(clocks.length, 4);
});
test("period_only without a period cannot invent a clock range", () => {
assert.equal(declaredClockRange({ source: "period_only" }), null);
assert.deepEqual(
declaredClockRange({ source: "period_only", period: "evening" }),
DECLARED_PERIOD_RANGES.evening,
);
});
+33 -2
View File
@@ -348,14 +348,45 @@ test("core create: 422 profile_incomplete without a profile", async () => {
assert.equal(response.body.code, "profile_incomplete");
});
test("core create: 422 birth_time_not_usable for reported status", async () => {
test("core create: 422 birth_time_not_usable without a concrete reported minute", async () => {
const response = await resolveReportCreate(baseDeps({
profile: profileFixture({ birth_time_status: "reported", active_birth_time: "05:30:00" }),
profile: profileFixture({
birth_time_status: "reported",
birth_time_source: "period_only",
reported_birth_time: null,
active_birth_time: null,
}),
}));
assert.equal(response.status, 422);
assert.equal(response.body.code, "birth_time_not_usable");
});
test("core create: 201 for a reported minute uses that clock and directional policy", async () => {
const hours: number[] = [];
const minutes: number[] = [];
const capture: { input: unknown } = { input: null };
const response = await resolveReportCreate(baseDeps({
profile: profileFixture({
birth_time_status: "reported",
birth_time_source: "hospital_record",
reported_birth_time: "08:16:00",
active_birth_time: "05:30:00",
}),
runWorkflow: async (input) => {
hours.push(input.hour);
minutes.push(input.minute);
return chartPayload();
},
createAgent: () => capturingAgent(capture),
}));
assert.equal(response.status, 201);
assert.deepEqual([...new Set(hours)], [8]);
assert.deepEqual([...new Set(minutes)], [16]);
const bundle = capture.input as ReportEvidenceBundleV2;
assert.equal(bundle.subject.birthTimeStatus, "reported");
assert.equal(bundle.answerPolicy.birthTimePolicy, "reported_directional_only");
});
test("core create: 422 birth_time_not_usable for incomplete profile fields", async () => {
const response = await resolveReportCreate(baseDeps({
profile: profileFixture({ latitude: null, longitude: null }),
+5 -4
View File
@@ -179,12 +179,13 @@ test("no birth data or chat text is sent from the entry component", () => {
assert.doesNotMatch(componentSource, /const body = \{[\s\S]*?latitude/);
});
test("global report copy uses the canonical profile, preserves accepted/confirmed truth, and makes background work explicit", () => {
test("global report copy uses the canonical profile, allows an unrectified minute, and makes background work explicit", () => {
assert.match(componentSource, /authenticated user's canonical profile/);
assert.match(reportCenterSource, /已采用或已确认的出生资料/);
assert.match(reportCenterSource, /有填报到分钟的出生时间即可生成/);
assert.match(reportCenterSource, /完整本命报告需要具体分钟/);
assert.match(reportCenterSource, /与某一次对话无关/);
assert.match(reportCenterSource, /后台继续处理/);
assert.match(componentSource, /根据已采用或已确认的个人出生资料生成完整报告/);
assert.match(componentSource, /根据已保存的具体出生分钟生成完整报告/);
assert.doesNotMatch(reportCenterSource, /基于你已确认的出生资料生成/);
assert.doesNotMatch(componentSource, /根据已确认的个人出生资料生成完整报告/);
assert.doesNotMatch(componentSource, /evidenceState|workflowReceipt/);
@@ -216,7 +217,7 @@ test("entry is global in the sidebar and absent from the active session header",
);
});
test("reported/candidate birth times remain unusable for server-side report creation", () => {
test("accepted/confirmed remain the only statuses ready for verified natal consultation", () => {
const draftBase = {
date: "1990-01-01",
time: "12:00",
+5 -4
View File
@@ -97,16 +97,17 @@ test("profiles without a usable birth minute receive user-centered starter promp
}
assert.ok(generalGuidedJyotishTopics.every((topic) => /我/.test(topic.prompt)));
assert.ok(generalGuidedJyotishTopics.every((topic) => !/印度占星|一般如何|通常|哪些因素|证据层/.test(topic.prompt)));
assert.match(pageSource, /const starterThemes = personalChartAvailable \? themes : generalGuidedJyotishTopics/);
assert.match(pageSource, /const starterThemes = personalConsultationAvailable \? themes : generalGuidedJyotishTopics/);
// The birth-time boundary is disclosed next to the topics the reader is about to pick from.
const themeSectionHeading = sourceBetween(
pageSource,
'id="starter-themes-heading"',
"starter-theme-accordion");
assert.match(themeSectionHeading, /personalChartAvailable/);
assert.match(themeSectionHeading, /natalMinuteAvailable/);
assert.match(themeSectionHeading, /出生时间不足以支持的部分,我会明确说明,不会补造具体分钟/);
assert.match(pageSource, /请帮我看一下今天的运势,重点告诉我适合推进什么、需要注意什么/);
assert.match(pageSource, /personalChartAvailable \? "daily_starlanguage" : null/);
assert.match(pageSource, /GENERAL_NO_MINUTE_DAILY_FORTUNE_QUESTION/);
assert.match(pageSource, /chooseSuggestedQuestion\(\s*dailyStarlanguageQuestion,\s*"timing",\s*"daily_starlanguage"/);
assert.doesNotMatch(pageSource, /personalChartAvailable \? "daily_starlanguage" : null/);
});