Files
Jyotisha/frontend/src/lib/birth-time-journey-dynamic-state.ts
T
2026-07-19 08:46:59 +08:00

238 lines
7.9 KiB
TypeScript

import { z } from "zod";
import {
dynamicControlStateSchema,
persistedDynamicChoiceQuestionSchema,
serverChoiceEvidenceSchema,
storedChoiceAnswerSchema,
toPublicDynamicChoiceQuestion,
} from "./birth-time-dynamic-choice-internal.ts";
import type {
DynamicControlState,
PersistedDynamicChoiceQuestion,
ServerChoiceEvidence,
StoredChoiceAnswer,
} from "./birth-time-dynamic-choice-internal.ts";
import { dynamicJourneyTurnStateSchema } from "./birth-time-journey-turn-protocol.ts";
import type { DynamicJourneyTurnState } from "./birth-time-journey-turn-protocol.ts";
import { timeRangeSchema } from "./birth-time-dynamic-choice.ts";
import type {
DynamicStoredRectificationCase,
LegacyStoredRectificationCase,
StoredRectificationCase,
} from "./birth-time-journey-service.ts";
import type { JourneySnapshot } from "./birth-time-journey.ts";
const agentContextSchema = z.array(
z.string().min(1).max(240).refine((value) => value.trim().length > 0),
).max(10).readonly();
export const dynamicPrivateStateSchema = z.object({
candidateModel: z.record(z.unknown()).nullable(),
currentChoiceQuestion: persistedDynamicChoiceQuestionSchema.nullable(),
choiceAnswers: z.array(storedChoiceAnswerSchema).max(50).readonly(),
choiceEvidence: z.array(serverChoiceEvidenceSchema).max(10).readonly(),
dynamicControl: dynamicControlStateSchema,
agentContext: agentContextSchema,
}).strict().readonly();
const dynamicPrivateRowSchema = z.object({
case_id: z.string().uuid(),
user_id: z.string().uuid(),
candidate_model: z.record(z.unknown()).nullable(),
current_choice_question: persistedDynamicChoiceQuestionSchema.nullable(),
choice_answers: z.array(storedChoiceAnswerSchema).max(50).readonly(),
choice_evidence: z.array(serverChoiceEvidenceSchema).max(10).readonly(),
dynamic_control: dynamicControlStateSchema,
agent_context: agentContextSchema,
}).strict().readonly();
export type DynamicPrivateJourneyState = {
readonly candidateModel: Readonly<Record<string, unknown>> | null;
readonly currentChoiceQuestion: PersistedDynamicChoiceQuestion | null;
readonly choiceAnswers: readonly StoredChoiceAnswer[];
readonly choiceEvidence: readonly ServerChoiceEvidence[];
readonly dynamicControl: DynamicControlState;
readonly agentContext: readonly string[];
};
export class BirthTimeDynamicStateMissingError extends Error {
readonly name = "BirthTimeDynamicStateMissingError";
readonly caseId: string;
constructor(caseId: string) {
super(`Dynamic birth-time state for ${caseId} is missing`);
this.caseId = caseId;
}
}
export class BirthTimeDynamicStateInvalidError extends Error {
readonly name = "BirthTimeDynamicStateInvalidError";
readonly caseId: string;
constructor(caseId: string) {
super(`Dynamic birth-time state for ${caseId} is invalid`);
this.caseId = caseId;
}
}
export function parseDynamicPrivateRow(
value: unknown,
userId: string,
caseId: string,
): DynamicPrivateJourneyState {
if (value === null) throw new BirthTimeDynamicStateMissingError(caseId);
const parsed = dynamicPrivateRowSchema.safeParse(value);
if (!parsed.success || parsed.data.case_id !== caseId || parsed.data.user_id !== userId) {
throw new BirthTimeDynamicStateInvalidError(caseId);
}
return dynamicPrivateStateSchema.parse({
candidateModel: parsed.data.candidate_model,
currentChoiceQuestion: parsed.data.current_choice_question,
choiceAnswers: parsed.data.choice_answers,
choiceEvidence: parsed.data.choice_evidence,
dynamicControl: parsed.data.dynamic_control,
agentContext: parsed.data.agent_context,
});
}
export function dynamicTurnMatchesPrivateQuestion(
turn: DynamicJourneyTurnState,
state: DynamicPrivateJourneyState,
): boolean {
const action = turn.nextAction;
if (action.kind !== "ask_dynamic_choice") return true;
if (state.currentChoiceQuestion === null) return false;
const projected = toPublicDynamicChoiceQuestion(state.currentChoiceQuestion);
return projected.questionId === action.question.questionId
&& projected.prompt === action.question.prompt
&& projected.options.length === action.question.options.length
&& projected.options.every((option, index) => {
const persisted = action.question.options[index];
return persisted !== undefined
&& option.optionId === persisted.optionId
&& option.label === persisted.label
&& option.kind === persisted.kind;
});
}
function rangeFrom(snapshot: JourneySnapshot) {
const { startTime, endTime } = snapshot.reportedRange;
return timeRangeSchema.parse(startTime === null && endTime === null
? { startTime: "00:00", endTime: "23:59" }
: { startTime, endTime });
}
export function createInitialDynamicState(
snapshot: JourneySnapshot,
asOfDate: string,
): {
readonly turn: DynamicJourneyTurnState;
readonly privateState: DynamicPrivateJourneyState;
} {
const currentRange = rangeFrom(snapshot);
const dynamicControl = dynamicControlStateSchema.parse({
asOfDate,
answeredCount: 0,
effectiveAnswerCount: 0,
plateauCount: 0,
questionFingerprints: [],
partitionFingerprints: [],
dismissedOpportunityIds: [],
recentRanges: [currentRange],
pausedAction: null,
lastActionReceipt: null,
});
const ready = snapshot.state === "ready";
return {
turn: dynamicJourneyTurnStateSchema.parse({
journeyProtocol: "dynamic-choice-v2",
turnVersion: 0,
nextAction: ready
? { kind: "ready", activeTime: snapshot.activeTime }
: { kind: "generate_dynamic_question" },
progress: {
phase: ready ? "ready" : "question",
answeredCount: 0,
effectiveAnswerCount: 0,
currentRange,
previousRange: null,
plateauCount: 0,
},
permissions: { canConfirmCandidate: false },
}),
privateState: {
candidateModel: null,
currentChoiceQuestion: null,
choiceAnswers: [],
choiceEvidence: [],
dynamicControl,
agentContext: [],
},
};
}
const terminalLegacyActions = new Set([
"present_low_result",
"present_medium_result",
"candidate_saved",
"request_candidate_confirmation",
"ready",
]);
export function isTerminalLegacyCase(value: StoredRectificationCase): boolean {
return value.snapshot.state === "candidate"
|| value.snapshot.state === "confirming"
|| value.snapshot.state === "ready"
|| terminalLegacyActions.has(value.turnState?.nextAction.kind ?? "");
}
export function prepareLegacyDynamicUpgrade(
value: LegacyStoredRectificationCase,
asOfDate: string,
): DynamicStoredRectificationCase {
const currentRange = rangeFrom(value.snapshot);
const confirmedCount = value.lifeEvents?.length ?? 0;
const effectiveAnswerCount = Math.min(confirmedCount, 10);
const answeredCount = Math.min(confirmedCount, 50);
const dynamicControl = dynamicControlStateSchema.parse({
asOfDate,
answeredCount,
effectiveAnswerCount,
plateauCount: 0,
questionFingerprints: [],
partitionFingerprints: [],
dismissedOpportunityIds: [],
recentRanges: [currentRange],
pausedAction: null,
lastActionReceipt: null,
});
const dynamicTurnState = dynamicJourneyTurnStateSchema.parse({
journeyProtocol: "dynamic-choice-v2",
turnVersion: value.turnVersion ?? 0,
nextAction: { kind: "generate_dynamic_question" },
progress: {
phase: "question",
answeredCount,
effectiveAnswerCount,
currentRange,
previousRange: null,
plateauCount: 0,
},
permissions: { canConfirmCandidate: false },
});
return {
...value,
journeyProtocol: "dynamic-choice-v2",
turnVersion: value.turnVersion ?? 0,
processedActionIds: value.processedActionIds ?? [],
persistedProgress: value.persistedProgress ?? { adaptiveRound: 0, askedDomains: [] },
turnState: null,
evidenceDraft: null,
dynamicTurnState,
candidateModel: null,
currentChoiceQuestion: null,
choiceAnswers: [],
choiceEvidence: [],
dynamicControl,
agentContext: [],
};
}