From 73fe62e9c31d4560fe48fdf68a0bc71711708b19 Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Fri, 17 Jul 2026 15:48:27 +0800 Subject: [PATCH] feat: expose birth time journey API --- .../src/app/api/birth-time-journey/route.ts | 127 ++++++++++++ frontend/src/lib/birth-time-journey-engine.ts | 56 +++++ frontend/src/lib/birth-time-journey-store.ts | 191 ++++++++++++++++++ 3 files changed, 374 insertions(+) create mode 100644 frontend/src/app/api/birth-time-journey/route.ts create mode 100644 frontend/src/lib/birth-time-journey-engine.ts create mode 100644 frontend/src/lib/birth-time-journey-store.ts diff --git a/frontend/src/app/api/birth-time-journey/route.ts b/frontend/src/app/api/birth-time-journey/route.ts new file mode 100644 index 00000000..8fbed961 --- /dev/null +++ b/frontend/src/app/api/birth-time-journey/route.ts @@ -0,0 +1,127 @@ +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, +} from "@/lib/birth-time-journey-service"; +import { + createSupabaseBirthTimeJourneyStore, + BirthTimeJourneyStoreError, +} from "@/lib/birth-time-journey-store"; +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("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 { + try { + return await request.json(); + } catch (error) { + if (error instanceof SyntaxError) return null; + throw error; + } +} + +export async function POST(request: Request) { + let supabase: Awaited>; + try { + supabase = await createServerSupabaseClient(); + } 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(supabase), + 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, + )); + 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 BirthTimeJourneyStoreError || error instanceof BirthTimeJourneyEngineError) { + return NextResponse.json( + { error: "生时评估暂时不可用", message: "已保留当前资料,请稍后重试。" }, + { status: 503 }, + ); + } + throw error; + } +} diff --git a/frontend/src/lib/birth-time-journey-engine.ts b/frontend/src/lib/birth-time-journey-engine.ts new file mode 100644 index 00000000..9d77ae37 --- /dev/null +++ b/frontend/src/lib/birth-time-journey-engine.ts @@ -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 { + 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); + }, + }; +} diff --git a/frontend/src/lib/birth-time-journey-store.ts b/frontend/src/lib/birth-time-journey-store.ts new file mode 100644 index 00000000..d701e328 --- /dev/null +++ b/frontend/src/lib/birth-time-journey-store.ts @@ -0,0 +1,191 @@ +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 type { JourneySnapshot } from "./birth-time-journey.ts"; + +const assistantIntentSchema = z.enum([ + "confirm_stable_record", + "explain_sensitive_boundary", + "explain_assessment_unavailable", + "start_light_rectification", + "start_standard_rectification", + "start_period_rectification", + "collect_time_clues", + "continue_rectification_questions", + "present_saved_candidate_range", +]); + +const snapshotSchema = z.object({ + state: z.enum(["rectifying", "candidate", "ready"]), + assistantIntent: assistantIntentSchema, + input: z.enum(["none", "rectification_questions", "time_clue"]), + route: z.enum(["direct_chart", "rectification"]), + confidence: z.literal("high").nullable(), + canApply: z.boolean(), + activeTime: z.string().nullable(), + reportedRange: z.object({ + label: z.string(), + startTime: z.string().nullable(), + endTime: z.string().nullable(), + }), +}); + +const answerSchema = z.enum(["A", "B", "C", "D"]); +const storedCaseSchema = z.object({ + id: z.string().uuid(), + user_id: z.string().uuid(), + journey_snapshot: snapshotSchema, + 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; + return { + id: parsed.id, + userId: parsed.user_id, + snapshot: parsed.journey_snapshot, + questionnaire: parseRectificationQuestionnaire(parsed.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"); + }, + }; +}