Merge branch 'codex/birth-time-journey'
# Conflicts: # frontend/src/app/globals.css
This commit is contained in:
@@ -0,0 +1,140 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { parseBirthTimeProfile } from "@/lib/birth-time-journey-adapters";
|
||||
import {
|
||||
createJyotishBirthTimeJourneyEngine,
|
||||
BirthTimeJourneyEngineError,
|
||||
} from "@/lib/birth-time-journey-engine";
|
||||
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";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
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(),
|
||||
questionId: z.string().trim().min(1).max(120),
|
||||
answer: z.enum(["A", "B", "C", "D"]),
|
||||
}).strict(),
|
||||
]);
|
||||
|
||||
async function requestPayload(request: Request): Promise<unknown> {
|
||||
try {
|
||||
return await request.json();
|
||||
} catch (error) {
|
||||
if (error instanceof SyntaxError) return null;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
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(
|
||||
{ error: "服务尚未配置", message: "请先配置 Supabase 环境变量。" },
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
const { data: { user }, error: authError } = await supabase.auth.getUser();
|
||||
if (authError || !user) {
|
||||
return NextResponse.json(
|
||||
{ error: "请先登录", message: "登录后才能继续出生时间评估。" },
|
||||
{ status: 401 },
|
||||
);
|
||||
}
|
||||
|
||||
const parsed = eventSchema.safeParse(await requestPayload(request));
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: "生时评估请求格式不正确", details: parsed.error.flatten() },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const service = createBirthTimeJourneyService({
|
||||
store: createSupabaseBirthTimeJourneyStore(journeyStoreClient),
|
||||
engine: createJyotishBirthTimeJourneyEngine(),
|
||||
});
|
||||
|
||||
try {
|
||||
switch (parsed.data.type) {
|
||||
case "assess": {
|
||||
const { data: profile, error } = await supabase
|
||||
.from("profiles")
|
||||
.select("birth_date,reported_birth_time,birth_time_source,birth_time_period,birth_time_clue,uncertainty_before_minutes,uncertainty_after_minutes,latitude,longitude,timezone_offset")
|
||||
.eq("id", user.id)
|
||||
.maybeSingle();
|
||||
if (error) throw new BirthTimeJourneyStoreError("load_case");
|
||||
if (!profile) {
|
||||
return NextResponse.json(
|
||||
{ error: "出生资料尚未完成", message: "请先填写出生日期、时间情况和地点。" },
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
const assessment = parseBirthTimeProfile(profile);
|
||||
return NextResponse.json(await service.assess(user.id, assessment));
|
||||
}
|
||||
case "answer_question":
|
||||
return NextResponse.json(await service.answerQuestion(
|
||||
user.id,
|
||||
parsed.data.caseId,
|
||||
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;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json(
|
||||
{ error: "出生资料尚未完成", message: "请检查出生时间情况和地点后重试。" },
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
if (error instanceof RectificationCaseNotFoundError) {
|
||||
return NextResponse.json(
|
||||
{ error: "校正记录不存在", message: "请重新开始出生时间评估。" },
|
||||
{ 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: "已保留当前资料,请稍后重试。" },
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -295,6 +295,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); }
|
||||
@@ -448,6 +485,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); }
|
||||
.danger-primary:not(:disabled):hover { background: color-mix(in srgb, var(--color-danger) 88%, var(--color-ink)); }
|
||||
.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) {
|
||||
@@ -488,6 +526,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); }
|
||||
.account-modal h2 { font-size: var(--type-title-lg); }
|
||||
|
||||
+242
-37
@@ -4,12 +4,30 @@ import Link from "next/link";
|
||||
import { ArrowUp, ArrowUpRight, ChevronRight, Gift, KeyRound, LogOut, Menu, Plus, Sparkles, Square, UserRound, 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 AccountDialog = "profile" | "redeem" | "logout";
|
||||
type SessionReadResult = { readonly sessions: ChatSession[]; readonly fallbackSessionIds: string[] };
|
||||
@@ -86,6 +103,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>> = {
|
||||
@@ -134,6 +175,14 @@ const emptyProfile: Profile = {
|
||||
name: "",
|
||||
date: "",
|
||||
time: "",
|
||||
reportedTime: "",
|
||||
birthTimeSource: "",
|
||||
birthTimePeriod: "",
|
||||
birthTimeClue: "",
|
||||
uncertaintyBeforeMinutes: null,
|
||||
uncertaintyAfterMinutes: null,
|
||||
birthTimeStatus: "",
|
||||
rectificationCaseId: "",
|
||||
countryCode: "CN",
|
||||
provinceCode: "",
|
||||
cityCode: "",
|
||||
@@ -181,18 +230,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) {
|
||||
@@ -206,7 +255,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 },
|
||||
@@ -251,13 +300,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;
|
||||
@@ -266,6 +340,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 : "",
|
||||
@@ -315,15 +397,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 ?? [];
|
||||
@@ -350,7 +423,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} />
|
||||
</>
|
||||
);
|
||||
@@ -470,6 +543,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);
|
||||
@@ -515,6 +592,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;
|
||||
@@ -546,7 +625,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",
|
||||
@@ -570,6 +657,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);
|
||||
@@ -604,7 +692,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(),
|
||||
@@ -649,6 +737,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) {
|
||||
@@ -976,7 +1078,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,
|
||||
@@ -993,16 +1095,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 {
|
||||
@@ -1035,7 +1166,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 {
|
||||
@@ -1059,9 +1190,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 {
|
||||
@@ -1069,6 +1204,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();
|
||||
@@ -1371,6 +1543,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,
|
||||
@@ -1601,7 +1774,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" ref={creditTrigger} type="button" onClick={() => openAccountDialog("redeem", creditTrigger.current)} aria-label={account ? `余额 ${account.credits} 点,兑换点数` : accountError || "读取余额中"}>
|
||||
<Sparkles className="credit-icon" aria-hidden="true" />
|
||||
@@ -1617,8 +1794,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} />}
|
||||
</>
|
||||
@@ -1633,10 +1812,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>
|
||||
@@ -1655,6 +1834,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 && (onboardingPending ? (
|
||||
@@ -1748,7 +1949,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>
|
||||
)}
|
||||
@@ -1760,7 +1961,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,143 @@
|
||||
import { z } from "zod";
|
||||
import {
|
||||
birthTimeAssessmentSchema,
|
||||
type BirthTimeAssessment,
|
||||
} from "./birth-time-journey.ts";
|
||||
import type {
|
||||
RectificationAnswer,
|
||||
RectificationQuestionnaire,
|
||||
} from "./birth-time-journey-service.ts";
|
||||
|
||||
const profileSchema = z.object({
|
||||
birth_date: z.string(),
|
||||
reported_birth_time: z.string().nullable().optional(),
|
||||
birth_time_source: z.enum([
|
||||
"hospital_record",
|
||||
"family_exact",
|
||||
"approximate",
|
||||
"period_only",
|
||||
"unknown",
|
||||
]),
|
||||
birth_time_period: z.enum([
|
||||
"early_morning",
|
||||
"morning",
|
||||
"afternoon",
|
||||
"evening",
|
||||
"late_night",
|
||||
]).nullable().optional(),
|
||||
birth_time_clue: z.string().nullable().optional(),
|
||||
uncertainty_before_minutes: z.number().int().nullable().optional(),
|
||||
uncertainty_after_minutes: z.number().int().nullable().optional(),
|
||||
latitude: z.number(),
|
||||
longitude: z.number(),
|
||||
timezone_offset: z.number(),
|
||||
});
|
||||
|
||||
const optionSchema = z.object({
|
||||
key: z.enum(["A", "B", "C", "D"]),
|
||||
label: z.string().trim().min(1),
|
||||
});
|
||||
|
||||
const questionSchema = z.object({
|
||||
id: z.string().trim().min(1),
|
||||
prompt: z.string().trim().min(1),
|
||||
options: z.array(optionSchema).optional(),
|
||||
});
|
||||
|
||||
const signSchema = z.object({ sign: z.string().trim().min(1) }).nullable().optional();
|
||||
const sampleSchema = z.object({
|
||||
ascendant: signSchema,
|
||||
varga_lagna: z.object({
|
||||
D9: signSchema,
|
||||
D10: signSchema,
|
||||
}).optional(),
|
||||
});
|
||||
|
||||
const questionnaireSchema = z.object({
|
||||
questions: z.array(questionSchema),
|
||||
candidate_scan: z.object({ samples: z.array(sampleSchema) }),
|
||||
}).passthrough();
|
||||
|
||||
const scoringSchema = z.object({
|
||||
answered_count: z.number().int().min(0),
|
||||
candidate_cluster_rankings: z.array(z.object({
|
||||
cluster: z.string().trim().min(1),
|
||||
score: z.number(),
|
||||
})),
|
||||
}).passthrough();
|
||||
|
||||
class UnexpectedProfileSourceError extends Error {
|
||||
readonly name = "UnexpectedProfileSourceError";
|
||||
|
||||
constructor(source: never) {
|
||||
super(`Unexpected profile birth-time source: ${JSON.stringify(source)}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function parseBirthTimeProfile(value: unknown): BirthTimeAssessment {
|
||||
const profile = profileSchema.parse(value);
|
||||
const location = {
|
||||
lat: profile.latitude,
|
||||
lon: profile.longitude,
|
||||
tz: profile.timezone_offset,
|
||||
};
|
||||
switch (profile.birth_time_source) {
|
||||
case "hospital_record":
|
||||
case "family_exact":
|
||||
case "approximate":
|
||||
return birthTimeAssessmentSchema.parse({
|
||||
date: profile.birth_date,
|
||||
source: profile.birth_time_source,
|
||||
reportedTime: profile.reported_birth_time?.slice(0, 5),
|
||||
uncertaintyBeforeMinutes: profile.uncertainty_before_minutes,
|
||||
uncertaintyAfterMinutes: profile.uncertainty_after_minutes,
|
||||
location,
|
||||
});
|
||||
case "period_only":
|
||||
return birthTimeAssessmentSchema.parse({
|
||||
date: profile.birth_date,
|
||||
source: profile.birth_time_source,
|
||||
period: profile.birth_time_period,
|
||||
location,
|
||||
});
|
||||
case "unknown":
|
||||
return birthTimeAssessmentSchema.parse({
|
||||
date: profile.birth_date,
|
||||
source: profile.birth_time_source,
|
||||
clue: profile.birth_time_clue ?? "",
|
||||
location,
|
||||
});
|
||||
default:
|
||||
throw new UnexpectedProfileSourceError(profile.birth_time_source);
|
||||
}
|
||||
}
|
||||
|
||||
export function parseRectificationQuestionnaire(value: unknown): RectificationQuestionnaire {
|
||||
const parsed = questionnaireSchema.parse(value);
|
||||
return {
|
||||
questions: parsed.questions.map((question) => ({
|
||||
id: question.id,
|
||||
prompt: question.prompt,
|
||||
...(question.options ? { options: question.options } : {}),
|
||||
})),
|
||||
samples: parsed.candidate_scan.samples.map((sample) => ({
|
||||
ascendantSign: sample.ascendant?.sign ?? null,
|
||||
d9Sign: sample.varga_lagna?.D9?.sign ?? null,
|
||||
d10Sign: sample.varga_lagna?.D10?.sign ?? null,
|
||||
})),
|
||||
raw: parsed,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseRectificationScoring(value: unknown) {
|
||||
const parsed = scoringSchema.parse(value);
|
||||
return {
|
||||
answeredCount: parsed.answered_count,
|
||||
candidateClusterRankings: parsed.candidate_cluster_rankings,
|
||||
raw: parsed,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseRectificationAnswer(value: unknown): RectificationAnswer {
|
||||
return z.enum(["A", "B", "C", "D"]).parse(value);
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import "server-only";
|
||||
|
||||
import {
|
||||
parseRectificationQuestionnaire,
|
||||
parseRectificationScoring,
|
||||
} from "./birth-time-journey-adapters.ts";
|
||||
import type { BirthTimeJourneyEngine } from "./birth-time-journey-service.ts";
|
||||
|
||||
export class BirthTimeJourneyEngineError extends Error {
|
||||
readonly name = "BirthTimeJourneyEngineError";
|
||||
readonly status: number;
|
||||
|
||||
constructor(status: number) {
|
||||
super(`Jyotish birth-time engine returned ${status}`);
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
async function postJson(apiBase: string, path: string, body: unknown): Promise<unknown> {
|
||||
const response = await fetch(`${apiBase}${path}`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
signal: AbortSignal.timeout(45_000),
|
||||
});
|
||||
const payload: unknown = await response.json();
|
||||
if (!response.ok) throw new BirthTimeJourneyEngineError(response.status);
|
||||
return payload;
|
||||
}
|
||||
|
||||
export function createJyotishBirthTimeJourneyEngine(
|
||||
apiBase = process.env.JYOTISH_API_BASE ?? "http://127.0.0.1:5200",
|
||||
): BirthTimeJourneyEngine {
|
||||
return {
|
||||
async scan(input) {
|
||||
const payload = await postJson(apiBase, "/api/active_rectification_questions", {
|
||||
birth_time: input.birthTime,
|
||||
uncertainty_minutes: input.uncertaintyMinutes,
|
||||
step_minutes: 1,
|
||||
lat: input.lat,
|
||||
lon: input.lon,
|
||||
tz: input.tz,
|
||||
ayanamsa: input.ayanamsa,
|
||||
});
|
||||
return { questionnaire: parseRectificationQuestionnaire(payload) };
|
||||
},
|
||||
|
||||
async score(input) {
|
||||
const payload = await postJson(apiBase, "/api/active_rectification_score", {
|
||||
questionnaire: input.questionnaire.raw,
|
||||
answers: input.answers,
|
||||
});
|
||||
return parseRectificationScoring(payload);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
import {
|
||||
assessBirthTime,
|
||||
withRectificationScoring,
|
||||
type BirthTimeAssessment,
|
||||
type JourneySnapshot,
|
||||
type RectificationScoring,
|
||||
type ScanStability,
|
||||
} from "./birth-time-journey.ts";
|
||||
|
||||
export type RectificationAnswer = "A" | "B" | "C" | "D";
|
||||
|
||||
export type RectificationQuestionnaire = {
|
||||
readonly questions: readonly {
|
||||
readonly id: string;
|
||||
readonly prompt: string;
|
||||
readonly options?: readonly {
|
||||
readonly key: RectificationAnswer;
|
||||
readonly label: string;
|
||||
}[];
|
||||
}[];
|
||||
readonly samples: readonly {
|
||||
readonly ascendantSign: string | null;
|
||||
readonly d9Sign: string | null;
|
||||
readonly d10Sign: string | null;
|
||||
}[];
|
||||
readonly raw: Readonly<Record<string, unknown>>;
|
||||
};
|
||||
|
||||
export type JourneyScanInput = {
|
||||
readonly birthTime: string;
|
||||
readonly uncertaintyMinutes: number;
|
||||
readonly lat: number;
|
||||
readonly lon: number;
|
||||
readonly tz: number;
|
||||
readonly ayanamsa: "lahiri";
|
||||
};
|
||||
|
||||
export type JourneyScoreInput = {
|
||||
readonly questionnaire: RectificationQuestionnaire;
|
||||
readonly answers: Readonly<Record<string, RectificationAnswer>>;
|
||||
};
|
||||
|
||||
export interface BirthTimeJourneyEngine {
|
||||
scan(input: JourneyScanInput): Promise<{ readonly questionnaire: RectificationQuestionnaire }>;
|
||||
score(input: JourneyScoreInput): Promise<RectificationScoring & { readonly raw: Readonly<Record<string, unknown>> }>;
|
||||
}
|
||||
|
||||
export type PersistedJourneyAssessment = {
|
||||
readonly userId: string;
|
||||
readonly assessment: BirthTimeAssessment;
|
||||
readonly snapshot: JourneySnapshot;
|
||||
readonly questionnaire: RectificationQuestionnaire | null;
|
||||
readonly candidateScan: RectificationQuestionnaire | null;
|
||||
};
|
||||
|
||||
export type StoredRectificationCase = {
|
||||
readonly id: string;
|
||||
readonly userId: string;
|
||||
readonly snapshot: JourneySnapshot;
|
||||
readonly questionnaire: RectificationQuestionnaire | null;
|
||||
readonly answers: Readonly<Record<string, RectificationAnswer>>;
|
||||
readonly scoring?: RectificationScoring & { readonly raw: Readonly<Record<string, unknown>> };
|
||||
};
|
||||
|
||||
export interface BirthTimeJourneyStore {
|
||||
saveAssessment(value: PersistedJourneyAssessment): Promise<string>;
|
||||
loadCase(userId: string, caseId: string): Promise<StoredRectificationCase | null>;
|
||||
saveScoring(value: StoredRectificationCase): Promise<void>;
|
||||
}
|
||||
|
||||
type BirthTimeJourneyPorts = {
|
||||
readonly store: BirthTimeJourneyStore;
|
||||
readonly engine: BirthTimeJourneyEngine;
|
||||
};
|
||||
|
||||
type JourneyResponse = {
|
||||
readonly caseId: string;
|
||||
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 {
|
||||
readonly name = "RectificationCaseNotFoundError";
|
||||
readonly caseId: string;
|
||||
|
||||
constructor(caseId: string) {
|
||||
super(`Rectification case ${caseId} was not found`);
|
||||
this.caseId = caseId;
|
||||
}
|
||||
}
|
||||
|
||||
export class RectificationQuestionsUnavailableError extends Error {
|
||||
readonly name = "RectificationQuestionsUnavailableError";
|
||||
}
|
||||
|
||||
function scanInput(assessment: BirthTimeAssessment): JourneyScanInput | 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(
|
||||
assessment.uncertaintyBeforeMinutes,
|
||||
assessment.uncertaintyAfterMinutes,
|
||||
),
|
||||
lat: assessment.location.lat,
|
||||
lon: assessment.location.lon,
|
||||
tz: assessment.location.tz,
|
||||
ayanamsa: "lahiri",
|
||||
};
|
||||
}
|
||||
|
||||
function questionnaireStability(questionnaire: RectificationQuestionnaire): ScanStability {
|
||||
if (questionnaire.samples.length < 2) return { kind: "unavailable" };
|
||||
const signatures = questionnaire.samples.map((sample) => {
|
||||
if (!sample.ascendantSign || !sample.d9Sign || !sample.d10Sign) return null;
|
||||
return `${sample.ascendantSign}|${sample.d9Sign}|${sample.d10Sign}`;
|
||||
});
|
||||
if (signatures.some((signature) => signature === null)) return { kind: "unavailable" };
|
||||
return new Set(signatures).size === 1 ? { kind: "stable" } : { kind: "sensitive" };
|
||||
}
|
||||
|
||||
async function scanAssessment(
|
||||
engine: BirthTimeJourneyEngine,
|
||||
assessment: BirthTimeAssessment,
|
||||
): Promise<{ readonly stability: ScanStability; readonly questionnaire: RectificationQuestionnaire | null }> {
|
||||
const input = scanInput(assessment);
|
||||
if (!input) return { stability: { kind: "not_required" }, questionnaire: null };
|
||||
try {
|
||||
const result = await engine.scan(input);
|
||||
return {
|
||||
stability: questionnaireStability(result.questionnaire),
|
||||
questionnaire: result.questionnaire,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
return { stability: { kind: "unavailable" }, questionnaire: null };
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export function createBirthTimeJourneyService(ports: BirthTimeJourneyPorts) {
|
||||
return {
|
||||
async assess(userId: string, assessment: BirthTimeAssessment): Promise<JourneyResponse> {
|
||||
const scan = await scanAssessment(ports.engine, assessment);
|
||||
const snapshot = assessBirthTime(assessment, scan.stability);
|
||||
const persisted = {
|
||||
userId,
|
||||
assessment,
|
||||
snapshot,
|
||||
questionnaire: scan.questionnaire,
|
||||
candidateScan: scan.questionnaire,
|
||||
} satisfies PersistedJourneyAssessment;
|
||||
const caseId = await ports.store.saveAssessment(persisted);
|
||||
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(
|
||||
userId: string,
|
||||
caseId: string,
|
||||
questionId: string,
|
||||
answer: RectificationAnswer,
|
||||
): 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, answers };
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
import "server-only";
|
||||
|
||||
import type { SupabaseClient } from "@supabase/supabase-js";
|
||||
import { z } from "zod";
|
||||
import { parseRectificationQuestionnaire } from "./birth-time-journey-adapters.ts";
|
||||
import type {
|
||||
BirthTimeJourneyStore,
|
||||
PersistedJourneyAssessment,
|
||||
StoredRectificationCase,
|
||||
} from "./birth-time-journey-service.ts";
|
||||
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: journeySnapshotSchema,
|
||||
questionnaire: z.record(z.unknown()),
|
||||
answers: z.record(answerSchema),
|
||||
scoring_result: z.record(z.unknown()),
|
||||
});
|
||||
|
||||
export class BirthTimeJourneyStoreError extends Error {
|
||||
readonly name = "BirthTimeJourneyStoreError";
|
||||
|
||||
constructor(readonly operation: "insert_case" | "update_profile" | "load_case" | "update_case") {
|
||||
super(`Birth-time journey persistence failed during ${operation}`);
|
||||
}
|
||||
}
|
||||
|
||||
function caseStatus(snapshot: JourneySnapshot) {
|
||||
switch (snapshot.state) {
|
||||
case "ready":
|
||||
return "confirmed";
|
||||
case "candidate":
|
||||
return "candidate";
|
||||
case "rectifying":
|
||||
return "rectifying";
|
||||
default: {
|
||||
const exhaustive: never = snapshot.state;
|
||||
return exhaustive;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function profileStatus(snapshot: JourneySnapshot) {
|
||||
return snapshot.state === "ready" ? "confirmed" : caseStatus(snapshot);
|
||||
}
|
||||
|
||||
function assessmentValues(value: PersistedJourneyAssessment) {
|
||||
const assessment = value.assessment;
|
||||
return {
|
||||
reportedTime: "reportedTime" in assessment ? assessment.reportedTime : null,
|
||||
period: assessment.source === "period_only" ? assessment.period : null,
|
||||
clue: assessment.source === "unknown" ? assessment.clue : null,
|
||||
before: "uncertaintyBeforeMinutes" in assessment
|
||||
? assessment.uncertaintyBeforeMinutes
|
||||
: null,
|
||||
after: "uncertaintyAfterMinutes" in assessment
|
||||
? assessment.uncertaintyAfterMinutes
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function createSupabaseBirthTimeJourneyStore(
|
||||
supabase: SupabaseClient,
|
||||
): BirthTimeJourneyStore {
|
||||
return {
|
||||
async saveAssessment(value) {
|
||||
const details = assessmentValues(value);
|
||||
const { data, error } = await supabase
|
||||
.from("birth_time_rectification_cases")
|
||||
.insert({
|
||||
user_id: value.userId,
|
||||
status: caseStatus(value.snapshot),
|
||||
reported_date: value.assessment.date,
|
||||
reported_time: details.reportedTime,
|
||||
reported_period: details.period,
|
||||
source: value.assessment.source,
|
||||
uncertainty_before_minutes: details.before,
|
||||
uncertainty_after_minutes: details.after,
|
||||
questionnaire: value.questionnaire?.raw ?? {},
|
||||
journey_snapshot: value.snapshot,
|
||||
candidate_scan: value.candidateScan?.raw ?? {},
|
||||
candidate_start: value.snapshot.reportedRange.startTime,
|
||||
candidate_end: value.snapshot.reportedRange.endTime,
|
||||
confirmed_time: value.snapshot.activeTime,
|
||||
confirmed_at: value.snapshot.state === "ready" ? new Date().toISOString() : null,
|
||||
})
|
||||
.select("id")
|
||||
.single();
|
||||
if (error) throw new BirthTimeJourneyStoreError("insert_case");
|
||||
const caseId = z.string().uuid().parse(data.id);
|
||||
|
||||
const { error: profileError } = await supabase
|
||||
.from("profiles")
|
||||
.update({
|
||||
reported_birth_time: details.reportedTime,
|
||||
active_birth_time: value.snapshot.activeTime,
|
||||
birth_time: value.snapshot.activeTime,
|
||||
birth_time_source: value.assessment.source,
|
||||
birth_time_period: details.period,
|
||||
birth_time_clue: details.clue,
|
||||
uncertainty_before_minutes: details.before,
|
||||
uncertainty_after_minutes: details.after,
|
||||
birth_time_status: profileStatus(value.snapshot),
|
||||
rectification_confidence: null,
|
||||
rectification_case_id: caseId,
|
||||
})
|
||||
.eq("id", value.userId);
|
||||
if (profileError) throw new BirthTimeJourneyStoreError("update_profile");
|
||||
return caseId;
|
||||
},
|
||||
|
||||
async loadCase(userId, caseId) {
|
||||
const { data, error } = await supabase
|
||||
.from("birth_time_rectification_cases")
|
||||
.select("id,user_id,journey_snapshot,questionnaire,answers,scoring_result")
|
||||
.eq("id", caseId)
|
||||
.eq("user_id", userId)
|
||||
.maybeSingle();
|
||||
if (error) throw new BirthTimeJourneyStoreError("load_case");
|
||||
if (!data) return null;
|
||||
const parsed = storedCaseSchema.parse(data);
|
||||
const scoring = Object.keys(parsed.scoring_result).length > 0
|
||||
? {
|
||||
answeredCount: 0,
|
||||
candidateClusterRankings: [],
|
||||
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,
|
||||
answers: parsed.answers,
|
||||
...(scoring ? { scoring } : {}),
|
||||
} satisfies StoredRectificationCase;
|
||||
},
|
||||
|
||||
async saveScoring(value) {
|
||||
const { error } = await supabase
|
||||
.from("birth_time_rectification_cases")
|
||||
.update({
|
||||
status: caseStatus(value.snapshot),
|
||||
journey_snapshot: value.snapshot,
|
||||
answers: value.answers,
|
||||
scoring_result: value.scoring?.raw ?? {},
|
||||
updated_at: new Date().toISOString(),
|
||||
})
|
||||
.eq("id", value.id)
|
||||
.eq("user_id", value.userId);
|
||||
if (error) throw new BirthTimeJourneyStoreError("update_case");
|
||||
|
||||
const { error: profileError } = await supabase
|
||||
.from("profiles")
|
||||
.update({ birth_time_status: profileStatus(value.snapshot) })
|
||||
.eq("id", value.userId)
|
||||
.eq("rectification_case_id", value.id);
|
||||
if (profileError) throw new BirthTimeJourneyStoreError("update_profile");
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
import { z } from "zod";
|
||||
|
||||
const dateSchema = z.string().regex(/^\d{4}-\d{2}-\d{2}$/);
|
||||
const timeSchema = z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/);
|
||||
const locationSchema = z.object({
|
||||
lat: z.number().min(-90).max(90),
|
||||
lon: z.number().min(-180).max(180),
|
||||
tz: z.number().min(-12).max(14),
|
||||
}).readonly();
|
||||
|
||||
const exactFields = {
|
||||
date: dateSchema,
|
||||
reportedTime: timeSchema,
|
||||
location: locationSchema,
|
||||
} as const;
|
||||
|
||||
export const birthTimeAssessmentSchema = z.union([
|
||||
z.object({
|
||||
...exactFields,
|
||||
source: z.literal("hospital_record"),
|
||||
uncertaintyBeforeMinutes: z.literal(2),
|
||||
uncertaintyAfterMinutes: z.literal(2),
|
||||
}).readonly(),
|
||||
z.object({
|
||||
...exactFields,
|
||||
source: z.literal("family_exact"),
|
||||
uncertaintyBeforeMinutes: z.union([z.literal(5), z.literal(10), z.literal(15)]),
|
||||
uncertaintyAfterMinutes: z.union([z.literal(5), z.literal(10), z.literal(15)]),
|
||||
}).readonly().refine(
|
||||
(value) => value.uncertaintyBeforeMinutes === value.uncertaintyAfterMinutes,
|
||||
{ message: "family uncertainty must be symmetric" },
|
||||
),
|
||||
z.object({
|
||||
...exactFields,
|
||||
source: z.literal("approximate"),
|
||||
uncertaintyBeforeMinutes: z.union([z.literal(15), z.literal(30), z.literal(60)]),
|
||||
uncertaintyAfterMinutes: z.union([z.literal(15), z.literal(30), z.literal(60)]),
|
||||
}).readonly().refine(
|
||||
(value) => value.uncertaintyBeforeMinutes === value.uncertaintyAfterMinutes,
|
||||
{ message: "approximate uncertainty must be symmetric" },
|
||||
),
|
||||
z.object({
|
||||
date: dateSchema,
|
||||
source: z.literal("period_only"),
|
||||
period: z.enum(["early_morning", "morning", "afternoon", "evening", "late_night"]),
|
||||
location: locationSchema,
|
||||
}).readonly(),
|
||||
z.object({
|
||||
date: dateSchema,
|
||||
source: z.literal("unknown"),
|
||||
clue: z.string().trim().max(240).default(""),
|
||||
location: locationSchema,
|
||||
}).readonly(),
|
||||
]);
|
||||
|
||||
export type BirthTimeAssessment = z.infer<typeof birthTimeAssessmentSchema>;
|
||||
|
||||
export type ScanStability =
|
||||
| { readonly kind: "stable" }
|
||||
| { readonly kind: "sensitive" }
|
||||
| { readonly kind: "unavailable" }
|
||||
| { readonly kind: "not_required" };
|
||||
|
||||
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;
|
||||
readonly candidateClusterRankings: readonly {
|
||||
readonly cluster: string;
|
||||
readonly score: number;
|
||||
}[];
|
||||
};
|
||||
|
||||
class UnexpectedJourneyVariantError extends Error {
|
||||
readonly name = "UnexpectedJourneyVariantError";
|
||||
|
||||
constructor(value: never) {
|
||||
super(`Unexpected birth-time journey variant: ${JSON.stringify(value)}`);
|
||||
}
|
||||
}
|
||||
|
||||
const periodRanges = {
|
||||
early_morning: { label: "04:00—07:59", startTime: "04:00", endTime: "07:59" },
|
||||
morning: { label: "08:00—11:59", startTime: "08:00", endTime: "11:59" },
|
||||
afternoon: { label: "12:00—17:59", startTime: "12:00", endTime: "17:59" },
|
||||
evening: { label: "18:00—22:59", startTime: "18:00", endTime: "22:59" },
|
||||
late_night: { label: "23:00—03:59", startTime: "23:00", endTime: "03:59" },
|
||||
} as const;
|
||||
|
||||
function shiftedTime(time: string, offsetMinutes: number): string {
|
||||
const [hourText, minuteText] = time.split(":");
|
||||
const minutes = Number(hourText) * 60 + Number(minuteText) + offsetMinutes;
|
||||
const normalized = (minutes + 24 * 60) % (24 * 60);
|
||||
return `${String(Math.floor(normalized / 60)).padStart(2, "0")}:${String(normalized % 60).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function exactRange(time: string, before: number, after: number): JourneySnapshot["reportedRange"] {
|
||||
const startTime = shiftedTime(time, -before);
|
||||
const endTime = shiftedTime(time, after);
|
||||
return { label: `${startTime}—${endTime}`, startTime, endTime };
|
||||
}
|
||||
|
||||
function rectificationSnapshot(
|
||||
assistantIntent: JourneySnapshot["assistantIntent"],
|
||||
reportedRange: JourneySnapshot["reportedRange"],
|
||||
input: JourneySnapshot["input"] = "rectification_questions",
|
||||
): JourneySnapshot {
|
||||
return {
|
||||
state: "rectifying",
|
||||
assistantIntent,
|
||||
input,
|
||||
route: "rectification",
|
||||
confidence: null,
|
||||
canApply: false,
|
||||
activeTime: null,
|
||||
reportedRange,
|
||||
};
|
||||
}
|
||||
|
||||
export function assessBirthTime(
|
||||
assessment: BirthTimeAssessment,
|
||||
scanStability: ScanStability,
|
||||
): JourneySnapshot {
|
||||
switch (assessment.source) {
|
||||
case "hospital_record": {
|
||||
const reportedRange = exactRange(assessment.reportedTime, 2, 2);
|
||||
switch (scanStability.kind) {
|
||||
case "stable":
|
||||
return {
|
||||
state: "ready",
|
||||
assistantIntent: "confirm_stable_record",
|
||||
input: "none",
|
||||
route: "direct_chart",
|
||||
confidence: "high",
|
||||
canApply: true,
|
||||
activeTime: assessment.reportedTime,
|
||||
reportedRange,
|
||||
};
|
||||
case "sensitive":
|
||||
return rectificationSnapshot("explain_sensitive_boundary", reportedRange);
|
||||
case "unavailable":
|
||||
case "not_required":
|
||||
return rectificationSnapshot("explain_assessment_unavailable", reportedRange);
|
||||
default:
|
||||
throw new UnexpectedJourneyVariantError(scanStability);
|
||||
}
|
||||
}
|
||||
case "family_exact":
|
||||
return rectificationSnapshot(
|
||||
"start_light_rectification",
|
||||
exactRange(assessment.reportedTime, assessment.uncertaintyBeforeMinutes, assessment.uncertaintyAfterMinutes),
|
||||
);
|
||||
case "approximate":
|
||||
return rectificationSnapshot(
|
||||
"start_standard_rectification",
|
||||
exactRange(assessment.reportedTime, assessment.uncertaintyBeforeMinutes, assessment.uncertaintyAfterMinutes),
|
||||
);
|
||||
case "period_only":
|
||||
return rectificationSnapshot("start_period_rectification", periodRanges[assessment.period]);
|
||||
case "unknown":
|
||||
return rectificationSnapshot(
|
||||
"collect_time_clues",
|
||||
{ label: "全天待确认", startTime: null, endTime: null },
|
||||
"time_clue",
|
||||
);
|
||||
default:
|
||||
throw new UnexpectedJourneyVariantError(assessment);
|
||||
}
|
||||
}
|
||||
|
||||
export function withRectificationScoring(
|
||||
snapshot: JourneySnapshot,
|
||||
scoring: RectificationScoring,
|
||||
): JourneySnapshot {
|
||||
if (snapshot.route === "direct_chart") return snapshot;
|
||||
const hasCandidate = scoring.answeredCount >= 3 && scoring.candidateClusterRankings.length > 0;
|
||||
return {
|
||||
...snapshot,
|
||||
state: hasCandidate ? "candidate" : "rectifying",
|
||||
assistantIntent: hasCandidate ? "present_saved_candidate_range" : "continue_rectification_questions",
|
||||
canApply: false,
|
||||
activeTime: null,
|
||||
};
|
||||
}
|
||||
@@ -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],
|
||||
}),
|
||||
|
||||
Reference in New Issue
Block a user