test: verify conversational birth-time rectification

This commit is contained in:
Jesse_Chen
2026-07-21 14:40:43 +08:00
parent de9a687779
commit ffaa7dc9c5
9 changed files with 1138 additions and 22 deletions
@@ -22,6 +22,14 @@ import {
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 {
conversationalRectificationLatencyBucket,
createConversationalRectificationTelemetry,
recordConversationalRectificationTelemetry,
safeConversationalRectificationDeploymentSha,
type ConversationalRectificationTelemetryPayload,
type ConversationalRectificationTelemetrySink,
} from "../../../lib/birth-time-journey-telemetry.ts";
export const runtime = "nodejs";
export const maxDuration = 60;
@@ -34,9 +42,6 @@ type AuthenticatedRequest = Readonly<{
export type BirthTimeConversationRouteService = ConversationalRectificationService;
export type BirthTimeConversationRouteLog = Readonly<{
requestId: string;
actionId: string | null;
caseId: string | null;
code: string;
}>;
@@ -45,6 +50,9 @@ export type BirthTimeConversationPostDependencies = Readonly<{
createService(authenticated: AuthenticatedRequest): Promise<BirthTimeConversationRouteService>;
createRequestId?(request: Request): string;
log?(entry: BirthTimeConversationRouteLog): void;
telemetry?: ConversationalRectificationTelemetrySink;
deploymentSha?: string;
now?(): number;
}>;
type ProfileQueryResult = Readonly<{
@@ -580,6 +588,8 @@ async function createProductionService(
store: createSupabaseConversationalRectificationStore(admin),
billing: createSupabaseConversationalRectificationBilling(admin),
get rectificationPriceCredits() { return priceCredits(); },
allowNewCaseCreation:
process.env.RECTIFICATION_V3_CREATE_ENABLED?.trim().toLowerCase() !== "false",
async loadDeclaredProfile(userId) {
return loadProductionConversationalRectificationProfile({
async loadProfile(receivedUserId) {
@@ -665,11 +675,6 @@ function stableRequestId(request: Request): string {
: randomUUID();
}
function errorResponse(error: unknown) {
const publicError = toConversationalRectificationPublicError(error);
return Response.json(publicError, { status: publicError.status });
}
async function dispatch(
service: BirthTimeConversationRouteService,
userId: string,
@@ -685,27 +690,90 @@ async function dispatch(
}
}
function telemetryPhase(
turn: ConversationalRectificationTurn | 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 requestId = dependencies.createRequestId?.(request) ?? stableRequestId(request);
let actionId: string | null = null;
let caseId: string | null = null;
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";
try {
const authenticated = await dependencies.authenticate(request);
if (!authenticated) return errorResponse(new ConversationalRectificationError("authentication_required"));
if (!authenticated) throw new ConversationalRectificationError("authentication_required");
const parsed = conversationalRectificationCommandSchema.safeParse(await requestPayload(request));
if (!parsed.success) return errorResponse(new ConversationalRectificationError("invalid_command"));
actionId = parsed.data.actionId;
caseId = parsed.data.type === "start" ? parsed.data.actionId : parsed.data.caseId;
if (!parsed.success) throw new ConversationalRectificationError("invalid_command");
actionKind = parsed.data.type;
const service = await dependencies.createService(authenticated);
return Response.json(await dispatch(service, authenticated.userId, parsed.data));
const turn = await dispatch(service, authenticated.userId, parsed.data);
telemetry({
protocol: "conversational-evidence-v3",
phase: telemetryPhase(turn),
actionKind,
resultCategory: "success",
latencyBucket: conversationalRectificationLatencyBucket(now() - startedAt),
billingState: actionKind === "start" ? "unknown" : "unchanged",
errorCategory: "none",
deploymentSha,
});
return Response.json(turn);
} catch (error) {
const publicError = toConversationalRectificationPublicError(error);
dependencies.log?.({ requestId, actionId, caseId, code: publicError.code });
dependencies.log?.({ code: publicError.code });
telemetry({
protocol: "conversational-evidence-v3",
phase: "entry",
actionKind,
resultCategory: telemetryResultCategory(publicError.status),
latencyBucket: conversationalRectificationLatencyBucket(now() - startedAt),
billingState: publicError.code === "billing_failed" ? "unknown" : "not_applicable",
errorCategory: telemetryErrorCategory(publicError.code),
deploymentSha,
});
return Response.json(publicError, { status: publicError.status });
}
};
@@ -715,10 +783,11 @@ 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] request=${entry.requestId} action=${entry.actionId ?? "none"} case=${entry.caseId ?? "none"} code=${entry.code}`,
);
console.error(`[birth-time-conversation] code=${entry.code}`);
},
});
+16
View File
@@ -12,6 +12,10 @@ const gitCommit =
?? process.env.VERCEL_GIT_COMMIT_SHA
?? process.env.NEXT_PUBLIC_GIT_COMMIT
?? "unknown";
const rectificationV3CreationEnabled =
process.env.RECTIFICATION_V3_CREATE_ENABLED?.trim().toLowerCase() !== "false";
const rectificationV3MigrationsReady =
process.env.RECTIFICATION_V3_MIGRATIONS_READY?.trim().toLowerCase() === "true";
function envCheck(names: string[]): Check {
const missing = names.filter((name) => !process.env[name]);
@@ -66,6 +70,9 @@ export async function GET() {
jyotishApi: await jyotishApiCheck(),
};
const status = aggregate(checks);
const rectificationV3Ready = rectificationV3CreationEnabled
&& rectificationV3MigrationsReady
&& gitCommit !== "unknown";
return NextResponse.json(
{
status,
@@ -73,6 +80,15 @@ export async function GET() {
deployment: {
gitCommit,
},
rollout: {
conversationalRectificationV3: {
protocol: "conversational-evidence-v3",
newCaseCreation: rectificationV3CreationEnabled ? "enabled" : "paused",
migrations: rectificationV3MigrationsReady ? "ready" : "unverified",
syntheticSmoke: "required",
readyForNewCases: rectificationV3Ready,
},
},
checks,
},
{ status: status === "ok" ? 200 : 503 },