From fc1e9f1085c5b26c506fe2badaed077bdf118655 Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Tue, 21 Jul 2026 06:28:47 +0800 Subject: [PATCH] feat: make birth-time rectification a soft homepage flow --- frontend/src/app/api/account/route.ts | 50 ++- frontend/src/app/page.tsx | 400 +++++++++++++----- frontend/src/components/birth-time-intake.tsx | 4 +- .../unverified-birth-time-choice.tsx | 75 ++++ .../lib/birth-time-consultation-consent.ts | 96 +++++ frontend/src/lib/birth-time-intake-model.ts | 8 + frontend/tests/account-api.test.ts | 30 ++ .../birth-time-consultation-consent.test.ts | 108 +++++ .../birth-time-guided-review-fixes.test.ts | 6 +- frontend/tests/birth-time-intake.test.ts | 31 ++ .../tests/consultation-entrypoint.test.ts | 34 +- 11 files changed, 727 insertions(+), 115 deletions(-) create mode 100644 frontend/src/components/unverified-birth-time-choice.tsx create mode 100644 frontend/src/lib/birth-time-consultation-consent.ts create mode 100644 frontend/tests/account-api.test.ts create mode 100644 frontend/tests/birth-time-consultation-consent.test.ts diff --git a/frontend/src/app/api/account/route.ts b/frontend/src/app/api/account/route.ts index 0a8daad6..ee996ae0 100644 --- a/frontend/src/app/api/account/route.ts +++ b/frontend/src/app/api/account/route.ts @@ -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; + 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)) { diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index ab9a15c6..4445862a 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -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: () =>

正在加载生时校正对话…

, + }, +); + 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; label: string; prompt: stri { id: "timing", label: "时运", prompt: "未来哪些阶段值得把握?" }, ]; +const rectificationCardLabels = { + start: "开始生时校正", + resume: "继续上次校正", + revise: "再次校正", +} as const satisfies Record; + 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(null); const [synastryHistory, setSynastryHistory] = useState([]); const [dailyStarlanguageCard, setDailyStarlanguageCard] = useState(null); - const [birthRectificationPreview, setBirthRectificationPreview] = useState(null); const [profileNotice, setProfileNotice] = useState(""); const [account, setAccount] = useState(null); const [accountError, setAccountError] = useState(""); @@ -691,6 +715,15 @@ export default function Home() { const [pendingSessionId, setPendingSessionId] = useState(null); const [streamingReply, setStreamingReply] = useState(null); const [requestError, setRequestError] = useState(null); + const [birthTimeConsultationConsent, setBirthTimeConsultationConsent] = useState( + createBirthTimeConsultationConsentState, + ); + const [pendingBirthTimeChoice, setPendingBirthTimeChoice] = useState(null); + const [rectificationSurfaceOpen, setRectificationSurfaceOpen] = useState(false); + const [rectificationInitialTurn, setRectificationInitialTurn] = useState(null); + const [rectificationPendingQuestion, setRectificationPendingQuestion] = useState(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) { event.preventDefault(); if (!profileComplete) { @@ -2379,7 +2513,7 @@ export default function Home() { {!profileComplete && onboardingStep === "name" && accountError &&

{accountError}

} - {profileComplete && presetMessageFinished && (onboardingPending ? ( + {profileComplete && presetMessageFinished && !rectificationSurfaceOpen && !pendingBirthTimeChoice && (onboardingPending ? (
正在准备三个入门问题…
) : (
@@ -2409,9 +2543,9 @@ export default function Home() { +
+ {rectificationLoading ? ( +

正在恢复账户里的校正进度…

+ ) : rectificationError ? ( +
+

{rectificationError}

+ +
+ ) : ( + + )} + + )}
{activeSuggestions.length > 0 && (
{activeSuggestions.map((question) => ( - + ))}
)} @@ -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() {
-
{displayState.kind === "candidate" ? "当前工作排盘时间" : "当前排盘时间"}
+
{displayState.kind === "candidate" ? "待验证候选时间" : "当前排盘时间"}
{displayState.activeTime}
@@ -78,7 +78,7 @@ export function BirthTimeIntakeFields({ value, onPatch }: BirthTimeIntakeProps)
{displayState.kind === "candidate" && ( -

已用于当前排盘,但仍保留为候选结果,不会标记成出生记录中的确定分钟。

+

这仍是未确认候选,不会自动成为出生分钟;普通咨询前可以选择临时使用或先校正。

)} )} diff --git a/frontend/src/components/unverified-birth-time-choice.tsx b/frontend/src/components/unverified-birth-time-choice.tsx new file mode 100644 index 00000000..7038968c --- /dev/null +++ b/frontend/src/components/unverified-birth-time-choice.tsx @@ -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 ( +
+
+ 出生时间还没有完成校正 + 这不会阻止你继续使用 Jyotisha +
+

+ {canUseUnverifiedTime + ? "你可以只在当前聊天临时使用填报时间,也可以先完成生时校正再问。新建聊天后会再次温和提醒。" + : "你目前没有可直接使用的具体分钟,系统不会替你猜一个时间。可以先校正再继续这个问题。"} +

+
+ {canUseUnverifiedTime && ( + + )} + + +
+
+ ); +} diff --git a/frontend/src/lib/birth-time-consultation-consent.ts b/frontend/src/lib/birth-time-consultation-consent.ts new file mode 100644 index 00000000..0245d81a --- /dev/null +++ b/frontend/src/lib/birth-time-consultation-consent.ts @@ -0,0 +1,96 @@ +import type { BirthTimeDraft } from "./birth-time-intake-model.ts"; + +export type BirthTimeConsultationConsentState = Readonly>; + +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([ + "hospital_record", + "family_exact", + "approximate", +]); + +const unfinishedRectificationStatuses = new Set([ + "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); +} + +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; +} diff --git a/frontend/src/lib/birth-time-intake-model.ts b/frontend/src/lib/birth-time-intake-model.ts index 5f8fd379..6c383e5c 100644 --- a/frontend/src/lib/birth-time-intake-model.ts +++ b/frontend/src/lib/birth-time-intake-model.ts @@ -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"); diff --git a/frontend/tests/account-api.test.ts b/frontend/tests/account-api.test.ts new file mode 100644 index 00000000..ee36767f --- /dev/null +++ b/frontend/tests/account-api.test.ts @@ -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 \}\)/); +}); diff --git a/frontend/tests/birth-time-consultation-consent.test.ts b/frontend/tests/birth-time-consultation-consent.test.ts new file mode 100644 index 00000000..cf7fe6d3 --- /dev/null +++ b/frontend/tests/birth-time-consultation-consent.test.ts @@ -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 && \(/); +}); diff --git a/frontend/tests/birth-time-guided-review-fixes.test.ts b/frontend/tests/birth-time-guided-review-fixes.test.ts index f0144aa5..5658eada 100644 --- a/frontend/tests/birth-time-guided-review-fixes.test.ts +++ b/frontend/tests/birth-time-guided-review-fixes.test.ts @@ -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>并进入对话;原始填报<\/span>和本次候选结果<\/span>仍会保留<\/span>。/); - assert.match(pageSource, /当前使用候选时间排盘;原始填报范围<\/span>仍保留。/); + assert.match(pageSource, /未确认,可临时选择使用/); + assert.match(pageSource, / { diff --git a/frontend/tests/birth-time-intake.test.ts b/frontend/tests/birth-time-intake.test.ts index 08ac8b03..cd125845 100644 --- a/frontend/tests/birth-time-intake.test.ts +++ b/frontend/tests/birth-time-intake.test.ts @@ -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, /普通咨询前可以选择临时使用或先校正/); +}); diff --git a/frontend/tests/consultation-entrypoint.test.ts b/frontend/tests/consultation-entrypoint.test.ts index 37589e0d..ca23efe8 100644 --- a/frontend/tests/consultation-entrypoint.test.ts +++ b/frontend/tests/consultation-entrypoint.test.ts @@ -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, / { + 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");