Files
Jyotisha/frontend/src/lib/birth-time-journey-response-schema.ts
T

150 lines
11 KiB
TypeScript

import { z } from "zod";
import { candidateResultSchema, journeySnapshotSchema, lifeEventSchema } from "./birth-time-journey.ts";
import { deriveJourneyPermissions, evidenceDraftSchema, journeyPermissionsSchema, journeyProgressSchema, nextActionSchema } from "./birth-time-journey-turn.ts";
import { dynamicJourneyProgressSchema, dynamicNextActionSchema } from "./birth-time-journey-turn-protocol.ts";
import type { NextAction } from "./birth-time-journey-turn.ts";
export const answerSchema = z.enum(["A", "B", "C", "D"]);
const rectificationQuestionSchema = z.object({
id: z.string(), prompt: z.string(), round: z.number().int().min(1).optional(),
options: z.array(z.object({ key: answerSchema, label: z.string() }).strict().readonly()).readonly().optional(),
}).strict().readonly();
const candidateVargaSampleSchema = z.object({
ascendantSign: z.string().nullable(), d4Sign: z.string().nullable(), d9Sign: z.string().nullable(),
d10Sign: z.string().nullable(), d24Sign: z.string().nullable(), d30Sign: z.string().nullable(),
}).strict().readonly();
export const questionnaireSchema = z.object({
questions: z.array(rectificationQuestionSchema).readonly(), samples: z.array(candidateVargaSampleSchema).readonly(), raw: z.record(z.unknown()).readonly(),
}).strict().readonly();
export const scoringFields = {
answeredCount: z.number().int().min(0),
candidateClusterRankings: z.array(z.object({ cluster: z.string(), score: z.number() }).strict().readonly()).readonly(),
raw: z.record(z.unknown()).readonly(),
} as const;
const versionedScoringSchema = z.object({
...scoringFields, nextRound: z.number().int().min(1).nullable(), nextRoundQuestions: z.array(rectificationQuestionSchema).readonly(),
}).strict().readonly();
export const responseCore = { caseId: z.string().uuid(), snapshot: journeySnapshotSchema, questionnaire: questionnaireSchema.nullable() } as const;
export class JourneyResponseInvariantError extends Error {
readonly name = "JourneyResponseInvariantError";
}
function assertNever(value: never): never {
throw new JourneyResponseInvariantError(`Unexpected journey action: ${String(value)}`);
}
function resultActionId(action: NextAction): string | null | undefined {
switch (action.kind) {
case "present_low_result": case "present_medium_result": case "candidate_saved": case "request_candidate_confirmation": return action.resultId;
case "ask_baseline_evidence": case "ask_adaptive_evidence": case "review_evidence_draft": case "score_pending":
case "retry_scoring": case "ready": case "paused": return undefined;
default: return assertNever(action);
}
}
function actionMatchesProgress(value: {
readonly nextAction: NextAction;
readonly progress: z.infer<typeof journeyProgressSchema>;
readonly candidateResult: z.infer<typeof candidateResultSchema> | null;
readonly snapshot: z.infer<typeof journeySnapshotSchema>;
readonly evidenceDraft: z.infer<typeof evidenceDraftSchema> | null;
}): boolean {
switch (value.nextAction.kind) {
case "ask_baseline_evidence":
return value.progress.phase === "baseline" && (value.progress.confirmedEvidenceCount < 3 || value.progress.baselineDomainCount < 2) && value.nextAction.question.phase === "baseline" && value.candidateResult === null && (value.snapshot.state === "rectifying" || value.snapshot.state === "candidate") && value.snapshot.input === "rectification_questions" && value.snapshot.route === "rectification";
case "ask_adaptive_evidence":
return value.progress.phase === "adaptive" && value.progress.adaptiveRound >= 1 && value.progress.adaptiveRound <= value.progress.maxAdaptiveRounds && value.nextAction.question.phase === "adaptive" && value.candidateResult?.confidence === "low" && value.snapshot.state === "rectifying" && value.snapshot.route === "rectification";
case "review_evidence_draft": return value.progress.phase === "review" && value.snapshot.input === "life_events" && value.evidenceDraft?.draftId === value.nextAction.draftId;
case "score_pending": case "retry_scoring": return value.progress.phase === "scoring" && value.snapshot.route === "rectification";
case "present_low_result": return value.progress.phase === "result"
? (value.candidateResult === null || value.candidateResult.confidence === "low") && value.snapshot.state === "candidate"
: value.progress.phase === "adaptive" && value.progress.adaptiveRound >= value.progress.maxAdaptiveRounds && value.candidateResult?.confidence === "low";
case "present_medium_result": return value.progress.phase === "result" && value.candidateResult?.confidence === "medium" && value.snapshot.state === "candidate";
case "candidate_saved": return value.progress.phase === "result" && value.candidateResult?.confidence === "medium" && value.snapshot.state === "candidate";
case "request_candidate_confirmation": return value.progress.phase === "result" && value.candidateResult?.confidence === "high" && value.snapshot.state === "confirming";
case "ready": return value.progress.phase === "ready" && value.snapshot.state === "ready" && value.snapshot.route === "direct_chart";
case "paused": return value.progress.phase === "paused" && value.snapshot.state !== "ready";
default: return assertNever(value.nextAction);
}
}
const versionedJourneyResponseSchema = z.object({
...responseCore, journeyProtocol: z.undefined().optional(), scoring: versionedScoringSchema.nullable(), answers: z.record(answerSchema).readonly(), lifeEvents: z.array(lifeEventSchema).readonly(), candidateResult: candidateResultSchema.nullable(),
turnVersion: z.number().int().nonnegative(), nextAction: nextActionSchema, progress: journeyProgressSchema, permissions: journeyPermissionsSchema, evidenceDraft: evidenceDraftSchema.nullable(),
}).strict().superRefine((value, context) => {
const confirmedTime = value.snapshot.state === "ready" ? value.snapshot.activeTime : null;
const expectedPermissions = deriveJourneyPermissions(value.candidateResult, confirmedTime);
const guardedConfirmation = value.snapshot.state === "confirming" && value.snapshot.input === "candidate_confirmation" && value.snapshot.confidence === "high" && value.snapshot.activeTime === null && value.candidateResult?.confidence === "high" && value.candidateResult.winningSegment !== null;
if (value.snapshot.route === "direct_chart" && value.snapshot.activeTime === null) context.addIssue({ code: z.ZodIssueCode.custom, path: ["snapshot", "activeTime"], message: "direct chart requires an active time" });
if (value.snapshot.route === "rectification" && value.snapshot.canApply && !guardedConfirmation) context.addIssue({ code: z.ZodIssueCode.custom, path: ["snapshot", "canApply"], message: "rectification application requires guarded candidate confirmation" });
if (value.permissions.canConfirmCandidate !== expectedPermissions.canConfirmCandidate) context.addIssue({ code: z.ZodIssueCode.custom, path: ["permissions", "canConfirmCandidate"], message: "candidate confirmation permission must be derived from the scored result" });
const actionResult = resultActionId(value.nextAction);
if (actionResult !== undefined && actionResult !== (value.candidateResult?.resultId ?? null)) context.addIssue({ code: z.ZodIssueCode.custom, path: ["nextAction", "resultId"], message: "result actions must reference the current candidate result" });
if (!actionMatchesProgress(value)) context.addIssue({ code: z.ZodIssueCode.custom, path: ["progress", "phase"], message: "journey action must agree with deterministic progress and snapshot" });
if (value.nextAction.kind === "request_candidate_confirmation" && (!guardedConfirmation || !value.permissions.canConfirmCandidate)) context.addIssue({ code: z.ZodIssueCode.custom, path: ["nextAction"], message: "candidate confirmation requires the derived confirmation permission" });
if (value.nextAction.kind === "ready") {
const candidateAllowsTime = value.candidateResult === null ? value.snapshot.route === "direct_chart" : value.candidateResult.confidence === "high" && value.candidateResult.winningSegment?.representativeTime === value.nextAction.activeTime;
if (value.snapshot.activeTime !== value.nextAction.activeTime || !candidateAllowsTime) context.addIssue({ code: z.ZodIssueCode.custom, path: ["nextAction", "activeTime"], message: "ready action must match a confirmed snapshot and eligible candidate" });
}
}).readonly();
function dynamicActionMatchesPhase(value: {
readonly nextAction: z.infer<typeof dynamicNextActionSchema>;
readonly progress: z.infer<typeof dynamicJourneyProgressSchema>;
}): boolean {
switch (value.nextAction.kind) {
case "generate_dynamic_question": case "ask_dynamic_choice": case "retry_question_generation":
return value.progress.phase === "question";
case "clarify_unmatched_answer": return value.progress.phase === "clarification";
case "score_pending": case "retry_scoring": return value.progress.phase === "scoring";
case "present_low_result": case "present_medium_result": case "request_candidate_confirmation":
return value.progress.phase === "result";
case "ready": return value.progress.phase === "ready";
case "paused": return value.progress.phase === "paused";
default: return assertNever(value.nextAction);
}
}
const dynamicJourneyResponseSchema = z.object({
...responseCore,
scoring: versionedScoringSchema.nullable(),
answers: z.record(answerSchema).readonly(),
lifeEvents: z.array(lifeEventSchema).readonly(),
candidateResult: candidateResultSchema.nullable(),
journeyProtocol: z.literal("dynamic-choice-v2"),
turnVersion: z.number().int().nonnegative(),
nextAction: dynamicNextActionSchema,
progress: dynamicJourneyProgressSchema,
permissions: journeyPermissionsSchema,
evidenceDraft: z.null(),
}).strict().superRefine((value, context) => {
if (!dynamicActionMatchesPhase(value)) {
context.addIssue({ code: z.ZodIssueCode.custom, path: ["progress", "phase"], message: "dynamic action must agree with progress" });
}
const action = value.nextAction;
const candidateId = value.candidateResult?.resultId ?? null;
if (action.kind === "present_low_result" && action.resultId !== candidateId) {
context.addIssue({ code: z.ZodIssueCode.custom, path: ["nextAction", "resultId"], message: "low result must reference the current candidate" });
}
if ((action.kind === "present_medium_result" || action.kind === "request_candidate_confirmation")
&& action.resultId !== candidateId) {
context.addIssue({ code: z.ZodIssueCode.custom, path: ["nextAction", "resultId"], message: "result action must reference the current candidate" });
}
if (action.kind === "ready" && (value.snapshot.state !== "ready" || value.snapshot.activeTime !== action.activeTime)) {
context.addIssue({ code: z.ZodIssueCode.custom, path: ["nextAction", "activeTime"], message: "ready must match the active snapshot" });
}
}).readonly();
export type LegacyJourneyClientResponse = z.infer<typeof versionedJourneyResponseSchema>;
export type DynamicJourneyClientResponse = z.infer<typeof dynamicJourneyResponseSchema>;
export type JourneyClientResponse = LegacyJourneyClientResponse | DynamicJourneyClientResponse;
export type JourneyAnswer = z.infer<typeof answerSchema>;
export function parseVersionedJourneyResponse(value: unknown): JourneyClientResponse {
const protocol = z.object({ journeyProtocol: z.unknown().optional() }).passthrough().parse(value);
return protocol.journeyProtocol === "dynamic-choice-v2"
? dynamicJourneyResponseSchema.parse(value)
: versionedJourneyResponseSchema.parse(value);
}