feat: integrate birth time journey into onboarding
This commit is contained in:
@@ -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: "已保留当前资料,请稍后重试。" },
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user