feat: expose birth time journey API
This commit is contained in:
@@ -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");
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user