fix: reconcile consultation mode with saved birth time
This commit is contained in:
@@ -1368,3 +1368,18 @@
|
||||
- 防复发:零证据首轮不得执行分钟级技术计算或模型调用;非关键会话同步不得阻塞已持久化 case 的首轮交互;可选 handoff 读取必须由实际 handoff 状态触发。
|
||||
- 相关记录:BUG-055、BUG-065、BUG-067
|
||||
- 修复版本:待提交(本地可测)
|
||||
|
||||
## BUG-073 | 咨询入口与持久化出生时间能力不一致
|
||||
|
||||
- 状态:resolved
|
||||
- 首次发现:2026-07-25
|
||||
- 最近更新:2026-07-25
|
||||
- 影响面:首页状态文案、每日观察入口、推荐初始问题、`/api/consult` 出生时间模式判定
|
||||
- 用户现象:一类用户没有具体出生分钟,首页却仍诱导个人星盘问题;另一类用户已经保存准确到分钟的填报时间,旧标签页或旧客户端仍可能把咨询请求标为 `general_no_birth_time`,随后错误提示当前一般咨询模式不能生成个人星盘结论。
|
||||
- 触发条件:无分钟资料仍出现个人入口,或数据库已保存合法 `family_exact` / `hospital_record` / `approximate` 填报分钟,但客户端咨询模式停留在旧的 `general_no_birth_time`。
|
||||
- 根因:首页没有复用出生时间路由结果来约束入口能力;同时 `/api/consult` 只在客户端请求星盘模式时加载 profile,收到 `general_no_birth_time` 时完全信任客户端状态,没有用持久化资料纠正陈旧模式。
|
||||
- 修复:首页复用 `resolveBirthTimeConsultationRoute()`:无具体分钟时只显示一般知识文案和问题,有合法填报分钟时保留个人星盘入口。服务端对一般咨询请求也 best-effort 读取 profile;若持久化资料含合法未校正填报分钟则提升为 `unverified_birth_time`,若为 `confirmed` 且有合法 active time 则提升为 `verified_chart`,并继续由服务端 profile 构建星盘输入。`period_only` / `unknown` 仍保持一般咨询,不虚构分钟,也不把用户填报时间伪装成已校正时间。
|
||||
- 验证:聚焦测试覆盖无分钟首页入口、陈旧 general 请求被准确分钟 profile 提升为 `unverified_birth_time`,以及无具体分钟仍保持 `general_no_birth_time`;目标 ESLint、Webpack 构建与 `git diff --check` 通过。
|
||||
- 防复发:首页可点击入口必须与 `resolveBirthTimeConsultationRoute()` 的能力结果一致;服务端不得把客户端咨询模式当作出生资料真源,具体填报分钟必须进入 `unverified_birth_time`,只有 `confirmed + active_birth_time` 才能进入 `verified_chart`。
|
||||
- 相关记录:BUG-009
|
||||
- 修复版本:待提交(本地可测)
|
||||
|
||||
@@ -439,7 +439,7 @@ export async function POST(request: Request) {
|
||||
try {
|
||||
const { history } = parsed.data;
|
||||
const name = prepared.serverChart?.name ?? parsed.data.name;
|
||||
const consultationMode: ConsultationBirthTimeMode = parsed.data.consultationMode;
|
||||
const consultationMode: ConsultationBirthTimeMode = prepared.consultationMode;
|
||||
if (!shouldRunBirthChartWorkflow(consultationMode)) {
|
||||
const result = await getGeneralJyotishAgent(selectedModel).stream([
|
||||
{
|
||||
|
||||
+23
-13
@@ -75,7 +75,7 @@ import {
|
||||
isGuidedBirthTimePreview,
|
||||
previewRectificationJourney,
|
||||
} from "@/lib/birth-time-guided-preview";
|
||||
import { defaultGuidedJyotishTopics } from "@/lib/guided-jyotish-topics";
|
||||
import { defaultGuidedJyotishTopics, generalGuidedJyotishTopics } from "@/lib/guided-jyotish-topics";
|
||||
import { keepFocusWithin } from "@/lib/focus-trap";
|
||||
import { chatMessageViews, type ChatMessage } from "@/lib/chat-message-view";
|
||||
import { writeChatSession } from "@/lib/chat-session-write-contract";
|
||||
@@ -1123,6 +1123,9 @@ export default function Home() {
|
||||
}, [accountId, profile]);
|
||||
|
||||
const profileComplete = isProfileComplete(profile);
|
||||
const birthTimeRoute = resolveBirthTimeConsultationRoute(profile, birthTimeConsultationConsent, activeSessionId);
|
||||
const personalChartAvailable = birthTimeRoute.kind === "consult" && birthTimeRoute.mode !== "general_no_birth_time";
|
||||
const starterThemes = personalChartAvailable ? themes : generalGuidedJyotishTopics;
|
||||
const dailyStarlanguage = dailyStarlanguageCard ?? (profileComplete ? buildDailyStarlanguageCard(profile) : null);
|
||||
const onboardingPending = profileComplete && !onboarding && !onboardingError;
|
||||
const currentOnboardingMessage = onboardingJustCompleted
|
||||
@@ -1137,8 +1140,9 @@ export default function Home() {
|
||||
const shouldStreamOnboarding = !profileComplete;
|
||||
const presetMessageFinished = !shouldStreamOnboarding || presetMessageLength >= currentOnboardingMessage.length;
|
||||
const onboardingCardReady = presetMessageFinished || birthTimeAssessmentPhase !== null;
|
||||
const starterSuggestions = themes.map((theme) => onboarding?.suggestions.find((item) => item.theme === theme.id)
|
||||
?? { theme: theme.id, text: theme.prompt });
|
||||
const starterSuggestions = starterThemes.map((theme) => personalChartAvailable
|
||||
? onboarding?.suggestions.find((item) => item.theme === theme.id) ?? { theme: theme.id, text: theme.prompt }
|
||||
: { theme: theme.id, text: theme.prompt });
|
||||
const starterHomeVisible = profileComplete
|
||||
&& presetMessageFinished
|
||||
&& !onboardingPending
|
||||
@@ -2031,7 +2035,11 @@ export default function Home() {
|
||||
}
|
||||
|
||||
function draftDailyStarlanguageQuestion() {
|
||||
chooseSuggestedQuestion("深入看今日", "timing", "daily_starlanguage");
|
||||
chooseSuggestedQuestion(
|
||||
personalChartAvailable ? "深入看今日" : "印度占星通常如何观察每日趋势?",
|
||||
"timing",
|
||||
personalChartAvailable ? "daily_starlanguage" : null,
|
||||
);
|
||||
}
|
||||
|
||||
function synchronizeRectificationQuestion(
|
||||
@@ -3109,7 +3117,7 @@ export default function Home() {
|
||||
? (consultationPhase === "undo" ? "即将发送,可撤回" : activeStreamingText ? "正在回答" : "正在核对星盘信息")
|
||||
: rectificationSurfaceOpen || (!profileComplete && onboardingStep === "rectification")
|
||||
? "正在校正出生时间"
|
||||
: "基于星盘证据回答"}</span>
|
||||
: personalChartAvailable ? "基于星盘证据回答" : "回答一般占星知识"}</span>
|
||||
</div>
|
||||
<button className="credit-button" ref={creditTrigger} type="button" onClick={() => openAccountDialog("redeem", creditTrigger.current)} aria-label={account ? `余额 ${account.credits} 点,兑换点数` : accountError || "读取余额中"}>
|
||||
<Sparkles className="credit-icon" aria-hidden="true" />
|
||||
@@ -3205,7 +3213,9 @@ export default function Home() {
|
||||
<div className="starter-hero-copy">
|
||||
<p className="starter-greeting">{daypartGreeting},{profile.name.trim()}。</p>
|
||||
<h1 id="starter-heading">今天想先理清什么?</h1>
|
||||
<p className="starter-hero-note">从此刻最在意的事开始,我会结合你的星盘证据,帮你把问题拆得更清楚。</p>
|
||||
<p className="starter-hero-note">{personalChartAvailable
|
||||
? "从此刻最在意的事开始,我会结合你的星盘证据,帮你把问题拆得更清楚。"
|
||||
: "你可以先了解一般占星知识;完成生时校正后,再讨论个人星盘结论。"}</p>
|
||||
</div>
|
||||
<div className="starter-celestial-visual" aria-hidden="true">
|
||||
<span className="starter-orbit starter-orbit-outer" />
|
||||
@@ -3221,18 +3231,18 @@ export default function Home() {
|
||||
<button
|
||||
className="product-entrypoint-hitarea"
|
||||
type="button"
|
||||
aria-label="深入看今日"
|
||||
aria-label={personalChartAvailable ? "深入看今日" : "了解每日趋势的分析方法"}
|
||||
disabled={productEntrypointsDisabled}
|
||||
onClick={draftDailyStarlanguageQuestion}
|
||||
/>
|
||||
<div className="product-entrypoint-copy">
|
||||
<span className="product-entrypoint-kicker">每日观察</span>
|
||||
<h2 id="daily-starlanguage-title">今日星语</h2>
|
||||
<p>{dailyStarlanguage?.trend}</p>
|
||||
<span className="product-entrypoint-kicker">{personalChartAvailable ? "每日观察" : "一般知识"}</span>
|
||||
<h2 id="daily-starlanguage-title">{personalChartAvailable ? "今日星语" : "如何看每日趋势"}</h2>
|
||||
<p>{personalChartAvailable ? dailyStarlanguage?.trend : "了解印度占星通常会用哪些因素观察一天的主题。"}</p>
|
||||
</div>
|
||||
<div className="product-entrypoint-footer">
|
||||
<small>{dailyStarlanguage?.action}</small>
|
||||
<span className="product-entrypoint-action" aria-hidden="true">深入看今日 <ArrowUpRight className="starter-arrow" /></span>
|
||||
<small>{personalChartAvailable ? dailyStarlanguage?.action : "不依赖个人出生分钟。"}</small>
|
||||
<span className="product-entrypoint-action" aria-hidden="true">{personalChartAvailable ? "深入看今日" : "了解方法"} <ArrowUpRight className="starter-arrow" /></span>
|
||||
</div>
|
||||
</article>
|
||||
<article className="birth-rectification-card product-entrypoint-card" aria-labelledby="birth-rectification-title">
|
||||
@@ -3266,7 +3276,7 @@ export default function Home() {
|
||||
</div>
|
||||
<div className="starter-theme-accordion">
|
||||
{starterSuggestions.map((item) => {
|
||||
const theme = themes.find((candidate) => candidate.id === item.theme);
|
||||
const theme = starterThemes.find((candidate) => candidate.id === item.theme);
|
||||
return (
|
||||
<button
|
||||
className="starter-theme-card"
|
||||
|
||||
@@ -122,6 +122,20 @@ function optionalText(profile: RecordValue, key: string): string | null {
|
||||
return typeof value === "string" && value.trim() ? value.trim() : null;
|
||||
}
|
||||
|
||||
function persistedChartMode(value: unknown): Exclude<ConsultationBirthTimeMode, "general_no_birth_time"> | null {
|
||||
const profile = record(value);
|
||||
if (!profile) return null;
|
||||
const status = optionalText(profile, "birth_time_status");
|
||||
const source = optionalText(profile, "birth_time_source");
|
||||
const activeTime = optionalText(profile, "active_birth_time")?.slice(0, 5) ?? "";
|
||||
const reportedTime = optionalText(profile, "reported_birth_time")?.slice(0, 5) ?? "";
|
||||
if (status === "confirmed" && isBirthClockTime(activeTime)) return "verified_chart";
|
||||
if (status && allowedBirthTimeStatuses.has(status) && status !== "confirmed"
|
||||
&& source && concreteReportedSources.has(source)
|
||||
&& isBirthClockTime(reportedTime)) return "unverified_birth_time";
|
||||
return null;
|
||||
}
|
||||
|
||||
function legacyChinaPlaceLabel(profile: RecordValue): string | null {
|
||||
const countryCode = optionalText(profile, "country_code");
|
||||
const provinceCode = optionalText(profile, "province_code");
|
||||
@@ -245,17 +259,22 @@ function serverChartFromProfile(
|
||||
export async function prepareConsultationRoute<Reservation>(
|
||||
input: PrepareConsultationRouteInput<Reservation>,
|
||||
) {
|
||||
let profile: unknown;
|
||||
try {
|
||||
profile = await input.loadProfile(input.userId);
|
||||
} catch (error) {
|
||||
if (input.mode === "general_no_birth_time") profile = null;
|
||||
else if (error instanceof ConsultationProfileTruthError) throw error;
|
||||
else throw new ConsultationProfileTruthError("profile_unavailable");
|
||||
}
|
||||
|
||||
const consultationMode = input.mode === "general_no_birth_time"
|
||||
? persistedChartMode(profile) ?? input.mode
|
||||
: input.mode;
|
||||
let serverChart: ServerChartConsultation | null = null;
|
||||
if (input.mode !== "general_no_birth_time") {
|
||||
let profile: unknown;
|
||||
try {
|
||||
profile = await input.loadProfile(input.userId);
|
||||
} catch (error) {
|
||||
if (error instanceof ConsultationProfileTruthError) throw error;
|
||||
throw new ConsultationProfileTruthError("profile_unavailable");
|
||||
}
|
||||
if (consultationMode !== "general_no_birth_time") {
|
||||
const profileValue = record(profile);
|
||||
const selectedTime = input.mode === "verified_chart"
|
||||
const selectedTime = consultationMode === "verified_chart"
|
||||
? nullableClock(profileValue ?? {}, "active_birth_time")
|
||||
: nullableClock(profileValue ?? {}, "reported_birth_time");
|
||||
try {
|
||||
@@ -265,8 +284,8 @@ export async function prepareConsultationRoute<Reservation>(
|
||||
} catch {
|
||||
throw new ConsultationProfileTruthError("profile_unavailable");
|
||||
}
|
||||
serverChart = serverChartFromProfile(profile, input.mode);
|
||||
serverChart = serverChartFromProfile(profile, consultationMode);
|
||||
}
|
||||
const reservation = await input.reserve();
|
||||
return Object.freeze({ serverChart, reservation });
|
||||
return Object.freeze({ consultationMode, serverChart, reservation });
|
||||
}
|
||||
|
||||
@@ -48,3 +48,15 @@ export const defaultGuidedJyotishTopics: GuidedJyotishTopic[] = [
|
||||
claimBoundary: "精确月/日仍是探索性候选,未通过独立 holdout 前不升级。",
|
||||
},
|
||||
];
|
||||
|
||||
const generalPromptByTheme: Partial<Record<ConsultationTheme, string>> = {
|
||||
career: "印度占星一般会从哪些因素理解事业方向?",
|
||||
marriage: "印度占星一般如何分析关系模式?",
|
||||
wealth: "印度占星一般如何分析财富结构与风险?",
|
||||
timing: "印度占星中的时间推运通常会看哪些因素?",
|
||||
};
|
||||
|
||||
export const generalGuidedJyotishTopics: GuidedJyotishTopic[] = defaultGuidedJyotishTopics.map((topic) => ({
|
||||
...topic,
|
||||
prompt: generalPromptByTheme[topic.id] ?? topic.prompt,
|
||||
}));
|
||||
|
||||
@@ -71,7 +71,7 @@ test("browser source does not own private entrypoint prompts", () => {
|
||||
test("ordinary product drafts keep the public question and clear hidden routing after edits", () => {
|
||||
const source = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
|
||||
|
||||
assert.match(source, /chooseSuggestedQuestion\("深入看今日",[\s\S]*?"timing",[\s\S]*?"daily_starlanguage"\)/);
|
||||
assert.match(source, /personalChartAvailable \? "深入看今日"[\s\S]*?"timing",[\s\S]*?personalChartAvailable \? "daily_starlanguage" : null/);
|
||||
assert.match(source, /messages:\s*\[\.\.\.preservedMessages,[\s\S]*?\{ role: "user", text: question \}\]/);
|
||||
assert.match(source, /body:\s*JSON\.stringify\(\{[\s\S]*?entrypoint:\s*entrypoint \?\? undefined,[\s\S]*?question,/);
|
||||
assert.match(source, /onChange=\{\(event\) => \{[\s\S]*?setDraft\(event\.target\.value\);[\s\S]*?setDraftTheme\(null\);[\s\S]*?setDraftEntrypoint\(null\);/);
|
||||
@@ -319,7 +319,7 @@ test("starter homepage stays editorial and hides technical chart parameters", ()
|
||||
assert.match(starterHomepage, /className="starter-theme-accordion"/);
|
||||
assert.match(starterHomepage, /starterSuggestions\.map/);
|
||||
assert.doesNotMatch(starterHomepage, /evidencePreview|birthTimeDisplay|Vimshottari|D1|D9/);
|
||||
assert.match(source, /const starterSuggestions = themes\.map/);
|
||||
assert.match(source, /const starterSuggestions = starterThemes\.map/);
|
||||
assert.match(source, /composer-wrap-starter/);
|
||||
assert.match(styles, /\/\* Starter workbench \*\/[\s\S]*?\.starter-list \{[\s\S]*?grid-template-columns: minmax\(0, 1fr\);/);
|
||||
assert.match(styles, /\.starter-hero,[\s\S]*?\.product-entrypoints,[\s\S]*?\.starter-themes \{[\s\S]*?width: 100%;/);
|
||||
|
||||
@@ -226,19 +226,41 @@ test("historical timezone failure remains pre-billing", async () => {
|
||||
assert.equal(reserveCalls, 0);
|
||||
});
|
||||
|
||||
test("general route reserves without loading chart profile", async () => {
|
||||
let profileLoads = 0;
|
||||
test("stale general mode is upgraded from persisted exact-minute profile truth", async () => {
|
||||
const prepared = await prepareConsultationRoute({
|
||||
userId: "user-1",
|
||||
mode: "general_no_birth_time",
|
||||
loadProfile: async () => {
|
||||
profileLoads += 1;
|
||||
return profile;
|
||||
},
|
||||
loadProfile: async () => ({
|
||||
...profile,
|
||||
reported_birth_time: "14:49:00",
|
||||
birth_time_source: "family_exact",
|
||||
birth_time_status: "reported",
|
||||
}),
|
||||
reserve: async () => "reserved",
|
||||
});
|
||||
|
||||
assert.equal(profileLoads, 0);
|
||||
assert.equal(prepared.consultationMode, "unverified_birth_time");
|
||||
assert.equal(prepared.serverChart?.toolInput.hour, 14);
|
||||
assert.equal(prepared.serverChart?.toolInput.minute, 49);
|
||||
assert.equal(prepared.serverChart?.truth.selectedTimeKind, "reported");
|
||||
assert.equal(prepared.reservation, "reserved");
|
||||
});
|
||||
|
||||
test("general mode remains general when persisted profile has no concrete minute", async () => {
|
||||
const prepared = await prepareConsultationRoute({
|
||||
userId: "user-1",
|
||||
mode: "general_no_birth_time",
|
||||
loadProfile: async () => ({
|
||||
...profile,
|
||||
reported_birth_time: null,
|
||||
active_birth_time: null,
|
||||
birth_time_source: "period_only",
|
||||
birth_time_status: "reported",
|
||||
}),
|
||||
reserve: async () => "reserved",
|
||||
});
|
||||
|
||||
assert.equal(prepared.consultationMode, "general_no_birth_time");
|
||||
assert.equal(prepared.serverChart, null);
|
||||
assert.equal(prepared.reservation, "reserved");
|
||||
});
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
import {
|
||||
defaultGuidedJyotishTopics,
|
||||
generalGuidedJyotishTopics,
|
||||
} from "../src/lib/guided-jyotish-topics.ts";
|
||||
|
||||
const pageSource = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
|
||||
const globalStyles = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
|
||||
@@ -37,7 +41,7 @@ test("completed account initialization switches directly to the home cards", ()
|
||||
test("default starter questions are guided Jyotish topics with evidence and claim boundaries", () => {
|
||||
assert.match(pageSource, /defaultGuidedJyotishTopics/);
|
||||
assert.match(pageSource, /starterSuggestions\.map/);
|
||||
assert.match(pageSource, /themes\.find\(\(candidate\) => candidate\.id === item\.theme\)/);
|
||||
assert.match(pageSource, /starterThemes\.find\(\(candidate\) => candidate\.id === item\.theme\)/);
|
||||
assert.match(pageSource, /chooseSuggestedQuestion\(item\.text, item\.theme\)/);
|
||||
assert.match(guidedTopicsSource, /strictWorkflowRoute/);
|
||||
assert.match(guidedTopicsSource, /evidencePreview/);
|
||||
@@ -49,6 +53,20 @@ test("default starter questions are guided Jyotish topics with evidence and clai
|
||||
assert.match(guidedTopicsSource, /独立 holdout/);
|
||||
});
|
||||
|
||||
test("profiles without a usable birth minute only receive general-knowledge homepage prompts", () => {
|
||||
assert.deepEqual(generalGuidedJyotishTopics.map((topic) => topic.id), defaultGuidedJyotishTopics.map((topic) => topic.id));
|
||||
assert.deepEqual(generalGuidedJyotishTopics.map((topic) => topic.prompt), [
|
||||
"印度占星一般会从哪些因素理解事业方向?",
|
||||
"印度占星一般如何分析关系模式?",
|
||||
"印度占星一般如何分析财富结构与风险?",
|
||||
"印度占星中的时间推运通常会看哪些因素?",
|
||||
]);
|
||||
assert.match(pageSource, /const starterThemes = personalChartAvailable \? themes : generalGuidedJyotishTopics/);
|
||||
assert.match(pageSource, /personalChartAvailable[\s\S]*?回答一般占星知识/);
|
||||
assert.match(pageSource, /完成生时校正后,再讨论个人星盘结论/);
|
||||
assert.match(pageSource, /personalChartAvailable \? "daily_starlanguage" : null/);
|
||||
});
|
||||
|
||||
test("keeps follow-up suggestions visible while the user edits a draft", () => {
|
||||
// Given: the follow-up suggestion block and its render guard.
|
||||
const suggestionGuard = sourceBetween(
|
||||
|
||||
Reference in New Issue
Block a user