Merge branch 'feat/conversational-birth-time-rectification'
# Conflicts: # .github/workflows/deploy-production.yml # frontend/src/app/api/account/route.ts # frontend/src/app/globals.css # frontend/src/app/page.tsx # frontend/tests/health-deployment.test.ts
This commit is contained in:
@@ -1,4 +1,13 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import {
|
||||
parseRectificationPriceCredits,
|
||||
} from "@/lib/birth-time-consultation-consent";
|
||||
import { resolveAccountRectificationCase } from "@/lib/account-rectification-case";
|
||||
import {
|
||||
accountProfilePatchSchema,
|
||||
applyAccountProfileConcurrencyGuards,
|
||||
resolveAccountBirthTimeApplicationPatch,
|
||||
} from "@/lib/account-profile-patch";
|
||||
import { createAdminSupabaseClient, isAdminEmail } from "@/lib/supabase/admin";
|
||||
import {
|
||||
isSupabaseConfigurationError,
|
||||
@@ -7,43 +16,7 @@ import { createServerSupabaseClient } from "@/lib/supabase/server";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
type ProfilePatchPayload = {
|
||||
name?: unknown;
|
||||
birth_date?: unknown;
|
||||
birth_time?: unknown;
|
||||
reported_birth_time?: unknown;
|
||||
birth_time_source?: unknown;
|
||||
birth_time_period?: unknown;
|
||||
birth_time_clue?: unknown;
|
||||
uncertainty_before_minutes?: unknown;
|
||||
uncertainty_after_minutes?: unknown;
|
||||
country_code?: unknown;
|
||||
province_code?: unknown;
|
||||
city_code?: unknown;
|
||||
district_code?: unknown;
|
||||
latitude?: unknown;
|
||||
longitude?: unknown;
|
||||
timezone_offset?: unknown;
|
||||
};
|
||||
|
||||
const birthTimeSources = ["hospital_record", "family_exact", "approximate", "period_only", "unknown", "legacy_import"] as const;
|
||||
const birthTimePeriods = ["early_morning", "morning", "afternoon", "evening", "late_night"] as const;
|
||||
|
||||
function nullableString(value: unknown) {
|
||||
return typeof value === "string" && value.trim() ? value.trim() : null;
|
||||
}
|
||||
|
||||
function nullableNumber(value: unknown) {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
||||
}
|
||||
|
||||
function nullableInteger(value: unknown) {
|
||||
return typeof value === "number" && Number.isInteger(value) ? value : null;
|
||||
}
|
||||
|
||||
function nullableChoice(value: unknown, choices: readonly string[]) {
|
||||
return typeof value === "string" && choices.includes(value) ? value : null;
|
||||
}
|
||||
const unfinishedRectificationStatuses = ["starting", "active", "paused", "confirming"] as const;
|
||||
|
||||
function isMissingProfileColumn(error: { code?: string; message?: string } | null) {
|
||||
const message = error?.message?.toLowerCase() ?? "";
|
||||
@@ -61,21 +34,49 @@ export async function GET() {
|
||||
if (authError || !user) {
|
||||
return NextResponse.json({ error: "请先登录" }, { status: 401 });
|
||||
}
|
||||
const userId = user.id;
|
||||
|
||||
const rectificationPriceCredits = parseRectificationPriceCredits(
|
||||
process.env.RECTIFICATION_PRICE_CREDITS,
|
||||
);
|
||||
const admin = createAdminSupabaseClient();
|
||||
const { data: rectificationCaseRows, error: rectificationCaseError } = await admin
|
||||
.from("birth_time_rectification_cases")
|
||||
.select("id,journey_protocol,status,turn_version,revision_of_case_id,baseline_active_time,declared_birth_input,updated_at")
|
||||
.eq("user_id", user.id)
|
||||
.eq("journey_protocol", "conversational-evidence-v3")
|
||||
.in("status", [...unfinishedRectificationStatuses])
|
||||
.order("updated_at", { ascending: false })
|
||||
.limit(50);
|
||||
if (rectificationCaseError) {
|
||||
return NextResponse.json({ error: "暂时无法读取生时校正状态" }, { status: 500 });
|
||||
}
|
||||
|
||||
// Read the profile after the case snapshot. If a declaration edit races
|
||||
// with this request, matching uses the later profile and cannot resurrect
|
||||
// an older case. A concurrently created case simply appears on refresh.
|
||||
const { data: profile, error } = await supabase
|
||||
.from("profiles")
|
||||
.select("credits")
|
||||
.eq("id", user.id)
|
||||
.select("credits,active_birth_time,birth_time_status,birth_date,reported_birth_time,birth_time_source,birth_time_period,birth_time_clue,uncertainty_before_minutes,uncertainty_after_minutes,country_code,province_code,city_code,district_code,latitude,longitude,timezone_offset")
|
||||
.eq("id", userId)
|
||||
.single();
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: "暂时无法读取账户余额" }, { status: 500 });
|
||||
}
|
||||
const rectificationCase = resolveAccountRectificationCase(
|
||||
profile,
|
||||
Array.isArray(rectificationCaseRows) ? rectificationCaseRows : [],
|
||||
);
|
||||
|
||||
return NextResponse.json({
|
||||
user: { id: user.id, email: user.email ?? null },
|
||||
credits: profile.credits,
|
||||
isAdmin: isAdminEmail(user.email),
|
||||
rectificationPriceCredits,
|
||||
hasConfirmedBirthTime: profile.birth_time_status === "confirmed"
|
||||
&& typeof profile.active_birth_time === "string",
|
||||
rectificationCase,
|
||||
});
|
||||
} catch (error) {
|
||||
if (isSupabaseConfigurationError(error)) {
|
||||
@@ -93,52 +94,108 @@ export async function PATCH(request: Request) {
|
||||
if (authError || !user) {
|
||||
return NextResponse.json({ error: "请先登录" }, { status: 401 });
|
||||
}
|
||||
const userId = user.id;
|
||||
|
||||
const payload = await request.json().catch(() => null) as ProfilePatchPayload | null;
|
||||
if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
|
||||
return NextResponse.json({ error: "账户资料格式不正确" }, { status: 400 });
|
||||
}
|
||||
const parsedPayload = accountProfilePatchSchema.safeParse(
|
||||
await request.json().catch(() => null),
|
||||
);
|
||||
if (!parsedPayload.success) return NextResponse.json({
|
||||
error: "账户资料格式不正确",
|
||||
details: parsedPayload.error.flatten(),
|
||||
}, { status: 400 });
|
||||
const payload = parsedPayload.data;
|
||||
|
||||
const admin = createAdminSupabaseClient();
|
||||
let { data: currentProfile, error: currentProfileError } = await admin
|
||||
.from("profiles")
|
||||
.select("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,latitude,longitude,timezone_offset")
|
||||
.eq("id", userId)
|
||||
.maybeSingle();
|
||||
if (currentProfileError && isMissingProfileColumn(currentProfileError)) {
|
||||
const fallback = await admin
|
||||
.from("profiles")
|
||||
.select("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", userId)
|
||||
.maybeSingle();
|
||||
currentProfile = fallback.data ? {
|
||||
...fallback.data,
|
||||
latitude: undefined,
|
||||
longitude: undefined,
|
||||
timezone_offset: undefined,
|
||||
} : null;
|
||||
currentProfileError = fallback.error;
|
||||
}
|
||||
if (currentProfileError) {
|
||||
return NextResponse.json({ error: "暂时无法核对现有出生资料" }, { status: 500 });
|
||||
}
|
||||
const applicationPatch = currentProfile
|
||||
? resolveAccountBirthTimeApplicationPatch(currentProfile, payload)
|
||||
: {};
|
||||
const baseProfile = {
|
||||
id: user.id,
|
||||
name: nullableString(payload.name),
|
||||
birth_date: nullableString(payload.birth_date),
|
||||
birth_time: nullableString(payload.birth_time),
|
||||
reported_birth_time: nullableString(payload.reported_birth_time),
|
||||
birth_time_source: nullableChoice(payload.birth_time_source, birthTimeSources),
|
||||
birth_time_period: nullableChoice(payload.birth_time_period, birthTimePeriods),
|
||||
birth_time_clue: nullableString(payload.birth_time_clue),
|
||||
uncertainty_before_minutes: nullableInteger(payload.uncertainty_before_minutes),
|
||||
uncertainty_after_minutes: nullableInteger(payload.uncertainty_after_minutes),
|
||||
country_code: nullableString(payload.country_code),
|
||||
province_code: nullableString(payload.province_code),
|
||||
city_code: nullableString(payload.city_code),
|
||||
district_code: nullableString(payload.district_code),
|
||||
id: userId,
|
||||
...(payload.name !== undefined ? { name: payload.name } : {}),
|
||||
...(payload.birth_date !== undefined ? { birth_date: payload.birth_date } : {}),
|
||||
...(payload.reported_birth_time !== undefined
|
||||
? { reported_birth_time: payload.reported_birth_time }
|
||||
: {}),
|
||||
...(payload.birth_time_source !== undefined
|
||||
? { birth_time_source: payload.birth_time_source }
|
||||
: {}),
|
||||
...(payload.birth_time_period !== undefined
|
||||
? { birth_time_period: payload.birth_time_period }
|
||||
: {}),
|
||||
...(payload.birth_time_clue !== undefined
|
||||
? { birth_time_clue: payload.birth_time_clue }
|
||||
: {}),
|
||||
...(payload.uncertainty_before_minutes !== undefined
|
||||
? { uncertainty_before_minutes: payload.uncertainty_before_minutes }
|
||||
: {}),
|
||||
...(payload.uncertainty_after_minutes !== undefined
|
||||
? { uncertainty_after_minutes: payload.uncertainty_after_minutes }
|
||||
: {}),
|
||||
...(payload.country_code !== undefined ? { country_code: payload.country_code } : {}),
|
||||
...(payload.province_code !== undefined ? { province_code: payload.province_code } : {}),
|
||||
...(payload.city_code !== undefined ? { city_code: payload.city_code } : {}),
|
||||
...(payload.district_code !== undefined ? { district_code: payload.district_code } : {}),
|
||||
...applicationPatch,
|
||||
updated_at: new Date().toISOString(),
|
||||
};
|
||||
const withCoordinates = {
|
||||
...baseProfile,
|
||||
latitude: nullableNumber(payload.latitude),
|
||||
longitude: nullableNumber(payload.longitude),
|
||||
timezone_offset: nullableNumber(payload.timezone_offset),
|
||||
...(payload.latitude !== undefined ? { latitude: payload.latitude } : {}),
|
||||
...(payload.longitude !== undefined ? { longitude: payload.longitude } : {}),
|
||||
...(payload.timezone_offset !== undefined ? { timezone_offset: payload.timezone_offset } : {}),
|
||||
};
|
||||
const withoutCoordinates = baseProfile;
|
||||
let { data, error } = await admin
|
||||
.from("profiles")
|
||||
.upsert(withCoordinates, { onConflict: "id" })
|
||||
.select("id")
|
||||
.single();
|
||||
const invalidatesUnconfirmedApplication = Object.keys(applicationPatch).length > 0;
|
||||
async function writeProfile(values: Record<string, unknown>) {
|
||||
if (!currentProfile) {
|
||||
return admin
|
||||
.from("profiles")
|
||||
.upsert(values, { onConflict: "id" })
|
||||
.select("id")
|
||||
.maybeSingle();
|
||||
}
|
||||
let query = admin.from("profiles").update(values).eq("id", userId);
|
||||
if (invalidatesUnconfirmedApplication) {
|
||||
query = applyAccountProfileConcurrencyGuards(query, currentProfile);
|
||||
}
|
||||
return query.select("id").maybeSingle();
|
||||
}
|
||||
|
||||
let { data, error } = await writeProfile(withCoordinates);
|
||||
if (error && isMissingProfileColumn(error)) {
|
||||
const fallback = await admin
|
||||
.from("profiles")
|
||||
.upsert(withoutCoordinates, { onConflict: "id" })
|
||||
.select("id")
|
||||
.single();
|
||||
const fallback = await writeProfile(withoutCoordinates);
|
||||
data = fallback.data;
|
||||
error = fallback.error;
|
||||
}
|
||||
|
||||
if (!error && !data && invalidatesUnconfirmedApplication) {
|
||||
return NextResponse.json({
|
||||
error: "出生时间状态已经变化",
|
||||
message: "最新确认结果已保留,请刷新后重新编辑。",
|
||||
}, { status: 409 });
|
||||
}
|
||||
if (error || !data) {
|
||||
return NextResponse.json({ error: "暂时无法保存账户资料" }, { status: 500 });
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ export async function POST(request: Request) {
|
||||
const admin = createAdminSupabaseClient();
|
||||
const { data: stored, error: caseError } = await admin
|
||||
.from("birth_time_rectification_cases")
|
||||
.select("id,user_id,status,candidate_result_id,candidate_result,turn_state")
|
||||
.select("id,user_id,journey_protocol,status,candidate_result_id,candidate_result,turn_state")
|
||||
.eq("id", parsed.data.caseId)
|
||||
.eq("user_id", user.id)
|
||||
.maybeSingle();
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { createAdminSupabaseClient } from "@/lib/supabase/admin";
|
||||
import { createServerSupabaseClient } from "@/lib/supabase/server";
|
||||
import { createRectificationHandoffService } from "@/lib/rectification-handoff-service";
|
||||
import {
|
||||
createRectificationHandoffHandlers,
|
||||
type RectificationHandoffRouteDependencies,
|
||||
} from "@/lib/rectification-handoff-route";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
const productionDependencies: RectificationHandoffRouteDependencies = {
|
||||
async authenticate() {
|
||||
const supabase = await createServerSupabaseClient();
|
||||
const { data: { user }, error } = await supabase.auth.getUser();
|
||||
return error || !user ? null : { userId: user.id };
|
||||
},
|
||||
service() {
|
||||
return createRectificationHandoffService(createAdminSupabaseClient());
|
||||
},
|
||||
};
|
||||
|
||||
const handlers = createRectificationHandoffHandlers(productionDependencies);
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
return await handlers.get();
|
||||
} catch {
|
||||
return Response.json(
|
||||
{ code: "handoff_unavailable", message: "原问题交接服务暂时不可用。" },
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
return await handlers.post(request);
|
||||
} catch {
|
||||
return Response.json(
|
||||
{ code: "handoff_unavailable", message: "原问题交接服务暂时不可用。" },
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,803 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import {
|
||||
conversationalRectificationCommandSchema,
|
||||
type ConversationalRectificationCommand,
|
||||
type ConversationalRectificationTurn,
|
||||
} from "../../../lib/conversational-rectification/contracts.ts";
|
||||
import {
|
||||
ConversationalRectificationError,
|
||||
toConversationalRectificationPublicError,
|
||||
} from "../../../lib/conversational-rectification/errors.ts";
|
||||
import {
|
||||
createConversationalRectificationService,
|
||||
conversationalRectificationTelemetryOutcome,
|
||||
evidencePredatesBirthDate,
|
||||
type ConversationalRectificationPacketBuildInput,
|
||||
type ConversationalRectificationService,
|
||||
} from "../../../lib/conversational-rectification/orchestrator.ts";
|
||||
import {
|
||||
declaredBirthInputSchema,
|
||||
type DeclaredBirthInput,
|
||||
type LifeEventEvidence,
|
||||
} from "../../../lib/conversational-rectification/persistence-contracts.ts";
|
||||
import type { RectificationNarrativeGenerator } from "../../../lib/conversational-rectification/narrative-agent.ts";
|
||||
import type { BirthTimeJourneyEngine, RectificationQuestionnaire } from "../../../lib/birth-time-journey-service.ts";
|
||||
import type { CandidateResult, LifeEvent } from "../../../lib/birth-time-evidence.ts";
|
||||
import { conversationalRectificationCreationPolicyFromEnvironment } from "../../../lib/conversational-rectification/creation-policy.ts";
|
||||
import {
|
||||
conversationalRectificationLatencyBucket,
|
||||
createConversationalRectificationTelemetry,
|
||||
recordConversationalRectificationTelemetry,
|
||||
safeConversationalRectificationDeploymentSha,
|
||||
type ConversationalRectificationTelemetryPayload,
|
||||
type ConversationalRectificationTelemetrySink,
|
||||
} from "../../../lib/birth-time-journey-telemetry.ts";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const maxDuration = 60;
|
||||
|
||||
type AuthenticatedRequest = Readonly<{
|
||||
userId: string;
|
||||
context: unknown;
|
||||
}>;
|
||||
|
||||
export type BirthTimeConversationRouteService = ConversationalRectificationService;
|
||||
|
||||
export type BirthTimeConversationRouteLog = Readonly<{
|
||||
code: string;
|
||||
}>;
|
||||
|
||||
export type BirthTimeConversationPostDependencies = Readonly<{
|
||||
authenticate(request: Request): Promise<AuthenticatedRequest | null>;
|
||||
createService(authenticated: AuthenticatedRequest): Promise<BirthTimeConversationRouteService>;
|
||||
createRequestId?(request: Request): string;
|
||||
log?(entry: BirthTimeConversationRouteLog): void;
|
||||
telemetry?: ConversationalRectificationTelemetrySink;
|
||||
deploymentSha?: string;
|
||||
now?(): number;
|
||||
}>;
|
||||
|
||||
type ProfileQueryResult = Readonly<{
|
||||
data: unknown;
|
||||
error: unknown;
|
||||
}>;
|
||||
|
||||
type ProfileClient = {
|
||||
from(table: string): {
|
||||
select(columns: string): {
|
||||
eq(column: string, value: string): {
|
||||
maybeSingle(): PromiseLike<ProfileQueryResult>;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
async function authenticateProductionRequest(): Promise<AuthenticatedRequest | null> {
|
||||
const { createServerSupabaseClient } = await import("../../../lib/supabase/server.ts");
|
||||
const serverClient = await createServerSupabaseClient();
|
||||
const { data: { user }, error } = await serverClient.auth.getUser();
|
||||
if (error || !user) return null;
|
||||
return { userId: user.id, context: serverClient };
|
||||
}
|
||||
|
||||
async function requestPayload(request: Request): Promise<unknown> {
|
||||
try {
|
||||
return await request.json();
|
||||
} catch (error) {
|
||||
if (error instanceof SyntaxError) return null;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function profileRecord(value: unknown): Record<string, unknown> | null {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value)
|
||||
? value as Record<string, unknown>
|
||||
: null;
|
||||
}
|
||||
|
||||
function text(value: unknown): string | null {
|
||||
return typeof value === "string" && value.trim() ? value.trim() : null;
|
||||
}
|
||||
|
||||
function finiteNumber(value: unknown): number | null {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
||||
}
|
||||
|
||||
function integer(value: unknown): number | null {
|
||||
return typeof value === "number" && Number.isInteger(value) ? value : null;
|
||||
}
|
||||
|
||||
function declaredBirthInputFromProfile(value: unknown): DeclaredBirthInput {
|
||||
const profile = profileRecord(value);
|
||||
if (!profile) throw new ConversationalRectificationError("profile_incomplete");
|
||||
const birthDate = text(profile.birth_date);
|
||||
const source = text(profile.birth_time_source);
|
||||
const cityCode = text(profile.city_code);
|
||||
const latitude = finiteNumber(profile.latitude);
|
||||
const longitude = finiteNumber(profile.longitude);
|
||||
const timezoneOffset = finiteNumber(profile.timezone_offset);
|
||||
if (!birthDate || !source || !cityCode || latitude === null || longitude === null
|
||||
|| timezoneOffset === null) {
|
||||
throw new ConversationalRectificationError("profile_incomplete");
|
||||
}
|
||||
const birthplace = {
|
||||
...(text(profile.country_code) ? { countryCode: text(profile.country_code) } : {}),
|
||||
...(text(profile.province_code) ? { provinceCode: text(profile.province_code) } : {}),
|
||||
cityCode,
|
||||
...(text(profile.district_code) ? { districtCode: text(profile.district_code) } : {}),
|
||||
latitude,
|
||||
longitude,
|
||||
timezoneOffset,
|
||||
};
|
||||
const common = {
|
||||
birthDate,
|
||||
birthTimeClue: text(profile.birth_time_clue),
|
||||
birthplace,
|
||||
};
|
||||
const reportedTime = text(profile.reported_birth_time)?.slice(0, 5) ?? null;
|
||||
const period = text(profile.birth_time_period);
|
||||
const before = integer(profile.uncertainty_before_minutes);
|
||||
const after = integer(profile.uncertainty_after_minutes);
|
||||
let declaredBirthInput: unknown;
|
||||
switch (source) {
|
||||
case "hospital_record":
|
||||
declaredBirthInput = {
|
||||
...common, source, reportedTime,
|
||||
uncertaintyBeforeMinutes: 2, uncertaintyAfterMinutes: 2,
|
||||
};
|
||||
break;
|
||||
case "family_exact":
|
||||
case "approximate":
|
||||
declaredBirthInput = {
|
||||
...common, source, reportedTime,
|
||||
uncertaintyBeforeMinutes: before, uncertaintyAfterMinutes: after,
|
||||
};
|
||||
break;
|
||||
case "period_only":
|
||||
declaredBirthInput = { ...common, source, reportedPeriod: period };
|
||||
break;
|
||||
case "unknown":
|
||||
declaredBirthInput = { ...common, source };
|
||||
break;
|
||||
case "legacy_import":
|
||||
declaredBirthInput = {
|
||||
...common,
|
||||
source,
|
||||
...(reportedTime ? { reportedTime } : {}),
|
||||
...(period ? { reportedPeriod: period } : {}),
|
||||
...(before === null ? {} : { uncertaintyBeforeMinutes: before }),
|
||||
...(after === null ? {} : { uncertaintyAfterMinutes: after }),
|
||||
};
|
||||
break;
|
||||
default:
|
||||
throw new ConversationalRectificationError("profile_incomplete");
|
||||
}
|
||||
const parsed = declaredBirthInputSchema.safeParse(declaredBirthInput);
|
||||
if (!parsed.success) throw new ConversationalRectificationError("profile_incomplete");
|
||||
return parsed.data;
|
||||
}
|
||||
|
||||
export function declaredBirthInputForLegacyCase(
|
||||
currentProfileValue: unknown,
|
||||
legacyCaseValue: unknown,
|
||||
): DeclaredBirthInput {
|
||||
const currentProfile = profileRecord(currentProfileValue);
|
||||
const legacyCase = profileRecord(legacyCaseValue);
|
||||
if (!currentProfile || !legacyCase) {
|
||||
throw new ConversationalRectificationError("profile_incomplete");
|
||||
}
|
||||
return declaredBirthInputFromProfile({
|
||||
...currentProfile,
|
||||
birth_date: legacyCase.reported_date,
|
||||
reported_birth_time: legacyCase.reported_time,
|
||||
birth_time_source: legacyCase.source,
|
||||
birth_time_period: legacyCase.reported_period,
|
||||
uncertainty_before_minutes: legacyCase.uncertainty_before_minutes,
|
||||
uncertainty_after_minutes: legacyCase.uncertainty_after_minutes,
|
||||
});
|
||||
}
|
||||
|
||||
export type ProductionConversationalRectificationProfileDependencies = Readonly<{
|
||||
loadProfile(userId: string): Promise<unknown>;
|
||||
loadRectificationCase(userId: string, caseId: string): Promise<unknown>;
|
||||
}>;
|
||||
|
||||
export async function loadProductionConversationalRectificationProfile(
|
||||
dependencies: ProductionConversationalRectificationProfileDependencies,
|
||||
userId: string,
|
||||
): Promise<Readonly<{
|
||||
declaredBirthInput: DeclaredBirthInput;
|
||||
revisionOfCaseId: string | null;
|
||||
legacyCaseId: string | null;
|
||||
}>> {
|
||||
const profileValue = await dependencies.loadProfile(userId);
|
||||
const profile = profileRecord(profileValue);
|
||||
if (!profile) throw new ConversationalRectificationError("profile_incomplete");
|
||||
const declaredBirthInput = declaredBirthInputFromProfile(profile);
|
||||
const priorCaseId = text(profile.rectification_case_id);
|
||||
if (!priorCaseId) return {
|
||||
declaredBirthInput,
|
||||
revisionOfCaseId: null,
|
||||
legacyCaseId: null,
|
||||
};
|
||||
|
||||
const prior = profileRecord(await dependencies.loadRectificationCase(userId, priorCaseId));
|
||||
const terminalV3Revision = prior
|
||||
&& text(prior.id) === priorCaseId
|
||||
&& text(prior.journey_protocol) === "conversational-evidence-v3"
|
||||
&& (text(prior.status) === "completed" || text(prior.status) === "abandoned");
|
||||
const protocol = prior ? text(prior.journey_protocol) : null;
|
||||
const status = prior ? text(prior.status) : null;
|
||||
const unfinishedLegacyStatuses = new Set([
|
||||
"assessing",
|
||||
"rectifying",
|
||||
"candidate",
|
||||
"confirming",
|
||||
]);
|
||||
const unfinishedLegacy = prior
|
||||
&& text(prior.id) === priorCaseId
|
||||
&& (protocol === "legacy-guided-v1" || protocol === "dynamic-choice-v2")
|
||||
&& status !== null
|
||||
&& unfinishedLegacyStatuses.has(status);
|
||||
return {
|
||||
declaredBirthInput,
|
||||
revisionOfCaseId: terminalV3Revision ? priorCaseId : null,
|
||||
legacyCaseId: unfinishedLegacy ? priorCaseId : null,
|
||||
};
|
||||
}
|
||||
|
||||
function priceCredits(): number {
|
||||
const raw = process.env.RECTIFICATION_PRICE_CREDITS?.trim() ?? "1";
|
||||
const value = Number(raw);
|
||||
if (!Number.isSafeInteger(value) || value < 1 || value > 100) {
|
||||
throw new ConversationalRectificationError("service_unavailable");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function minute(value: string): number {
|
||||
const [hour = 0, part = 0] = value.split(":").map(Number);
|
||||
return hour * 60 + part;
|
||||
}
|
||||
|
||||
function clock(value: number): string {
|
||||
const normalized = ((value % 1_440) + 1_440) % 1_440;
|
||||
return `${String(Math.floor(normalized / 60)).padStart(2, "0")}:${String(normalized % 60).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function declaredRange(input: DeclaredBirthInput): { readonly startTime: string; readonly endTime: string } {
|
||||
if (input.source === "period_only") {
|
||||
return {
|
||||
early_morning: { startTime: "04:00", endTime: "07:59" },
|
||||
morning: { startTime: "08:00", endTime: "11:59" },
|
||||
afternoon: { startTime: "12:00", endTime: "17:59" },
|
||||
evening: { startTime: "18:00", endTime: "22:59" },
|
||||
late_night: { startTime: "23:00", endTime: "03:59" },
|
||||
}[input.reportedPeriod];
|
||||
}
|
||||
if (input.source === "unknown") return { startTime: "00:00", endTime: "23:59" };
|
||||
if (input.source === "legacy_import" && !input.reportedTime) {
|
||||
if (input.reportedPeriod) {
|
||||
return declaredRange({ ...input, source: "period_only", reportedPeriod: input.reportedPeriod });
|
||||
}
|
||||
return { startTime: "00:00", endTime: "23:59" };
|
||||
}
|
||||
const reportedTime = input.reportedTime;
|
||||
if (!reportedTime) throw new ConversationalRectificationError("profile_incomplete");
|
||||
const before = input.uncertaintyBeforeMinutes ?? 2;
|
||||
const after = input.uncertaintyAfterMinutes ?? 2;
|
||||
return {
|
||||
startTime: clock(minute(reportedTime) - before),
|
||||
endTime: clock(minute(reportedTime) + after),
|
||||
};
|
||||
}
|
||||
|
||||
function scanCoordinates(range: { readonly startTime: string; readonly endTime: string }) {
|
||||
const start = minute(range.startTime);
|
||||
let end = minute(range.endTime);
|
||||
if (end < start) end += 1_440;
|
||||
const center = Math.round((start + end) / 2);
|
||||
return {
|
||||
centerTime: clock(center),
|
||||
uncertaintyMinutes: Math.max(1, Math.ceil((end - start) / 2)),
|
||||
};
|
||||
}
|
||||
|
||||
function boundedScanRanges(range: { readonly startTime: string; readonly endTime: string }) {
|
||||
const start = minute(range.startTime);
|
||||
let end = minute(range.endTime);
|
||||
if (end < start) end += 1_440;
|
||||
if (end - start <= 360) return [range];
|
||||
|
||||
const ranges: Array<{ readonly startTime: string; readonly endTime: string }> = [];
|
||||
let cursor = start;
|
||||
while (end - cursor > 360) {
|
||||
ranges.push({ startTime: clock(cursor), endTime: clock(cursor + 360) });
|
||||
cursor += 360;
|
||||
}
|
||||
if (cursor < end) {
|
||||
// A symmetric integer-minute scan needs an even endpoint span. Pull an
|
||||
// odd final span back by one minute, overlapping rather than inventing a
|
||||
// minute outside the user's declared range.
|
||||
const finalStart = (end - cursor) % 2 === 0 ? cursor : cursor - 1;
|
||||
ranges.push({ startTime: clock(finalStart), endTime: clock(end) });
|
||||
}
|
||||
return ranges;
|
||||
}
|
||||
|
||||
function currentRange(input: ConversationalRectificationPacketBuildInput) {
|
||||
const start = input.privateCandidate?.rangeStart;
|
||||
const end = input.privateCandidate?.rangeEnd;
|
||||
return start && end ? { startTime: start, endTime: end } : declaredRange(input.declaredBirthInput);
|
||||
}
|
||||
|
||||
function scoreableLifeEvents(
|
||||
evidence: readonly LifeEventEvidence[],
|
||||
birthDate: string,
|
||||
): LifeEvent[] {
|
||||
return evidence.flatMap((item) => {
|
||||
if (item.scoreable !== true || !item.dateValue
|
||||
|| !(["day", "month", "year"] as const).includes(item.datePrecision as "day" | "month" | "year")
|
||||
|| evidencePredatesBirthDate(item, birthDate)) {
|
||||
return [];
|
||||
}
|
||||
if (item.domain === "family" || item.domain === "other") return [];
|
||||
return [{
|
||||
id: item.id,
|
||||
domain: item.domain,
|
||||
precision: item.datePrecision as "day" | "month" | "year",
|
||||
date: item.dateValue,
|
||||
} as LifeEvent];
|
||||
}).slice(-6);
|
||||
}
|
||||
|
||||
function sampleTimes(scan: RectificationQuestionnaire): readonly { readonly sampleIndex: number; readonly time: string }[] {
|
||||
const raw = profileRecord(scan.raw.candidate_scan);
|
||||
const samples = Array.isArray(raw?.samples) ? raw.samples : [];
|
||||
const links = samples.flatMap((item, sampleIndex) => {
|
||||
const rawTime = text(profileRecord(item)?.time);
|
||||
const match = rawTime?.match(/(?:^|[T\s])(([01]\d|2[0-3]):[0-5]\d)/);
|
||||
return match?.[1] ? [{ sampleIndex, time: match[1] }] : [];
|
||||
});
|
||||
if (links.length !== scan.samples.length) {
|
||||
throw new ConversationalRectificationError("service_unavailable");
|
||||
}
|
||||
return links;
|
||||
}
|
||||
|
||||
function timeOffsetFromRangeStart(time: string, rangeStart: string): number {
|
||||
const start = minute(rangeStart);
|
||||
let value = minute(time);
|
||||
if (value < start) value += 1_440;
|
||||
return value - start;
|
||||
}
|
||||
|
||||
function timeIsInsideRange(
|
||||
time: string,
|
||||
range: { readonly startTime: string; readonly endTime: string },
|
||||
): boolean {
|
||||
const offset = timeOffsetFromRangeStart(time, range.startTime);
|
||||
const endOffset = timeOffsetFromRangeStart(range.endTime, range.startTime);
|
||||
return offset <= endOffset;
|
||||
}
|
||||
|
||||
function mergeQuestionnaireScans(
|
||||
scans: readonly RectificationQuestionnaire[],
|
||||
range: { readonly startTime: string; readonly endTime: string },
|
||||
): RectificationQuestionnaire {
|
||||
const first = scans[0];
|
||||
if (!first) throw new ConversationalRectificationError("service_unavailable");
|
||||
|
||||
const byTime = new Map<string, {
|
||||
sample: RectificationQuestionnaire["samples"][number];
|
||||
rawSample: unknown;
|
||||
}>();
|
||||
const questions = new Map<string, RectificationQuestionnaire["questions"][number]>();
|
||||
for (const scan of scans) {
|
||||
for (const question of scan.questions) {
|
||||
if (!questions.has(question.id)) questions.set(question.id, question);
|
||||
}
|
||||
const rawCandidateScan = profileRecord(scan.raw.candidate_scan);
|
||||
const rawSamples = Array.isArray(rawCandidateScan?.samples) ? rawCandidateScan.samples : [];
|
||||
for (const link of sampleTimes(scan)) {
|
||||
const sample = scan.samples[link.sampleIndex];
|
||||
const rawSample = rawSamples[link.sampleIndex];
|
||||
if (!sample || rawSample === undefined || !timeIsInsideRange(link.time, range)) continue;
|
||||
if (!byTime.has(link.time)) byTime.set(link.time, { sample, rawSample });
|
||||
}
|
||||
}
|
||||
const merged = [...byTime.entries()].sort(([left], [right]) =>
|
||||
timeOffsetFromRangeStart(left, range.startTime)
|
||||
- timeOffsetFromRangeStart(right, range.startTime));
|
||||
const firstCandidateScan = profileRecord(first.raw.candidate_scan) ?? {};
|
||||
return {
|
||||
questions: [...questions.values()],
|
||||
samples: merged.map(([, item]) => item.sample),
|
||||
raw: {
|
||||
...first.raw,
|
||||
candidate_scan: {
|
||||
...firstCandidateScan,
|
||||
samples: merged.map(([, item]) => item.rawSample),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function layerMetadata(scan: RectificationQuestionnaire, calculationVersion: string) {
|
||||
const layers = [
|
||||
["D1", "ascendantSign"],
|
||||
["D4", "d4Sign"],
|
||||
["D9", "d9Sign"],
|
||||
["D10", "d10Sign"],
|
||||
["D24", "d24Sign"],
|
||||
["D30", "d30Sign"],
|
||||
] as const;
|
||||
const availableLayers = layers
|
||||
.filter(([, key]) => scan.samples.some((sample) => typeof sample[key] === "string" && sample[key]?.trim()))
|
||||
.map(([layer]) => layer);
|
||||
return {
|
||||
availableLayers,
|
||||
layerReferences: Object.fromEntries(availableLayers.map((layer) => [
|
||||
layer,
|
||||
[`server-scan-${calculationVersion}-${layer.toLowerCase()}`],
|
||||
])),
|
||||
};
|
||||
}
|
||||
|
||||
function boundaryDistance(range: { readonly startTime: string; readonly endTime: string }, representative: string) {
|
||||
const start = minute(range.startTime);
|
||||
let end = minute(range.endTime);
|
||||
let value = minute(representative);
|
||||
if (end < start) end += 1_440;
|
||||
if (value < start) value += 1_440;
|
||||
return Math.max(0, Math.min(value - start, end - value));
|
||||
}
|
||||
|
||||
export async function buildProductionConversationalRectificationPacket(
|
||||
engine: BirthTimeJourneyEngine,
|
||||
input: ConversationalRectificationPacketBuildInput,
|
||||
) {
|
||||
const place = input.declaredBirthInput.birthplace;
|
||||
if (place.latitude === undefined || place.longitude === undefined) {
|
||||
throw new ConversationalRectificationError("profile_incomplete");
|
||||
}
|
||||
const baseRange = currentRange(input);
|
||||
const events = scoreableLifeEvents(
|
||||
input.evidence as readonly LifeEventEvidence[],
|
||||
input.declaredBirthInput.birthDate,
|
||||
);
|
||||
const eventScore: CandidateResult | null = events.length >= 3
|
||||
? await engine.scoreEvents({
|
||||
birthDate: input.declaredBirthInput.birthDate,
|
||||
startTime: baseRange.startTime,
|
||||
endTime: baseRange.endTime,
|
||||
lat: place.latitude,
|
||||
lon: place.longitude,
|
||||
tz: place.timezoneOffset,
|
||||
events,
|
||||
})
|
||||
: null;
|
||||
const selectedRange = !input.preserveCandidateRange && eventScore?.winningSegment
|
||||
? { startTime: eventScore.winningSegment.startTime, endTime: eventScore.winningSegment.endTime }
|
||||
: baseRange;
|
||||
const questionnaires: RectificationQuestionnaire[] = [];
|
||||
for (const scanRange of boundedScanRanges(selectedRange)) {
|
||||
const scanPoint = scanCoordinates(scanRange);
|
||||
const { questionnaire } = await engine.scan({
|
||||
birthTime: `${input.declaredBirthInput.birthDate} ${scanPoint.centerTime}`,
|
||||
uncertaintyMinutes: scanPoint.uncertaintyMinutes,
|
||||
lat: place.latitude,
|
||||
lon: place.longitude,
|
||||
tz: place.timezoneOffset,
|
||||
ayanamsa: "lahiri",
|
||||
});
|
||||
questionnaires.push(questionnaire);
|
||||
}
|
||||
const questionnaire = mergeQuestionnaireScans(questionnaires, selectedRange);
|
||||
const candidateDifferences = await engine.buildDifferencePacket({
|
||||
caseId: input.caseId,
|
||||
asOfDate: input.asOfDate,
|
||||
birthDate: input.declaredBirthInput.birthDate,
|
||||
startTime: selectedRange.startTime,
|
||||
endTime: selectedRange.endTime,
|
||||
lat: place.latitude,
|
||||
lon: place.longitude,
|
||||
tz: place.timezoneOffset,
|
||||
evidence: [],
|
||||
dismissedOpportunityIds: [],
|
||||
questionFingerprints: [],
|
||||
partitionFingerprints: [],
|
||||
recentRanges: [],
|
||||
candidateModel: null,
|
||||
});
|
||||
const calculationVersion = eventScore
|
||||
? `${candidateDifferences.packet.scoringVersion}+${eventScore.algorithmVersion}`
|
||||
: candidateDifferences.packet.scoringVersion;
|
||||
const metadata = layerMetadata(questionnaire, calculationVersion);
|
||||
const representative = eventScore?.winningSegment?.representativeTime
|
||||
?? scanCoordinates(selectedRange).centerTime;
|
||||
const { buildRectificationTechnicalPacket } = await import(
|
||||
"../../../lib/conversational-rectification/technical-packet.ts"
|
||||
);
|
||||
return {
|
||||
packet: buildRectificationTechnicalPacket({
|
||||
scan: questionnaire,
|
||||
candidateDifferences,
|
||||
eventScore: input.preserveCandidateRange && eventScore
|
||||
? { ...eventScore, confidence: "low", canApply: false, winningSegment: null }
|
||||
: eventScore,
|
||||
consultation: {
|
||||
source: "server_consultation_workflow",
|
||||
calculationVersion,
|
||||
availableLayers: metadata.availableLayers,
|
||||
layerReferences: metadata.layerReferences,
|
||||
timeLinkedScanSamples: sampleTimes(questionnaire),
|
||||
boundaryDistanceMinutes: boundaryDistance(selectedRange, representative),
|
||||
futureWindows: [],
|
||||
},
|
||||
}),
|
||||
resultId: eventScore?.resultId ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
async function productionNarrativeGenerator(): Promise<RectificationNarrativeGenerator> {
|
||||
const [{ defaultLanguageModel }, { Agent }] = await Promise.all([
|
||||
import("../../../mastra/model.ts"),
|
||||
import("@mastra/core/agent"),
|
||||
]);
|
||||
const model = defaultLanguageModel();
|
||||
if (!model) {
|
||||
return {
|
||||
modelId: "deterministic-rectification-fallback",
|
||||
async generate() { throw new Error("NarrativeModelUnavailable"); },
|
||||
};
|
||||
}
|
||||
const agent = new Agent({
|
||||
id: `conversational-rectification-${model.id}`,
|
||||
name: "Conversational Rectification Narrator",
|
||||
model: model.model,
|
||||
instructions: "Return only the exact JSON object requested by the user prompt. Use only supplied packet facts. Never invent times, layers, references, scores, dates, or confirmation state.",
|
||||
});
|
||||
return {
|
||||
modelId: model.id,
|
||||
async generate(prompt) {
|
||||
const result = await agent.generate([{ role: "user", content: prompt }]);
|
||||
return { text: result.text };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function createProductionService(
|
||||
authenticated: AuthenticatedRequest,
|
||||
): Promise<BirthTimeConversationRouteService> {
|
||||
const [
|
||||
{ createAdminSupabaseClient },
|
||||
{ createSupabaseConversationalRectificationStore },
|
||||
{ createSupabaseConversationalRectificationBilling },
|
||||
{ createJyotishBirthTimeJourneyEngine },
|
||||
narrativeGenerator,
|
||||
] = await Promise.all([
|
||||
import("../../../lib/supabase/admin.ts"),
|
||||
import("../../../lib/conversational-rectification/store.ts"),
|
||||
import("../../../lib/conversational-rectification/billing.ts"),
|
||||
import("../../../lib/birth-time-journey-engine.ts"),
|
||||
productionNarrativeGenerator(),
|
||||
]);
|
||||
const admin = createAdminSupabaseClient();
|
||||
const profileClient = authenticated.context as ProfileClient;
|
||||
const engine = createJyotishBirthTimeJourneyEngine();
|
||||
return createConversationalRectificationService({
|
||||
store: createSupabaseConversationalRectificationStore(admin),
|
||||
billing: createSupabaseConversationalRectificationBilling(admin),
|
||||
get rectificationPriceCredits() { return priceCredits(); },
|
||||
allowNewCaseCreation: conversationalRectificationCreationPolicyFromEnvironment(
|
||||
authenticated.userId,
|
||||
).allowNewCaseCreation,
|
||||
async loadDeclaredProfile(userId) {
|
||||
return loadProductionConversationalRectificationProfile({
|
||||
async loadProfile(receivedUserId) {
|
||||
const { data, error } = await profileClient
|
||||
.from("profiles")
|
||||
.select("birth_date,reported_birth_time,active_birth_time,birth_time_source,birth_time_period,birth_time_clue,uncertainty_before_minutes,uncertainty_after_minutes,country_code,province_code,city_code,district_code,latitude,longitude,timezone_offset,rectification_case_id")
|
||||
.eq("id", receivedUserId)
|
||||
.maybeSingle();
|
||||
if (error) throw new ConversationalRectificationError("store_unavailable");
|
||||
return data;
|
||||
},
|
||||
async loadRectificationCase(receivedUserId, receivedCaseId) {
|
||||
const { data, error } = await admin
|
||||
.from("birth_time_rectification_cases")
|
||||
.select("id,journey_protocol,status")
|
||||
.eq("id", receivedCaseId)
|
||||
.eq("user_id", receivedUserId)
|
||||
.maybeSingle();
|
||||
if (error) throw new ConversationalRectificationError("store_unavailable");
|
||||
return data;
|
||||
},
|
||||
}, userId);
|
||||
},
|
||||
async loadLegacyCase(userId, legacyCaseId) {
|
||||
const { createJourneyLoadClient, loadStoredRectificationCase } = await import(
|
||||
"../../../lib/birth-time-journey-case-loader.ts"
|
||||
);
|
||||
const { data: identity, error } = await admin
|
||||
.from("birth_time_rectification_cases")
|
||||
.select("id,user_id,journey_protocol,status,reported_date,reported_time,reported_period,source,uncertainty_before_minutes,uncertainty_after_minutes")
|
||||
.eq("id", legacyCaseId)
|
||||
.eq("user_id", userId)
|
||||
.maybeSingle();
|
||||
if (error || !identity
|
||||
|| (identity.journey_protocol !== "legacy-guided-v1"
|
||||
&& identity.journey_protocol !== "dynamic-choice-v2")) return null;
|
||||
const { data: currentProfile, error: profileError } = await profileClient
|
||||
.from("profiles")
|
||||
.select("birth_date,reported_birth_time,active_birth_time,birth_time_source,birth_time_period,birth_time_clue,uncertainty_before_minutes,uncertainty_after_minutes,country_code,province_code,city_code,district_code,latitude,longitude,timezone_offset,rectification_case_id")
|
||||
.eq("id", userId)
|
||||
.maybeSingle();
|
||||
if (profileError || !currentProfile) {
|
||||
throw new ConversationalRectificationError("store_unavailable");
|
||||
}
|
||||
const declaredBirthInput = declaredBirthInputForLegacyCase(currentProfile, identity);
|
||||
const loaded = await loadStoredRectificationCase(
|
||||
createJourneyLoadClient(admin),
|
||||
userId,
|
||||
legacyCaseId,
|
||||
);
|
||||
if (!loaded) return null;
|
||||
const winning = loaded.candidateResult?.winningSegment;
|
||||
const snapshotRange = loaded.snapshot.reportedRange;
|
||||
const currentRange = loaded.journeyProtocol === "dynamic-choice-v2"
|
||||
? loaded.dynamicTurnState.progress.currentRange
|
||||
: winning
|
||||
? { startTime: winning.startTime, endTime: winning.endTime }
|
||||
: snapshotRange.startTime && snapshotRange.endTime
|
||||
? { startTime: snapshotRange.startTime, endTime: snapshotRange.endTime }
|
||||
: null;
|
||||
if (!currentRange) throw new ConversationalRectificationError("store_unavailable");
|
||||
return {
|
||||
caseId: loaded.id,
|
||||
userId: loaded.userId,
|
||||
journeyProtocol: loaded.journeyProtocol,
|
||||
status: identity.status,
|
||||
turnVersion: loaded.turnVersion ?? 0,
|
||||
declaredBirthInput,
|
||||
currentRange,
|
||||
lifeEvents: loaded.lifeEvents ?? [],
|
||||
};
|
||||
},
|
||||
buildTechnicalPacket: (input) => buildProductionConversationalRectificationPacket(engine, input),
|
||||
narrativeGenerator,
|
||||
asOfDate: () => new Date().toISOString().slice(0, 10),
|
||||
});
|
||||
}
|
||||
|
||||
function stableRequestId(request: Request): string {
|
||||
const supplied = request.headers.get("x-request-id");
|
||||
return supplied && /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(supplied)
|
||||
? supplied.toLowerCase()
|
||||
: randomUUID();
|
||||
}
|
||||
|
||||
async function dispatch(
|
||||
service: BirthTimeConversationRouteService,
|
||||
userId: string,
|
||||
command: ConversationalRectificationCommand,
|
||||
): Promise<ConversationalRectificationTurn> {
|
||||
switch (command.type) {
|
||||
case "start": return service.start(userId, command);
|
||||
case "resume": return service.resume(userId, command);
|
||||
case "answer": return service.answer(userId, command);
|
||||
case "pause": return service.pause(userId, command);
|
||||
case "abandon": return service.abandon(userId, command);
|
||||
case "confirm": return service.confirm(userId, command);
|
||||
}
|
||||
}
|
||||
|
||||
function telemetryPhase(
|
||||
turn: Pick<ConversationalRectificationTurn, "status"> | null,
|
||||
): ConversationalRectificationTelemetryPayload["phase"] {
|
||||
switch (turn?.status) {
|
||||
case "active": return "collecting_evidence";
|
||||
case "paused": return "paused";
|
||||
case "confirming": return "confirming";
|
||||
case "completed": return "completed";
|
||||
case "abandoned": return "abandoned";
|
||||
default: return "entry";
|
||||
}
|
||||
}
|
||||
|
||||
function telemetryErrorCategory(
|
||||
code: string,
|
||||
): ConversationalRectificationTelemetryPayload["errorCategory"] {
|
||||
if (code === "authentication_required") return "authentication";
|
||||
if (code === "invalid_command" || code === "profile_incomplete") return "validation";
|
||||
if (code === "stale_turn" || code === "action_conflict" || code === "candidate_changed"
|
||||
|| code === "invalid_transition" || code === "case_not_found") return "conflict";
|
||||
if (code === "billing_failed") return "billing";
|
||||
if (code === "service_unavailable" || code === "store_unavailable") return "dependency";
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
function telemetryResultCategory(
|
||||
status: number,
|
||||
): ConversationalRectificationTelemetryPayload["resultCategory"] {
|
||||
if (status === 409) return "conflict";
|
||||
if (status >= 400 && status < 500) return "rejected";
|
||||
return "failed";
|
||||
}
|
||||
|
||||
export function createBirthTimeConversationPostHandler(
|
||||
dependencies: BirthTimeConversationPostDependencies,
|
||||
) {
|
||||
return async function handleBirthTimeConversationPost(request: Request): Promise<Response> {
|
||||
const startedAt = dependencies.now?.() ?? Date.now();
|
||||
const now = dependencies.now ?? Date.now;
|
||||
const telemetry = dependencies.telemetry
|
||||
? createConversationalRectificationTelemetry(dependencies.telemetry)
|
||||
: recordConversationalRectificationTelemetry;
|
||||
const deploymentSha = safeConversationalRectificationDeploymentSha(
|
||||
dependencies.deploymentSha
|
||||
?? process.env.GITHUB_SHA
|
||||
?? process.env.VERCEL_GIT_COMMIT_SHA
|
||||
?? process.env.NEXT_PUBLIC_GIT_COMMIT,
|
||||
);
|
||||
dependencies.createRequestId?.(request);
|
||||
let actionKind: ConversationalRectificationTelemetryPayload["actionKind"] = "unknown";
|
||||
let service: BirthTimeConversationRouteService | null = null;
|
||||
try {
|
||||
const authenticated = await dependencies.authenticate(request);
|
||||
if (!authenticated) throw new ConversationalRectificationError("authentication_required");
|
||||
|
||||
const parsed = conversationalRectificationCommandSchema.safeParse(await requestPayload(request));
|
||||
if (!parsed.success) throw new ConversationalRectificationError("invalid_command");
|
||||
actionKind = parsed.data.type;
|
||||
|
||||
service = await dependencies.createService(authenticated);
|
||||
const turn = await dispatch(service, authenticated.userId, parsed.data);
|
||||
const outcome = conversationalRectificationTelemetryOutcome(service);
|
||||
telemetry({
|
||||
protocol: "conversational-evidence-v3",
|
||||
phase: telemetryPhase(turn),
|
||||
actionKind,
|
||||
resultCategory: "success",
|
||||
latencyBucket: conversationalRectificationLatencyBucket(now() - startedAt),
|
||||
billingState: outcome?.billingState ?? (actionKind === "start" ? "unknown" : "unchanged"),
|
||||
errorCategory: "none",
|
||||
deploymentSha,
|
||||
});
|
||||
return Response.json(turn);
|
||||
} catch (error) {
|
||||
const publicError = toConversationalRectificationPublicError(error);
|
||||
const outcome = service ? conversationalRectificationTelemetryOutcome(service) : null;
|
||||
dependencies.log?.({ code: publicError.code });
|
||||
telemetry({
|
||||
protocol: "conversational-evidence-v3",
|
||||
phase: telemetryPhase(outcome?.caseStatus ? { status: outcome.caseStatus } : null),
|
||||
actionKind,
|
||||
resultCategory: telemetryResultCategory(publicError.status),
|
||||
latencyBucket: conversationalRectificationLatencyBucket(now() - startedAt),
|
||||
billingState: outcome?.billingState
|
||||
?? (publicError.code === "billing_failed" ? "unknown" : "not_applicable"),
|
||||
errorCategory: telemetryErrorCategory(publicError.code),
|
||||
deploymentSha,
|
||||
});
|
||||
return Response.json(publicError, { status: publicError.status });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const productionPost = createBirthTimeConversationPostHandler({
|
||||
authenticate: authenticateProductionRequest,
|
||||
createService: createProductionService,
|
||||
createRequestId: stableRequestId,
|
||||
deploymentSha: process.env.GITHUB_SHA
|
||||
?? process.env.VERCEL_GIT_COMMIT_SHA
|
||||
?? process.env.NEXT_PUBLIC_GIT_COMMIT,
|
||||
log(entry) {
|
||||
console.error(`[birth-time-conversation] code=${entry.code}`);
|
||||
},
|
||||
});
|
||||
|
||||
export async function POST(request: Request) {
|
||||
return productionPost(request);
|
||||
}
|
||||
@@ -184,6 +184,15 @@ export async function POST(request: Request) {
|
||||
resultId: parsed.data.resultId,
|
||||
time: parsed.data.time,
|
||||
}), "turn_advanced");
|
||||
case "confirm_dynamic_candidate":
|
||||
return responseWithJourneyMetric(service.confirmDynamicCandidate({
|
||||
userId: user.id,
|
||||
caseId: parsed.data.caseId,
|
||||
actionId: parsed.data.actionId,
|
||||
expectedVersion: parsed.data.turnVersion,
|
||||
resultId: parsed.data.resultId,
|
||||
time: parsed.data.time,
|
||||
}), "turn_advanced");
|
||||
default: {
|
||||
const exhaustive: never = parsed.data;
|
||||
return exhaustive;
|
||||
|
||||
@@ -2,6 +2,7 @@ import { NextResponse } from "next/server";
|
||||
import {
|
||||
consultationInputSchema,
|
||||
consultationWorkflowReceipt,
|
||||
getGeneralJyotishAgent,
|
||||
getJyotishAgent,
|
||||
runConsultationWorkflow,
|
||||
} from "@/mastra";
|
||||
@@ -19,16 +20,30 @@ import { reserveConsultationModel } from "@/lib/consultation-model-selection";
|
||||
import { createAdminSupabaseClient } from "@/lib/supabase/admin";
|
||||
import { createServerSupabaseClient } from "@/lib/supabase/server";
|
||||
import { streamTextResponse } from "@/lib/stream-text-response";
|
||||
import { guardPreciseTimingOutput } from "@/lib/timing-output-guard";
|
||||
import {
|
||||
applyBirthTimeModeToWorkflowContext,
|
||||
consultationBirthTimeModeSchema,
|
||||
createBirthTimeModeOutputGuard,
|
||||
shouldRunBirthChartWorkflow,
|
||||
type ConsultationBirthTimeMode,
|
||||
} from "@/lib/consultation-birth-time-mode";
|
||||
import {
|
||||
ConsultationProfileTruthError,
|
||||
prepareConsultationRoute,
|
||||
} from "@/lib/consultation-route-service";
|
||||
import {
|
||||
createRectificationHandoffService,
|
||||
type RectificationHandoffExecution,
|
||||
type RectificationHandoffService,
|
||||
} from "@/lib/rectification-handoff-service";
|
||||
import { z } from "zod";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const maxDuration = 60;
|
||||
|
||||
const chatRequestSchema = consultationInputSchema.extend({
|
||||
const chatRequestMetadataSchema = z.object({
|
||||
requestId: z.string().uuid(),
|
||||
modelId: z.string().trim().min(1).max(64),
|
||||
entrypoint: consultationEntrypointSchema.optional(),
|
||||
name: z.string().trim().max(80).optional().default(""),
|
||||
history: z
|
||||
.array(
|
||||
@@ -41,6 +56,32 @@ const chatRequestSchema = consultationInputSchema.extend({
|
||||
.default([]),
|
||||
});
|
||||
|
||||
const rectificationHandoffSchema = z.object({
|
||||
caseId: z.string().uuid(),
|
||||
turnVersion: z.number().int().nonnegative(),
|
||||
claimActionId: z.string().uuid(),
|
||||
requestId: z.string().uuid(),
|
||||
}).strict();
|
||||
|
||||
const chartChatRequestSchema = consultationInputSchema.extend({
|
||||
...chatRequestMetadataSchema.shape,
|
||||
consultationMode: consultationBirthTimeModeSchema.exclude(["general_no_birth_time"])
|
||||
.optional()
|
||||
.default("verified_chart"),
|
||||
entrypoint: consultationEntrypointSchema.optional(),
|
||||
rectificationHandoff: rectificationHandoffSchema.optional(),
|
||||
}).strict();
|
||||
|
||||
const generalChatRequestSchema = z.object({
|
||||
...chatRequestMetadataSchema.shape,
|
||||
consultationMode: z.literal("general_no_birth_time"),
|
||||
question: z.string().trim().min(1).max(500),
|
||||
theme: z.enum(["career", "marriage", "wealth", "timing", "general"]),
|
||||
entrypoint: z.undefined().optional(),
|
||||
}).strict();
|
||||
|
||||
const chatRequestSchema = z.union([generalChatRequestSchema, chartChatRequestSchema]);
|
||||
|
||||
function currentTimeContext(now = new Date()) {
|
||||
const chinaTime = new Date(now.getTime() + 8 * 60 * 60 * 1000)
|
||||
.toISOString()
|
||||
@@ -119,20 +160,13 @@ export async function POST(request: Request) {
|
||||
);
|
||||
}
|
||||
|
||||
const userControlledPrompt = [
|
||||
parsed.data.question,
|
||||
...parsed.data.history
|
||||
.filter((message) => message.role === "user")
|
||||
.map((message) => message.text),
|
||||
].join("\n");
|
||||
if (blocksPromptExtraction(userControlledPrompt)) {
|
||||
if (parsed.data.entrypoint === "birth_time_rectification") {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "无法处理该请求",
|
||||
message:
|
||||
"我不能提供系统提示词、技能原文或任何密钥。你可以继续询问占星相关问题。",
|
||||
error: "旧版生时校正入口已停用",
|
||||
message: "请从首页生时校正卡片开始或继续对话式校正,本次不会扣点。",
|
||||
},
|
||||
{ status: 400 },
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -145,20 +179,157 @@ export async function POST(request: Request) {
|
||||
|
||||
const userId = user.id;
|
||||
const requestId = parsed.data.requestId;
|
||||
let modelSelection;
|
||||
try {
|
||||
modelSelection = await reserveConsultationModel(
|
||||
parsed.data.modelId,
|
||||
resolveLanguageModel,
|
||||
() =>
|
||||
runCreditRpc(
|
||||
accounting,
|
||||
"begin_consultation_credit",
|
||||
userId,
|
||||
requestId,
|
||||
),
|
||||
const handoff = "rectificationHandoff" in parsed.data
|
||||
? parsed.data.rectificationHandoff
|
||||
: undefined;
|
||||
let handoffService: RectificationHandoffService | null = null;
|
||||
let handoffExecution: RectificationHandoffExecution | null = null;
|
||||
let handoffSettlement: Promise<void> | null = null;
|
||||
|
||||
async function settleHandoff(emitted: boolean) {
|
||||
if (!handoff || !handoffService || !handoffExecution
|
||||
|| handoffExecution.status !== "ready") return;
|
||||
handoffSettlement ??= handoffService.settle({
|
||||
userId,
|
||||
caseId: handoff.caseId,
|
||||
claimActionId: handoff.claimActionId,
|
||||
requestId: handoff.requestId,
|
||||
emitted,
|
||||
}).then(() => undefined);
|
||||
await handoffSettlement;
|
||||
}
|
||||
|
||||
if (handoff) {
|
||||
if (parsed.data.consultationMode !== "verified_chart"
|
||||
|| parsed.data.entrypoint !== undefined
|
||||
|| requestId !== handoff.requestId) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "原问题交接请求不一致",
|
||||
message: "请刷新校正结果后重新点击继续,本次不会扣点。",
|
||||
},
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
try {
|
||||
handoffService = createRectificationHandoffService(accounting);
|
||||
handoffExecution = await handoffService.beginExecution({
|
||||
userId,
|
||||
caseId: handoff.caseId,
|
||||
turnVersion: handoff.turnVersion,
|
||||
claimActionId: handoff.claimActionId,
|
||||
requestId: handoff.requestId,
|
||||
question: parsed.data.question,
|
||||
});
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "原问题状态已经变化",
|
||||
message: "请刷新后查看最新状态,本次不会扣点。",
|
||||
},
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
if (handoffExecution.status !== "ready") {
|
||||
const consumed = handoffExecution.status === "consumed";
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: consumed ? "原问题已经继续回答" : "原问题正在另一处继续",
|
||||
message: consumed
|
||||
? "刷新后即可查看最新状态,不会再次扣点。"
|
||||
: "请等待当前回答完成后刷新,本次不会重复扣点。",
|
||||
},
|
||||
{ status: consumed ? 410 : 409 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const userControlledPrompt = [
|
||||
parsed.data.question,
|
||||
...parsed.data.history
|
||||
.filter((message) => message.role === "user")
|
||||
.map((message) => message.text),
|
||||
].join("\n");
|
||||
if (blocksPromptExtraction(userControlledPrompt)) {
|
||||
if (handoffExecution?.status === "ready") {
|
||||
try {
|
||||
await settleHandoff(false);
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{ error: "暂时无法释放原问题", message: "请稍后刷新状态。" },
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
}
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "无法处理该请求",
|
||||
message:
|
||||
"我不能提供系统提示词、技能原文或任何密钥。你可以继续询问占星相关问题。",
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
let prepared;
|
||||
try {
|
||||
prepared = await prepareConsultationRoute({
|
||||
userId,
|
||||
mode: parsed.data.consultationMode,
|
||||
async loadProfile(profileUserId) {
|
||||
const { data, error } = await supabase
|
||||
.from("profiles")
|
||||
.select("name,birth_date,reported_birth_time,active_birth_time,birth_time_source,birth_time_status,country_code,province_code,city_code,district_code,latitude,longitude,timezone_offset")
|
||||
.eq("id", profileUserId)
|
||||
.single();
|
||||
if (error || !data) throw new ConsultationProfileTruthError("profile_unavailable");
|
||||
return data;
|
||||
},
|
||||
reserve: () => reserveConsultationModel(
|
||||
parsed.data.modelId,
|
||||
resolveLanguageModel,
|
||||
() => handoffExecution?.billingReused
|
||||
? Promise.resolve({
|
||||
success: true,
|
||||
credits: handoffExecution.credits ?? null,
|
||||
error_code: null,
|
||||
})
|
||||
: runCreditRpc(
|
||||
accounting,
|
||||
"begin_consultation_credit",
|
||||
userId,
|
||||
requestId,
|
||||
),
|
||||
),
|
||||
});
|
||||
} catch (error) {
|
||||
if (handoffExecution?.status === "ready") {
|
||||
try {
|
||||
await settleHandoff(false);
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "暂时无法释放原问题",
|
||||
message: "请稍后刷新状态,本次不会重复扣点。",
|
||||
},
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
}
|
||||
if (error instanceof ConsultationProfileTruthError) {
|
||||
const modeChanged = error.code === "mode_changed";
|
||||
return NextResponse.json(
|
||||
modeChanged
|
||||
? {
|
||||
error: "出生时间状态已经变化",
|
||||
message: "请刷新后重新选择使用填报时间、一般咨询或先完成校正,本次不会扣点。",
|
||||
}
|
||||
: {
|
||||
error: "暂时无法核对完整出生资料",
|
||||
message: "出生日期、时间来源或出生地点资料不完整或不一致,请重新保存后再试,本次不会扣点。",
|
||||
},
|
||||
{ status: modeChanged ? 409 : 503 },
|
||||
);
|
||||
}
|
||||
const reason = error instanceof Error ? error.name : "UnknownError";
|
||||
console.error(
|
||||
`[billing] reservation failed request=${requestId} reason=${reason}`,
|
||||
@@ -169,7 +340,19 @@ export async function POST(request: Request) {
|
||||
);
|
||||
}
|
||||
|
||||
const modelSelection = prepared.reservation;
|
||||
|
||||
if (modelSelection.status === "unavailable") {
|
||||
if (handoffExecution?.status === "ready") {
|
||||
try {
|
||||
await settleHandoff(false);
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{ error: "暂时无法释放原问题", message: "请稍后刷新状态。" },
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
}
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "模型暂不可用",
|
||||
@@ -183,6 +366,16 @@ export async function POST(request: Request) {
|
||||
const reserveResult = modelSelection.reservation;
|
||||
|
||||
if (!reserveResult.success) {
|
||||
if (handoffExecution?.status === "ready") {
|
||||
try {
|
||||
await settleHandoff(false);
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{ error: "暂时无法释放原问题", message: "请稍后刷新状态。" },
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
}
|
||||
const insufficient = reserveResult.error_code === "insufficient_credits";
|
||||
return NextResponse.json(
|
||||
{
|
||||
@@ -196,6 +389,17 @@ export async function POST(request: Request) {
|
||||
}
|
||||
|
||||
async function cancel() {
|
||||
if (handoffExecution?.status === "ready") {
|
||||
try {
|
||||
await settleHandoff(false);
|
||||
} catch (error) {
|
||||
const reason = error instanceof Error ? error.name : "UnknownError";
|
||||
console.error(
|
||||
`[billing] handoff release failed request=${requestId} reason=${reason}`,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await runCreditRpc(
|
||||
accounting,
|
||||
@@ -212,6 +416,10 @@ export async function POST(request: Request) {
|
||||
}
|
||||
|
||||
async function complete() {
|
||||
if (handoffExecution?.status === "ready") {
|
||||
await settleHandoff(true);
|
||||
return;
|
||||
}
|
||||
const result = await runCreditRpc(
|
||||
accounting,
|
||||
"complete_consultation_credit",
|
||||
@@ -229,12 +437,65 @@ export async function POST(request: Request) {
|
||||
}
|
||||
|
||||
try {
|
||||
const { history, name } = parsed.data;
|
||||
const { history } = parsed.data;
|
||||
const name = prepared.serverChart?.name ?? parsed.data.name;
|
||||
const consultationMode: ConsultationBirthTimeMode = parsed.data.consultationMode;
|
||||
if (!shouldRunBirthChartWorkflow(consultationMode)) {
|
||||
const result = await getGeneralJyotishAgent(selectedModel).stream([
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
currentTimeContext(requestTime),
|
||||
name ? `用户称呼:${name}` : "",
|
||||
"当前是用户明确选择的无出生分钟一般咨询。不得计算或推断个人星盘;不得补 00:00、时段中点或任何候选分钟。",
|
||||
resolvedQuestion.modelQuestion,
|
||||
].filter(Boolean).join("\n"),
|
||||
},
|
||||
]);
|
||||
const completeAndRecordUsage = async () => {
|
||||
await complete();
|
||||
void recordModelUsage(
|
||||
accounting,
|
||||
userId,
|
||||
requestId,
|
||||
modelSelection.usageModelId,
|
||||
result.totalUsage,
|
||||
);
|
||||
};
|
||||
const settleInterrupted = (emitted: boolean) =>
|
||||
settle(emitted ? completeAndRecordUsage : cancel);
|
||||
return streamTextResponse(result.textStream, {
|
||||
transformText: createBirthTimeModeOutputGuard(consultationMode, false),
|
||||
mode: "mastra",
|
||||
requestId,
|
||||
headers: {
|
||||
"x-jyotish-workflow-route": "general-no-birth-time",
|
||||
"x-jyotish-workflow-status": "ready",
|
||||
"x-jyotish-technique-truth": "not-applicable",
|
||||
"x-jyotish-precise-timing": "blocked",
|
||||
"x-jyotish-missing-layers": "birth-minute",
|
||||
"x-jyotish-birth-time-mode": consultationMode,
|
||||
},
|
||||
...(handoff ? { onFirstOutput: () => settle(completeAndRecordUsage) } : {}),
|
||||
onComplete: () => settle(completeAndRecordUsage),
|
||||
onError: (_error, emitted) => settleInterrupted(emitted),
|
||||
onCancel: settleInterrupted,
|
||||
});
|
||||
}
|
||||
|
||||
if (!prepared.serverChart) throw new Error("server_chart_truth_missing");
|
||||
const toolInput = consultationInputSchema.parse({
|
||||
...parsed.data,
|
||||
...prepared.serverChart.toolInput,
|
||||
// Unverified use is still a normal chart calculation with a hard answer
|
||||
// boundary. It must never reactivate the retired rectification questionnaire.
|
||||
entryMode: "direct_chart",
|
||||
question: resolvedQuestion.modelQuestion,
|
||||
theme: parsed.data.theme,
|
||||
});
|
||||
const workflowContext = await runConsultationWorkflow(toolInput);
|
||||
const workflowContext = applyBirthTimeModeToWorkflowContext(
|
||||
await runConsultationWorkflow(toolInput),
|
||||
consultationMode,
|
||||
);
|
||||
const workflowReceipt = consultationWorkflowReceipt(workflowContext);
|
||||
|
||||
const result = await getJyotishAgent(selectedModel, workflowContext).stream([
|
||||
@@ -265,10 +526,10 @@ export async function POST(request: Request) {
|
||||
const settleInterrupted = (emitted: boolean) =>
|
||||
settle(emitted ? completeAndRecordUsage : cancel);
|
||||
return streamTextResponse(result.textStream, {
|
||||
transformText:
|
||||
workflowReceipt.preciseTiming === "blocked"
|
||||
? guardPreciseTimingOutput
|
||||
: undefined,
|
||||
transformText: createBirthTimeModeOutputGuard(
|
||||
consultationMode,
|
||||
workflowReceipt.preciseTiming !== "blocked",
|
||||
),
|
||||
mode: "mastra",
|
||||
requestId,
|
||||
headers: {
|
||||
@@ -277,7 +538,9 @@ export async function POST(request: Request) {
|
||||
"x-jyotish-technique-truth": workflowReceipt.techniqueTruth,
|
||||
"x-jyotish-precise-timing": workflowReceipt.preciseTiming,
|
||||
"x-jyotish-missing-layers": workflowReceipt.missingLayers,
|
||||
"x-jyotish-birth-time-mode": consultationMode,
|
||||
},
|
||||
...(handoff ? { onFirstOutput: () => settle(completeAndRecordUsage) } : {}),
|
||||
onComplete: () => settle(completeAndRecordUsage),
|
||||
onError: (_error, emitted) => settleInterrupted(emitted),
|
||||
onCancel: settleInterrupted,
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import {
|
||||
conversationalRectificationCreationPolicyFromEnvironment,
|
||||
conversationalRectificationDeploymentShaFromEnvironment,
|
||||
} from "../../../lib/conversational-rectification/creation-policy.ts";
|
||||
|
||||
type Check = {
|
||||
status: "ok" | "degraded" | "blocked";
|
||||
@@ -7,11 +11,6 @@ type Check = {
|
||||
};
|
||||
|
||||
const jyotishApiBase = process.env.JYOTISH_API_BASE ?? "http://127.0.0.1:5200";
|
||||
const gitCommit =
|
||||
process.env.GITHUB_SHA
|
||||
?? process.env.VERCEL_GIT_COMMIT_SHA
|
||||
?? process.env.NEXT_PUBLIC_GIT_COMMIT
|
||||
?? "unknown";
|
||||
|
||||
function envCheck(names: string[]): Check {
|
||||
const missing = names.filter((name) => !process.env[name]);
|
||||
@@ -58,6 +57,10 @@ function aggregate(checks: Record<string, Check>) {
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
const gitCommit = conversationalRectificationDeploymentShaFromEnvironment();
|
||||
const rectificationV3MigrationsReady =
|
||||
process.env.RECTIFICATION_V3_MIGRATIONS_READY?.trim().toLowerCase() === "true";
|
||||
const creationPolicy = conversationalRectificationCreationPolicyFromEnvironment();
|
||||
const checks = {
|
||||
web: { status: "ok" } satisfies Check,
|
||||
supabasePublicConfig: envCheck(["NEXT_PUBLIC_SUPABASE_URL", "NEXT_PUBLIC_SUPABASE_ANON_KEY"]),
|
||||
@@ -66,6 +69,8 @@ export async function GET() {
|
||||
jyotishApi: await jyotishApiCheck(),
|
||||
};
|
||||
const status = aggregate(checks);
|
||||
const rectificationV3Ready = status === "ok"
|
||||
&& creationPolicy.audience === "public";
|
||||
return NextResponse.json(
|
||||
{
|
||||
status,
|
||||
@@ -73,6 +78,16 @@ export async function GET() {
|
||||
deployment: {
|
||||
gitCommit,
|
||||
},
|
||||
rollout: {
|
||||
conversationalRectificationV3: {
|
||||
protocol: "conversational-evidence-v3",
|
||||
newCaseCreation: creationPolicy.audience === "paused" ? "paused" : "enabled",
|
||||
creationAudience: creationPolicy.audience,
|
||||
migrations: rectificationV3MigrationsReady ? "ready" : "unverified",
|
||||
syntheticSmoke: creationPolicy.smokeMatchesDeployment ? "matched" : "pending",
|
||||
readyForNewCases: rectificationV3Ready,
|
||||
},
|
||||
},
|
||||
checks,
|
||||
},
|
||||
{ status: status === "ok" ? 200 : 503 },
|
||||
|
||||
Reference in New Issue
Block a user