import { replayedDynamicAction } from "./birth-time-dynamic-action-replay.ts"; import type { PersistedDynamicChoiceQuestion } from "./birth-time-dynamic-choice-internal.ts"; import { dynamicDifferenceInput } from "./birth-time-dynamic-engine-input.ts"; import { publicQuestionAction } from "./birth-time-dynamic-transitions.ts"; import { answerTransition, isDynamicTerminal, withDynamicAction } from "./birth-time-dynamic-transitions.ts"; import { createDynamicSpecialActions } from "./birth-time-dynamic-special-actions.ts"; import { storedDynamicJourneyResponse } from "./birth-time-journey-response.ts"; import type { BirthTimeJourneyEngine, BirthTimeJourneyPorts, DynamicStoredRectificationCase, StoredRectificationCase, } from "./birth-time-journey-service.ts"; import { createDynamicScoringJobSpec } from "./birth-time-scoring-job.ts"; import { StaleJourneyTurnError } from "./birth-time-journey-store-errors.ts"; type ChoiceCommand = { readonly caseId: string; readonly actionId: string; readonly turnVersion: number; readonly questionId: string; readonly optionId: string; }; type TurnCommand = Pick; type QuestionCommand = TurnCommand & { readonly unmatchedNote?: string | null }; export class BirthTimeDynamicActionError extends Error { readonly name = "BirthTimeDynamicActionError"; readonly reason: "case_not_found" | "invalid_turn" | "terminal" | "unavailable"; constructor(reason: BirthTimeDynamicActionError["reason"]) { super(`Birth-time dynamic action ${reason}`); this.reason = reason; } } function requireDynamic(value: StoredRectificationCase | null): DynamicStoredRectificationCase { if (value === null) throw new BirthTimeDynamicActionError("case_not_found"); if (value.journeyProtocol !== "dynamic-choice-v2") { throw new BirthTimeDynamicActionError("invalid_turn"); } return value; } function dynamicEngine(engine: BirthTimeJourneyPorts["engine"]): Pick { if (!("buildDifferencePacket" in engine) || !("scoreChoices" in engine)) { throw new BirthTimeDynamicActionError("unavailable"); } return engine; } function stale(stored: DynamicStoredRectificationCase, expected: number): StaleJourneyTurnError { return new StaleJourneyTurnError(stored.id, expected, stored.turnVersion); } function terminalSnapshot(stored: DynamicStoredRectificationCase) { return { ...stored.snapshot, state: "candidate" as const, assistantIntent: "present_saved_candidate_range" as const, input: "candidate_actions" as const, confidence: stored.candidateResult?.confidence ?? "low" as const, canApply: false, activeTime: null, }; } export function createDynamicJourneyActions(ports: BirthTimeJourneyPorts) { async function load(userId: string, caseId: string) { return requireDynamic(await ports.store.loadCase(userId, caseId)); } function requireMutable(stored: DynamicStoredRectificationCase): void { if (isDynamicTerminal(stored)) throw new BirthTimeDynamicActionError("terminal"); } async function save( stored: DynamicStoredRectificationCase, updated: DynamicStoredRectificationCase, actionId: string, ) { return ports.store.saveDynamicTurn(updated, stored.turnVersion, actionId); } const specialActions = createDynamicSpecialActions(ports, load, requireMutable); return { ...specialActions, async answerDynamicChoice(userId: string, command: ChoiceCommand) { const stored = await load(userId, command.caseId); const lastAnswer = stored.choiceAnswers.at(-1); const actionKind = stored.dynamicTurnState.nextAction.kind; const receipt = stored.dynamicControl.lastActionReceipt; if (replayedDynamicAction(stored, command.actionId, command.turnVersion, () => ( lastAnswer?.questionId === command.questionId && lastAnswer.optionId === command.optionId && receipt?.kind === "answer_choice" && receipt.actionId === command.actionId.toLowerCase() && receipt.turnVersion === command.turnVersion && receipt.questionId === command.questionId && receipt.optionId === command.optionId && (lastAnswer.kind === "primary" ? actionKind === "score_pending" : lastAnswer.kind === "unknown" ? actionKind === "generate_dynamic_question" : actionKind === "clarify_unmatched_answer") ))) { return storedDynamicJourneyResponse(stored); } requireMutable(stored); const action = stored.dynamicTurnState.nextAction; const question = stored.currentChoiceQuestion; if (stored.turnVersion !== command.turnVersion || action.kind !== "ask_dynamic_choice" || action.question.questionId !== command.questionId || question?.questionId !== command.questionId) { throw stale(stored, command.turnVersion); } const option = question.options.find((item) => item.optionId === command.optionId); if (!option) throw stale(stored, command.turnVersion); const transitioned = answerTransition({ stored, option, answeredAt: (ports.now?.() ?? new Date()).toISOString(), jobId: globalThis.crypto.randomUUID(), nextVersion: stored.turnVersion + 1, }); const updated = { ...transitioned, dynamicControl: { ...transitioned.dynamicControl, lastActionReceipt: { actionId: command.actionId.toLowerCase(), kind: "answer_choice" as const, turnVersion: command.turnVersion, questionId: command.questionId, optionId: command.optionId, }, }, }; if (option.kind === "primary") { const createJob = ports.store.createDynamicScoringJob; if (!createJob) throw new BirthTimeDynamicActionError("unavailable"); const spec = createDynamicScoringJobSpec( updated.dynamicTurnState.nextAction.kind === "score_pending" ? updated.dynamicTurnState.nextAction.jobId : "", updated.choiceEvidence, ports.now?.() ?? new Date(), ); const saved = await createJob( updated, stored.turnVersion, command.actionId, question.questionId, spec, ); return storedDynamicJourneyResponse(saved); } return storedDynamicJourneyResponse(await save(stored, updated, command.actionId)); }, async loadDynamicQuestionBuild(userId: string, command: QuestionCommand) { const stored = await load(userId, command.caseId); requireMutable(stored); const kind = stored.dynamicTurnState.nextAction.kind; if (stored.turnVersion !== command.turnVersion || (kind !== "generate_dynamic_question" && kind !== "retry_question_generation")) { throw stale(stored, command.turnVersion); } return dynamicEngine(ports.engine).buildDifferencePacket(dynamicDifferenceInput(stored)); }, async commitDynamicQuestion( userId: string, command: QuestionCommand, question: PersistedDynamicChoiceQuestion | null, ) { const stored = await load(userId, command.caseId); const receipt = stored.dynamicControl.lastActionReceipt; if (replayedDynamicAction(stored, command.actionId, command.turnVersion, () => ( receipt?.kind === "commit_question" && receipt.actionId === command.actionId.toLowerCase() && receipt.turnVersion === command.turnVersion && receipt.submittedQuestionFingerprint === (question?.questionFingerprint ?? null) && receipt.submittedPartitionFingerprint === (question?.candidatePartitionFingerprint ?? null) ))) { return { nextAction: stored.dynamicTurnState.nextAction }; } requireMutable(stored); const kind = stored.dynamicTurnState.nextAction.kind; if (stored.turnVersion !== command.turnVersion || (kind !== "generate_dynamic_question" && kind !== "retry_question_generation")) { throw stale(stored, command.turnVersion); } const repeated = question !== null && ( stored.dynamicControl.questionFingerprints.includes(question.questionFingerprint) || stored.dynamicControl.partitionFingerprints.includes(question.candidatePartitionFingerprint) ); const nextQuestion = repeated ? null : question; const action = nextQuestion === null ? { kind: "present_low_result" as const, resultId: stored.candidateResult?.resultId ?? null } : publicQuestionAction(nextQuestion); const updated = withDynamicAction(stored, action, stored.turnVersion + 1); const priorControl = nextQuestion === null ? stored.dynamicControl : { ...stored.dynamicControl, questionFingerprints: [...stored.dynamicControl.questionFingerprints, nextQuestion.questionFingerprint], partitionFingerprints: [...stored.dynamicControl.partitionFingerprints, nextQuestion.candidatePartitionFingerprint], }; const saved = await save(stored, { ...updated, snapshot: nextQuestion === null ? terminalSnapshot(stored) : stored.snapshot, currentChoiceQuestion: nextQuestion, dynamicControl: { ...priorControl, lastActionReceipt: { actionId: command.actionId.toLowerCase(), kind: "commit_question" as const, turnVersion: command.turnVersion, outcome: nextQuestion === null ? "terminal" as const : "question" as const, questionId: nextQuestion?.questionId ?? null, questionFingerprint: nextQuestion?.questionFingerprint ?? null, partitionFingerprint: nextQuestion?.candidatePartitionFingerprint ?? null, submittedQuestionFingerprint: question?.questionFingerprint ?? null, submittedPartitionFingerprint: question?.candidatePartitionFingerprint ?? null, }, }, }, command.actionId); return { nextAction: saved.dynamicTurnState.nextAction }; }, async resumeDynamic(userId: string, caseId: string) { const stored = await load(userId, caseId); if (isDynamicTerminal(stored) || stored.dynamicTurnState.nextAction.kind !== "paused") { return storedDynamicJourneyResponse(stored); } const paused = stored.dynamicControl.pausedAction; if (paused === null) throw new BirthTimeDynamicActionError("invalid_turn"); const action = paused.kind === "ask_dynamic_choice" ? stored.currentChoiceQuestion?.questionId === paused.questionId ? publicQuestionAction(stored.currentChoiceQuestion) : null : paused; if (action === null) throw new BirthTimeDynamicActionError("invalid_turn"); const updated = withDynamicAction(stored, action, stored.turnVersion + 1); const actionId = globalThis.crypto.randomUUID(); const saved = await save(stored, { ...updated, dynamicControl: { ...stored.dynamicControl, pausedAction: null, lastActionReceipt: { actionId, kind: "resume", turnVersion: stored.turnVersion, }, }, }, actionId); return storedDynamicJourneyResponse(saved); }, }; }