Files
Jyotisha/frontend/src/lib/birth-time-dynamic-actions.ts
T
2026-07-19 09:12:45 +08:00

262 lines
11 KiB
TypeScript

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<ChoiceCommand, "caseId" | "actionId" | "turnVersion">;
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<BirthTimeJourneyEngine,
"buildDifferencePacket" | "scoreChoices"
> {
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);
},
};
}