feat: expose birth time journey API

This commit is contained in:
Jesse_Chen
2026-07-17 15:48:27 +08:00
parent e88d7a11b6
commit 73fe62e9c3
3 changed files with 374 additions and 0 deletions
@@ -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<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>>;
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;
}
}
@@ -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,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");
},
};
}