fix: harden dynamic rectification orchestration

This commit is contained in:
Jesse_Chen
2026-07-19 08:46:59 +08:00
parent 7aa800b846
commit 50c1de20d3
16 changed files with 572 additions and 90 deletions
@@ -0,0 +1,30 @@
import type { DynamicStoredRectificationCase } from "./birth-time-journey-service.ts";
import { StaleJourneyTurnError } from "./birth-time-journey-store-errors.ts";
export function replayedDynamicAction(
stored: DynamicStoredRectificationCase,
actionId: string,
expectedVersion: number,
matches: () => boolean,
): boolean {
if (!stored.processedActionIds.includes(actionId.toLowerCase())) return false;
if (stored.turnVersion === expectedVersion + 1 && matches()) return true;
throw new StaleJourneyTurnError(stored.id, expectedVersion, stored.turnVersion);
}
export function samePersistedDynamicReceipt(
proposed: DynamicStoredRectificationCase,
current: DynamicStoredRectificationCase,
actionId: string,
expectedVersion: number,
): boolean {
const expected = proposed.dynamicControl.lastActionReceipt;
const actual = current.dynamicControl.lastActionReceipt;
if (expected?.actionId !== actionId) return actual?.actionId !== actionId;
return current.turnVersion === expectedVersion + 1
&& actual?.actionId === expected.actionId
&& actual.kind === expected.kind
&& actual.turnVersion === expected.turnVersion
&& actual.questionId === expected.questionId
&& actual.note === expected.note;
}
+26 -68
View File
@@ -1,13 +1,9 @@
import { z } from "zod";
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,
toPausedDynamicAction,
withDynamicAction,
} 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,
@@ -27,7 +23,6 @@ type ChoiceCommand = {
};
type TurnCommand = Pick<ChoiceCommand, "caseId" | "actionId" | "turnVersion">;
type QuestionCommand = TurnCommand & { readonly unmatchedNote?: string | null };
type UnmatchedCommand = TurnCommand & { readonly questionId: string; readonly note: string };
export class BirthTimeDynamicActionError extends Error {
readonly name = "BirthTimeDynamicActionError";
@@ -88,14 +83,26 @@ export function createDynamicJourneyActions(ports: BirthTimeJourneyPorts) {
) {
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);
requireMutable(stored);
if (stored.processedActionIds.includes(command.actionId.toLowerCase())) {
const lastAnswer = stored.choiceAnswers.at(-1);
const actionKind = stored.dynamicTurnState.nextAction.kind;
if (replayedDynamicAction(stored, command.actionId, command.turnVersion, () => (
lastAnswer?.questionId === command.questionId && lastAnswer.optionId === command.optionId
&& stored.dynamicControl.lastActionReceipt?.actionId !== command.actionId.toLowerCase()
&& (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
@@ -135,33 +142,6 @@ export function createDynamicJourneyActions(ports: BirthTimeJourneyPorts) {
return storedDynamicJourneyResponse(await save(stored, updated, command.actionId));
},
async submitUnmatchedContext(userId: string, command: UnmatchedCommand) {
const stored = await load(userId, command.caseId);
requireMutable(stored);
const question = stored.currentChoiceQuestion;
if (stored.turnVersion !== command.turnVersion
|| stored.dynamicTurnState.nextAction.kind !== "clarify_unmatched_answer"
|| question?.questionId !== command.questionId) throw stale(stored, command.turnVersion);
const note = z.string().trim().max(240).parse(command.note);
const action = { kind: "generate_dynamic_question" as const };
const updated = withDynamicAction(stored, action, stored.turnVersion + 1);
const next = {
...updated,
currentChoiceQuestion: null,
agentContext: note.length === 0
? stored.agentContext
: [...stored.agentContext.slice(-9), note],
dynamicControl: {
...stored.dynamicControl,
dismissedOpportunityIds: [
...stored.dynamicControl.dismissedOpportunityIds,
question.opportunityId,
],
},
};
return storedDynamicJourneyResponse(await save(stored, next, command.actionId));
},
async loadDynamicQuestionBuild(userId: string, command: QuestionCommand) {
const stored = await load(userId, command.caseId);
requireMutable(stored);
@@ -179,10 +159,17 @@ export function createDynamicJourneyActions(ports: BirthTimeJourneyPorts) {
question: PersistedDynamicChoiceQuestion | null,
) {
const stored = await load(userId, command.caseId);
requireMutable(stored);
if (stored.processedActionIds.includes(command.actionId.toLowerCase())) {
if (replayedDynamicAction(stored, command.actionId, command.turnVersion, () => (
question === null
? stored.currentChoiceQuestion === null
&& stored.dynamicTurnState.nextAction.kind === "present_low_result"
: stored.currentChoiceQuestion?.questionId === question.questionId
&& stored.currentChoiceQuestion.questionFingerprint === question.questionFingerprint
&& stored.dynamicControl.lastActionReceipt?.actionId !== command.actionId.toLowerCase()
))) {
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")) {
@@ -210,20 +197,6 @@ export function createDynamicJourneyActions(ports: BirthTimeJourneyPorts) {
return { nextAction: saved.dynamicTurnState.nextAction };
},
async pauseDynamic(userId: string, caseId: string, actionId: string, turnVersion: number) {
const stored = await load(userId, caseId);
requireMutable(stored);
if (stored.turnVersion !== turnVersion || stored.dynamicTurnState.nextAction.kind === "paused") {
throw stale(stored, turnVersion);
}
const pausedAction = toPausedDynamicAction(stored.dynamicTurnState.nextAction);
const updated = withDynamicAction(stored, { kind: "paused" }, stored.turnVersion + 1);
return storedDynamicJourneyResponse(await save(stored, {
...updated,
dynamicControl: { ...stored.dynamicControl, pausedAction },
}, actionId));
},
async resumeDynamic(userId: string, caseId: string) {
const stored = await load(userId, caseId);
if (isDynamicTerminal(stored) || stored.dynamicTurnState.nextAction.kind !== "paused") {
@@ -245,20 +218,5 @@ export function createDynamicJourneyActions(ports: BirthTimeJourneyPorts) {
return storedDynamicJourneyResponse(saved);
},
async finishDynamic(userId: string, caseId: string, actionId: string, turnVersion: number) {
const stored = await load(userId, caseId);
requireMutable(stored);
if (stored.turnVersion !== turnVersion) throw stale(stored, turnVersion);
const candidate = stored.candidateResult;
const action = candidate?.confidence === "medium"
? { kind: "present_medium_result" as const, resultId: candidate.resultId }
: { kind: "present_low_result" as const, resultId: candidate?.resultId ?? null };
const updated = withDynamicAction(stored, action, stored.turnVersion + 1);
return storedDynamicJourneyResponse(await save(stored, {
...updated,
snapshot: terminalSnapshot(stored),
currentChoiceQuestion: null,
}, actionId));
},
};
}
@@ -84,6 +84,7 @@ export type StoredChoiceAnswer = {
readonly kind: PublicChoiceKind;
readonly opportunityId: string;
readonly answeredAt: string;
readonly unmatchedContext?: string;
};
export type ServerChoiceEvidence = {
@@ -120,6 +121,13 @@ export type DynamicControlState = {
readonly dismissedOpportunityIds: readonly string[];
readonly recentRanges: readonly TimeRange[];
readonly pausedAction: PausedDynamicAction | null;
readonly lastActionReceipt?: {
readonly actionId: string;
readonly kind: "unmatched_context" | "pause" | "finish";
readonly turnVersion: number;
readonly questionId?: string;
readonly note?: string;
} | null;
};
const evidencePartitionBaseSchema = z.object({
@@ -199,6 +207,7 @@ export const storedChoiceAnswerSchema = z.object({
kind: publicChoiceKindSchema,
opportunityId: z.string().trim().min(1),
answeredAt: z.string().datetime({ offset: true }),
unmatchedContext: z.string().max(240).optional(),
}).strict().readonly();
export const serverChoiceEvidenceSchema = z.object({
@@ -236,6 +245,13 @@ export const dynamicControlStateSchema = z.object({
dismissedOpportunityIds: z.array(z.string().trim().min(1)).readonly(),
recentRanges: z.array(timeRangeSchema).readonly(),
pausedAction: pausedDynamicActionSchema.nullable(),
lastActionReceipt: z.object({
actionId: z.string().uuid(),
kind: z.enum(["unmatched_context", "pause", "finish"]),
turnVersion: z.number().int().nonnegative(),
questionId: z.string().trim().min(1).optional(),
note: z.string().max(240).optional(),
}).strict().readonly().nullable().optional(),
}).strict().readonly();
export function toPublicDynamicChoiceQuestion(
@@ -0,0 +1,64 @@
import type { DynamicChoiceScoringResult } from "./birth-time-dynamic-choice-internal.ts";
import type { TimeRange } from "./birth-time-dynamic-choice.ts";
import { BirthTimeScoringJobError } from "./birth-time-scoring-job.ts";
function minute(value: string): number {
const [hour, part] = value.split(":").map(Number);
return hour * 60 + part;
}
function rangeMinutes(range: TimeRange): readonly number[] {
const start = minute(range.startTime);
const end = minute(range.endTime);
const values = [start];
let current = start;
while (current !== end) {
current = (current + 1) % 1_440;
values.push(current);
}
return values;
}
function segmentIsCoherent(
result: DynamicChoiceScoringResult,
currentRange: TimeRange,
): boolean {
const segment = result.candidate.winningSegment;
if (segment === null) return true;
const candidates = rangeMinutes(currentRange);
const start = candidates.indexOf(minute(segment.startTime));
const end = candidates.indexOf(minute(segment.endTime));
if (start < 0 || end < start) return false;
const width = end - start + 1;
const representative = candidates[start + Math.floor((width - 1) / 2)];
return segment.widthMinutes === width
&& minute(segment.representativeTime) === representative;
}
export function assertDynamicScoringResult(
result: DynamicChoiceScoringResult,
currentRange: TimeRange,
): void {
const candidate = result.candidate;
const segment = candidate.winningSegment;
const blocked = candidate.reasons.includes("missing_mandatory_layers");
const high = !blocked
&& result.effectiveAnswerCount >= 4
&& result.dimensionCount >= 3
&& segment !== null
&& segment.widthMinutes <= 5
&& candidate.marginPercent >= 20;
const medium = !blocked
&& result.effectiveAnswerCount >= 3
&& result.dimensionCount >= 2
&& segment !== null
&& segment.widthMinutes <= 15
&& candidate.marginPercent >= 10;
const expected = high ? "high" : medium ? "medium" : "low";
if (!segmentIsCoherent(result, currentRange)
|| candidate.confidence !== expected
|| candidate.canApply !== high
|| candidate.evidence.length !== 0) {
throw new BirthTimeScoringJobError("invalid_result");
}
}
@@ -4,6 +4,7 @@ import {
dynamicDifferenceInput,
} from "./birth-time-dynamic-engine-input.ts";
import { completeDynamicScoreTransition, isDynamicTerminal, withDynamicAction } from "./birth-time-dynamic-transitions.ts";
import { assertDynamicScoringResult } from "./birth-time-dynamic-result-validator.ts";
import { storedDynamicJourneyResponse } from "./birth-time-journey-response.ts";
import type {
BirthTimeJourneyEngine,
@@ -94,6 +95,7 @@ export function createDynamicScoringService(ports: BirthTimeJourneyPorts) {
const result = dynamicChoiceScoringResultSchema.parse(
await engine.scoreChoices(dynamicChoiceScoreInput(stored)),
);
assertDynamicScoringResult(result, stored.dynamicTurnState.progress.currentRange);
requireCounts(stored, result);
const build = await engine.buildDifferencePacket(dynamicDifferenceInput(stored));
const useful = build.packet.opportunities.filter((opportunity) => (
@@ -0,0 +1,133 @@
import { z } from "zod";
import { replayedDynamicAction } from "./birth-time-dynamic-action-replay.ts";
import { toPausedDynamicAction, withDynamicAction } from "./birth-time-dynamic-transitions.ts";
import { storedDynamicJourneyResponse } from "./birth-time-journey-response.ts";
import type { BirthTimeJourneyPorts, DynamicStoredRectificationCase } from "./birth-time-journey-service.ts";
import { StaleJourneyTurnError } from "./birth-time-journey-store-errors.ts";
type UnmatchedCommand = {
readonly caseId: string;
readonly actionId: string;
readonly turnVersion: number;
readonly questionId: string;
readonly note: string;
};
function stale(stored: DynamicStoredRectificationCase, expected: number) {
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 createDynamicSpecialActions(ports: BirthTimeJourneyPorts, load: (
userId: string, caseId: string,
) => Promise<DynamicStoredRectificationCase>, requireMutable: (
stored: DynamicStoredRectificationCase,
) => void) {
const save = (stored: DynamicStoredRectificationCase, updated: DynamicStoredRectificationCase, actionId: string) => (
ports.store.saveDynamicTurn(updated, stored.turnVersion, actionId)
);
return {
async submitUnmatchedContext(userId: string, command: UnmatchedCommand) {
const stored = await load(userId, command.caseId);
const note = z.string().trim().max(240).parse(command.note);
const priorAnswer = stored.choiceAnswers.at(-1);
const receipt = stored.dynamicControl.lastActionReceipt;
if (replayedDynamicAction(stored, command.actionId, command.turnVersion, () => (
stored.dynamicTurnState.nextAction.kind === "generate_dynamic_question"
&& priorAnswer?.kind === "unmatched" && priorAnswer.questionId === command.questionId
&& priorAnswer.unmatchedContext === note
&& receipt?.actionId === command.actionId.toLowerCase()
&& receipt.kind === "unmatched_context" && receipt.turnVersion === command.turnVersion
&& receipt.questionId === command.questionId && receipt.note === note
))) return storedDynamicJourneyResponse(stored);
requireMutable(stored);
const question = stored.currentChoiceQuestion;
if (stored.turnVersion !== command.turnVersion
|| stored.dynamicTurnState.nextAction.kind !== "clarify_unmatched_answer"
|| question?.questionId !== command.questionId
|| priorAnswer?.kind !== "unmatched"
|| priorAnswer.questionId !== command.questionId) throw stale(stored, command.turnVersion);
const updated = withDynamicAction(stored, { kind: "generate_dynamic_question" }, stored.turnVersion + 1);
const saved = await save(stored, {
...updated,
currentChoiceQuestion: null,
choiceAnswers: [...stored.choiceAnswers.slice(0, -1), { ...priorAnswer, unmatchedContext: note }],
agentContext: note.length === 0 ? stored.agentContext : [...stored.agentContext.slice(-9), note],
dynamicControl: {
...stored.dynamicControl,
dismissedOpportunityIds: [...stored.dynamicControl.dismissedOpportunityIds, question.opportunityId],
lastActionReceipt: {
actionId: command.actionId.toLowerCase(), kind: "unmatched_context",
turnVersion: command.turnVersion, questionId: command.questionId, note,
},
},
}, command.actionId);
return storedDynamicJourneyResponse(saved);
},
async pauseDynamic(userId: string, caseId: string, actionId: string, turnVersion: number) {
const stored = await load(userId, caseId);
const receipt = stored.dynamicControl.lastActionReceipt;
if (replayedDynamicAction(stored, actionId, turnVersion, () => (
stored.dynamicTurnState.nextAction.kind === "paused"
&& stored.dynamicControl.pausedAction !== null
&& receipt?.actionId === actionId.toLowerCase()
&& receipt.kind === "pause" && receipt.turnVersion === turnVersion
))) return storedDynamicJourneyResponse(stored);
requireMutable(stored);
if (stored.turnVersion !== turnVersion || stored.dynamicTurnState.nextAction.kind === "paused") {
throw stale(stored, turnVersion);
}
const pausedAction = toPausedDynamicAction(stored.dynamicTurnState.nextAction);
const updated = withDynamicAction(stored, { kind: "paused" }, stored.turnVersion + 1);
const saved = await save(stored, {
...updated,
dynamicControl: {
...stored.dynamicControl,
pausedAction,
lastActionReceipt: { actionId: actionId.toLowerCase(), kind: "pause", turnVersion },
},
}, actionId);
return storedDynamicJourneyResponse(saved);
},
async finishDynamic(userId: string, caseId: string, actionId: string, turnVersion: number) {
const stored = await load(userId, caseId);
const receipt = stored.dynamicControl.lastActionReceipt;
if (replayedDynamicAction(stored, actionId, turnVersion, () => (
(stored.dynamicTurnState.nextAction.kind === "present_low_result"
|| stored.dynamicTurnState.nextAction.kind === "present_medium_result")
&& receipt?.actionId === actionId.toLowerCase()
&& receipt.kind === "finish" && receipt.turnVersion === turnVersion
))) return storedDynamicJourneyResponse(stored);
requireMutable(stored);
if (stored.turnVersion !== turnVersion) throw stale(stored, turnVersion);
const candidate = stored.candidateResult;
const action = candidate?.confidence === "medium"
? { kind: "present_medium_result" as const, resultId: candidate.resultId }
: { kind: "present_low_result" as const, resultId: candidate?.resultId ?? null };
const updated = withDynamicAction(stored, action, stored.turnVersion + 1);
const saved = await save(stored, {
...updated,
snapshot: terminalSnapshot(stored),
currentChoiceQuestion: null,
dynamicControl: {
...stored.dynamicControl,
lastActionReceipt: { actionId: actionId.toLowerCase(), kind: "finish", turnVersion },
},
}, actionId);
return storedDynamicJourneyResponse(saved);
},
};
}
@@ -1,4 +1,5 @@
import { z } from "zod";
import { samePersistedDynamicReceipt } from "./birth-time-dynamic-action-replay.ts";
import { toPublicDynamicChoiceQuestion } from "./birth-time-dynamic-choice-internal.ts";
import {
dynamicJourneyTurnStateSchema,
@@ -143,8 +144,10 @@ export function createDynamicTurnPersistence(
});
if (result.error) {
const current = await loadCase(value.userId, value.id);
if (isStaleRpc(result.error) && current?.processedActionIds?.includes(receipt)) {
return loadedDynamic(value.userId, value.id);
if (isStaleRpc(result.error) && current?.journeyProtocol === "dynamic-choice-v2"
&& current.processedActionIds.includes(receipt)) {
if (samePersistedDynamicReceipt(value, current, receipt, expectedVersion)) return current;
throw new StaleJourneyTurnError(value.id, expectedVersion, current.turnVersion);
}
if (isStaleRpc(result.error)) {
throw new StaleJourneyTurnError(
@@ -138,6 +138,7 @@ export function createInitialDynamicState(
dismissedOpportunityIds: [],
recentRanges: [currentRange],
pausedAction: null,
lastActionReceipt: null,
});
const ready = snapshot.state === "ready";
return {
@@ -201,6 +202,7 @@ export function prepareLegacyDynamicUpgrade(
dismissedOpportunityIds: [],
recentRanges: [currentRange],
pausedAction: null,
lastActionReceipt: null,
});
const dynamicTurnState = dynamicJourneyTurnStateSchema.parse({
journeyProtocol: "dynamic-choice-v2",