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
+154 -21
View File
@@ -5,6 +5,7 @@ import {
getGeneralJyotishAgent,
getJyotishAgent,
getLegacyJyotishAgent,
getWindowJyotishAgent,
runConsultationWorkflow,
} from "@/mastra";
import { blocksPromptExtraction } from "@/lib/consult-safety";
@@ -25,6 +26,7 @@ import {
import {
consultationEntrypointSchema,
resolveConsultationQuestion,
shouldLoadGeneralDailyPanchanga,
} from "@/lib/consultation-entrypoint";
import { CreditRpcError } from "@/lib/consultation-billing";
import { reserveConsultationModel } from "@/lib/consultation-model-selection";
@@ -39,6 +41,7 @@ import {
AGENT_TIMEOUT_MS,
consultationGenerationSettings,
createConsultationAgentContext,
createWindowConsultationAgentContext,
consultationModelStepTelemetry,
consultationStepBudgetReceipt,
createConsultationRuntimeHooks,
@@ -50,6 +53,7 @@ import {
consultationBirthTimeModeSchema,
createBirthTimeModeOutputGuard,
shouldRunBirthChartWorkflow,
shouldRunDeclaredWindowWorkflow,
type ConsultationBirthTimeMode,
} from "@/lib/consultation-birth-time-mode";
import {
@@ -89,7 +93,7 @@ const chatRequestMetadataSchema = z.object({
const chartChatRequestSchema = consultationInputSchema.extend({
...chatRequestMetadataSchema.shape,
consultationMode: consultationBirthTimeModeSchema.exclude(["general_no_birth_time"])
consultationMode: consultationBirthTimeModeSchema.exclude(["general_no_birth_time", "declared_birth_window"])
.optional()
.default("verified_chart"),
entrypoint: consultationEntrypointSchema.optional(),
@@ -103,7 +107,19 @@ const generalChatRequestSchema = z.object({
entrypoint: z.literal("daily_starlanguage").optional(),
}).strict();
const chatRequestSchema = z.union([generalChatRequestSchema, chartChatRequestSchema]);
const windowChatRequestSchema = z.object({
...chatRequestMetadataSchema.shape,
consultationMode: z.literal("declared_birth_window"),
question: z.string().trim().min(1).max(500),
theme: consultationDomainSchema,
entrypoint: z.literal("daily_starlanguage").optional(),
}).strict();
const chatRequestSchema = z.union([
generalChatRequestSchema,
windowChatRequestSchema,
chartChatRequestSchema,
]);
const consultationReservationSchema = z.object({
success: z.boolean(),
@@ -160,6 +176,24 @@ function generalDailyContextPrompt(context: GeneralDailyPanchangaContext | null)
].join("\n");
}
function generalNoMinuteInstruction(hasPublicDaily: boolean) {
return hasPublicDaily
? "当前是无出生分钟的公共今日趋势咨询。可依据服务器提供的公共 Panchanga 摘要回答,但不得计算或推断个人星盘;不得补 00:00、时段中点或任何候选分钟。"
: "当前没有具体出生分钟。不得计算或推断个人星盘,也不得补 00:00、时段中点或任何候选分钟。请直接回答用户的问题:能答的部分照实说,需要出生分钟的部分明确标出限制。不要把整轮对话改成「一般知识或生时校正」二选一,也不要把服务端当前时间当成用户提供的出生时间。";
}
function declaredWindowInstruction() {
return "当前是声明出生窗口咨询,没有单一出生分钟。如需个人结构结论,必须调用 run-jyotish-window-consultation。探针时刻不是出生时间,不得把 00:00、时段中点、中午或任一探针写成出生分钟。只能根据稳定层作方向性回答;上升或宫位若在窗口内变化,列出可能星座,不得写成「你的上升是 X」。精确应期一律不可用。";
}
function usesPublicDailyGeneralAgent(
consultationMode: ConsultationBirthTimeMode,
generalDailyContext: GeneralDailyPanchangaContext | null,
) {
return consultationMode === "general_no_birth_time"
|| (consultationMode === "declared_birth_window" && Boolean(generalDailyContext));
}
function chinaCalendarDate(now: Date) {
return new Date(now.getTime() + 8 * 60 * 60 * 1000).toISOString().slice(0, 10);
}
@@ -307,7 +341,7 @@ export async function POST(request: Request) {
async loadProfile(profileUserId) {
const { data, error } = await supabase
.from("profiles")
.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,birth_place_label,birth_place_type,birth_place_provider,birth_place_provider_id,timezone_id,timezone_source")
.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,birth_place_label,birth_place_type,birth_place_provider,birth_place_provider_id,timezone_id,timezone_source")
.eq("id", profileUserId)
.single();
if (error || !data) throw new ConsultationProfileTruthError("profile_unavailable");
@@ -638,11 +672,11 @@ export async function POST(request: Request) {
content: [
currentTimeContext(requestTime),
name ? `用户称呼:${name}` : "",
consultationMode === "general_no_birth_time"
? generalDailyContext
? "当前是无出生分钟的公共今日趋势咨询。可依据服务器提供的公共 Panchanga 摘要回答,但不得计算或推断个人星盘;不得补 00:00、时段中点或任何候选分钟。"
: "当前是用户明确选择的无出生分钟一般咨询。不得计算或推断个人星盘;不得补 00:00、时段中点或任何候选分钟。"
: "先加载 Jyotish Skill;如需新的个人星盘结论,必须调用服务器绑定的排盘工具。事业/财富/婚恋/家庭按 skill Level 2 模板写:原始结构、六步宫位、Yoga 表、时机、综合、文末技法审计表,然后才是现代生活措辞。不要复述内部 JSON 字段。",
consultationMode === "general_no_birth_time" || (consultationMode === "declared_birth_window" && generalDailyContext)
? generalNoMinuteInstruction(Boolean(generalDailyContext))
: consultationMode === "declared_birth_window"
? declaredWindowInstruction()
: "先加载 Jyotish Skill;如需新的个人星盘结论,必须调用服务器绑定的排盘工具。事业/财富/婚恋/家庭按 skill Level 2 模板写:原始结构、六步宫位、Yoga 表、时机、综合、文末技法审计表,然后才是现代生活措辞。不要复述内部 JSON 字段。",
generalDailyContextPrompt(generalDailyContext),
resolvedQuestion.modelQuestion,
].filter(Boolean).join("\n"),
@@ -656,16 +690,23 @@ export async function POST(request: Request) {
hooks,
...consultationGenerationSettings(selectedModel.model),
};
const workflowReceipt: WorkflowReceipt = consultationMode === "general_no_birth_time"
const workflowReceipt: WorkflowReceipt = usesPublicDailyGeneralAgent(consultationMode, generalDailyContext)
? {
route: generalDailyContext ? "general-daily-panchanga" : "general-no-birth-time",
status: "ready",
preciseTiming: "blocked",
missingLayers: ["birth-minute"],
}
: { route: "pending", status: "blocked", preciseTiming: "blocked", missingLayers: [] };
: consultationMode === "declared_birth_window"
? {
route: "declared-birth-window",
status: "blocked",
preciseTiming: "blocked",
missingLayers: ["birth-minute"],
}
: { route: "pending", status: "blocked", preciseTiming: "blocked", missingLayers: [] };
if (consultationMode === "general_no_birth_time") {
if (usesPublicDailyGeneralAgent(consultationMode, generalDailyContext)) {
state.workflowReceipt = workflowReceipt;
const agent = getGeneralJyotishAgent(selectedModel);
const result = await agent.stream(baseMessages, streamOptions);
@@ -705,7 +746,10 @@ export async function POST(request: Request) {
requireTool: false,
retryForAnswer,
continueAfterDisconnect: true,
transformText: createBirthTimeModeOutputGuard(consultationMode, false),
transformText: createBirthTimeModeOutputGuard(
generalDailyContext ? "general_no_birth_time" : consultationMode,
false,
),
toolStatus: () => "ready",
receipt: executionReceipt,
headers: { "x-jyotish-birth-time-mode": consultationMode },
@@ -726,12 +770,97 @@ export async function POST(request: Request) {
});
}
if (shouldRunDeclaredWindowWorkflow(consultationMode)) {
if (!prepared.declaredWindow) throw new Error("declared_window_truth_missing");
const agentContext = createWindowConsultationAgentContext({
userId,
sessionId,
requestId,
consultationMode: "declared_birth_window",
plan: prepared.preReserveResult,
theme: consultationTheme,
declaredWindow: prepared.declaredWindow,
abortSignal: agentAbortSignal,
state,
});
const agent = getWindowJyotishAgent(selectedModel, agentContext);
const result = await agent.stream(baseMessages, streamOptions);
usages.push(result.totalUsage);
const retry = async () => {
const retried = await agent.stream([
...baseMessages,
{
role: "user" as const,
content: "运行合同不完整:本次尚未取得声明窗口计算结果。请调用 run-jyotish-window-consultation 完成计算,再据此回答;不要在工具参数中添加出生分钟。",
},
], streamOptions);
usages.push(retried.totalUsage);
return retried.fullStream;
};
const retryForAnswer = async () => {
const retried = await agent.stream([
...baseMessages,
{
role: "user" as const,
content: "服务器窗口计算已经完成,但上一轮没有输出任何回答文本。请重新取回本次计算结果,然后直接给出回答;不要只描述过程或工具调用。",
},
], streamOptions);
usages.push(retried.totalUsage);
return retried.fullStream;
};
const executionReceipt = (): AgentExecutionReceipt => ({
runId: requestId,
runtime: "mastra-agentic",
skill: {
name: "jyotish-vedic-astrology",
loaded: state.jyotishSkillBound,
referenceReads: state.skillReferenceReadCount,
methodologySections: state.methodologySectionCount,
},
steps: publicConsultationRuntimeSteps(state),
stepBudget: consultationStepBudgetReceipt(state),
workflow: state.workflowReceipt ?? workflowReceipt,
techniqueTruth: state.techniqueTruth ?? "declared-window",
...(state.techniqueAuditTable?.length
? { techniqueAuditTable: state.techniqueAuditTable }
: {}),
});
return streamAgentResponse({
runId: requestId,
requestId,
state,
stream: result.fullStream,
requireTool: true,
retry,
retryForAnswer,
continueAfterDisconnect: true,
transformText: createBirthTimeModeOutputGuard(consultationMode, false),
toolStatus: () => workflowStatus(state.workflowReceipt?.status),
receipt: executionReceipt,
headers: { "x-jyotish-birth-time-mode": consultationMode },
onFirstActivity: markFirstActivity,
onFirstOutput: markFirstText,
onComplete: (output, agentExecutionReceipt) => settleRun(() => completeResponse(
output,
mergeUsage(usages),
state.techniqueTruth ?? "declared-window",
state.workflowReceipt ?? workflowReceipt,
agentExecutionReceipt,
), undefined),
onError: (error) => settleRun(
cancel,
toAgentObservabilityErrorCode(error),
),
onCancel: () => settleRun(cancel, "cancelled"),
});
}
if (!prepared.serverChart) throw new Error("server_chart_truth_missing");
const agentContext = createConsultationAgentContext({
userId,
sessionId,
requestId,
consultationMode,
consultationMode: consultationMode === "verified_chart" ? "verified_chart" : "unverified_birth_time",
plan: prepared.preReserveResult,
theme: consultationTheme,
serverChart: prepared.serverChart,
@@ -818,16 +947,19 @@ export async function POST(request: Request) {
try {
const { history } = parsed.data;
const name = prepared.serverChart?.name ?? parsed.data.name;
const name = prepared.serverChart?.name ?? prepared.declaredWindow?.name ?? parsed.data.name;
const consultationMode: ConsultationBirthTimeMode = prepared.consultationMode;
const generalDailyContext = consultationMode === "general_no_birth_time"
&& parsed.data.entrypoint === "daily_starlanguage"
const generalDailyContext = shouldLoadGeneralDailyPanchanga({
consultationMode,
entrypoint: parsed.data.entrypoint,
visibleQuestion: parsed.data.question,
})
? await loadGeneralDailyPanchangaContext({
date: currentDate,
reference: prepared.generalDailyReference,
})
: null;
if (shouldUseAgenticRuntime(user)) {
if (shouldUseAgenticRuntime(user) || shouldRunDeclaredWindowWorkflow(consultationMode)) {
return await runAgenticConsultation(consultationMode, history, name, generalDailyContext);
}
if (!shouldRunBirthChartWorkflow(consultationMode)) {
@@ -837,9 +969,7 @@ export async function POST(request: Request) {
content: [
currentTimeContext(requestTime),
name ? `用户称呼:${name}` : "",
generalDailyContext
? "当前是无出生分钟的公共今日趋势咨询。可依据服务器提供的公共 Panchanga 摘要回答,但不得计算或推断个人星盘;不得补 00:00、时段中点或任何候选分钟。"
: "当前是用户明确选择的无出生分钟一般咨询。不得计算或推断个人星盘;不得补 00:00、时段中点或任何候选分钟。",
generalNoMinuteInstruction(Boolean(generalDailyContext)),
generalDailyContextPrompt(generalDailyContext),
resolvedQuestion.modelQuestion,
].filter(Boolean).join("\n"),
@@ -862,7 +992,10 @@ export async function POST(request: Request) {
: cancel,
);
return streamTextResponse(result.textStream, {
transformText: createBirthTimeModeOutputGuard(consultationMode, false),
transformText: createBirthTimeModeOutputGuard(
generalDailyContext ? "general_no_birth_time" : consultationMode,
false,
),
mode: "mastra",
requestId,
continueAfterDisconnect: true,
+1 -1
View File
@@ -124,7 +124,7 @@ export async function POST(request: Request) {
if (userId) {
const result = await supabase
.from("profiles")
.select("name,birth_date,active_birth_time,birth_time_status,latitude,longitude,timezone_offset,birth_place_label")
.select("name,birth_date,reported_birth_time,active_birth_time,birth_time_source,birth_time_status,latitude,longitude,timezone_offset,birth_place_label")
.eq("id", userId)
.maybeSingle();
profile = result.data ?? null;
+50 -25
View File
@@ -46,7 +46,12 @@ import {
type BeamAvatar,
type BeamAvatarPatch,
} from "@/lib/beam-avatar";
import type { ConsultationEntrypoint } from "@/lib/consultation-entrypoint";
import {
GENERAL_NO_MINUTE_DAILY_FORTUNE_QUESTION,
isGeneralDailyFortuneQuestion,
isRectificationHandoffQuestion,
type ConsultationEntrypoint,
} from "@/lib/consultation-entrypoint";
import {
applyBirthTimeDraftPatch,
applyPersistedBirthTime,
@@ -1287,12 +1292,15 @@ export default function Home() {
const profileComplete = isProfileComplete(profile);
const birthTimeRoute = resolveBirthTimeConsultationRoute(profile, birthTimeConsultationConsent, activeSessionId);
const personalChartAvailable = birthTimeRoute.kind === "consult" && birthTimeRoute.mode !== "general_no_birth_time";
const natalMinuteAvailable = birthTimeRoute.kind === "consult"
&& (birthTimeRoute.mode === "verified_chart" || birthTimeRoute.mode === "unverified_birth_time");
const personalConsultationAvailable = birthTimeRoute.kind === "consult"
&& birthTimeRoute.mode !== "general_no_birth_time";
const dailyStarlanguageFingerprint = dailyStarlanguageProfileKey(profile);
const starterThemes = personalChartAvailable ? themes : generalGuidedJyotishTopics;
const dailyStarlanguageQuestion = !personalChartAvailable
? "请帮我看一下今天的运势,重点告诉我适合推进什么、需要注意什么。"
const starterThemes = personalConsultationAvailable ? themes : generalGuidedJyotishTopics;
const dailyStarlanguageQuestion = !natalMinuteAvailable
? GENERAL_NO_MINUTE_DAILY_FORTUNE_QUESTION
: dailyStarlanguage.kind === "unavailable"
? "从今日问起"
: "深入看今日";
@@ -1302,7 +1310,7 @@ export default function Home() {
? "正在写下今天的星语。"
: "今天的星语还没写出来。";
const dailyStarlanguageAction = dailyStarlanguage.kind === "ready" ? dailyStarlanguage.card.action : "";
const dailyStarlanguageBusy = personalChartAvailable && dailyStarlanguage.kind === "pending";
const dailyStarlanguageBusy = natalMinuteAvailable && dailyStarlanguage.kind === "pending";
const onboardingPending = profileComplete && !onboarding && !onboardingError;
const currentOnboardingMessage = onboardingJustCompleted
? startGreeting || completedOnboardingMessage(profileDraft.name.trim())
@@ -1316,7 +1324,7 @@ export default function Home() {
const shouldStreamOnboarding = !profileComplete;
const presetMessageFinished = !shouldStreamOnboarding || presetMessageLength >= currentOnboardingMessage.length;
const onboardingCardReady = presetMessageFinished || birthTimeAssessmentPhase !== null;
const starterSuggestions = starterThemes.map((theme) => personalChartAvailable
const starterSuggestions = starterThemes.map((theme) => personalConsultationAvailable
? onboarding?.suggestions.find((item) => item.theme === theme.id) ?? { theme: theme.id, text: theme.prompt }
: { theme: theme.id, text: theme.prompt });
const starterHomeVisible = profileComplete
@@ -1814,7 +1822,7 @@ export default function Home() {
}, [accountId, hydrated, onboardingFingerprint, profile.name, profileComplete]);
useEffect(() => {
if (!hydrated || !accountId || !profileComplete || !personalChartAvailable) return;
if (!hydrated || !accountId || !profileComplete || !natalMinuteAvailable) return;
const today = calendarDateInTimeZone(new Date(), profile.timezoneId);
const fingerprint = dailyStarlanguageProfileKey(profile);
const stored = readStoredDailyStarlanguage(accountId);
@@ -1852,7 +1860,7 @@ export default function Home() {
controller.abort();
if (retryTimer !== undefined) clearTimeout(retryTimer);
};
}, [accountId, dailyStarlanguageFingerprint, hydrated, personalChartAvailable, profileComplete]);
}, [accountId, dailyStarlanguageFingerprint, hydrated, natalMinuteAvailable, profileComplete]);
useEffect(() => {
if (starterHomeVisible) {
@@ -2485,7 +2493,7 @@ export default function Home() {
chooseSuggestedQuestion(
dailyStarlanguageQuestion,
"timing",
personalChartAvailable ? "daily_starlanguage" : null,
"daily_starlanguage",
);
}
@@ -2944,6 +2952,12 @@ export default function Home() {
): Promise<boolean> {
const originalQuestion = text;
const question = text.trim();
const consultEntrypoint = entrypoint
?? (isRectificationHandoffQuestion(question)
? "birth_time_rectification" as const
: isGeneralDailyFortuneQuestion(question)
? "daily_starlanguage" as const
: null);
const resumeRequestId = options.resumeRequestId;
const resuming = Boolean(resumeRequestId);
const liveSession = targetSessionId
@@ -2967,8 +2981,19 @@ export default function Home() {
return false;
}
if (entrypoint === "birth_time_rectification") {
await openRectificationFromHomepage();
if (consultEntrypoint === "birth_time_rectification") {
const previousUserQuestion = [...currentSession.messages]
.reverse()
.find((message) => message.role === "user")
?.text
.trim() ?? null;
const pendingQuestion = previousUserQuestion && !isRectificationHandoffQuestion(previousUserQuestion)
? previousUserQuestion
: null;
await openRectificationFromHomepage(pendingQuestion);
setDraft("");
setDraftTheme(null);
setDraftEntrypoint(null);
return false;
}
@@ -3036,7 +3061,7 @@ export default function Home() {
requestId,
sessionId,
question: originalQuestion,
entrypoint,
entrypoint: consultEntrypoint,
theme,
previousSession: rollbackSession,
optimisticSession: userSession,
@@ -3052,7 +3077,7 @@ export default function Home() {
requestId,
question: originalQuestion,
theme,
entrypoint,
entrypoint: consultEntrypoint,
}));
} catch {
// Private-mode storage must not block send.
@@ -3117,7 +3142,7 @@ export default function Home() {
if (!options.restoreOnFailure && activeSessionIdRef.current === sessionId) {
setDraft(originalQuestion);
setDraftTheme(theme);
setDraftEntrypoint(entrypoint);
setDraftEntrypoint(consultEntrypoint);
}
setRequestError({
sessionId,
@@ -3135,7 +3160,7 @@ export default function Home() {
pendingConsultation.current = {
...pendingConsultation.current,
question: originalQuestion,
entrypoint,
entrypoint: consultEntrypoint,
theme,
optimisticSession: userSession,
phase: "streaming",
@@ -3154,8 +3179,8 @@ export default function Home() {
modelId: currentSession.modelId,
name: profile.name,
consultationMode: consultationRoute.mode,
entrypoint: entrypoint ?? undefined,
...(consultationRoute.mode === "general_no_birth_time" ? {} : {
entrypoint: consultEntrypoint ?? undefined,
...(consultationRoute.mode === "general_no_birth_time" || consultationRoute.mode === "declared_birth_window" ? {} : {
year,
month,
day,
@@ -3700,20 +3725,20 @@ export default function Home() {
<button
className="product-entrypoint-hitarea"
type="button"
aria-label={personalChartAvailable ? dailyStarlanguageQuestion : "查看今日运势"}
aria-label={natalMinuteAvailable ? dailyStarlanguageQuestion : "查看今日运势"}
disabled={productEntrypointsDisabled}
onClick={draftDailyStarlanguageQuestion}
/>
<div className="product-entrypoint-copy">
<h2 id="daily-starlanguage-title">{personalChartAvailable ? "今日星语" : "每日运势"}</h2>
<h2 id="daily-starlanguage-title">{natalMinuteAvailable ? "今日星语" : "每日运势"}</h2>
<p
aria-busy={dailyStarlanguageBusy}
role={personalChartAvailable && dailyStarlanguage.kind !== "ready" ? "status" : undefined}
>{personalChartAvailable ? dailyStarlanguageTrend : "看看今天的整体节奏、适合推进的事和需要留意的地方。"}</p>
role={natalMinuteAvailable && dailyStarlanguage.kind !== "ready" ? "status" : undefined}
>{natalMinuteAvailable ? dailyStarlanguageTrend : "看看今天的整体节奏、适合推进的事和需要留意的地方。"}</p>
</div>
<div className="product-entrypoint-footer">
<small>{personalChartAvailable ? dailyStarlanguageAction : "不支持的个人判断会明确说明,不会补造出生时间。"}</small>
<span className="product-entrypoint-action" aria-hidden="true">{personalChartAvailable ? dailyStarlanguageQuestion : "查看今日运势"} <ArrowUpRight className="starter-arrow" /></span>
<small>{natalMinuteAvailable ? dailyStarlanguageAction : "不支持的个人判断会明确说明,不会补造出生时间。"}</small>
<span className="product-entrypoint-action" aria-hidden="true">{natalMinuteAvailable ? dailyStarlanguageQuestion : "查看今日运势"} <ArrowUpRight className="starter-arrow" /></span>
</div>
</article>
<article className="birth-rectification-card product-entrypoint-card" aria-labelledby="birth-rectification-title">
@@ -3746,7 +3771,7 @@ export default function Home() {
<section className="starter-themes" aria-labelledby="starter-themes-heading">
<div className="starter-section-heading">
<h2 id="starter-themes-heading">从一个主题开始</h2>
<p>{personalChartAvailable
<p>{natalMinuteAvailable
? "选择一个你现在想解决的问题。"
: "选择一个你现在想解决的问题;出生时间不足以支持的部分,我会明确说明,不会补造具体分钟。"}</p>
</div>