feat: expose dynamic rectification actions
This commit is contained in:
@@ -22,6 +22,20 @@ export const birthTimeGuideRequestSchema = z.discriminatedUnion("type", [
|
||||
turnVersion: turnVersionSchema,
|
||||
message: z.string().trim().min(1).max(500),
|
||||
}).strict(),
|
||||
z.object({
|
||||
type: z.literal("generate_dynamic_question"),
|
||||
caseId: caseIdSchema,
|
||||
actionId: actionIdSchema,
|
||||
turnVersion: turnVersionSchema,
|
||||
}).strict(),
|
||||
z.object({
|
||||
type: z.literal("reframe_unmatched"),
|
||||
caseId: caseIdSchema,
|
||||
actionId: actionIdSchema,
|
||||
turnVersion: turnVersionSchema,
|
||||
questionId: z.string().uuid(),
|
||||
note: z.string().trim().max(240).default(""),
|
||||
}).strict(),
|
||||
]).readonly();
|
||||
|
||||
export type BirthTimeGuideRequest = z.infer<typeof birthTimeGuideRequestSchema>;
|
||||
|
||||
@@ -8,6 +8,9 @@ export function createIdentityRequestCache<T>() {
|
||||
if (existing) return existing;
|
||||
const request = load();
|
||||
requests.set(identity, request);
|
||||
void request.catch(() => {
|
||||
if (requests.get(identity) === request) requests.delete(identity);
|
||||
});
|
||||
return request;
|
||||
},
|
||||
};
|
||||
@@ -33,3 +36,9 @@ export function publishCurrentJourney(input: PublishCurrentJourneyInput): boolea
|
||||
input.publish(input.next);
|
||||
return true;
|
||||
}
|
||||
|
||||
export function claimMutation(gate: { current: boolean }): (() => void) | null {
|
||||
if (gate.current) return null;
|
||||
gate.current = true;
|
||||
return () => { gate.current = false; };
|
||||
}
|
||||
|
||||
@@ -135,6 +135,16 @@ export function pollBirthTimeScoring(caseId: string, jobId: string, signal?: Abo
|
||||
return sendJourneyEvent({ type: "poll_scoring", caseId, jobId }, signal);
|
||||
}
|
||||
|
||||
export function answerDynamicBirthTimeChoice(input: {
|
||||
readonly caseId: string;
|
||||
readonly actionId: string;
|
||||
readonly turnVersion: number;
|
||||
readonly questionId: string;
|
||||
readonly optionId: string;
|
||||
}) {
|
||||
return sendJourneyEvent({ type: "answer_dynamic_choice", ...input });
|
||||
}
|
||||
|
||||
export function submitBirthTimeLifeEvents(caseId: string, events: readonly LifeEvent[]) {
|
||||
return sendJourneyEvent({ type: "submit_life_events", caseId, events });
|
||||
}
|
||||
@@ -210,3 +220,27 @@ export async function draftBirthTimeEvidence(
|
||||
const envelope = guideDraftEnvelopeSchema.parse(payload);
|
||||
return { ...envelope, turn: parseJourneyResponse(envelope.turn) };
|
||||
}
|
||||
|
||||
export async function generateDynamicBirthTimeQuestion(
|
||||
caseId: string,
|
||||
actionId: string,
|
||||
turnVersion: number,
|
||||
) {
|
||||
const payload = await sendGuideEvent({
|
||||
type: "generate_dynamic_question",
|
||||
caseId,
|
||||
actionId,
|
||||
turnVersion,
|
||||
});
|
||||
return parseJourneyResponse(payload);
|
||||
}
|
||||
|
||||
export async function reframeUnmatchedBirthTimeAnswer(input: {
|
||||
readonly caseId: string;
|
||||
readonly actionId: string;
|
||||
readonly turnVersion: number;
|
||||
readonly questionId: string;
|
||||
readonly note: string;
|
||||
}) {
|
||||
return parseJourneyResponse(await sendGuideEvent({ type: "reframe_unmatched", ...input }));
|
||||
}
|
||||
|
||||
@@ -10,6 +10,11 @@ const mutationFields = {
|
||||
turnVersion: turnVersionSchema,
|
||||
} as const;
|
||||
const revisionValidationId = "00000000-0000-4000-8000-000000000000";
|
||||
const publicChoiceFields = {
|
||||
...mutationFields,
|
||||
questionId: z.string().uuid(),
|
||||
optionId: z.string().uuid(),
|
||||
} as const;
|
||||
|
||||
export const birthTimeJourneyRequestSchema = z.discriminatedUnion("type", [
|
||||
z.object({ type: z.literal("assess") }).strict(),
|
||||
@@ -49,6 +54,7 @@ export const birthTimeJourneyRequestSchema = z.discriminatedUnion("type", [
|
||||
z.object({ type: z.literal("skip_evidence_question"), ...mutationFields }).strict(),
|
||||
z.object({ type: z.literal("pause_rectification"), ...mutationFields }).strict(),
|
||||
z.object({ type: z.literal("finish_rectification"), ...mutationFields }).strict(),
|
||||
z.object({ type: z.literal("answer_dynamic_choice"), ...publicChoiceFields }).strict(),
|
||||
z.object({
|
||||
type: z.literal("revise_evidence_draft"),
|
||||
...mutationFields,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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"]);
|
||||
@@ -69,7 +70,7 @@ function actionMatchesProgress(value: {
|
||||
}
|
||||
|
||||
const versionedJourneyResponseSchema = z.object({
|
||||
...responseCore, scoring: versionedScoringSchema.nullable(), answers: z.record(answerSchema).readonly(), lifeEvents: z.array(lifeEventSchema).readonly(), candidateResult: candidateResultSchema.nullable(),
|
||||
...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;
|
||||
@@ -88,9 +89,61 @@ const versionedJourneyResponseSchema = z.object({
|
||||
}
|
||||
}).readonly();
|
||||
|
||||
export type JourneyClientResponse = z.infer<typeof versionedJourneyResponseSchema>;
|
||||
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 {
|
||||
return versionedJourneyResponseSchema.parse(value);
|
||||
const protocol = z.object({ journeyProtocol: z.unknown().optional() }).passthrough().parse(value);
|
||||
return protocol.journeyProtocol === "dynamic-choice-v2"
|
||||
? dynamicJourneyResponseSchema.parse(value)
|
||||
: versionedJourneyResponseSchema.parse(value);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user