feat: expose dynamic rectification actions

This commit is contained in:
Jesse_Chen
2026-07-19 09:45:32 +08:00
parent 5c5b60b3e4
commit 806d2babbf
15 changed files with 579 additions and 101 deletions
@@ -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);
}