feat: integrate birth time journey into onboarding

This commit is contained in:
Jesse_Chen
2026-07-17 16:19:11 +08:00
parent 73fe62e9c3
commit 49054172ac
19 changed files with 1136 additions and 104 deletions
+9
View File
@@ -93,6 +93,15 @@ The base unit is 4px. Tokens are `--space-1: 4px`, `--space-2: 8px`, `--space-3:
- **States:** default, hover, focus with deep-brown ring, disabled, invalid, loading.
- **Accessibility:** persistent label where practical; composer has an explicit accessible label.
### Birth time intake
- **Structure:** birth date, five radio choice rows for time knowledge, then only the time, uncertainty, period, or clue field required by the selected source.
- **Surface:** choice rows use the warm canvas and hairline system; the selected row uses `--color-action-soft` with a deep-brown border, never a dark promotional card.
- **States:** no source selected, source selected, source-specific details incomplete, ready to continue, assessing, rectifying, candidate saved, confirmed.
- **Copy:** labels describe what the user actually knows. Candidate results explicitly distinguish a reported time, a candidate range, and an active chart time.
- **Accessibility:** native radio inputs remain focusable, every conditional field has a persistent label, status text uses live regions, and the complete flow is keyboard operable.
- **Motion:** source-dependent fields enter with the existing 180ms opacity/vertical reveal; reduced-motion removes the translation.
### Model selector
- **Structure:** a compact text trigger sits below the composer and opens an upward popover aligned to its left edge. The trigger shows only the active model name; each option shows only its model name and radio selection state.
@@ -8,11 +8,13 @@ import {
import {
createBirthTimeJourneyService,
RectificationCaseNotFoundError,
RectificationQuestionsUnavailableError,
} from "@/lib/birth-time-journey-service";
import {
createSupabaseBirthTimeJourneyStore,
BirthTimeJourneyStoreError,
} from "@/lib/birth-time-journey-store";
import { createAdminSupabaseClient } from "@/lib/supabase/admin";
import { isSupabaseConfigurationError } from "@/lib/supabase/config";
import { createServerSupabaseClient } from "@/lib/supabase/server";
@@ -21,6 +23,7 @@ export const maxDuration = 60;
const eventSchema = z.discriminatedUnion("type", [
z.object({ type: z.literal("assess") }).strict(),
z.object({ type: z.literal("resume"), caseId: z.string().uuid() }).strict(),
z.object({
type: z.literal("answer_question"),
caseId: z.string().uuid(),
@@ -40,8 +43,10 @@ async function requestPayload(request: Request): Promise<unknown> {
export async function POST(request: Request) {
let supabase: Awaited<ReturnType<typeof createServerSupabaseClient>>;
let journeyStoreClient: ReturnType<typeof createAdminSupabaseClient>;
try {
supabase = await createServerSupabaseClient();
journeyStoreClient = createAdminSupabaseClient();
} catch (error) {
if (isSupabaseConfigurationError(error)) {
return NextResponse.json(
@@ -69,7 +74,7 @@ export async function POST(request: Request) {
}
const service = createBirthTimeJourneyService({
store: createSupabaseBirthTimeJourneyStore(supabase),
store: createSupabaseBirthTimeJourneyStore(journeyStoreClient),
engine: createJyotishBirthTimeJourneyEngine(),
});
@@ -98,6 +103,8 @@ export async function POST(request: Request) {
parsed.data.questionId,
parsed.data.answer,
));
case "resume":
return NextResponse.json(await service.resume(user.id, parsed.data.caseId));
default: {
const exhaustive: never = parsed.data;
return exhaustive;
@@ -116,6 +123,12 @@ export async function POST(request: Request) {
{ status: 404 },
);
}
if (error instanceof RectificationQuestionsUnavailableError) {
return NextResponse.json(
{ error: "校正问题暂不可用", message: "当前资料已安全保留,请稍后重新评估。" },
{ status: 409 },
);
}
if (error instanceof BirthTimeJourneyStoreError || error instanceof BirthTimeJourneyEngineError) {
return NextResponse.json(
{ error: "生时评估暂时不可用", message: "已保留当前资料,请稍后重试。" },
+3 -2
View File
@@ -44,7 +44,8 @@ function hasCompleteBirthProfile(profile: Record<string, unknown>) {
return Boolean(
profile.name
&& profile.birth_date
&& profile.birth_time
&& (profile.active_birth_time || profile.birth_time)
&& (profile.birth_time_status === "confirmed" || (!profile.birth_time_status && profile.birth_time))
&& profile.country_code
&& profile.province_code
&& profile.city_code,
@@ -74,7 +75,7 @@ export async function POST() {
const { data: profile, error: profileError } = await admin
.from("profiles")
.select("name,birth_date,birth_time,country_code,province_code,city_code,onboarding_payload,onboarding_version,onboarding_generated_at")
.select("name,birth_date,birth_time,active_birth_time,birth_time_status,country_code,province_code,city_code,onboarding_payload,onboarding_version,onboarding_generated_at")
.eq("id", user.id)
.maybeSingle();
+39
View File
@@ -301,6 +301,43 @@ button:disabled { cursor: default; opacity: .45; }
.onboarding-card { border: 1px solid var(--color-border); max-width: 680px; margin: var(--space-2) 0 var(--space-5); padding: var(--space-6); border-color: var(--color-border); border-radius: var(--radius-lg); background: var(--color-canvas-soft); }
.onboarding-card-heading b { font-family: var(--font-display); font-size: var(--type-title-md); font-weight: 400; }
.onboarding-card-heading small { color: var(--color-ink-secondary); line-height: 1.5; font-size: var(--type-caption); }
.birth-time-intake { display: grid; gap: var(--space-4); }
.birth-time-source-fieldset { margin: 0; padding: 0; border: 0; }
.birth-time-source-fieldset legend { margin-bottom: var(--space-2); color: var(--color-ink-secondary); font-size: 11px; font-weight: 600; }
.birth-time-source-list { display: grid; gap: var(--space-2); }
.birth-time-source-option { min-height: 64px; display: grid; grid-template-columns: 18px minmax(0, 1fr); align-items: center; gap: var(--space-3); padding: var(--space-3) var(--space-4); border: 1px solid var(--color-border); border-radius: var(--radius-md); background: var(--color-canvas); cursor: pointer; transition: border-color 120ms ease-out, background-color 120ms ease-out, transform 120ms ease-out; }
.birth-time-source-option.is-selected { border-color: var(--color-action); background: var(--color-action-soft); }
.birth-time-source-option input { width: 16px; height: 16px; margin: 0; accent-color: var(--color-action); }
.birth-time-source-option > span { display: grid; gap: 3px; }
.birth-time-source-option b { color: var(--color-ink); font-size: var(--type-body-sm); font-weight: 600; }
.birth-time-source-option small, .birth-time-detail-note, .birth-time-legacy-note { margin: 0; color: var(--color-ink-secondary); font-size: var(--type-caption); line-height: 1.5; }
.birth-time-detail-grid { display: grid; grid-template-columns: minmax(0, 180px) minmax(0, 1fr); align-items: end; gap: var(--space-3); }
.birth-time-detail-grid > label { min-width: 0; }
.birth-time-detail-note { padding-bottom: 12px; }
.birth-time-legacy-note { padding: var(--space-3); border-left: 2px solid var(--color-warning); background: var(--color-canvas-muted); }
.birth-time-rectification { max-width: 680px; display: grid; gap: var(--space-5); }
.birth-time-assessment-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: var(--space-4); }
.birth-time-assessment-heading > div { display: grid; gap: var(--space-1); }
.birth-time-assessment-heading span { color: var(--color-action); font-size: var(--type-overline); font-weight: 600; letter-spacing: .08em; }
.birth-time-assessment-heading h2 { margin: 0; font-family: var(--font-display); font-size: var(--type-title-lg); font-weight: 400; }
.birth-time-status-badge { min-height: 30px; display: inline-flex; align-items: center; padding: 0 var(--space-3); border: 1px solid color-mix(in srgb, var(--color-warning) 46%, var(--color-border)); border-radius: var(--radius-md); background: color-mix(in srgb, var(--color-warning) 10%, var(--color-canvas)); color: var(--color-ink-secondary) !important; font-size: 11px !important; letter-spacing: .04em !important; }
.birth-time-range-summary { display: grid; grid-template-columns: 1fr 1fr; gap: 1px; margin: 0; overflow: hidden; border: 1px solid var(--color-border); border-radius: var(--radius-md); background: var(--color-border); }
.birth-time-range-summary > div { display: grid; gap: var(--space-1); padding: var(--space-3) var(--space-4); background: var(--color-canvas); }
.birth-time-range-summary dt { color: var(--color-ink-tertiary); font-size: 11px; }
.birth-time-range-summary dd { margin: 0; color: var(--color-ink); font-size: var(--type-body-sm); font-variant-numeric: tabular-nums; }
.birth-time-assistant-intent, .birth-time-assessment-unavailable { margin: 0; padding: var(--space-4); border-left: 2px solid var(--color-action); background: var(--color-action-soft); color: var(--color-ink); font-size: var(--type-body-sm); line-height: 1.6; }
.birth-time-question-list { display: grid; gap: var(--space-4); }
.birth-time-question-progress { display: flex; justify-content: space-between; color: var(--color-ink-secondary); font-size: var(--type-caption); }
.birth-time-question { display: grid; gap: var(--space-3); margin: 0; padding: var(--space-4) 0 0; border: 0; border-top: 1px solid var(--color-border); }
.birth-time-question legend { display: flex; align-items: flex-start; gap: var(--space-2); color: var(--color-ink); font-size: var(--type-body-sm); font-weight: 600; line-height: 1.55; }
.birth-time-question legend span { width: 24px; height: 24px; display: inline-grid; flex: 0 0 24px; place-items: center; border: 1px solid var(--color-border-strong); border-radius: var(--radius-xs); color: var(--color-ink-secondary); font-size: 11px; }
.birth-time-answer-list { display: grid; grid-template-columns: 1fr 1fr; gap: var(--space-2); }
.birth-time-answer-list button { min-height: 52px; display: flex; align-items: center; gap: var(--space-2); padding: var(--space-2) var(--space-3); border: 1px solid var(--color-border); border-radius: var(--radius-md); background: var(--color-canvas); cursor: pointer; text-align: left; font-size: var(--type-caption); line-height: 1.4; transition: border-color 120ms ease-out, background-color 120ms ease-out, transform 120ms ease-out; }
.birth-time-answer-list button > span { color: var(--color-action); font-family: var(--font-mono); font-weight: 600; }
.birth-time-answer-list button.is-selected { border-color: var(--color-action); background: var(--color-action-soft); }
.birth-time-question > small { color: var(--color-ink-secondary); font-size: 11px; }
.birth-time-retry-card { display: grid; justify-items: start; gap: var(--space-3); }
.birth-time-retry-card p { margin: 0; color: var(--color-ink-secondary); font-size: var(--type-body-sm); line-height: 1.6; }
.starter-list { border-top: 1px solid var(--color-border); display: grid; grid-template-columns: 1.08fr .92fr; grid-template-rows: 1fr 1fr; gap: var(--space-3); border: 0; }
.starter-list button { width: 100%; display: grid; align-items: center; border-bottom: 1px solid var(--color-border); cursor: pointer; text-align: left; transition: background-color 120ms ease-out, transform 120ms ease-out; min-height: 92px; grid-template-columns: minmax(0, 1fr) 20px; gap: var(--space-3); padding: var(--space-5); border: 0; border-radius: var(--radius-lg); background: var(--color-canvas-muted); }
@@ -439,6 +476,7 @@ input:disabled, select:disabled { color: var(--color-ink-tertiary); background:
.composer-suggestions button:not(:disabled):hover { border-color: var(--color-action); background: var(--color-canvas); color: var(--color-action-hover); }
.button-secondary:not(:disabled):hover { background: var(--color-canvas-muted); }
.section-toggle:not(:disabled):hover { color: var(--color-action-hover); }
.birth-time-source-option:hover, .birth-time-answer-list button:not(:disabled):hover { border-color: var(--color-action); }
}
@media (min-width: 768px) and (max-width: 900px) {
@@ -478,6 +516,7 @@ input:disabled, select:disabled { color: var(--color-ink-tertiary); background:
}
@media (max-width: 480px) {
.birth-time-detail-grid, .birth-time-range-summary, .birth-time-answer-list { grid-template-columns: 1fr; }
.welcome > .onboarding-message:first-child .message-bubble p { font-size: var(--type-title-lg); }
.starter-content span { font-size: var(--type-title-sm); }
.profile-dialog h2 { font-size: var(--type-display-sm); }
+242 -37
View File
@@ -4,12 +4,30 @@ import Link from "next/link";
import { ArrowUp, ArrowUpRight, ChevronRight, Menu, Minus, Plus, Sparkles, Square, X } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import type { FormEvent, KeyboardEvent } from "react";
import { BirthTimeIntakeFields } from "@/components/birth-time-intake";
import { BirthTimeRectification } from "@/components/birth-time-rectification";
import { ChatMessageContent } from "@/components/chat-message-content";
import { ModelSelector } from "@/components/model-selector";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { chinaLocations, type ProvinceNode } from "@/data/china-locations";
import { parseAgentReply, type ReplyTheme } from "@/lib/agent-reply";
import {
assistantIntentCopy,
birthTimePersistenceValues,
describeBirthTimeDraft,
isBirthTimeDraftReady,
type BirthTimeDraft,
type BirthTimeSource,
} from "@/lib/birth-time-intake-model";
import {
answerBirthTimeQuestion,
parseJourneyResponse,
requestBirthTimeAssessment,
resumeBirthTimeJourney,
type JourneyAnswer,
type JourneyClientResponse,
} from "@/lib/birth-time-journey-client";
import { keepFocusWithin } from "@/lib/focus-trap";
import {
SessionModelPersistenceQueue,
@@ -24,14 +42,13 @@ import { createBrowserSupabaseClient } from "@/lib/supabase/client";
type Theme = ReplyTheme;
type Message = { role: "user" | "assistant"; text: string; suggestions?: string[] };
type Profile = {
type Profile = BirthTimeDraft & {
name: string;
date: string;
time: string;
countryCode: "CN";
provinceCode: string;
cityCode: string;
districtCode: string;
rectificationCaseId: string;
};
type ChatSession = { id: string; title: string; theme: Theme; modelId: string; messages: Message[]; updatedAt: number };
type RequestError = { sessionId: string; message: string };
@@ -40,7 +57,7 @@ type BirthPlace = { label: string; lat: number; lon: number; tz: number };
type Account = { user: { id: string; email: string | null }; credits: number; isAdmin: boolean };
type OnboardingSuggestion = { theme: Exclude<Theme, "general">; text: string };
type OnboardingContent = { greeting: string; suggestions: OnboardingSuggestion[] };
type OnboardingStep = "name" | "birth" | "place";
type OnboardingStep = "name" | "birth" | "place" | "rectification";
type GreetingPeriod = "morning" | "noon" | "afternoon" | "evening" | "late-night";
type SessionReadResult = { readonly sessions: ChatSession[]; readonly fallbackSessionIds: string[] };
type PendingConsultation = {
@@ -73,6 +90,30 @@ const previewModelCatalog = parsePublicModelCatalog({
],
});
const previewRectificationJourney = parseJourneyResponse({
caseId: "7299894c-10a8-4b45-91d1-339007282c50",
snapshot: {
state: "rectifying",
assistantIntent: "start_standard_rectification",
input: "rectification_questions",
route: "rectification",
confidence: null,
canApply: false,
activeTime: null,
reportedRange: { label: "14:00—15:00", startTime: "14:00", endTime: "15:00" },
},
questionnaire: {
questions: [
{ id: "education_shift", prompt: "求学阶段是否发生过一次明显的环境或方向变化?" },
{ id: "career_shift", prompt: "工作早期是否经历过一次清晰的行业、岗位或城市切换?" },
{ id: "relationship_milestone", prompt: "重要关系或婚姻节点是否集中在某个明确年份?" },
],
samples: [],
raw: {},
},
scoring: null,
});
const presetOnboardingMessage = "你好,我是 Jyotisha。\n开始前,我想先认识你。\n请问我该怎么称呼你?";
const greetingVariants: Record<GreetingPeriod, Array<(name: string) => string>> = {
@@ -121,6 +162,14 @@ const emptyProfile: Profile = {
name: "",
date: "",
time: "",
reportedTime: "",
birthTimeSource: "",
birthTimePeriod: "",
birthTimeClue: "",
uncertaintyBeforeMinutes: null,
uncertaintyAfterMinutes: null,
birthTimeStatus: "",
rectificationCaseId: "",
countryCode: "CN",
provinceCode: "",
cityCode: "",
@@ -168,18 +217,18 @@ function selectedBirthPlace(profile: Profile): BirthPlace | null {
function missingProfileStep(profile: Profile): OnboardingStep | null {
if (!profile.name.trim()) return "name";
if (!profile.date || !profile.time) return "birth";
if (!isBirthTimeDraftReady(profile)) return "birth";
if (!selectedBirthPlace(profile)) return "place";
if (!profile.time || profile.birthTimeStatus !== "confirmed") return "rectification";
return null;
}
function birthQuestion(name: string) {
return `${name},你好。接下来请告诉我你的出生日期和时间。时间越准确,后面的判断越可靠`;
return `${name},你好。接下来请告诉我出生日期,以及你对出生时间知道到什么程度。不确定也没关系,我不会要求你猜一个具体时间`;
}
function formatBirthMoment(profile: Profile) {
const [year, month, day] = profile.date.split("-").map(Number);
return `${year}${month}${day}${profile.time}`;
return describeBirthTimeDraft(profile);
}
function placeQuestion(profile: Profile) {
@@ -193,7 +242,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 || !profile.time || !birthPlace) return [];
if (!name || !profile.date || !profile.time || profile.birthTimeStatus !== "confirmed" || !birthPlace) return [];
return [
{ role: "assistant", text: presetOnboardingMessage },
@@ -238,13 +287,38 @@ function readProfile(value: unknown): Profile {
const profile = value as Partial<Profile> & {
birth_date?: unknown;
birth_time?: unknown;
reported_birth_time?: unknown;
active_birth_time?: unknown;
birth_time_source?: unknown;
birth_time_period?: unknown;
birth_time_clue?: unknown;
uncertainty_before_minutes?: unknown;
uncertainty_after_minutes?: unknown;
birth_time_status?: unknown;
rectification_case_id?: unknown;
country_code?: unknown;
province_code?: unknown;
city_code?: unknown;
district_code?: unknown;
};
const date = typeof profile.birth_date === "string" ? profile.birth_date : profile.date;
const time = typeof profile.birth_time === "string" ? profile.birth_time.slice(0, 5) : profile.time;
const legacyTime = typeof profile.birth_time === "string" ? profile.birth_time.slice(0, 5) : profile.time;
const time = typeof profile.active_birth_time === "string"
? profile.active_birth_time.slice(0, 5)
: legacyTime;
const reportedTime = typeof profile.reported_birth_time === "string"
? profile.reported_birth_time.slice(0, 5)
: time;
const knownSources: readonly BirthTimeSource[] = [
"hospital_record", "family_exact", "approximate", "period_only", "unknown", "legacy_import",
];
const source = knownSources.find((item) => item === profile.birth_time_source)
?? (time ? "legacy_import" : "");
const knownPeriods = ["early_morning", "morning", "afternoon", "evening", "late_night"] as const;
const period = knownPeriods.find((item) => item === profile.birth_time_period) ?? "";
const knownStatuses = ["reported", "assessing", "rectifying", "candidate", "confirmed"] as const;
const status = knownStatuses.find((item) => item === profile.birth_time_status)
?? (time ? "confirmed" : "");
const provinceCode = typeof profile.province_code === "string" ? profile.province_code : profile.provinceCode;
const cityCode = typeof profile.city_code === "string" ? profile.city_code : profile.cityCode;
const districtCode = typeof profile.district_code === "string" ? profile.district_code : profile.districtCode;
@@ -253,6 +327,14 @@ function readProfile(value: unknown): Profile {
name: typeof profile.name === "string" ? profile.name.slice(0, 80) : "",
date: typeof date === "string" ? date : "",
time: typeof time === "string" ? time : "",
reportedTime: typeof reportedTime === "string" ? reportedTime : "",
birthTimeSource: source,
birthTimePeriod: period,
birthTimeClue: typeof profile.birth_time_clue === "string" ? profile.birth_time_clue.slice(0, 240) : "",
uncertaintyBeforeMinutes: typeof profile.uncertainty_before_minutes === "number" ? profile.uncertainty_before_minutes : null,
uncertaintyAfterMinutes: typeof profile.uncertainty_after_minutes === "number" ? profile.uncertainty_after_minutes : null,
birthTimeStatus: status,
rectificationCaseId: typeof profile.rectification_case_id === "string" ? profile.rectification_case_id : "",
countryCode: "CN",
provinceCode: typeof provinceCode === "string" ? provinceCode : "",
cityCode: typeof cityCode === "string" ? cityCode : "",
@@ -302,15 +384,6 @@ function readSessions(value: unknown, catalog: PublicLanguageModelCatalog | null
return { sessions, fallbackSessionIds };
}
function BirthMomentFields({ value, onChange }: { value: Profile; onChange: (profile: Profile) => void }) {
return (
<div className="profile-grid">
<label><span></span><input required type="date" value={value.date} onChange={(event) => onChange({ ...value, date: event.target.value })} /></label>
<label><span></span><input required type="time" value={value.time} onChange={(event) => onChange({ ...value, time: event.target.value })} /></label>
</div>
);
}
function BirthLocationFields({ value, onChange }: { value: Profile; onChange: (profile: Profile) => void }) {
const province = findProvince(value.provinceCode);
const cities = province?.cities ?? [];
@@ -337,7 +410,7 @@ function ProfileFields({ value, onChange }: { value: Profile; onChange: (profile
<span></span>
<input id="profile-name" required autoComplete="name" maxLength={80} placeholder="例如:林遥" value={value.name} onChange={(event) => onChange({ ...value, name: event.target.value })} />
</label>
<BirthMomentFields value={value} onChange={onChange} />
<BirthTimeIntakeFields value={value} onPatch={(patch) => onChange({ ...value, ...patch })} />
<BirthLocationFields value={value} onChange={onChange} />
</>
);
@@ -457,6 +530,10 @@ export default function Home() {
const [onboardingError, setOnboardingError] = useState("");
const [onboardingStep, setOnboardingStep] = useState<OnboardingStep>("name");
const [onboardingJustCompleted, setOnboardingJustCompleted] = useState(false);
const [birthTimeJourney, setBirthTimeJourney] = useState<JourneyClientResponse | null>(null);
const [birthTimeAnswers, setBirthTimeAnswers] = useState<Readonly<Record<string, JourneyAnswer>>>({});
const [birthTimeQuestionPending, setBirthTimeQuestionPending] = useState("");
const [birthTimeError, setBirthTimeError] = useState("");
const [startGreeting, setStartGreeting] = useState("");
const [presetMessageLength, setPresetMessageLength] = useState(0);
const conversationEnd = useRef<HTMLDivElement>(null);
@@ -499,6 +576,8 @@ export default function Home() {
? birthQuestion(profileDraft.name.trim())
: onboardingStep === "place"
? placeQuestion(profileDraft)
: onboardingStep === "rectification" && birthTimeJourney
? assistantIntentCopy(birthTimeJourney.snapshot.assistantIntent)
: presetOnboardingMessage;
const shouldStreamOnboarding = !profileComplete || onboardingJustCompleted;
const presetMessageFinished = !shouldStreamOnboarding || presetMessageLength >= currentOnboardingMessage.length;
@@ -530,7 +609,15 @@ export default function Home() {
: {
name: "林遥",
date: "1990-06-15",
time: "12:30",
time: previewMode === "birth-time-rectification" ? "" : "12:30",
reportedTime: previewMode === "birth-time-rectification" ? "14:30" : "12:30",
birthTimeSource: previewMode === "birth-time-rectification" ? "approximate" : "legacy_import",
birthTimePeriod: "",
birthTimeClue: "",
uncertaintyBeforeMinutes: previewMode === "birth-time-rectification" ? 30 : null,
uncertaintyAfterMinutes: previewMode === "birth-time-rectification" ? 30 : null,
birthTimeStatus: previewMode === "birth-time-rectification" ? "rectifying" : "confirmed",
rectificationCaseId: previewMode === "birth-time-rectification" ? previewRectificationJourney.caseId : "",
countryCode: "CN",
provinceCode: "110000",
cityCode: "110000-city",
@@ -554,6 +641,7 @@ export default function Home() {
setModelCatalog(previewModelCatalog);
setProfile(previewProfile);
setProfileDraft(previewProfile);
if (previewMode === "birth-time-rectification") setBirthTimeJourney(previewRectificationJourney);
setOnboardingStep(missingProfileStep(previewProfile) ?? "name");
setSessions([previewSession]);
setActiveSessionId(previewSession.id);
@@ -588,7 +676,7 @@ export default function Home() {
const [profileResult, sessionsResult] = await Promise.all([
supabase
.from("profiles")
.select("name,birth_date,birth_time,country_code,province_code,city_code,district_code")
.select("name,birth_date,birth_time,reported_birth_time,active_birth_time,birth_time_source,birth_time_period,birth_time_clue,uncertainty_before_minutes,uncertainty_after_minutes,birth_time_status,rectification_case_id,country_code,province_code,city_code,district_code")
.eq("id", nextAccount.user.id)
.abortSignal(controller.signal)
.maybeSingle(),
@@ -633,6 +721,20 @@ export default function Home() {
setOnboardingStep(missingProfileStep(nextProfile) ?? "name");
setSessions(nextSessions);
setActiveSessionId(nextSessions[0].id);
if ((nextProfile.birthTimeStatus === "rectifying" || nextProfile.birthTimeStatus === "candidate")
&& nextProfile.rectificationCaseId) {
try {
const resumed = await resumeBirthTimeJourney(nextProfile.rectificationCaseId);
if (!controller.signal.aborted) {
setBirthTimeJourney(resumed);
setBirthTimeAnswers(resumed.answers);
}
} catch (caught) {
if (!controller.signal.aborted) {
setBirthTimeError(caught instanceof Error ? caught.message : "暂时无法继续上次的时间校正。");
}
}
}
if (modelCatalogResult.unavailable) {
setComposerNotice("模型服务暂时不可用,当前无法发送问题。");
} else if (parsedSessions.fallbackSessionIds.length > 0) {
@@ -915,7 +1017,7 @@ export default function Home() {
.update({
name: nextProfile.name.trim() || null,
birth_date: nextProfile.date || null,
birth_time: nextProfile.time || null,
...birthTimePersistenceValues(nextProfile),
country_code: nextProfile.countryCode,
province_code: nextProfile.provinceCode || null,
city_code: nextProfile.cityCode || null,
@@ -932,16 +1034,45 @@ export default function Home() {
if (!data) throw new Error("账户档案不存在,请重新登录后再试。");
}
async function assessSavedBirthTime(nextProfile: Profile) {
const result = process.env.NODE_ENV === "development" && uiPreview.current
? previewRectificationJourney
: await requestBirthTimeAssessment();
const nextStatus = result.snapshot.state === "ready"
? "confirmed"
: result.snapshot.state === "candidate"
? "candidate"
: "rectifying";
const assessedProfile: Profile = {
...nextProfile,
time: result.snapshot.activeTime ?? "",
birthTimeStatus: nextStatus,
rectificationCaseId: result.caseId,
};
setBirthTimeJourney(result);
setBirthTimeAnswers({});
setBirthTimeError("");
setProfile(assessedProfile);
setProfileDraft(assessedProfile);
return assessedProfile;
}
async function saveProfile(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!isProfileComplete(profileDraft) || !account || profileSaving) return;
if (!profileDraft.name.trim() || !isBirthTimeDraftReady(profileDraft) || !selectedBirthPlace(profileDraft) || !account || profileSaving) return;
setProfileSaving(true);
setProfileNotice("");
setAccountError("");
try {
await persistProfile(profileDraft);
setProfile(profileDraft);
setProfileNotice("出生资料已保存到云端,可在同一账号的其他设备使用。");
const nextProfile = profileDraft.birthTimeStatus === "confirmed"
? profileDraft
: await assessSavedBirthTime(profileDraft);
setProfile(nextProfile);
setProfileDraft(nextProfile);
setProfileNotice(nextProfile.birthTimeStatus === "confirmed"
? "出生资料已保存到云端,可在同一账号的其他设备使用。"
: "资料已保存,当前时间仍在校正中,不会用于正式排盘。");
} catch (caught) {
setAccountError(friendlyError(caught instanceof Error ? caught.message : "出生资料保存失败"));
} finally {
@@ -974,7 +1105,7 @@ export default function Home() {
async function saveOnboardingBirth(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!profileDraft.date || !profileDraft.time || !account || profileSaving) return;
if (!isBirthTimeDraftReady(profileDraft) || !account || profileSaving) return;
setProfileSaving(true);
setAccountError("");
try {
@@ -998,9 +1129,13 @@ export default function Home() {
setAccountError("");
try {
await persistProfile(profileDraft);
setProfile(profileDraft);
const assessedProfile = await assessSavedBirthTime(profileDraft);
setPresetMessageLength(0);
setOnboardingJustCompleted(true);
if (assessedProfile.birthTimeStatus === "confirmed") {
setOnboardingJustCompleted(true);
} else {
setOnboardingStep("rectification");
}
} catch (caught) {
setAccountError(friendlyError(caught instanceof Error ? caught.message : "出生地点保存失败"));
} finally {
@@ -1008,6 +1143,43 @@ export default function Home() {
}
}
async function saveBirthTimeAnswer(questionId: string, answer: JourneyAnswer) {
if (!birthTimeJourney || birthTimeQuestionPending) return;
setBirthTimeQuestionPending(questionId);
setBirthTimeError("");
try {
const result = process.env.NODE_ENV === "development" && uiPreview.current
? birthTimeJourney
: await answerBirthTimeQuestion(birthTimeJourney.caseId, questionId, answer);
setBirthTimeJourney(result);
setBirthTimeAnswers(process.env.NODE_ENV === "development" && uiPreview.current
? (current) => ({ ...current, [questionId]: answer })
: result.answers);
const birthTimeStatus = result.snapshot.state === "candidate" ? "candidate" : "rectifying";
setProfile((current) => ({ ...current, birthTimeStatus }));
setProfileDraft((current) => ({ ...current, birthTimeStatus }));
} catch (caught) {
setBirthTimeError(caught instanceof Error ? caught.message : "这条回答暂时无法保存,请重试。");
} finally {
setBirthTimeQuestionPending("");
}
}
async function retryBirthTimeAssessment() {
if (!account || profileSaving) return;
setProfileSaving(true);
setBirthTimeError("");
try {
const assessedProfile = await assessSavedBirthTime(profileDraft);
setPresetMessageLength(0);
if (assessedProfile.birthTimeStatus === "confirmed") setOnboardingJustCompleted(true);
} catch (caught) {
setBirthTimeError(caught instanceof Error ? caught.message : "生时评估暂时不可用,请稍后重试。");
} finally {
setProfileSaving(false);
}
}
async function redeem(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const code = redeemCode.trim();
@@ -1312,6 +1484,7 @@ export default function Home() {
lon: birthPlace.lon,
tz: birthPlace.tz,
theme,
entryMode: profile.birthTimeStatus === "confirmed" ? "direct_chart" : "rectification",
question,
history: currentSession.messages.slice(-12).map((message) => ({
role: message.role,
@@ -1530,7 +1703,11 @@ export default function Home() {
<button className="mobile-menu" ref={mobileMenuTrigger} aria-label="打开聊天记录" aria-controls="chat-sidebar" aria-expanded={mobileSidebarOpen} type="button" onClick={() => setMobileSidebarOpen(true)}><Menu aria-hidden="true" /></button>
<div>
<strong>{activeSession?.title || "新对话"}</strong>
<span><i className={`status ${isLoading ? "status-loading" : "status-idle"}`} />{isLoading ? (consultationPhase === "undo" ? "即将发送,可撤回" : activeStreamingText ? "正在回答" : "正在核对星盘信息") : "基于星盘证据回答"}</span>
<span><i className={`status ${isLoading ? "status-loading" : "status-idle"}`} />{isLoading
? (consultationPhase === "undo" ? "即将发送,可撤回" : activeStreamingText ? "正在回答" : "正在核对星盘信息")
: !profileComplete && onboardingStep === "rectification"
? "正在校正出生时间"
: "基于星盘证据回答"}</span>
</div>
<button className="credit-button" type="button" onClick={() => openAccount(account?.credits === 0)} aria-label={account ? `余额 ${account.credits} 点,打开账户与兑换码` : accountError || "读取余额中"}>
<Sparkles className="credit-icon" aria-hidden="true" />
@@ -1546,8 +1723,10 @@ export default function Home() {
<OnboardingChatMessage role="assistant" text={presetOnboardingMessage} streaming={onboardingStep === "name" && !profileComplete} length={presetMessageLength} />
{(onboardingStep !== "name" || profileComplete) && <OnboardingChatMessage role="user" text={profileDraft.name.trim()} />}
{(onboardingStep !== "name" || profileComplete) && <OnboardingChatMessage role="assistant" text={birthQuestion(profileDraft.name.trim())} streaming={onboardingStep === "birth" && !profileComplete} length={presetMessageLength} />}
{(onboardingStep === "place" || profileComplete) && <OnboardingChatMessage role="user" text={formatBirthMoment(profileDraft)} />}
{(onboardingStep === "place" || profileComplete) && <OnboardingChatMessage role="assistant" text={placeQuestion(profileDraft)} streaming={onboardingStep === "place" && !profileComplete} length={presetMessageLength} />}
{(onboardingStep === "place" || onboardingStep === "rectification" || profileComplete) && <OnboardingChatMessage role="user" text={formatBirthMoment(profileDraft)} />}
{(onboardingStep === "place" || onboardingStep === "rectification" || profileComplete) && <OnboardingChatMessage role="assistant" text={placeQuestion(profileDraft)} streaming={onboardingStep === "place" && !profileComplete} length={presetMessageLength} />}
{onboardingStep === "rectification" && selectedBirthPlace(profileDraft) && <OnboardingChatMessage role="user" text={selectedBirthPlace(profileDraft)?.label ?? ""} />}
{onboardingStep === "rectification" && birthTimeJourney && <OnboardingChatMessage role="assistant" text={currentOnboardingMessage} streaming length={presetMessageLength} />}
{profileComplete && onboardingJustCompleted && selectedBirthPlace(profileDraft) && <OnboardingChatMessage role="user" text={selectedBirthPlace(profileDraft)?.label ?? ""} />}
{profileComplete && onboardingJustCompleted && <OnboardingChatMessage role="assistant" text={currentOnboardingMessage} streaming length={presetMessageLength} />}
</>
@@ -1562,10 +1741,10 @@ export default function Home() {
<div className="onboarding-card-reveal">
<div className="onboarding-card-reveal-inner">
<form className="profile-form onboarding-card onboarding-step-card" onSubmit={saveOnboardingBirth}>
<div className="onboarding-card-heading"><b></b><small></small></div>
<BirthMomentFields value={profileDraft} onChange={setProfileDraft} />
<div className="onboarding-card-heading"><b></b><small></small></div>
<BirthTimeIntakeFields value={profileDraft} onPatch={(patch) => setProfileDraft((current) => ({ ...current, ...patch }))} />
{accountError && <p className="form-error" role="alert">{accountError}</p>}
<div className="onboarding-card-actions"><button className="button-primary" type="submit" disabled={profileSaving || !profileDraft.date || !profileDraft.time}>{profileSaving ? "保存中" : "确定"}</button></div>
<div className="onboarding-card-actions"><button className="button-primary" type="submit" disabled={profileSaving || !isBirthTimeDraftReady(profileDraft)}>{profileSaving ? "保存中" : "继续"}</button></div>
</form>
</div>
</div>
@@ -1584,6 +1763,28 @@ export default function Home() {
</div>
)}
{!profileComplete && onboardingStep === "rectification" && presetMessageFinished && birthTimeJourney && (
<div className="onboarding-card-reveal">
<div className="onboarding-card-reveal-inner">
<BirthTimeRectification
journey={birthTimeJourney}
answers={birthTimeAnswers}
pendingQuestionId={birthTimeQuestionPending}
error={birthTimeError}
onAnswer={(questionId, answer) => void saveBirthTimeAnswer(questionId, answer)}
/>
</div>
</div>
)}
{!profileComplete && onboardingStep === "rectification" && presetMessageFinished && !birthTimeJourney && (
<div className="onboarding-card birth-time-retry-card" role="status">
<b></b>
<p>{birthTimeError || "资料已经保留,但暂时无法恢复校正进度。系统不会应用未经验证的具体时间。"}</p>
<button className="button-primary" type="button" disabled={profileSaving} onClick={() => void retryBirthTimeAssessment()}>{profileSaving ? "评估中" : "重新评估"}</button>
</div>
)}
{!profileComplete && onboardingStep === "name" && accountError && <p className="form-error onboarding-inline-error" role="alert">{accountError}</p>}
{profileComplete && presetMessageFinished && !draft.trim() && (onboardingPending ? (
@@ -1677,7 +1878,7 @@ export default function Home() {
<Square aria-hidden="true" />
</Button>
) : (
<Button aria-label={!profileComplete ? "确认称呼" : "发送"} 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 || !account || !modelCatalog || (!profileComplete && (onboardingStep !== "name" || !presetMessageFinished || profileSaving))} size="icon" type="submit">
<ArrowUp aria-hidden="true" />
</Button>
)}
@@ -1689,7 +1890,11 @@ export default function Home() {
disabled={!activeSession || isLoading || cancellationPending || creatingSession}
onSelect={(modelId) => void selectSessionModel(modelId)}
/>
<p className={composerNotice || consultationPhase === "undo" ? "composer-notice" : undefined} role={composerNotice || consultationPhase === "undo" ? "status" : undefined}>{composerNotice || (consultationPhase === "undo" ? "已加入发送队列,2.5 秒内可免费撤回。" : !profileComplete && onboardingStep === "name" ? "Enter 确认称呼" : "Enter 发送 · Shift + Enter 换行")}</p>
<p className={composerNotice || consultationPhase === "undo" ? "composer-notice" : undefined} role={composerNotice || consultationPhase === "undo" ? "status" : undefined}>{composerNotice || (consultationPhase === "undo"
? "已加入发送队列,2.5 秒内可免费撤回。"
: !profileComplete
? onboardingStep === "name" ? "Enter 确认称呼" : onboardingStep === "rectification" ? "完成上方生时校正后可提问" : "请先完成上方资料"
: "Enter 发送 · Shift + Enter 换行")}</p>
</div>
</div>
</section>
@@ -0,0 +1,174 @@
"use client";
import { useId } from "react";
import {
birthTimePeriodOptions,
birthTimeSourceOptions,
type BirthTimeDraft,
type BirthTimeDraftPatch,
type BirthTimeSource,
} from "@/lib/birth-time-intake-model";
type BirthTimeIntakeProps = {
readonly value: BirthTimeDraft;
readonly onPatch: (patch: BirthTimeDraftPatch) => void;
};
const sourceDefaults = {
hospital_record: {
birthTimePeriod: "",
birthTimeClue: "",
uncertaintyBeforeMinutes: 2,
uncertaintyAfterMinutes: 2,
},
family_exact: {
birthTimePeriod: "",
birthTimeClue: "",
uncertaintyBeforeMinutes: 10,
uncertaintyAfterMinutes: 10,
},
approximate: {
birthTimePeriod: "",
birthTimeClue: "",
uncertaintyBeforeMinutes: 30,
uncertaintyAfterMinutes: 30,
},
period_only: {
reportedTime: "",
birthTimeClue: "",
uncertaintyBeforeMinutes: null,
uncertaintyAfterMinutes: null,
},
unknown: {
reportedTime: "",
birthTimePeriod: "",
uncertaintyBeforeMinutes: null,
uncertaintyAfterMinutes: null,
},
} as const satisfies Record<Exclude<BirthTimeSource, "" | "legacy_import">, BirthTimeDraftPatch>;
export function BirthTimeIntakeFields({ value, onPatch }: BirthTimeIntakeProps) {
const groupId = useId();
const source = value.birthTimeSource;
const isConfirmed = value.birthTimeStatus === "confirmed";
const usesClockTime = source === "hospital_record"
|| source === "family_exact"
|| source === "approximate"
|| source === "legacy_import";
return (
<div className="birth-time-intake">
<label>
<span></span>
<input
required
disabled={isConfirmed}
type="date"
value={value.date}
onChange={(event) => onPatch({ date: event.target.value })}
/>
</label>
{source === "legacy_import" && (
<p className="birth-time-legacy-note">
</p>
)}
{!isConfirmed && <fieldset className="birth-time-source-fieldset">
<legend></legend>
<div className="birth-time-source-list">
{birthTimeSourceOptions.map((option) => (
<label
className={`birth-time-source-option ${source === option.value ? "is-selected" : ""}`}
key={option.value}
>
<input
checked={source === option.value}
name={`birth-time-source-${groupId}`}
type="radio"
value={option.value}
onChange={() => onPatch({
birthTimeSource: option.value,
birthTimeStatus: "reported",
time: "",
...sourceDefaults[option.value],
})}
/>
<span>
<b>{option.label}</b>
<small>{option.hint}</small>
</span>
</label>
))}
</div>
</fieldset>}
{usesClockTime && (
<div className="birth-time-detail-grid onboarding-card-reveal">
<label>
<span>{isConfirmed ? "当前排盘时间" : source === "approximate" ? "大概时间" : "记录时间"}</span>
<input
required
disabled={isConfirmed}
type="time"
value={value.reportedTime || value.time}
onChange={(event) => onPatch({ reportedTime: event.target.value })}
/>
</label>
{source === "hospital_record" && (
<p className="birth-time-detail-note"> 2 D9 / D10 </p>
)}
{(source === "family_exact" || source === "approximate") && (
<label>
<span></span>
<select
value={value.uncertaintyBeforeMinutes ?? ""}
onChange={(event) => {
const minutes = Number(event.target.value);
onPatch({ uncertaintyBeforeMinutes: minutes, uncertaintyAfterMinutes: minutes });
}}
>
{(source === "family_exact" ? [5, 10, 15] : [15, 30, 60]).map((minutes) => (
<option key={minutes} value={minutes}> {minutes} </option>
))}
</select>
</label>
)}
</div>
)}
{source === "period_only" && (
<label className="onboarding-card-reveal">
<span></span>
<select
required
value={value.birthTimePeriod}
onChange={(event) => {
const period = birthTimePeriodOptions.find((option) => option.value === event.target.value);
if (period) onPatch({ birthTimePeriod: period.value });
}}
>
<option value="" disabled></option>
{birthTimePeriodOptions.map((option) => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
</label>
)}
{source === "unknown" && (
<label className="onboarding-card-reveal">
<span>线</span>
<textarea
maxLength={240}
placeholder="例如:家人只记得天黑以后,或可以再询问长辈"
rows={3}
value={value.birthTimeClue}
onChange={(event) => onPatch({ birthTimeClue: event.target.value })}
/>
</label>
)}
</div>
);
}
@@ -0,0 +1,93 @@
"use client";
import { assistantIntentCopy } from "@/lib/birth-time-intake-model";
import type {
JourneyAnswer,
JourneyClientResponse,
} from "@/lib/birth-time-journey-client";
type BirthTimeRectificationProps = {
readonly journey: JourneyClientResponse;
readonly answers: Readonly<Record<string, JourneyAnswer>>;
readonly pendingQuestionId: string;
readonly error: string;
readonly onAnswer: (questionId: string, answer: JourneyAnswer) => void;
};
const fallbackOptions = [
{ key: "A", label: "明确有,而且时间大致吻合" },
{ key: "B", label: "有类似经历,但时间或程度不完全确定" },
{ key: "C", label: "没有明显发生" },
{ key: "D", label: "不确定 / 不记得" },
] as const;
export function BirthTimeRectification({
journey,
answers,
pendingQuestionId,
error,
onAnswer,
}: BirthTimeRectificationProps) {
const questions = journey.questionnaire?.questions.slice(0, 3) ?? [];
const answeredCount = Object.keys(answers).length;
return (
<section className="birth-time-rectification onboarding-card" aria-labelledby="birth-time-assessment-title">
<div className="birth-time-assessment-heading">
<div>
<span></span>
<h2 id="birth-time-assessment-title">
{journey.snapshot.state === "candidate" ? "候选范围已保存" : "需要先缩小时间范围"}
</h2>
</div>
<span className="birth-time-status-badge">
{journey.snapshot.state === "candidate" ? "候选" : "校正中"}
</span>
</div>
<dl className="birth-time-range-summary">
<div><dt></dt><dd>{journey.snapshot.reportedRange.label}</dd></div>
<div><dt></dt><dd></dd></div>
</dl>
<p className="birth-time-assistant-intent" role="status">
{assistantIntentCopy(journey.snapshot.assistantIntent)}
</p>
{questions.length > 0 ? (
<div className="birth-time-question-list">
<div className="birth-time-question-progress">
<b></b>
<span>{Math.min(answeredCount, questions.length)} / {questions.length}</span>
</div>
{questions.map((question, index) => (
<fieldset className="birth-time-question" key={question.id}>
<legend><span>{index + 1}</span>{question.prompt}</legend>
<div className="birth-time-answer-list">
{(question.options?.length ? question.options : fallbackOptions).map((option) => (
<button
aria-pressed={answers[question.id] === option.key}
className={answers[question.id] === option.key ? "is-selected" : ""}
disabled={Boolean(pendingQuestionId)}
key={option.key}
type="button"
onClick={() => onAnswer(question.id, option.key)}
>
<span>{option.key}</span>{option.label}
</button>
))}
</div>
{pendingQuestionId === question.id && <small role="status"></small>}
</fieldset>
))}
</div>
) : (
<p className="birth-time-assessment-unavailable">
</p>
)}
{error && <p className="form-error" role="alert">{error}</p>}
</section>
);
}
+150
View File
@@ -0,0 +1,150 @@
import type { JourneySnapshot } from "./birth-time-journey.ts";
export type BirthTimeSource =
| ""
| "hospital_record"
| "family_exact"
| "approximate"
| "period_only"
| "unknown"
| "legacy_import";
export type BirthTimePeriod =
| ""
| "early_morning"
| "morning"
| "afternoon"
| "evening"
| "late_night";
export type BirthTimeStatus =
| ""
| "reported"
| "assessing"
| "rectifying"
| "candidate"
| "confirmed";
export type BirthTimeDraft = {
readonly date: string;
readonly time: string;
readonly reportedTime: string;
readonly birthTimeSource: BirthTimeSource;
readonly birthTimePeriod: BirthTimePeriod;
readonly birthTimeClue: string;
readonly uncertaintyBeforeMinutes: number | null;
readonly uncertaintyAfterMinutes: number | null;
readonly birthTimeStatus: BirthTimeStatus;
};
export type BirthTimeDraftPatch = Partial<BirthTimeDraft>;
export const birthTimeSourceOptions = [
{ value: "hospital_record", label: "出生证明或医院记录", hint: "先检查前后两分钟是否稳定" },
{ value: "family_exact", label: "家人明确记得具体时间", hint: "进行 5—15 分钟轻量校正" },
{ value: "approximate", label: "只记得大概几点", hint: "按你选择的误差范围扫描" },
{ value: "period_only", label: "只知道早晨、上午、下午或晚上", hint: "先从时段范围做粗筛" },
{ value: "unknown", label: "完全不知道", hint: "不要求你随便填写具体时间" },
] as const;
export const birthTimePeriodOptions = [
{ value: "early_morning", label: "凌晨 / 清晨(04:00—07:59" },
{ value: "morning", label: "上午(08:00—11:59" },
{ value: "afternoon", label: "下午(12:00—17:59" },
{ value: "evening", label: "晚上(18:00—22:59" },
{ value: "late_night", label: "深夜(23:00—03:59" },
] as const;
const periodLabels = {
"": "未选择时段",
early_morning: "凌晨或清晨",
morning: "上午",
afternoon: "下午",
evening: "晚上",
late_night: "深夜",
} as const satisfies Record<BirthTimePeriod, string>;
const intentCopy = {
confirm_stable_record: "医院记录前后两分钟内结构稳定,可以直接作为当前排盘时间。",
explain_sensitive_boundary: "这个时间靠近敏感边界,需要先做轻量校正,暂不直接排盘。",
explain_assessment_unavailable: "暂时无法完成稳定性检查,系统不会冒险应用这个具体时间。",
start_light_rectification: "先用几个高区分度问题检查家人记忆范围。",
start_standard_rectification: "这个误差范围内存在多个候选,需要先回答几个生活经历问题。",
start_period_rectification: "已保留你知道的时段,接下来先做粗粒度候选筛选。",
collect_time_clues: "不会要求你猜一个具体时间,先从家人线索和大致时段开始。",
continue_rectification_questions: "已记录这条线索,还需要更多证据才能形成候选范围。",
present_saved_candidate_range: "目前只能保存候选范围,还没有足够证据应用到具体分钟。",
} as const satisfies Record<JourneySnapshot["assistantIntent"], string>;
export function assistantIntentCopy(intent: JourneySnapshot["assistantIntent"]) {
return intentCopy[intent];
}
export function isBirthTimeDraftReady(draft: BirthTimeDraft) {
if (!draft.date) return false;
switch (draft.birthTimeSource) {
case "hospital_record":
case "legacy_import":
return Boolean(draft.reportedTime || draft.time);
case "family_exact":
return Boolean(draft.reportedTime)
&& [5, 10, 15].includes(draft.uncertaintyBeforeMinutes ?? -1)
&& draft.uncertaintyBeforeMinutes === draft.uncertaintyAfterMinutes;
case "approximate":
return Boolean(draft.reportedTime)
&& [15, 30, 60].includes(draft.uncertaintyBeforeMinutes ?? -1)
&& draft.uncertaintyBeforeMinutes === draft.uncertaintyAfterMinutes;
case "period_only":
return Boolean(draft.birthTimePeriod);
case "unknown":
return true;
case "":
return false;
default: {
const exhaustive: never = draft.birthTimeSource;
return exhaustive;
}
}
}
export function birthTimePersistenceValues(draft: BirthTimeDraft) {
const reportedTime = draft.reportedTime || draft.time || null;
const uncertainty = draft.birthTimeSource === "hospital_record"
? 2
: draft.birthTimeSource === "family_exact" || draft.birthTimeSource === "approximate"
? draft.uncertaintyBeforeMinutes
: null;
return {
reported_birth_time: reportedTime,
birth_time_source: draft.birthTimeSource || null,
birth_time_period: draft.birthTimePeriod || null,
birth_time_clue: draft.birthTimeClue.trim() || null,
uncertainty_before_minutes: uncertainty,
uncertainty_after_minutes: uncertainty,
};
}
export function describeBirthTimeDraft(draft: BirthTimeDraft) {
const [year, month, day] = draft.date.split("-").map(Number);
const date = `${year}${month}${day}`;
switch (draft.birthTimeSource) {
case "hospital_record":
return `${date} ${draft.reportedTime}(医院记录)`;
case "family_exact":
return `${date} ${draft.reportedTime}(家人明确记得,前后 ${draft.uncertaintyBeforeMinutes} 分钟)`;
case "approximate":
return `${date},约 ${draft.reportedTime}(前后 ${draft.uncertaintyBeforeMinutes} 分钟)`;
case "period_only":
return `${date}${periodLabels[draft.birthTimePeriod]}`;
case "unknown":
return `${date},具体时间未知`;
case "legacy_import":
return `${date} ${draft.reportedTime || draft.time}(既有已确认资料)`;
case "":
return date;
default: {
const exhaustive: never = draft.birthTimeSource;
return exhaustive;
}
}
}
@@ -0,0 +1,116 @@
import { z } from "zod";
import { journeySnapshotSchema } from "./birth-time-journey.ts";
const answerSchema = z.enum(["A", "B", "C", "D"]);
const questionnaireSchema = z.object({
questions: z.array(z.object({
id: z.string(),
prompt: z.string(),
options: z.array(z.object({
key: answerSchema,
label: z.string(),
})).optional(),
})),
samples: z.array(z.object({
ascendantSign: z.string().nullable(),
d9Sign: z.string().nullable(),
d10Sign: z.string().nullable(),
})),
raw: z.record(z.unknown()),
});
const scoringSchema = z.object({
answeredCount: z.number().int().min(0),
candidateClusterRankings: z.array(z.object({
cluster: z.string(),
score: z.number(),
})),
raw: z.record(z.unknown()),
});
const journeyResponseSchema = z.object({
caseId: z.string().uuid(),
snapshot: journeySnapshotSchema,
questionnaire: questionnaireSchema.nullable(),
scoring: scoringSchema.nullable(),
answers: z.record(answerSchema).default({}),
}).superRefine((value, context) => {
if (value.snapshot.route === "rectification" && value.snapshot.canApply) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ["snapshot", "canApply"],
message: "rectification results cannot apply an exact time",
});
}
if (value.snapshot.route === "direct_chart" && !value.snapshot.activeTime) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ["snapshot", "activeTime"],
message: "direct chart requires an active time",
});
}
});
const errorPayloadSchema = z.object({
message: z.string().optional(),
error: z.string().optional(),
});
export type JourneyClientResponse = z.infer<typeof journeyResponseSchema>;
export type JourneyAnswer = z.infer<typeof answerSchema>;
export class BirthTimeJourneyRequestError extends Error {
readonly name = "BirthTimeJourneyRequestError";
readonly status: number;
constructor(status: number, message: string) {
super(message);
this.status = status;
}
}
export function parseJourneyResponse(value: unknown): JourneyClientResponse {
return journeyResponseSchema.parse(value);
}
async function responsePayload(response: Response): Promise<unknown> {
try {
return await response.json();
} catch (error) {
if (error instanceof SyntaxError) return null;
throw error;
}
}
async function sendJourneyEvent(event: Readonly<Record<string, unknown>>) {
const response = await fetch("/api/birth-time-journey", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(event),
});
const payload = await responsePayload(response);
if (!response.ok) {
const parsedError = errorPayloadSchema.safeParse(payload);
const message = parsedError.success
? parsedError.data.message ?? parsedError.data.error ?? "生时评估暂时不可用"
: "生时评估暂时不可用";
throw new BirthTimeJourneyRequestError(response.status, message);
}
return parseJourneyResponse(payload);
}
export function requestBirthTimeAssessment() {
return sendJourneyEvent({ type: "assess" });
}
export function answerBirthTimeQuestion(
caseId: string,
questionId: string,
answer: JourneyAnswer,
) {
return sendJourneyEvent({ type: "answer_question", caseId, questionId, answer });
}
export function resumeBirthTimeJourney(caseId: string) {
return sendJourneyEvent({ type: "resume", caseId });
}
+40 -4
View File
@@ -57,7 +57,7 @@ export type StoredRectificationCase = {
readonly id: string;
readonly userId: string;
readonly snapshot: JourneySnapshot;
readonly questionnaire: RectificationQuestionnaire;
readonly questionnaire: RectificationQuestionnaire | null;
readonly answers: Readonly<Record<string, RectificationAnswer>>;
readonly scoring?: RectificationScoring & { readonly raw: Readonly<Record<string, unknown>> };
};
@@ -78,6 +78,7 @@ type JourneyResponse = {
readonly snapshot: JourneySnapshot;
readonly questionnaire: RectificationQuestionnaire | null;
readonly scoring: (RectificationScoring & { readonly raw: Readonly<Record<string, unknown>> }) | null;
readonly answers: Readonly<Record<string, RectificationAnswer>>;
};
export class RectificationCaseNotFoundError extends Error {
@@ -90,8 +91,30 @@ export class RectificationCaseNotFoundError extends Error {
}
}
export class RectificationQuestionsUnavailableError extends Error {
readonly name = "RectificationQuestionsUnavailableError";
}
function scanInput(assessment: BirthTimeAssessment): JourneyScanInput | null {
if (!("reportedTime" in assessment)) return null;
if (assessment.source === "unknown") return null;
if (assessment.source === "period_only") {
const periodScan = {
early_morning: { time: "06:00", uncertainty: 120 },
morning: { time: "10:00", uncertainty: 120 },
afternoon: { time: "15:00", uncertainty: 180 },
evening: { time: "20:30", uncertainty: 150 },
late_night: { time: "01:30", uncertainty: 150 },
} as const;
const scan = periodScan[assessment.period];
return {
birthTime: `${assessment.date} ${scan.time}`,
uncertaintyMinutes: scan.uncertainty,
lat: assessment.location.lat,
lon: assessment.location.lon,
tz: assessment.location.tz,
ayanamsa: "lahiri",
};
}
return {
birthTime: `${assessment.date} ${assessment.reportedTime}`,
uncertaintyMinutes: Math.max(
@@ -148,7 +171,19 @@ export function createBirthTimeJourneyService(ports: BirthTimeJourneyPorts) {
candidateScan: scan.questionnaire,
} satisfies PersistedJourneyAssessment;
const caseId = await ports.store.saveAssessment(persisted);
return { caseId, snapshot, questionnaire: scan.questionnaire, scoring: null };
return { caseId, snapshot, questionnaire: scan.questionnaire, scoring: null, answers: {} };
},
async resume(userId: string, caseId: string): Promise<JourneyResponse> {
const stored = await ports.store.loadCase(userId, caseId);
if (!stored) throw new RectificationCaseNotFoundError(caseId);
return {
caseId,
snapshot: stored.snapshot,
questionnaire: stored.questionnaire,
scoring: stored.scoring ?? null,
answers: stored.answers,
};
},
async answerQuestion(
@@ -159,12 +194,13 @@ export function createBirthTimeJourneyService(ports: BirthTimeJourneyPorts) {
): Promise<JourneyResponse> {
const stored = await ports.store.loadCase(userId, caseId);
if (!stored) throw new RectificationCaseNotFoundError(caseId);
if (!stored.questionnaire) throw new RectificationQuestionsUnavailableError();
const answers = { ...stored.answers, [questionId]: answer };
const scoring = await ports.engine.score({ questionnaire: stored.questionnaire, answers });
const snapshot = withRectificationScoring(stored.snapshot, scoring);
const updated = { ...stored, answers, scoring, snapshot } satisfies StoredRectificationCase;
await ports.store.saveScoring(updated);
return { caseId, snapshot, questionnaire: stored.questionnaire, scoring };
return { caseId, snapshot, questionnaire: stored.questionnaire, scoring, answers };
},
};
}
+9 -30
View File
@@ -8,40 +8,16 @@ import type {
PersistedJourneyAssessment,
StoredRectificationCase,
} from "./birth-time-journey-service.ts";
import type { JourneySnapshot } from "./birth-time-journey.ts";
const assistantIntentSchema = z.enum([
"confirm_stable_record",
"explain_sensitive_boundary",
"explain_assessment_unavailable",
"start_light_rectification",
"start_standard_rectification",
"start_period_rectification",
"collect_time_clues",
"continue_rectification_questions",
"present_saved_candidate_range",
]);
const snapshotSchema = z.object({
state: z.enum(["rectifying", "candidate", "ready"]),
assistantIntent: assistantIntentSchema,
input: z.enum(["none", "rectification_questions", "time_clue"]),
route: z.enum(["direct_chart", "rectification"]),
confidence: z.literal("high").nullable(),
canApply: z.boolean(),
activeTime: z.string().nullable(),
reportedRange: z.object({
label: z.string(),
startTime: z.string().nullable(),
endTime: z.string().nullable(),
}),
});
import {
journeySnapshotSchema,
type JourneySnapshot,
} from "./birth-time-journey.ts";
const answerSchema = z.enum(["A", "B", "C", "D"]);
const storedCaseSchema = z.object({
id: z.string().uuid(),
user_id: z.string().uuid(),
journey_snapshot: snapshotSchema,
journey_snapshot: journeySnapshotSchema,
questionnaire: z.record(z.unknown()),
answers: z.record(answerSchema),
scoring_result: z.record(z.unknown()),
@@ -156,11 +132,14 @@ export function createSupabaseBirthTimeJourneyStore(
raw: parsed.scoring_result,
}
: undefined;
const questionnaire = Object.keys(parsed.questionnaire).length > 0
? parseRectificationQuestionnaire(parsed.questionnaire)
: null;
return {
id: parsed.id,
userId: parsed.user_id,
snapshot: parsed.journey_snapshot,
questionnaire: parseRectificationQuestionnaire(parsed.questionnaire),
questionnaire,
answers: parsed.answers,
...(scoring ? { scoring } : {}),
} satisfies StoredRectificationCase;
+26 -23
View File
@@ -61,29 +61,32 @@ export type ScanStability =
| { readonly kind: "unavailable" }
| { readonly kind: "not_required" };
export type JourneySnapshot = {
readonly state: "rectifying" | "candidate" | "ready";
readonly assistantIntent:
| "confirm_stable_record"
| "explain_sensitive_boundary"
| "explain_assessment_unavailable"
| "start_light_rectification"
| "start_standard_rectification"
| "start_period_rectification"
| "collect_time_clues"
| "continue_rectification_questions"
| "present_saved_candidate_range";
readonly input: "none" | "rectification_questions" | "time_clue";
readonly route: "direct_chart" | "rectification";
readonly confidence: "high" | null;
readonly canApply: boolean;
readonly activeTime: string | null;
readonly reportedRange: {
readonly label: string;
readonly startTime: string | null;
readonly endTime: string | null;
};
};
export const journeySnapshotSchema = z.object({
state: z.enum(["rectifying", "candidate", "ready"]),
assistantIntent: z.enum([
"confirm_stable_record",
"explain_sensitive_boundary",
"explain_assessment_unavailable",
"start_light_rectification",
"start_standard_rectification",
"start_period_rectification",
"collect_time_clues",
"continue_rectification_questions",
"present_saved_candidate_range",
]),
input: z.enum(["none", "rectification_questions", "time_clue"]),
route: z.enum(["direct_chart", "rectification"]),
confidence: z.literal("high").nullable(),
canApply: z.boolean(),
activeTime: z.string().nullable(),
reportedRange: z.object({
label: z.string(),
startTime: z.string().nullable(),
endTime: z.string().nullable(),
}).readonly(),
}).readonly();
export type JourneySnapshot = z.infer<typeof journeySnapshotSchema>;
export type RectificationScoring = {
readonly answeredCount: number;
+4 -2
View File
@@ -16,6 +16,7 @@ export const consultationInputSchema = z.object({
city: z.string().trim().min(1).max(120),
question: z.string().trim().min(1).max(500),
theme: z.enum(["career", "marriage", "wealth", "timing", "general"]),
entryMode: z.enum(["direct_chart", "rectification"]).default("direct_chart"),
});
export type ConsultationInput = z.infer<typeof consultationInputSchema>;
@@ -33,12 +34,13 @@ const jyotishSkillPath = process.env.JYOTISH_SKILL_PATH?.trim()
|| path.resolve(process.cwd(), "..", "skills", "jyotish-vedic-astrology");
export async function runConsultationWorkflow(input: ConsultationInput) {
const { entryMode, ...workflowInput } = input;
const response = await fetch(`${apiBase}/api/consultation_workflow`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
...input,
entry_mode: "direct_chart",
...workflowInput,
entry_mode: entryMode,
question_text: input.question,
theme: input.theme === "general" ? ["career", "marriage", "wealth"] : [input.theme],
}),
@@ -195,17 +195,14 @@ grant update (
updated_at
) on table public.birth_time_rectification_cases to authenticated;
revoke update (birth_time) on table public.profiles from authenticated;
grant update (
reported_birth_time,
active_birth_time,
birth_time_source,
birth_time_period,
birth_time_clue,
uncertainty_before_minutes,
uncertainty_after_minutes,
birth_time_status,
rectification_confidence,
rectification_case_id
uncertainty_after_minutes
) on table public.profiles to authenticated;
commit;
+81
View File
@@ -0,0 +1,81 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
assistantIntentCopy,
birthTimePersistenceValues,
describeBirthTimeDraft,
isBirthTimeDraftReady,
type BirthTimeDraft,
} from "../src/lib/birth-time-intake-model.ts";
const emptyDraft: BirthTimeDraft = {
date: "1993-04-17",
time: "",
reportedTime: "",
birthTimeSource: "",
birthTimePeriod: "",
birthTimeClue: "",
uncertaintyBeforeMinutes: null,
uncertaintyAfterMinutes: null,
birthTimeStatus: "",
};
test("birth time intake requires only the fields selected by the source", () => {
const hospital = {
...emptyDraft,
birthTimeSource: "hospital_record",
reportedTime: "08:16",
} satisfies BirthTimeDraft;
const period = {
...emptyDraft,
birthTimeSource: "period_only",
birthTimePeriod: "evening",
} satisfies BirthTimeDraft;
const incompleteApproximate = {
...emptyDraft,
birthTimeSource: "approximate",
reportedTime: "14:30",
} satisfies BirthTimeDraft;
assert.equal(isBirthTimeDraftReady(hospital), true);
assert.equal(isBirthTimeDraftReady(period), true);
assert.equal(isBirthTimeDraftReady(incompleteApproximate), false);
assert.equal(isBirthTimeDraftReady({ ...emptyDraft, birthTimeSource: "unknown" }), true);
});
test("birth time declaration payload cannot write deterministic application fields", () => {
const draft = {
...emptyDraft,
time: "14:24",
reportedTime: "14:30",
birthTimeSource: "approximate",
uncertaintyBeforeMinutes: 30,
uncertaintyAfterMinutes: 30,
birthTimeStatus: "confirmed",
} satisfies BirthTimeDraft;
assert.deepEqual(birthTimePersistenceValues(draft), {
reported_birth_time: "14:30",
birth_time_source: "approximate",
birth_time_period: null,
birth_time_clue: null,
uncertainty_before_minutes: 30,
uncertainty_after_minutes: 30,
});
});
test("birth time intake describes uncertainty without claiming false precision", () => {
const approximate = {
...emptyDraft,
birthTimeSource: "approximate",
reportedTime: "14:30",
uncertaintyBeforeMinutes: 30,
uncertaintyAfterMinutes: 30,
} satisfies BirthTimeDraft;
assert.equal(describeBirthTimeDraft(approximate), "1993年4月17日,约 14:30(前后 30 分钟)");
assert.equal(
assistantIntentCopy("present_saved_candidate_range"),
"目前只能保存候选范围,还没有足够证据应用到具体分钟。",
);
});
@@ -0,0 +1,43 @@
import assert from "node:assert/strict";
import test from "node:test";
import { parseJourneyResponse } from "../src/lib/birth-time-journey-client.ts";
const snapshot = {
state: "rectifying",
assistantIntent: "start_standard_rectification",
input: "rectification_questions",
route: "rectification",
confidence: null,
canApply: false,
activeTime: null,
reportedRange: { label: "14:00—15:00", startTime: "14:00", endTime: "15:00" },
} as const;
test("journey client parses the sanitized API response", () => {
const parsed = parseJourneyResponse({
caseId: "7299894c-10a8-4b45-91d1-339007282c50",
snapshot,
questionnaire: {
questions: [{
id: "education_environment_shift",
prompt: "是否有明显学业变化?",
options: [{ key: "A", label: "明确有" }],
}],
samples: [],
raw: {},
},
scoring: null,
});
assert.equal(parsed.snapshot.route, "rectification");
assert.equal(parsed.questionnaire?.questions[0]?.id, "education_environment_shift");
});
test("journey client rejects an API response that tries to apply a rectification result", () => {
assert.throws(() => parseJourneyResponse({
caseId: "7299894c-10a8-4b45-91d1-339007282c50",
snapshot: { ...snapshot, canApply: true, activeTime: "14:24" },
questionnaire: null,
scoring: null,
}));
});
@@ -150,3 +150,67 @@ test("journey service accumulates answers while preserving the application gate"
assert.equal(result.snapshot.canApply, false);
assert.deepEqual(memory.savedCase()?.answers, scoredAnswers);
});
test("journey service resumes an owner-scoped unfinished case", async () => {
const questionnaire = scanWithSigns(["Cancer", "Leo"]).questionnaire;
const storedCase: StoredRectificationCase = {
id: "case-1",
userId: "user-1",
snapshot: {
state: "rectifying",
assistantIntent: "continue_rectification_questions",
input: "rectification_questions",
route: "rectification",
confidence: null,
canApply: false,
activeTime: null,
reportedRange: { label: "14:00—15:00", startTime: "14:00", endTime: "15:00" },
},
questionnaire,
answers: { education_environment_shift: "A" },
};
const service = createBirthTimeJourneyService({
store: memoryStore(storedCase).store,
engine: {
async scan() { throw new Error("not used"); },
async score() { throw new Error("not used"); },
},
});
const result = await service.resume("user-1", "case-1");
assert.equal(result.caseId, "case-1");
assert.deepEqual(result.answers, { education_environment_shift: "A" });
assert.equal(result.snapshot.canApply, false);
});
test("journey service resumes a fail-closed case without a questionnaire", async () => {
const storedCase: StoredRectificationCase = {
id: "case-2",
userId: "user-1",
snapshot: {
state: "rectifying",
assistantIntent: "explain_assessment_unavailable",
input: "rectification_questions",
route: "rectification",
confidence: null,
canApply: false,
activeTime: null,
reportedRange: { label: "08:14—08:18", startTime: "08:14", endTime: "08:18" },
},
questionnaire: null,
answers: {},
};
const service = createBirthTimeJourneyService({
store: memoryStore(storedCase).store,
engine: {
async scan() { throw new Error("not used"); },
async score() { throw new Error("not used"); },
},
});
const result = await service.resume("user-1", "case-2");
assert.equal(result.questionnaire, null);
assert.equal(result.snapshot.canApply, false);
});
@@ -3,6 +3,7 @@ import test from "node:test";
import {
assessBirthTime,
birthTimeAssessmentSchema,
journeySnapshotSchema,
withRectificationScoring,
} from "../src/lib/birth-time-journey.ts";
@@ -25,6 +26,7 @@ test("birth time journey sends a stable hospital record directly to charting", (
assert.equal(snapshot.canApply, true);
assert.equal(snapshot.activeTime, "08:16");
assert.equal(snapshot.assistantIntent, "confirm_stable_record");
assert.equal(journeySnapshotSchema.safeParse(snapshot).success, true);
});
test("birth time journey fails a sensitive hospital record closed into rectification", () => {
+25
View File
@@ -9,6 +9,7 @@ MIGRATION = (
/ "migrations"
/ "20260717020000_birth_time_journey.sql"
)
FRONTEND = Path(__file__).resolve().parents[1] / "frontend"
def _sql() -> str:
@@ -40,6 +41,12 @@ def test_birth_time_profile_contract_separates_reported_and_active_times() -> No
assert "new.reported_birth_time is distinct from old.reported_birth_time" in sql
assert "raise exception 'reported_birth_time_is_immutable'" in sql
assert "new.birth_time := new.active_birth_time" in sql
assert "revoke update (birth_time) on table public.profiles from authenticated" in sql
client_grant = sql.split("grant update ( reported_birth_time", 1)[1]
client_grant = client_grant.split(") on table public.profiles to authenticated", 1)[0]
assert "active_birth_time" not in client_grant
assert "birth_time_status" not in client_grant
assert "rectification_case_id" not in client_grant
def test_birth_time_profile_contract_constrains_deterministic_states() -> None:
@@ -92,3 +99,21 @@ def test_rectification_cases_are_owner_scoped_and_auditable() -> None:
assert "grant insert" in sql
assert "grant update" in sql
assert "grant delete" not in sql
def test_web_onboarding_uses_the_deterministic_free_journey() -> None:
page = (FRONTEND / "src" / "app" / "page.tsx").read_text(encoding="utf-8")
route = (
FRONTEND / "src" / "app" / "api" / "birth-time-journey" / "route.ts"
).read_text(encoding="utf-8")
mastra = (FRONTEND / "src" / "mastra" / "index.ts").read_text(encoding="utf-8")
assert "<BirthTimeIntakeFields" in page
assert "requestBirthTimeAssessment" in page
assert "<BirthTimeRectification" in page
assert "reported_birth_time" in page
assert "active_birth_time" in page
assert "birthTimeStatus" in page
assert "consultation-billing" not in route
assert 'entry_mode: entryMode' in mastra
assert 'entry_mode: "direct_chart"' not in mastra