feat: make birth-time rectification a soft homepage flow

This commit is contained in:
Jesse_Chen
2026-07-21 06:28:47 +08:00
parent 5c8d965f16
commit fc1e9f1085
11 changed files with 727 additions and 115 deletions
+49 -1
View File
@@ -1,4 +1,8 @@
import { NextResponse } from "next/server";
import {
parseRectificationPriceCredits,
type AccountRectificationCaseState,
} from "@/lib/birth-time-consultation-consent";
import { createAdminSupabaseClient, isAdminEmail } from "@/lib/supabase/admin";
import {
isSupabaseConfigurationError,
@@ -28,6 +32,7 @@ type ProfilePatchPayload = {
const birthTimeSources = ["hospital_record", "family_exact", "approximate", "period_only", "unknown", "legacy_import"] as const;
const birthTimePeriods = ["early_morning", "morning", "afternoon", "evening", "late_night"] as const;
const rectificationStatuses = ["starting", "active", "paused", "confirming", "completed", "abandoned"] as const;
function nullableString(value: unknown) {
return typeof value === "string" && value.trim() ? value.trim() : null;
@@ -53,6 +58,28 @@ function isMissingProfileColumn(error: { code?: string; message?: string } | nul
|| message.includes("column");
}
function projectRectificationCase(value: unknown): AccountRectificationCaseState | null {
if (value === null || typeof value !== "object") return null;
const row = value as Record<string, unknown>;
const status = rectificationStatuses.find((candidate) => candidate === row.status);
if (typeof row.id !== "string"
|| row.journey_protocol !== "conversational-evidence-v3"
|| !status
|| typeof row.turn_version !== "number"
|| !Number.isSafeInteger(row.turn_version)
|| row.turn_version < 0) {
throw new Error("invalid rectification case projection");
}
return Object.freeze({
caseId: row.id,
journeyProtocol: "conversational-evidence-v3",
status,
turnVersion: row.turn_version,
isRevision: typeof row.revision_of_case_id === "string",
preservesActiveTime: typeof row.baseline_active_time === "string",
});
}
export async function GET() {
try {
const supabase = await createServerSupabaseClient();
@@ -62,9 +89,12 @@ export async function GET() {
return NextResponse.json({ error: "请先登录" }, { status: 401 });
}
const rectificationPriceCredits = parseRectificationPriceCredits(
process.env.RECTIFICATION_PRICE_CREDITS,
);
const { data: profile, error } = await supabase
.from("profiles")
.select("credits")
.select("credits,active_birth_time,birth_time_status")
.eq("id", user.id)
.single();
@@ -72,10 +102,28 @@ export async function GET() {
return NextResponse.json({ error: "暂时无法读取账户余额" }, { status: 500 });
}
const admin = createAdminSupabaseClient();
const { data: rectificationCaseRow, error: rectificationCaseError } = await admin
.from("birth_time_rectification_cases")
.select("id,journey_protocol,status,turn_version,revision_of_case_id,baseline_active_time,updated_at")
.eq("user_id", user.id)
.eq("journey_protocol", "conversational-evidence-v3")
.order("updated_at", { ascending: false })
.limit(1)
.maybeSingle();
if (rectificationCaseError) {
return NextResponse.json({ error: "暂时无法读取生时校正状态" }, { status: 500 });
}
const rectificationCase = projectRectificationCase(rectificationCaseRow);
return NextResponse.json({
user: { id: user.id, email: user.email ?? null },
credits: profile.credits,
isAdmin: isAdminEmail(user.email),
rectificationPriceCredits,
hasConfirmedBirthTime: profile.birth_time_status === "confirmed"
&& typeof profile.active_birth_time === "string",
rectificationCase,
});
} catch (error) {
if (isSupabaseConfigurationError(error)) {
+292 -108
View File
@@ -13,6 +13,7 @@ import {
import { BirthTimeIntakeFields } from "@/components/birth-time-intake";
import { ChatMessageContent } from "@/components/chat-message-content";
import { AgentAvatar, ChatMessageRow } from "@/components/chat-message-row";
import { UnverifiedBirthTimeChoice } from "@/components/unverified-birth-time-choice";
import { ModelSelector } from "@/components/model-selector";
import { Button } from "@/components/ui/button";
import { SidebarInset, SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar";
@@ -25,15 +26,29 @@ import {
birthTimeDisplayState,
birthTimePersistenceValues,
describeBirthTimeDraft,
isDeclaredBirthProfileComplete,
isBirthTimeDraftReady,
isBirthTimeReadyForConsultation,
type BirthTimeDraft,
type BirthTimeSource,
} from "@/lib/birth-time-intake-model";
import {
canUseUnverifiedBirthTime,
clearBirthTimeConsultationConsent,
createBirthTimeConsultationConsentState,
grantBirthTimeConsultationConsent,
hasBirthTimeConsultationConsent,
requiresBirthTimeConsent,
resolveRectificationCardAction,
unverifiedBirthTime,
type AccountRectificationCaseState,
type BirthTimeConsultationConsentState,
type RectificationCardAction,
} from "@/lib/birth-time-consultation-consent";
import { sendConversationalRectificationCommand } from "@/lib/conversational-rectification/client";
import type { ConversationalRectificationTurn } from "@/lib/conversational-rectification/contracts";
import { useBirthTimeGuidedJourney } from "@/hooks/use-birth-time-guided-journey";
import {
requestBirthTimeAssessment,
resumeBirthTimeJourney,
type JourneyClientResponse,
} from "@/lib/birth-time-journey-client";
import {
@@ -73,6 +88,15 @@ const BirthTimeRectification = dynamic(
},
);
const ConversationalBirthTimeRectification = dynamic(
() => import("@/components/conversational-birth-time-rectification")
.then((module) => module.ConversationalBirthTimeRectification),
{
ssr: false,
loading: () => <p className="birth-time-assistant-intent" role="status"></p>,
},
);
type Theme = ReplyTheme;
type Message = ChatMessage;
type Profile = BirthTimeDraft & {
@@ -118,7 +142,14 @@ type ChatSession = { id: string; title: string; theme: Theme; modelId: string; m
type RequestError = { sessionId: string; message: string };
type StreamingReply = { sessionId: string; text: string };
type BirthPlace = { label: string; lat: number; lon: number; tz: number };
type Account = { user: { id: string; email: string | null }; credits: number; isAdmin: boolean };
type Account = {
user: { id: string; email: string | null };
credits: number;
isAdmin: boolean;
rectificationPriceCredits: number;
hasConfirmedBirthTime: boolean;
rectificationCase: AccountRectificationCaseState | null;
};
type OnboardingStep = "name" | "birth" | "place" | "rectification";
type AccountDialog = "profile" | "redeem" | "logout";
type DailyStarlanguageCard = { trend: string; action: string; caution: string };
@@ -129,14 +160,13 @@ type DailyStarlanguageApiResponse = {
claim_status?: "exploratory_unvalidated";
boundary?: "not_deterministic_prediction";
};
type BirthRectificationPreview = {
status?: "ok" | "blocked";
candidate_scan?: { start?: string; end?: string; candidate_count?: number };
question_count?: number;
boundary?: "not_auto_rectified";
source?: "active_rectification_questions" | "fallback_unavailable";
};
type SessionReadResult = { readonly sessions: ChatSession[]; readonly fallbackSessionIds: string[] };
type PendingBirthTimeChoice = Readonly<{
sessionId: string;
question: string;
entrypoint: ConsultationEntrypoint | null;
theme: Theme;
}>;
type PendingConsultation = {
readonly requestId: string;
readonly sessionId: string;
@@ -160,6 +190,12 @@ const themes: Array<{ id: Exclude<Theme, "general">; label: string; prompt: stri
{ id: "timing", label: "时运", prompt: "未来哪些阶段值得把握?" },
];
const rectificationCardLabels = {
start: "开始生时校正",
resume: "继续上次校正",
revise: "再次校正",
} as const satisfies Record<RectificationCardAction, string>;
const accountDialogTitles = {
profile: "个人资料",
redeem: "兑换点数",
@@ -384,21 +420,10 @@ async function fetchDailyStarlanguage(profile: Profile) {
return payload.card;
}
async function fetchBirthRectificationPreview(profile: Profile) {
const response = await fetch("/api/birth-rectification", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ profile }),
});
if (!response.ok) throw new Error("birth_rectification_preview_unavailable");
return await response.json().catch(() => null) as BirthRectificationPreview | null;
}
function missingProfileStep(profile: Profile): OnboardingStep | null {
if (!profile.name.trim()) return "name";
if (!isBirthTimeDraftReady(profile)) return "birth";
if (!isDeclaredBirthProfileComplete(profile)) return "birth";
if (!selectedBirthPlace(profile)) return "place";
if (!isBirthTimeReadyForConsultation(profile)) return "rectification";
return null;
}
@@ -421,7 +446,7 @@ function completedOnboardingMessage(name: string) {
function completedOnboardingTranscript(profile: Profile, greeting: string): Message[] {
const name = profile.name.trim();
const birthPlace = selectedBirthPlace(profile);
if (!name || !profile.date || !isBirthTimeReadyForConsultation(profile) || !birthPlace) return [];
if (!name || !isDeclaredBirthProfileComplete(profile) || !birthPlace) return [];
return [
{ role: "assistant", text: presetOnboardingMessage },
@@ -666,7 +691,6 @@ export default function Home() {
const [synastryReportCard, setSynastryReportCard] = useState<SynastryReportCard | null>(null);
const [synastryHistory, setSynastryHistory] = useState<SynastryReportCard[]>([]);
const [dailyStarlanguageCard, setDailyStarlanguageCard] = useState<DailyStarlanguageCard | null>(null);
const [birthRectificationPreview, setBirthRectificationPreview] = useState<BirthRectificationPreview | null>(null);
const [profileNotice, setProfileNotice] = useState("");
const [account, setAccount] = useState<Account | null>(null);
const [accountError, setAccountError] = useState("");
@@ -691,6 +715,15 @@ export default function Home() {
const [pendingSessionId, setPendingSessionId] = useState<string | null>(null);
const [streamingReply, setStreamingReply] = useState<StreamingReply | null>(null);
const [requestError, setRequestError] = useState<RequestError | null>(null);
const [birthTimeConsultationConsent, setBirthTimeConsultationConsent] = useState<BirthTimeConsultationConsentState>(
createBirthTimeConsultationConsentState,
);
const [pendingBirthTimeChoice, setPendingBirthTimeChoice] = useState<PendingBirthTimeChoice | null>(null);
const [rectificationSurfaceOpen, setRectificationSurfaceOpen] = useState(false);
const [rectificationInitialTurn, setRectificationInitialTurn] = useState<ConversationalRectificationTurn | null>(null);
const [rectificationPendingQuestion, setRectificationPendingQuestion] = useState<string | null>(null);
const [rectificationLoading, setRectificationLoading] = useState(false);
const [rectificationError, setRectificationError] = useState("");
const [hydrated, setHydrated] = useState(false);
const [profileSaving, setProfileSaving] = useState(false);
const [creatingSession, setCreatingSession] = useState(false);
@@ -744,6 +777,11 @@ export default function Home() {
const productEntrypointsDisabled = !hydrated || Boolean(pendingSessionId) || cancellationPending || !account || !modelCatalog;
const activeStreamingText = streamingReply && streamingReply.sessionId === activeSession?.id ? streamingReply.text : "";
const accountId = account?.user.id;
const rectificationCardAction = resolveRectificationCardAction({
rectificationCase: account?.rectificationCase ?? null,
hasConfirmedBirthTime: account?.hasConfirmedBirthTime ?? false,
});
const rectificationCardLabel = rectificationCardLabels[rectificationCardAction];
const onboardingFingerprint = onboardingProfileFingerprint(profile);
useEffect(() => {
@@ -917,7 +955,14 @@ export default function Home() {
messages: previewMessages,
updatedAt: timestamp(),
};
setAccount({ user: { id: "preview-user", email: "preview@local.test" }, credits: 8, isAdmin: false });
setAccount({
user: { id: "preview-user", email: "preview@local.test" },
credits: 8,
isAdmin: false,
rectificationPriceCredits: 1,
hasConfirmedBirthTime: previewProfile.birthTimeStatus === "confirmed",
rectificationCase: null,
});
setModelCatalog(previewModelCatalog);
setProfile(previewProfile);
setProfileDraft(previewProfile);
@@ -1006,20 +1051,6 @@ export default function Home() {
setOnboardingStep(missingProfileStep(nextProfile) ?? "name");
setSessions(nextSessions);
setActiveSessionId(nextSessions[0].id);
if ((nextProfile.birthTimeStatus === "rectifying"
|| (nextProfile.birthTimeStatus === "candidate" && !nextProfile.time))
&& nextProfile.rectificationCaseId) {
try {
const resumed = await resumeBirthTimeJourney(nextProfile.rectificationCaseId);
if (!controller.signal.aborted) {
setBirthTimeJourney(resumed);
}
} catch (caught) {
if (!controller.signal.aborted) {
setBirthTimeError(caught instanceof Error ? caught.message : "暂时无法继续上次的时间校正。");
}
}
}
if (modelCatalogResult.unavailable) {
setComposerNotice("模型服务暂时不可用,当前无法发送问题。");
} else if (parsedSessions.fallbackSessionIds.length > 0) {
@@ -1128,22 +1159,6 @@ export default function Home() {
return () => {
cancelled = true;
};
}, [hydrated, profile.date, profile.time, profile.birthTimeStatus, profile.provinceCode, profile.cityCode, profileComplete]);
useEffect(() => {
if (!hydrated || !profileComplete) return;
let cancelled = false;
setBirthRectificationPreview(null);
void fetchBirthRectificationPreview(profile)
.then((preview) => {
if (!cancelled) setBirthRectificationPreview(preview);
})
.catch(() => {
if (!cancelled) setBirthRectificationPreview({ status: "blocked", boundary: "not_auto_rectified", source: "fallback_unavailable" });
});
return () => {
cancelled = true;
};
}, [hydrated, profile, profileComplete]);
useEffect(() => {
@@ -1238,6 +1253,8 @@ export default function Home() {
const previousSessions = sessions;
const nextSessions = sessions.filter((item) => item.id !== session.id);
setSessions(nextSessions);
setBirthTimeConsultationConsent((current) => clearBirthTimeConsultationConsent(current, session.id));
setPendingBirthTimeChoice((current) => current?.sessionId === session.id ? null : current);
setPinnedSessionIds((current) => current.filter((id) => id !== session.id));
setArchivedSessionIds((current) => current.filter((id) => id !== session.id));
if (activeSessionId === session.id) setActiveSessionId(nextSessions[0]?.id ?? "");
@@ -1295,6 +1312,7 @@ export default function Home() {
setDraft("");
setDraftTheme(null);
setDraftEntrypoint(null);
setPendingBirthTimeChoice(null);
setComposerNotice("");
setRequestError(null);
try {
@@ -1313,6 +1331,7 @@ export default function Home() {
function selectSession(sessionId: string) {
setActiveSessionId(sessionId);
setPendingBirthTimeChoice(null);
setDraft("");
setDraftEntrypoint(null);
setComposerNotice("");
@@ -1515,14 +1534,11 @@ export default function Home() {
setAccountError("");
try {
await persistProfile(profileDraft);
const nextProfile = profileDraft.birthTimeStatus === "confirmed"
? profileDraft
: await assessSavedBirthTime(profileDraft);
setProfile(nextProfile);
setProfileDraft(nextProfile);
setProfileNotice(nextProfile.birthTimeStatus === "confirmed"
setProfile(profileDraft);
setProfileDraft(profileDraft);
setProfileNotice(profileDraft.birthTimeStatus === "confirmed"
? "出生资料已保存到云端,可在同一账号的其他设备使用。"
: "资料已保存,当前时间仍在校正中,不会用于正式排盘。");
: "出生资料已保存。你可以先使用填报时间询问,也可以从首页卡片开始校正。");
} catch (caught) {
setAccountError(friendlyError(caught instanceof Error ? caught.message : "出生资料保存失败"));
} finally {
@@ -1561,15 +1577,7 @@ export default function Home() {
setAccountError("");
try {
await persistProfile(profileDraft);
if (birthTimeRevisionPending.current) {
setBirthTimeAssessmentPhase("assessing");
const assessedProfile = await assessSavedBirthTime(profileDraft);
birthTimeRevisionPending.current = false;
setPresetMessageLength(0);
if (assessedProfile.birthTimeStatus === "confirmed") setOnboardingJustCompleted(true);
else setOnboardingStep("rectification");
return;
}
birthTimeRevisionPending.current = false;
setProfile(profileDraft);
setPresetMessageLength(0);
const nextStep = missingProfileStep(profileDraft);
@@ -1598,14 +1606,11 @@ export default function Home() {
setAccountError("");
try {
await persistProfile(profileDraft);
setBirthTimeAssessmentPhase("assessing");
const assessedProfile = await assessSavedBirthTime(profileDraft);
setProfile(profileDraft);
setProfileDraft(profileDraft);
setStartGreeting(createStartGreeting(profileDraft.name));
setPresetMessageLength(0);
if (assessedProfile.birthTimeStatus === "confirmed") {
setOnboardingJustCompleted(true);
} else {
setOnboardingStep("rectification");
}
setOnboardingJustCompleted(true);
} catch (caught) {
setAccountError(friendlyError(caught instanceof Error ? caught.message : "出生地点保存失败"));
} finally {
@@ -1720,12 +1725,77 @@ export default function Home() {
chooseSuggestedQuestion("深入看今日", "timing", "daily_starlanguage");
}
function draftBirthTimeRectificationQuestion() {
chooseSuggestedQuestion(
birthTimeDisplay ? "再次校正" : "生时校正",
"timing",
"birth_time_rectification",
);
async function openBirthTimeRectification(pendingConsultationQuestion: string | null = null) {
if (!account || rectificationLoading) return;
const action = resolveRectificationCardAction({
rectificationCase: account.rectificationCase,
hasConfirmedBirthTime: account.hasConfirmedBirthTime,
});
setPendingBirthTimeChoice(null);
setDraft("");
setDraftTheme(null);
setDraftEntrypoint(null);
setRectificationPendingQuestion(pendingConsultationQuestion);
setRectificationInitialTurn(null);
setRectificationError("");
setRectificationSurfaceOpen(true);
if (action !== "resume" || !account.rectificationCase) return;
setRectificationLoading(true);
try {
const current = account.rectificationCase;
const turn = await sendConversationalRectificationCommand({
type: "resume",
caseId: current.caseId,
actionId: globalThis.crypto.randomUUID(),
turnVersion: current.turnVersion,
});
setRectificationInitialTurn(turn);
} catch (caught) {
setRectificationError(caught instanceof Error
? caught.message
: "生时校正暂时无法继续,请稍后重试。");
} finally {
setRectificationLoading(false);
}
}
function handleConversationalRectificationTurn(turn: ConversationalRectificationTurn) {
setRectificationInitialTurn(turn);
setAccount((current) => current ? {
...current,
hasConfirmedBirthTime: current.hasConfirmedBirthTime
|| (turn.status === "completed" && turn.candidate.status === "confirmed"),
rectificationCase: {
caseId: turn.caseId,
journeyProtocol: "conversational-evidence-v3",
status: turn.status,
turnVersion: turn.turnVersion,
isRevision: current.rectificationCase?.isRevision
?? current.hasConfirmedBirthTime,
preservesActiveTime: current.rectificationCase?.preservesActiveTime
?? current.hasConfirmedBirthTime,
},
} : current);
if (turn.status === "completed"
&& turn.candidate.status === "confirmed"
&& turn.candidate.representativeTime) {
setProfile((current) => ({
...current,
time: turn.candidate.representativeTime ?? current.time,
birthTimeStatus: "confirmed",
rectificationCaseId: turn.caseId,
}));
setProfileDraft((current) => ({
...current,
time: turn.candidate.representativeTime ?? current.time,
birthTimeStatus: "confirmed",
rectificationCaseId: turn.caseId,
}));
}
void fetchAccount()
.then((latest) => setAccount(latest))
.catch(() => undefined);
}
async function draftSynastryQuestionFromChart(record: ChartLibraryRecord) {
@@ -1919,30 +1989,59 @@ export default function Home() {
text: string,
requestedTheme?: Theme,
entrypoint: ConsultationEntrypoint | null = null,
consentGrantedForRequest = false,
) {
const originalQuestion = text;
const question = text.trim();
if (!question || !activeSession || !modelCatalog || pendingSessionId || cancellationInFlight.current || pendingConsultation.current || !account) return;
if (account.credits <= 0) {
openAccountDialog("redeem", creditTrigger.current);
return;
}
if (!isProfileComplete(profile)) {
openAccountDialog("profile");
setProfileNotice("请先补充出生资料,才能进行星盘计算。");
return;
}
if (entrypoint === "birth_time_rectification") {
await openBirthTimeRectification(null);
return;
}
const birthPlace = selectedBirthPlace(profile);
if (!birthPlace) return;
const currentSession = activeSession;
const theme = requestedTheme ?? currentSession.theme;
const sessionId = currentSession.id;
const consultationTime = profile.birthTimeStatus === "confirmed"
? profile.time
: unverifiedBirthTime(profile);
const hasSessionConsent = hasBirthTimeConsultationConsent(
birthTimeConsultationConsent,
sessionId,
);
if (!consultationTime
|| (requiresBirthTimeConsent(profile)
&& !hasSessionConsent
&& !consentGrantedForRequest)) {
setPendingBirthTimeChoice({
sessionId,
question,
entrypoint,
theme,
});
setComposerNotice(consultationTime
? "请选择在当前聊天临时使用填报时间,或先校正再询问。"
: "你还没有可使用的具体出生分钟,可以先校正再询问。");
return;
}
if (account.credits <= 0) {
openAccountDialog("redeem", creditTrigger.current);
return;
}
const [year, month, day] = profile.date.split("-").map(Number);
const [hour, minute] = profile.time.split(":").map(Number);
const [hour, minute] = consultationTime.split(":").map(Number);
const preservedMessages = onboardingJustCompleted && currentSession.messages.length === 0
? completedOnboardingTranscript(profile, startGreeting)
@@ -2179,6 +2278,41 @@ export default function Home() {
}
}
function useUnverifiedTimeForPendingConsultation() {
if (!pendingBirthTimeChoice
|| !activeSession
|| pendingBirthTimeChoice.sessionId !== activeSession.id
|| !canUseUnverifiedBirthTime(profile)) return;
const pending = pendingBirthTimeChoice;
setBirthTimeConsultationConsent((current) => grantBirthTimeConsultationConsent(
current,
activeSession.id,
));
setPendingBirthTimeChoice(null);
setComposerNotice("本次聊天会标明出生时间尚未校正;新聊天会重新提醒。");
void send(pending.question, pending.theme, pending.entrypoint, true);
}
function rectifyBeforePendingConsultation() {
if (!pendingBirthTimeChoice
|| !activeSession
|| pendingBirthTimeChoice.sessionId !== activeSession.id) return;
const pending = pendingBirthTimeChoice;
setPendingBirthTimeChoice(null);
setComposerNotice("");
void openBirthTimeRectification(pending.question);
}
function cancelPendingBirthTimeChoice() {
if (!pendingBirthTimeChoice) return;
setDraft(pendingBirthTimeChoice.question);
setDraftTheme(pendingBirthTimeChoice.theme);
setDraftEntrypoint(pendingBirthTimeChoice.entrypoint);
setPendingBirthTimeChoice(null);
setComposerNotice("");
window.requestAnimationFrame(() => composerInput.current?.focus());
}
function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!profileComplete) {
@@ -2379,7 +2513,7 @@ export default function Home() {
{!profileComplete && onboardingStep === "name" && accountError && <p className="form-error onboarding-inline-error" role="alert">{accountError}</p>}
{profileComplete && presetMessageFinished && (onboardingPending ? (
{profileComplete && presetMessageFinished && !rectificationSurfaceOpen && !pendingBirthTimeChoice && (onboardingPending ? (
<div className="starter-loading" role="status"></div>
) : (
<div className="starter-list" aria-label="Jyotisha 推荐的初始问题">
@@ -2409,9 +2543,9 @@ export default function Home() {
<button
className="product-entrypoint-hitarea"
type="button"
aria-label={birthTimeDisplay ? "再次校正" : "生时校正"}
disabled={productEntrypointsDisabled}
onClick={draftBirthTimeRectificationQuestion}
aria-label={`${rectificationCardLabel},固定费用 ${account.rectificationPriceCredits}`}
disabled={productEntrypointsDisabled || rectificationLoading}
onClick={() => void openBirthTimeRectification()}
/>
<div className="daily-starlanguage-heading">
<span></span>
@@ -2419,25 +2553,25 @@ export default function Home() {
<dl>
{birthTimeDisplay ? (
<>
<div><dt>{birthTimeDisplay.kind === "candidate" ? "当前工作排盘时间" : "当前排盘时间"}</dt><dd>{birthTimeDisplay.activeTime}</dd></div>
<div><dt></dt><dd>{birthTimeDisplay.kind === "candidate" ? "候选时间(已用于排盘)" : "已确认"}</dd></div>
<div><dt>{birthTimeDisplay.kind === "candidate" ? "待验证候选时间" : "当前排盘时间"}</dt><dd>{birthTimeDisplay.activeTime}</dd></div>
<div><dt></dt><dd>{birthTimeDisplay.kind === "candidate" ? "未确认,可临时选择使用" : "已确认"}</dd></div>
<div><dt></dt><dd>{birthTimeDisplay.reportedLabel}</dd></div>
</>
) : (
<>
<div><dt></dt><dd>{birthRectificationPreview?.candidate_scan?.start && birthRectificationPreview?.candidate_scan?.end ? `${birthRectificationPreview.candidate_scan.start} ${birthRectificationPreview.candidate_scan.end}` : "默认先扫描前后 30 分钟"}</dd></div>
<div><dt></dt><dd>{birthRectificationPreview?.candidate_scan?.candidate_count ? `${birthRectificationPreview.candidate_scan.candidate_count}` : "待后端生成"}</dd></div>
<div><dt></dt><dd>{birthRectificationPreview?.question_count ? `${birthRectificationPreview.question_count} 个事件问题` : "需补关键人生事件"}</dd></div>
<div><dt></dt><dd>{formatBirthMoment(profile)}</dd></div>
<div><dt></dt><dd>{rectificationCardAction === "resume" ? "已有进度,可继续" : "尚未建立进行中的校正案例"}</dd></div>
<div><dt></dt><dd> + </dd></div>
</>
)}
</dl>
<div className="product-entrypoint-footer">
<small>{birthTimeDisplay?.kind === "candidate"
? <>使<span className="phrase-nowrap"></span></>
: birthTimeDisplay?.kind === "confirmed"
? "当前排盘时间已经确认。"
: "不能直接改写默认星盘;需事件证据验证。"}</small>
<span className="product-entrypoint-action" aria-hidden="true">{birthTimeDisplay ? "再次校正" : "生时校正"} <ArrowUpRight className="starter-arrow" /></span>
<small>{rectificationCardAction === "resume"
? account.rectificationCase?.preservesActiveTime
? "新校正进行中;旧确认时间继续有效,继续同一案例不重复收费。"
: "继续同一案例不重复收费。"
: `固定费用 ${account.rectificationPriceCredits} 点;首轮有效分析生成后收取。`}</small>
<span className="product-entrypoint-action" aria-hidden="true">{rectificationCardLabel} <ArrowUpRight className="starter-arrow" /></span>
</div>
</article>
</div>
@@ -2465,13 +2599,63 @@ export default function Home() {
<div ref={conversationEnd} />
</div>
)}
{pendingBirthTimeChoice?.sessionId === activeSession?.id && (
<UnverifiedBirthTimeChoice
canUseUnverifiedTime={canUseUnverifiedBirthTime(profile)}
pending={rectificationLoading}
unverifiedTime={unverifiedBirthTime(profile)}
onCancel={cancelPendingBirthTimeChoice}
onRectifyFirst={rectifyBeforePendingConsultation}
onUseUnverifiedTime={useUnverifiedTimeForPendingConsultation}
/>
)}
{rectificationSurfaceOpen && (
<section className="onboarding-card" aria-label="生时校正">
<div className="onboarding-card-heading">
<b>{rectificationCardLabel}</b>
<small>{rectificationCardAction === "resume"
? "继续同一账户案例,不重复收费"
: `固定费用 ${account.rectificationPriceCredits} 点;首轮有效分析生成后收取`}</small>
</div>
<div className="onboarding-card-actions">
<button
className="button-secondary"
disabled={rectificationLoading}
type="button"
onClick={() => setRectificationSurfaceOpen(false)}
>
</button>
</div>
{rectificationLoading ? (
<p role="status"></p>
) : rectificationError ? (
<div role="alert">
<p className="form-error">{rectificationError}</p>
<button
className="button-primary"
type="button"
onClick={() => void openBirthTimeRectification(rectificationPendingQuestion)}
>
</button>
</div>
) : (
<ConversationalBirthTimeRectification
initialTurn={rectificationInitialTurn}
pendingConsultationQuestion={rectificationPendingQuestion}
onTurn={handleConversationalRectificationTurn}
/>
)}
</section>
)}
</div>
<div className="composer-wrap">
{activeSuggestions.length > 0 && (
<div className="composer-suggestions" aria-label="推荐继续提问">
{activeSuggestions.map((question) => (
<button key={question} type="button" disabled={!account || !modelCatalog || isLoading || cancellationPending} onClick={() => chooseSuggestedQuestion(question)}>{question}</button>
<button key={question} type="button" disabled={!account || !modelCatalog || isLoading || cancellationPending || Boolean(pendingBirthTimeChoice) || rectificationSurfaceOpen} onClick={() => chooseSuggestedQuestion(question)}>{question}</button>
))}
</div>
)}
@@ -2490,7 +2674,7 @@ export default function Home() {
: "例如:未来半年是否适合换工作?"}
rows={1}
maxLength={!profileComplete && onboardingStep === "name" ? 80 : 500}
disabled={isLoading || cancellationPending || (!profileComplete && (onboardingStep !== "name" || !presetMessageFinished || profileSaving))}
disabled={isLoading || cancellationPending || Boolean(pendingBirthTimeChoice) || rectificationSurfaceOpen || (!profileComplete && (onboardingStep !== "name" || !presetMessageFinished || profileSaving))}
value={draft}
onChange={(event) => {
setDraft(event.target.value);
@@ -2512,7 +2696,7 @@ export default function Home() {
<Square aria-hidden="true" />
</Button>
) : (
<Button aria-label={!profileComplete && onboardingStep === "name" ? "确认称呼" : "发送"} disabled={!draft.trim() || Boolean(pendingSessionId) || cancellationPending || !account || !modelCatalog || (!profileComplete && (onboardingStep !== "name" || !presetMessageFinished || profileSaving))} size="icon" type="submit">
<Button aria-label={!profileComplete && onboardingStep === "name" ? "确认称呼" : "发送"} disabled={!draft.trim() || Boolean(pendingSessionId) || cancellationPending || Boolean(pendingBirthTimeChoice) || rectificationSurfaceOpen || !account || !modelCatalog || (!profileComplete && (onboardingStep !== "name" || !presetMessageFinished || profileSaving))} size="icon" type="submit">
<ArrowUp aria-hidden="true" />
</Button>
)}
@@ -69,7 +69,7 @@ export function BirthTimeIntakeFields({ value, onPatch }: BirthTimeIntakeProps)
</div>
<dl>
<div>
<dt>{displayState.kind === "candidate" ? "当前工作排盘时间" : "当前排盘时间"}</dt>
<dt>{displayState.kind === "candidate" ? "待验证候选时间" : "当前排盘时间"}</dt>
<dd>{displayState.activeTime}</dd>
</div>
<div>
@@ -78,7 +78,7 @@ export function BirthTimeIntakeFields({ value, onPatch }: BirthTimeIntakeProps)
</div>
</dl>
{displayState.kind === "candidate" && (
<p></p>
<p>使</p>
)}
</section>
)}
@@ -0,0 +1,75 @@
"use client";
import { useId } from "react";
type UnverifiedBirthTimeChoiceProps = Readonly<{
canUseUnverifiedTime: boolean;
unverifiedTime?: string | null;
pending?: boolean;
onUseUnverifiedTime: () => void;
onRectifyFirst: () => void;
onCancel: () => void;
}>;
export function UnverifiedBirthTimeChoice({
canUseUnverifiedTime,
unverifiedTime,
pending = false,
onUseUnverifiedTime,
onRectifyFirst,
onCancel,
}: UnverifiedBirthTimeChoiceProps) {
const titleId = useId();
const descriptionId = useId();
return (
<section
aria-busy={pending}
aria-describedby={descriptionId}
aria-labelledby={titleId}
aria-live="polite"
className="onboarding-card birth-time-transition-card"
role="dialog"
>
<div className="onboarding-card-heading">
<b id={titleId}></b>
<small>使 Jyotisha</small>
</div>
<p id={descriptionId}>
{canUseUnverifiedTime
? "你可以只在当前聊天临时使用填报时间,也可以先完成生时校正再问。新建聊天后会再次温和提醒。"
: "你目前没有可直接使用的具体分钟,系统不会替你猜一个时间。可以先校正再继续这个问题。"}
</p>
<div className="onboarding-card-actions">
{canUseUnverifiedTime && (
<button
className="button-secondary"
disabled={pending}
type="button"
onClick={onUseUnverifiedTime}
>
{unverifiedTime
? `先用 ${unverifiedTime}(未校正)询问`
: "先用未校正时间询问"}
</button>
)}
<button
className="button-primary"
disabled={pending}
type="button"
onClick={onRectifyFirst}
>
{pending ? "正在进入生时校正…" : "先校正再询问"}
</button>
<button
className="button-secondary"
disabled={pending}
type="button"
onClick={onCancel}
>
</button>
</div>
</section>
);
}
@@ -0,0 +1,96 @@
import type { BirthTimeDraft } from "./birth-time-intake-model.ts";
export type BirthTimeConsultationConsentState = Readonly<Record<string, true>>;
export type AccountRectificationCaseState = Readonly<{
caseId: string;
journeyProtocol: "conversational-evidence-v3";
status: "starting" | "active" | "paused" | "confirming" | "completed" | "abandoned";
turnVersion: number;
isRevision: boolean;
preservesActiveTime: boolean;
}>;
export type RectificationCardAction = "start" | "resume" | "revise";
const concreteReportedSources = new Set<BirthTimeDraft["birthTimeSource"]>([
"hospital_record",
"family_exact",
"approximate",
]);
const unfinishedRectificationStatuses = new Set<AccountRectificationCaseState["status"]>([
"starting",
"active",
"paused",
"confirming",
]);
export function createBirthTimeConsultationConsentState(): BirthTimeConsultationConsentState {
return Object.freeze({});
}
export function hasBirthTimeConsultationConsent(
state: BirthTimeConsultationConsentState,
sessionId: string,
): boolean {
return Boolean(sessionId && state[sessionId] === true);
}
export function grantBirthTimeConsultationConsent(
state: BirthTimeConsultationConsentState,
sessionId: string,
): BirthTimeConsultationConsentState {
if (!sessionId || state[sessionId]) return state;
return Object.freeze({ ...state, [sessionId]: true });
}
export function clearBirthTimeConsultationConsent(
state: BirthTimeConsultationConsentState,
sessionId: string,
): BirthTimeConsultationConsentState {
if (!sessionId || !state[sessionId]) return state;
return Object.freeze(Object.fromEntries(
Object.entries(state).filter(([candidate]) => candidate !== sessionId),
) as Record<string, true>);
}
export function unverifiedBirthTime(profile: BirthTimeDraft): string | null {
if (profile.birthTimeStatus === "confirmed") return null;
if (!concreteReportedSources.has(profile.birthTimeSource)) return null;
const time = profile.time || profile.reportedTime;
return /^([01]\d|2[0-3]):[0-5]\d$/.test(time) ? time : null;
}
export function canUseUnverifiedBirthTime(profile: BirthTimeDraft): boolean {
return unverifiedBirthTime(profile) !== null;
}
export function requiresBirthTimeConsent(profile: BirthTimeDraft): boolean {
return canUseUnverifiedBirthTime(profile);
}
export function resolveRectificationCardAction(input: Readonly<{
rectificationCase: AccountRectificationCaseState | null;
hasConfirmedBirthTime: boolean;
}>): RectificationCardAction {
if (input.rectificationCase
&& unfinishedRectificationStatuses.has(input.rectificationCase.status)) {
return "resume";
}
if (input.hasConfirmedBirthTime) return "revise";
return "start";
}
export function parseRectificationPriceCredits(raw: string | undefined): number {
if (raw === undefined) return 1;
const normalized = raw.trim();
if (!/^\d+$/.test(normalized)) {
throw new Error("RECTIFICATION_PRICE_CREDITS must be an integer from 1 through 100");
}
const price = Number(normalized);
if (!Number.isSafeInteger(price) || price < 1 || price > 100) {
throw new Error("RECTIFICATION_PRICE_CREDITS must be an integer from 1 through 100");
}
return price;
}
@@ -152,6 +152,14 @@ export function isBirthTimeDraftReady(draft: BirthTimeDraft) {
}
}
/**
* Whether the user has finished declaring what they actually know about birth time.
* This is an onboarding condition, not a claim that an exact chart minute is ready.
*/
export function isDeclaredBirthProfileComplete(draft: BirthTimeDraft) {
return isBirthTimeDraftReady(draft);
}
export function isBirthTimeReadyForConsultation(draft: BirthTimeDraft) {
return Boolean(draft.time)
&& (draft.birthTimeStatus === "candidate" || draft.birthTimeStatus === "confirmed");
+30
View File
@@ -0,0 +1,30 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
const source = readFileSync(new URL("../src/app/api/account/route.ts", import.meta.url), "utf8");
test("account API reads and returns the server-configured rectification price", () => {
assert.match(source, /parseRectificationPriceCredits\(\s*process\.env\.RECTIFICATION_PRICE_CREDITS,?\s*\)/);
assert.match(source, /rectificationPriceCredits/);
assert.doesNotMatch(source, /RECTIFICATION_PRICE_CREDITS[^\n]*\?\?\s*["']1["']/);
});
test("account API projects only the minimum case state needed by the homepage", () => {
const caseSelect = source.match(/\.from\("birth_time_rectification_cases"\)[\s\S]*?\.limit\(1\)/)?.[0] ?? "";
assert.match(caseSelect, /id,journey_protocol,status,turn_version,revision_of_case_id,baseline_active_time,updated_at/);
assert.doesNotMatch(caseSelect, /candidate_scan|event_evidence|validation_receipt|pending_consultation_question|journey_snapshot|turn_state/);
assert.match(source, /caseId:/);
assert.match(source, /journeyProtocol:/);
assert.match(source, /turnVersion:/);
assert.match(source, /preservesActiveTime:/);
});
test("account API scopes the service-role case lookup to the authenticated account", () => {
const caseSelect = source.match(/\.from\("birth_time_rectification_cases"\)[\s\S]*?\.limit\(1\)/)?.[0] ?? "";
assert.match(caseSelect, /\.eq\("user_id", user\.id\)/);
assert.match(caseSelect, /\.eq\("journey_protocol", "conversational-evidence-v3"\)/);
assert.match(caseSelect, /\.order\("updated_at", \{ ascending: false \}\)/);
});
@@ -0,0 +1,108 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import {
canUseUnverifiedBirthTime,
createBirthTimeConsultationConsentState,
grantBirthTimeConsultationConsent,
hasBirthTimeConsultationConsent,
parseRectificationPriceCredits,
requiresBirthTimeConsent,
resolveRectificationCardAction,
} from "../src/lib/birth-time-consultation-consent.ts";
import type { BirthTimeDraft } from "../src/lib/birth-time-intake-model.ts";
const reportedExactTime = {
date: "1997-08-08",
time: "",
reportedTime: "05:30",
birthTimeSource: "family_exact",
birthTimePeriod: "",
birthTimeClue: "",
uncertaintyBeforeMinutes: 10,
uncertaintyAfterMinutes: 10,
birthTimeStatus: "reported",
} satisfies BirthTimeDraft;
test("an unverified concrete time requires consent only until this chat grants it", () => {
const initial = createBirthTimeConsultationConsentState();
assert.equal(canUseUnverifiedBirthTime(reportedExactTime), true);
assert.equal(requiresBirthTimeConsent(reportedExactTime), true);
assert.equal(hasBirthTimeConsultationConsent(initial, "chat-a"), false);
const consented = grantBirthTimeConsultationConsent(initial, "chat-a");
assert.equal(hasBirthTimeConsultationConsent(consented, "chat-a"), true);
assert.equal(hasBirthTimeConsultationConsent(consented, "chat-b"), false);
assert.equal(hasBirthTimeConsultationConsent(initial, "chat-a"), false);
});
test("period-only and unknown declarations never pretend to provide an unverified minute", () => {
const periodOnly = {
...reportedExactTime,
reportedTime: "",
birthTimeSource: "period_only",
birthTimePeriod: "early_morning",
} satisfies BirthTimeDraft;
const unknown = {
...reportedExactTime,
reportedTime: "",
birthTimeSource: "unknown",
uncertaintyBeforeMinutes: null,
uncertaintyAfterMinutes: null,
} satisfies BirthTimeDraft;
assert.equal(canUseUnverifiedBirthTime(periodOnly), false);
assert.equal(canUseUnverifiedBirthTime(unknown), false);
assert.equal(requiresBirthTimeConsent(periodOnly), false);
assert.equal(requiresBirthTimeConsent(unknown), false);
});
test("confirmed time does not request unverified-use consent", () => {
const confirmed = {
...reportedExactTime,
time: "05:28",
birthTimeStatus: "confirmed",
} satisfies BirthTimeDraft;
assert.equal(canUseUnverifiedBirthTime(confirmed), false);
assert.equal(requiresBirthTimeConsent(confirmed), false);
});
test("card action resumes unfinished account cases and otherwise starts or revises", () => {
const unfinishedCase = {
caseId: "11111111-1111-4111-8111-111111111111",
journeyProtocol: "conversational-evidence-v3",
status: "paused",
turnVersion: 4,
isRevision: true,
preservesActiveTime: true,
} as const;
assert.equal(resolveRectificationCardAction({ rectificationCase: null, hasConfirmedBirthTime: false }), "start");
assert.equal(resolveRectificationCardAction({ rectificationCase: unfinishedCase, hasConfirmedBirthTime: true }), "resume");
assert.equal(resolveRectificationCardAction({
rectificationCase: { ...unfinishedCase, status: "completed" },
hasConfirmedBirthTime: true,
}), "revise");
assert.equal(resolveRectificationCardAction({
rectificationCase: { ...unfinishedCase, status: "abandoned" },
hasConfirmedBirthTime: false,
}), "start");
});
test("fixed rectification price uses a checked default and rejects invalid configured values", () => {
assert.equal(parseRectificationPriceCredits(undefined), 1);
assert.equal(parseRectificationPriceCredits(" 7 "), 7);
for (const invalid of ["", "0", "101", "1.5", "1e1", "free"]) {
assert.throws(() => parseRectificationPriceCredits(invalid), /RECTIFICATION_PRICE_CREDITS/);
}
});
test("soft choice announces itself and locks every action while rectification opens", () => {
const source = readFileSync(new URL("../src/components/unverified-birth-time-choice.tsx", import.meta.url), "utf8");
assert.match(source, /aria-live="polite"/);
assert.equal((source.match(/disabled=\{pending\}/g) ?? []).length, 3);
assert.match(source, /\{canUseUnverifiedTime && \(/);
});
@@ -145,12 +145,14 @@ test("terminal candidate owns one explicit next step and its completion error",
assert.match(globalCssSource, /\.birth-time-next-step/);
});
test("terminal and entrypoint CJK phrases stay intact at narrow widths", () => {
test("terminal CJK copy stays intact while homepage candidates remain unconfirmed in v3", () => {
const candidateResultSource = readFileSync(new URL("../src/components/birth-time-candidate-result.tsx", import.meta.url), "utf8");
const pageSource = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
assert.match(candidateResultSource, /作为<span className="phrase-nowrap">当前排盘时间<\/span>并进入对话;<span className="phrase-nowrap">原始填报<\/span>和本次<span className="phrase-nowrap">候选结果<\/span><span className="phrase-nowrap">仍会保留<\/span>。/);
assert.match(pageSource, /当前使用候选时间排盘;<span className="phrase-nowrap">原始填报范围<\/span>仍保留。/);
assert.match(pageSource, /未确认,可临时选择使用/);
assert.match(pageSource, /<ConversationalBirthTimeRectification/);
assert.doesNotMatch(pageSource, /当前使用候选时间排盘/);
});
test("completed rectification transcript does not repeat the birth place turn", () => {
+31
View File
@@ -1,4 +1,5 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import {
assistantIntentCopy,
@@ -6,6 +7,7 @@ import {
birthTimePersistenceValues,
describeBirthTimeDraft,
formatBirthDate,
isDeclaredBirthProfileComplete,
isBirthTimeReadyForConsultation,
isBirthTimeDraftReady,
parseBirthDate,
@@ -59,6 +61,28 @@ test("a persisted candidate working time can leave rectification onboarding", ()
assert.equal(isBirthTimeReadyForConsultation({ ...candidate, birthTimeStatus: "rectifying" }), false);
});
test("declared birth data completes onboarding without an active or confirmed minute", () => {
const declaredExact = {
...emptyDraft,
reportedTime: "05:30",
birthTimeSource: "family_exact",
uncertaintyBeforeMinutes: 10,
uncertaintyAfterMinutes: 10,
birthTimeStatus: "reported",
} satisfies BirthTimeDraft;
const declaredPeriod = {
...emptyDraft,
birthTimeSource: "period_only",
birthTimePeriod: "early_morning",
birthTimeStatus: "reported",
} satisfies BirthTimeDraft;
assert.equal(isDeclaredBirthProfileComplete(declaredExact), true);
assert.equal(isBirthTimeReadyForConsultation(declaredExact), false);
assert.equal(isDeclaredBirthProfileComplete(declaredPeriod), true);
assert.equal(isBirthTimeReadyForConsultation(declaredPeriod), false);
});
test("a persisted candidate working time takes precedence over the reported range", () => {
// Given: rectification saved a candidate minute while preserving the user's original period.
const candidate = {
@@ -132,3 +156,10 @@ test("birth date values round trip leap days and reject invalid input", () => {
assert.equal(parseBirthDate(""), undefined);
assert.equal(parseBirthDate("2001-02-29"), undefined);
});
test("candidate copy does not claim an unconfirmed minute is automatically in use", () => {
const source = readFileSync(new URL("../src/components/birth-time-intake.tsx", import.meta.url), "utf8");
assert.doesNotMatch(source, /已用于当前排盘/);
assert.match(source, /普通咨询前可以选择临时使用或先校正/);
});
+32 -2
View File
@@ -68,17 +68,47 @@ test("browser source does not own private entrypoint prompts", () => {
assert.doesNotMatch(source, /请基于已校验的出生资料继续/);
});
test("composer keeps the public question and clears hidden routing after edits", () => {
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*"timing",\s*"daily_starlanguage"\)/s);
assert.match(source, /birthTimeDisplay \? "" : "",\s*"timing",\s*"birth_time_rectification"/s);
assert.match(source, /messages:\s*\[\.\.\.preservedMessages,\s*\{ role: "user", text: question \}\]/s);
assert.match(source, /body:\s*JSON\.stringify\(\{[\s\S]*?entrypoint:\s*entrypoint \?\? undefined,[\s\S]*?question,/);
assert.match(source, /onChange=\{\(event\) => \{\s*setDraft\(event\.target\.value\);\s*setDraftTheme\(null\);\s*setDraftEntrypoint\(null\);/s);
assert.match(source, /setDraft\(pending\.question\);\s*setDraftTheme\(pending\.theme\);\s*setDraftEntrypoint\(pending\.entrypoint\);/s);
});
test("homepage birth-time card opens the v3 surface instead of ordinary consultation", () => {
const source = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
assert.match(source, /function openBirthTimeRectification/);
assert.match(source, /<ConversationalBirthTimeRectification/);
assert.match(source, /rectificationPriceCredits/);
assert.doesNotMatch(source, /chooseSuggestedQuestion\([\s\S]{0,180}"birth_time_rectification"/);
assert.doesNotMatch(source, /draftBirthTimeRectificationQuestion/);
});
test("ordinary consultation is softly diverted before calling consult", () => {
const source = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
const sendStart = source.indexOf("async function send(");
const consultCall = source.indexOf('fetch("/api/consult"', sendStart);
const softChoice = source.indexOf("setPendingBirthTimeChoice", sendStart);
assert.ok(sendStart >= 0);
assert.ok(softChoice > sendStart && softChoice < consultCall);
assert.match(source, /grantBirthTimeConsultationConsent\([\s\S]*activeSession\.id/s);
assert.match(source, /pendingConsultationQuestion=/);
});
test("profile and place saves do not auto-start the retired assessment flow", () => {
const source = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
const normalSave = source.slice(source.indexOf("async function saveProfile"), source.indexOf("async function saveOnboardingName"));
const placeSave = source.slice(source.indexOf("async function saveOnboardingPlace"), source.indexOf("function completeGuidedBirthTime"));
assert.doesNotMatch(normalSave, /assessSavedBirthTime|requestBirthTimeAssessment/);
assert.doesNotMatch(placeSave, /assessSavedBirthTime|requestBirthTimeAssessment/);
});
test("consult route expands an optional entrypoint for both Agent and tool input", () => {
const source = readFileSync(new URL("../src/app/api/consult/route.ts", import.meta.url), "utf8");