Merge branch 'codex/birth-time-journey'

# Conflicts:
#	frontend/src/app/globals.css
This commit is contained in:
Jesse_Chen
2026-07-17 16:34:31 +08:00
24 changed files with 2949 additions and 41 deletions
@@ -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;
}
}
+3 -2
View File
@@ -44,7 +44,8 @@ function hasCompleteBirthProfile(profile: Record<string, unknown>) {
return Boolean(
profile.name
&& profile.birth_date
&& profile.birth_time
&& (profile.active_birth_time || profile.birth_time)
&& (profile.birth_time_status === "confirmed" || (!profile.birth_time_status && profile.birth_time))
&& profile.country_code
&& profile.province_code
&& profile.city_code,
@@ -74,7 +75,7 @@ export async function POST() {
const { data: profile, error: profileError } = await admin
.from("profiles")
.select("name,birth_date,birth_time,country_code,province_code,city_code,onboarding_payload,onboarding_version,onboarding_generated_at")
.select("name,birth_date,birth_time,active_birth_time,birth_time_status,country_code,province_code,city_code,onboarding_payload,onboarding_version,onboarding_generated_at")
.eq("id", user.id)
.maybeSingle();
+39
View File
@@ -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
View File
@@ -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>
);
}
+150
View File
@@ -0,0 +1,150 @@
import type { JourneySnapshot } from "./birth-time-journey.ts";
export type BirthTimeSource =
| ""
| "hospital_record"
| "family_exact"
| "approximate"
| "period_only"
| "unknown"
| "legacy_import";
export type BirthTimePeriod =
| ""
| "early_morning"
| "morning"
| "afternoon"
| "evening"
| "late_night";
export type BirthTimeStatus =
| ""
| "reported"
| "assessing"
| "rectifying"
| "candidate"
| "confirmed";
export type BirthTimeDraft = {
readonly date: string;
readonly time: string;
readonly reportedTime: string;
readonly birthTimeSource: BirthTimeSource;
readonly birthTimePeriod: BirthTimePeriod;
readonly birthTimeClue: string;
readonly uncertaintyBeforeMinutes: number | null;
readonly uncertaintyAfterMinutes: number | null;
readonly birthTimeStatus: BirthTimeStatus;
};
export type BirthTimeDraftPatch = Partial<BirthTimeDraft>;
export const birthTimeSourceOptions = [
{ value: "hospital_record", label: "出生证明或医院记录", hint: "先检查前后两分钟是否稳定" },
{ value: "family_exact", label: "家人明确记得具体时间", hint: "进行 5—15 分钟轻量校正" },
{ value: "approximate", label: "只记得大概几点", hint: "按你选择的误差范围扫描" },
{ value: "period_only", label: "只知道早晨、上午、下午或晚上", hint: "先从时段范围做粗筛" },
{ value: "unknown", label: "完全不知道", hint: "不要求你随便填写具体时间" },
] as const;
export const birthTimePeriodOptions = [
{ value: "early_morning", label: "凌晨 / 清晨(04:00—07:59" },
{ value: "morning", label: "上午(08:00—11:59" },
{ value: "afternoon", label: "下午(12:00—17:59" },
{ value: "evening", label: "晚上(18:00—22:59" },
{ value: "late_night", label: "深夜(23:00—03:59" },
] as const;
const periodLabels = {
"": "未选择时段",
early_morning: "凌晨或清晨",
morning: "上午",
afternoon: "下午",
evening: "晚上",
late_night: "深夜",
} as const satisfies Record<BirthTimePeriod, string>;
const intentCopy = {
confirm_stable_record: "医院记录前后两分钟内结构稳定,可以直接作为当前排盘时间。",
explain_sensitive_boundary: "这个时间靠近敏感边界,需要先做轻量校正,暂不直接排盘。",
explain_assessment_unavailable: "暂时无法完成稳定性检查,系统不会冒险应用这个具体时间。",
start_light_rectification: "先用几个高区分度问题检查家人记忆范围。",
start_standard_rectification: "这个误差范围内存在多个候选,需要先回答几个生活经历问题。",
start_period_rectification: "已保留你知道的时段,接下来先做粗粒度候选筛选。",
collect_time_clues: "不会要求你猜一个具体时间,先从家人线索和大致时段开始。",
continue_rectification_questions: "已记录这条线索,还需要更多证据才能形成候选范围。",
present_saved_candidate_range: "目前只能保存候选范围,还没有足够证据应用到具体分钟。",
} as const satisfies Record<JourneySnapshot["assistantIntent"], string>;
export function assistantIntentCopy(intent: JourneySnapshot["assistantIntent"]) {
return intentCopy[intent];
}
export function isBirthTimeDraftReady(draft: BirthTimeDraft) {
if (!draft.date) return false;
switch (draft.birthTimeSource) {
case "hospital_record":
case "legacy_import":
return Boolean(draft.reportedTime || draft.time);
case "family_exact":
return Boolean(draft.reportedTime)
&& [5, 10, 15].includes(draft.uncertaintyBeforeMinutes ?? -1)
&& draft.uncertaintyBeforeMinutes === draft.uncertaintyAfterMinutes;
case "approximate":
return Boolean(draft.reportedTime)
&& [15, 30, 60].includes(draft.uncertaintyBeforeMinutes ?? -1)
&& draft.uncertaintyBeforeMinutes === draft.uncertaintyAfterMinutes;
case "period_only":
return Boolean(draft.birthTimePeriod);
case "unknown":
return true;
case "":
return false;
default: {
const exhaustive: never = draft.birthTimeSource;
return exhaustive;
}
}
}
export function birthTimePersistenceValues(draft: BirthTimeDraft) {
const reportedTime = draft.reportedTime || draft.time || null;
const uncertainty = draft.birthTimeSource === "hospital_record"
? 2
: draft.birthTimeSource === "family_exact" || draft.birthTimeSource === "approximate"
? draft.uncertaintyBeforeMinutes
: null;
return {
reported_birth_time: reportedTime,
birth_time_source: draft.birthTimeSource || null,
birth_time_period: draft.birthTimePeriod || null,
birth_time_clue: draft.birthTimeClue.trim() || null,
uncertainty_before_minutes: uncertainty,
uncertainty_after_minutes: uncertainty,
};
}
export function describeBirthTimeDraft(draft: BirthTimeDraft) {
const [year, month, day] = draft.date.split("-").map(Number);
const date = `${year}${month}${day}`;
switch (draft.birthTimeSource) {
case "hospital_record":
return `${date} ${draft.reportedTime}(医院记录)`;
case "family_exact":
return `${date} ${draft.reportedTime}(家人明确记得,前后 ${draft.uncertaintyBeforeMinutes} 分钟)`;
case "approximate":
return `${date},约 ${draft.reportedTime}(前后 ${draft.uncertaintyBeforeMinutes} 分钟)`;
case "period_only":
return `${date}${periodLabels[draft.birthTimePeriod]}`;
case "unknown":
return `${date},具体时间未知`;
case "legacy_import":
return `${date} ${draft.reportedTime || draft.time}(既有已确认资料)`;
case "":
return date;
default: {
const exhaustive: never = draft.birthTimeSource;
return exhaustive;
}
}
}
@@ -0,0 +1,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");
},
};
}
+209
View File
@@ -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,
};
}
+4 -2
View File
@@ -16,6 +16,7 @@ export const consultationInputSchema = z.object({
city: z.string().trim().min(1).max(120),
question: z.string().trim().min(1).max(500),
theme: z.enum(["career", "marriage", "wealth", "timing", "general"]),
entryMode: z.enum(["direct_chart", "rectification"]).default("direct_chart"),
});
export type ConsultationInput = z.infer<typeof consultationInputSchema>;
@@ -33,12 +34,13 @@ const jyotishSkillPath = process.env.JYOTISH_SKILL_PATH?.trim()
|| path.resolve(process.cwd(), "..", "skills", "jyotish-vedic-astrology");
export async function runConsultationWorkflow(input: ConsultationInput) {
const { entryMode, ...workflowInput } = input;
const response = await fetch(`${apiBase}/api/consultation_workflow`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
...input,
entry_mode: "direct_chart",
...workflowInput,
entry_mode: entryMode,
question_text: input.question,
theme: input.theme === "general" ? ["career", "marriage", "wealth"] : [input.theme],
}),