feat: orchestrate dynamic rectification turns

This commit is contained in:
Jesse_Chen
2026-07-19 08:25:33 +08:00
parent 45b63d5cef
commit 7aa800b846
20 changed files with 1946 additions and 28 deletions
@@ -5,7 +5,7 @@ import {
createJyotishBirthTimeJourneyEngine,
BirthTimeJourneyEngineError,
} from "@/lib/birth-time-journey-engine";
import { createBirthTimeJourneyService, RectificationCaseNotFoundError, RectificationQuestionsUnavailableError, type VersionedJourneyResponse } from "@/lib/birth-time-journey-service";
import { createBirthTimeJourneyService, RectificationCaseNotFoundError, RectificationQuestionsUnavailableError, type DynamicVersionedJourneyResponse, type VersionedJourneyResponse } from "@/lib/birth-time-journey-service";
import { BirthTimeJourneyActionError } from "@/lib/birth-time-journey-actions";
import { birthTimeJourneyRequestSchema } from "@/lib/birth-time-journey-request";
import { StaleJourneyTurnError } from "@/lib/birth-time-journey-turn-persistence";
@@ -40,11 +40,15 @@ async function requestPayload(request: Request): Promise<unknown> {
}
async function responseWithJourneyMetric(
action: Promise<VersionedJourneyResponse>,
action: Promise<VersionedJourneyResponse | DynamicVersionedJourneyResponse>,
name: Extract<JourneyMetricName, "turn_advanced" | "draft_corrected" | "journey_paused">,
): Promise<NextResponse> {
const response = await action;
recordJourneyTransitionMetric(response, name);
if (response.journeyProtocol === "dynamic-choice-v2") {
recordJourneyMetricEvent({ kind: "transition", name, phase: "adaptive" });
} else {
recordJourneyTransitionMetric(response, name);
}
return NextResponse.json(response);
}
@@ -0,0 +1,264 @@
import { z } from "zod";
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 { 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 };
type UnmatchedCommand = TurnCommand & { readonly questionId: string; readonly note: string };
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);
}
return {
async answerDynamicChoice(userId: string, command: ChoiceCommand) {
const stored = await load(userId, command.caseId);
requireMutable(stored);
if (stored.processedActionIds.includes(command.actionId.toLowerCase())) {
return storedDynamicJourneyResponse(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 updated = answerTransition({
stored,
option,
answeredAt: (ports.now?.() ?? new Date()).toISOString(),
jobId: globalThis.crypto.randomUUID(),
nextVersion: stored.turnVersion + 1,
});
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 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);
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);
requireMutable(stored);
if (stored.processedActionIds.includes(command.actionId.toLowerCase())) {
return { nextAction: stored.dynamicTurnState.nextAction };
}
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 saved = await save(stored, {
...updated,
snapshot: nextQuestion === null ? terminalSnapshot(stored) : stored.snapshot,
currentChoiceQuestion: nextQuestion,
dynamicControl: nextQuestion === null ? stored.dynamicControl : {
...stored.dynamicControl,
questionFingerprints: [...stored.dynamicControl.questionFingerprints, nextQuestion.questionFingerprint],
partitionFingerprints: [...stored.dynamicControl.partitionFingerprints, nextQuestion.candidatePartitionFingerprint],
},
}, command.actionId);
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") {
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 saved = await save(stored, {
...updated,
dynamicControl: { ...stored.dynamicControl, pausedAction: null },
}, globalThis.crypto.randomUUID());
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));
},
};
}
@@ -51,7 +51,23 @@ export type CandidateDifferenceBuild = {
readonly scoringPartitions: Readonly<Record<string, readonly ScoredEvidencePartition[]>>;
};
export type PersistedDynamicChoiceQuestion = PublicDynamicChoiceQuestion & {
export type PersistedDynamicChoiceOption =
| {
readonly optionId: string;
readonly label: string;
readonly kind: "primary";
readonly partitionId: string;
readonly candidateScores: Readonly<Record<string, number>>;
}
| {
readonly optionId: string;
readonly label: string;
readonly kind: "unknown" | "unmatched";
readonly partitionId: null;
readonly candidateScores: null;
};
export type PersistedDynamicChoiceQuestion = Omit<PublicDynamicChoiceQuestion, "options"> & {
readonly opportunityId: string;
readonly dimensionCode: string;
readonly estimatedInformationGain: number;
@@ -59,13 +75,7 @@ export type PersistedDynamicChoiceQuestion = PublicDynamicChoiceQuestion & {
readonly source: "agent" | "fallback";
readonly questionFingerprint: string;
readonly candidatePartitionFingerprint: string;
readonly options: readonly {
readonly optionId: string;
readonly label: string;
readonly kind: PublicChoiceKind;
readonly partitionId: string | null;
readonly candidateScores: Readonly<Record<string, number>> | null;
}[];
readonly options: readonly PersistedDynamicChoiceOption[];
};
export type StoredChoiceAnswer = {
@@ -0,0 +1,41 @@
import type {
DifferencePacketInput,
DynamicChoiceScoreInput,
DynamicStoredRectificationCase,
} from "./birth-time-journey-service.ts";
export class BirthTimeDynamicEngineInputError extends Error {
readonly name = "BirthTimeDynamicEngineInputError";
}
export function dynamicChoiceScoreInput(
stored: DynamicStoredRectificationCase,
): DynamicChoiceScoreInput {
const context = stored.eventContext;
if (!context) throw new BirthTimeDynamicEngineInputError();
const range = stored.dynamicTurnState.progress.currentRange;
return {
birthDate: context.birthDate,
startTime: range.startTime,
endTime: range.endTime,
lat: context.lat,
lon: context.lon,
tz: context.tz,
evidence: stored.choiceEvidence,
};
}
export function dynamicDifferenceInput(
stored: DynamicStoredRectificationCase,
): DifferencePacketInput {
return {
caseId: stored.id,
asOfDate: stored.dynamicControl.asOfDate,
...dynamicChoiceScoreInput(stored),
dismissedOpportunityIds: stored.dynamicControl.dismissedOpportunityIds,
questionFingerprints: stored.dynamicControl.questionFingerprints,
partitionFingerprints: stored.dynamicControl.partitionFingerprints,
recentRanges: stored.dynamicControl.recentRanges,
candidateModel: stored.candidateModel,
};
}
@@ -0,0 +1,123 @@
import { z } from "zod";
import { BirthTimeScoringJobError } from "./birth-time-scoring-job.ts";
import type {
BirthTimeJourneyStore,
DynamicStoredRectificationCase,
StoredRectificationCase,
} from "./birth-time-journey-service.ts";
import { BirthTimeJourneyStoreError, StaleJourneyTurnError } from "./birth-time-journey-store-errors.ts";
type RpcError = { readonly message: string };
type RpcResult = { readonly data: unknown; readonly error: RpcError | null };
export type DynamicScoringRpcClient = {
readonly rpc: (
name: string,
args: Readonly<Record<string, unknown>>,
) => PromiseLike<RpcResult>;
};
const versionSchema = z.number().int().nonnegative();
const claimSchema = z.union([
z.object({
claim_state: z.enum(["claimed", "processing", "completed"]),
algorithm_version: z.string().trim().min(1),
}).strict().readonly(),
z.array(z.object({
claim_state: z.enum(["claimed", "processing", "completed"]),
algorithm_version: z.string().trim().min(1),
}).strict().readonly()).length(1).transform((rows) => rows[0]),
]);
type DynamicScoringMethods = Required<Pick<BirthTimeJourneyStore,
"createDynamicScoringJob" | "claimDynamicScoringJob"
>>;
function privateState(value: DynamicStoredRectificationCase) {
return {
candidateModel: value.candidateModel,
currentChoiceQuestion: value.currentChoiceQuestion,
choiceAnswers: value.choiceAnswers,
choiceEvidence: value.choiceEvidence,
dynamicControl: value.dynamicControl,
agentContext: value.agentContext,
};
}
async function loadDynamic(
loadCase: (userId: string, caseId: string) => Promise<StoredRectificationCase | null>,
userId: string,
caseId: string,
): Promise<DynamicStoredRectificationCase> {
const stored = await loadCase(userId, caseId);
if (!stored || stored.journeyProtocol !== "dynamic-choice-v2") {
throw new BirthTimeJourneyStoreError("load_case");
}
return stored;
}
function claimError(message: string): BirthTimeScoringJobError {
if (message.includes("algorithm_mismatch")) {
return new BirthTimeScoringJobError("algorithm_mismatch");
}
if (message.includes("turn_invalid") || message.includes("result_inconsistent")) {
return new BirthTimeScoringJobError("invalid_turn");
}
return new BirthTimeScoringJobError("unavailable");
}
export function createDynamicScoringJobStore(
client: DynamicScoringRpcClient,
loadCase: (userId: string, caseId: string) => Promise<StoredRectificationCase | null>,
): DynamicScoringMethods {
return {
async createDynamicScoringJob(value, expectedVersion, actionId, questionId, job) {
const receipt = actionId.toLowerCase();
const result = await client.rpc("create_birth_time_dynamic_scoring_job", {
p_user_id: value.userId,
p_case_id: value.id,
p_job_id: job.jobId,
p_expected_version: expectedVersion,
p_action_id: receipt,
p_question_id: questionId,
p_evidence_fingerprint: job.evidenceFingerprint,
p_algorithm_version: job.algorithmVersion,
p_expires_at: job.expiresAt,
p_public_turn_state: { ...value.dynamicTurnState, turnVersion: expectedVersion + 1 },
p_snapshot: value.snapshot,
p_private_state: privateState(value),
});
if (result.error) {
const current = await loadCase(value.userId, value.id);
if (current?.journeyProtocol === "dynamic-choice-v2"
&& current.processedActionIds.includes(receipt)) return current;
if (result.error.message.includes("stale_birth_time_dynamic_scoring_job")) {
throw new StaleJourneyTurnError(value.id, expectedVersion, current?.turnVersion ?? 0);
}
throw new BirthTimeJourneyStoreError("update_case");
}
const version = versionSchema.safeParse(result.data);
if (!version.success || version.data !== expectedVersion + 1) {
throw new BirthTimeJourneyStoreError("update_case");
}
return loadDynamic(loadCase, value.userId, value.id);
},
async claimDynamicScoringJob(identity) {
const result = await client.rpc("claim_birth_time_dynamic_scoring_job", {
p_user_id: identity.userId,
p_case_id: identity.caseId,
p_job_id: identity.jobId,
p_evidence_fingerprint: identity.evidenceFingerprint,
p_algorithm_version: identity.algorithmVersion,
p_now: identity.now,
});
if (result.error) throw claimError(result.error.message);
const parsed = claimSchema.safeParse(result.data);
if (!parsed.success) throw new BirthTimeJourneyStoreError("load_case");
return {
kind: parsed.data.claim_state,
algorithmVersion: parsed.data.algorithm_version,
};
},
};
}
@@ -0,0 +1,140 @@
import { dynamicChoiceScoringResultSchema } from "./birth-time-dynamic-choice-internal.ts";
import {
dynamicChoiceScoreInput,
dynamicDifferenceInput,
} from "./birth-time-dynamic-engine-input.ts";
import { completeDynamicScoreTransition, isDynamicTerminal, withDynamicAction } from "./birth-time-dynamic-transitions.ts";
import { storedDynamicJourneyResponse } from "./birth-time-journey-response.ts";
import type {
BirthTimeJourneyEngine,
BirthTimeJourneyPorts,
DynamicStoredRectificationCase,
} from "./birth-time-journey-service.ts";
import {
BirthTimeScoringJobError,
dynamicChoiceScoringAlgorithmVersion,
dynamicEvidenceFingerprint,
} from "./birth-time-scoring-job.ts";
function engineFrom(ports: BirthTimeJourneyPorts): Pick<BirthTimeJourneyEngine,
"buildDifferencePacket" | "scoreChoices"
> {
if (!("buildDifferencePacket" in ports.engine) || !("scoreChoices" in ports.engine)) {
throw new BirthTimeScoringJobError("unavailable");
}
return ports.engine;
}
function requirePending(
value: Awaited<ReturnType<BirthTimeJourneyPorts["store"]["loadCase"]>>,
jobId: string,
): DynamicStoredRectificationCase {
if (!value || value.journeyProtocol !== "dynamic-choice-v2" || isDynamicTerminal(value)) {
throw new BirthTimeScoringJobError("invalid_turn");
}
const action = value.dynamicTurnState.nextAction;
if ((action.kind !== "score_pending" && action.kind !== "retry_scoring")
|| action.jobId !== jobId) throw new BirthTimeScoringJobError("invalid_turn");
return value;
}
function requireDynamic(
value: Awaited<ReturnType<BirthTimeJourneyPorts["store"]["loadCase"]>>,
): DynamicStoredRectificationCase {
if (!value || value.journeyProtocol !== "dynamic-choice-v2") {
throw new BirthTimeScoringJobError("invalid_turn");
}
return value;
}
function requireCounts(stored: DynamicStoredRectificationCase, result: ReturnType<
typeof dynamicChoiceScoringResultSchema.parse
>): void {
const dimensions = new Set(stored.choiceEvidence.map((item) => item.dimensionCode)).size;
const effective = stored.dynamicControl.effectiveAnswerCount;
if (result.effectiveAnswerCount !== effective
|| result.candidate.eventCount !== effective
|| result.dimensionCount !== dimensions
|| result.candidate.domainCount !== dimensions
|| result.candidate.algorithmVersion !== dynamicChoiceScoringAlgorithmVersion) {
throw new BirthTimeScoringJobError("invalid_result");
}
}
export function createDynamicScoringService(ports: BirthTimeJourneyPorts) {
return {
async poll(userId: string, caseId: string, jobId: string) {
const loaded = requireDynamic(await ports.store.loadCase(userId, caseId));
const claimJob = ports.store.claimDynamicScoringJob;
if (!claimJob) throw new BirthTimeScoringJobError("unavailable");
const fingerprint = dynamicEvidenceFingerprint(loaded.choiceEvidence);
const claim = await claimJob({
userId,
caseId,
jobId,
evidenceFingerprint: fingerprint,
algorithmVersion: dynamicChoiceScoringAlgorithmVersion,
now: (ports.now?.() ?? new Date()).toISOString(),
});
if (claim.kind === "completed") {
const completed = await ports.store.loadCase(userId, caseId);
if (!completed || completed.journeyProtocol !== "dynamic-choice-v2") {
throw new BirthTimeScoringJobError("unavailable");
}
return storedDynamicJourneyResponse(completed);
}
const stored = requirePending(loaded, jobId);
if (claim.kind === "processing") return storedDynamicJourneyResponse(stored);
if (claim.algorithmVersion !== dynamicChoiceScoringAlgorithmVersion) {
throw new BirthTimeScoringJobError("algorithm_mismatch");
}
const engine = engineFrom(ports);
let updated: DynamicStoredRectificationCase;
try {
const result = dynamicChoiceScoringResultSchema.parse(
await engine.scoreChoices(dynamicChoiceScoreInput(stored)),
);
requireCounts(stored, result);
const build = await engine.buildDifferencePacket(dynamicDifferenceInput(stored));
const useful = build.packet.opportunities.filter((opportunity) => (
opportunity.estimatedInformationGain > 0
&& !stored.dynamicControl.partitionFingerprints.includes(
opportunity.candidatePartitionFingerprint,
)
));
updated = completeDynamicScoreTransition({
stored,
candidate: result.candidate,
usefulOpportunityCount: useful.length,
repeatedOnly: build.packet.opportunities.length > 0 && useful.length === 0,
nextVersion: stored.turnVersion + 1,
candidateModel: build.candidateModel,
});
} catch (error) {
if (!(error instanceof Error)) throw error;
const retry = withDynamicAction(
stored,
{ kind: "retry_scoring", jobId },
stored.turnVersion + 1,
);
const failed = await ports.store.failDynamicScoringJob(retry, {
expectedVersion: stored.turnVersion,
jobId,
evidenceFingerprint: fingerprint,
algorithmVersion: dynamicChoiceScoringAlgorithmVersion,
failureCode: error instanceof BirthTimeScoringJobError
? error.reason
: "engine_error",
});
return storedDynamicJourneyResponse(failed);
}
const completed = await ports.store.completeDynamicScoringJob(updated, {
expectedVersion: stored.turnVersion,
jobId,
evidenceFingerprint: fingerprint,
algorithmVersion: dynamicChoiceScoringAlgorithmVersion,
});
return storedDynamicJourneyResponse(completed);
},
};
}
@@ -0,0 +1,15 @@
import { createDynamicJourneyActions } from "./birth-time-dynamic-actions.ts";
import { createDynamicScoringService } from "./birth-time-dynamic-scoring-service.ts";
import type {
BirthTimeJourneyPorts,
} from "./birth-time-journey-service.ts";
export function createDynamicJourneyMethods(ports: BirthTimeJourneyPorts) {
const actions = createDynamicJourneyActions(ports);
const scoring = createDynamicScoringService(ports);
return {
...actions,
generateDynamicQuestion: actions.loadDynamicQuestionBuild,
pollDynamicScoringJob: scoring.poll,
};
}
@@ -0,0 +1,205 @@
import { withCandidateResult } from "./birth-time-evidence.ts";
import type { CandidateResult } from "./birth-time-evidence.ts";
import { decideDynamicStop } from "./birth-time-dynamic-stop-policy.ts";
import { toPublicDynamicChoiceQuestion } from "./birth-time-dynamic-choice-internal.ts";
import type {
PausedDynamicAction,
PersistedDynamicChoiceQuestion,
} from "./birth-time-dynamic-choice-internal.ts";
import type { DynamicNextAction } from "./birth-time-journey-turn-protocol.ts";
import type { DynamicStoredRectificationCase } from "./birth-time-journey-service.ts";
const terminalKinds = new Set<DynamicNextAction["kind"]>([
"present_low_result",
"present_medium_result",
"request_candidate_confirmation",
"ready",
]);
export class DynamicPauseActionError extends Error {
readonly code = "invalid_dynamic_pause_action";
constructor(kind: DynamicNextAction["kind"]) {
super(`Cannot pause dynamic action ${kind}`);
this.name = "DynamicPauseActionError";
}
}
export function isDynamicTerminal(value: DynamicStoredRectificationCase): boolean {
return terminalKinds.has(value.dynamicTurnState.nextAction.kind);
}
export function publicQuestionAction(question: PersistedDynamicChoiceQuestion): DynamicNextAction {
return { kind: "ask_dynamic_choice", question: toPublicDynamicChoiceQuestion(question) };
}
export function toPausedDynamicAction(action: DynamicNextAction): PausedDynamicAction {
switch (action.kind) {
case "ask_dynamic_choice":
return { kind: action.kind, questionId: action.question.questionId };
case "generate_dynamic_question":
case "clarify_unmatched_answer":
case "retry_question_generation":
case "score_pending":
case "retry_scoring":
return action;
case "present_low_result":
case "present_medium_result":
case "request_candidate_confirmation":
case "ready":
case "paused":
throw new DynamicPauseActionError(action.kind);
}
}
export function progressPhase(action: DynamicNextAction): DynamicStoredRectificationCase["dynamicTurnState"]["progress"]["phase"] {
switch (action.kind) {
case "generate_dynamic_question":
case "ask_dynamic_choice":
case "retry_question_generation":
return "question";
case "clarify_unmatched_answer":
return "clarification";
case "score_pending":
case "retry_scoring":
return "scoring";
case "present_low_result":
case "present_medium_result":
case "request_candidate_confirmation":
return "result";
case "ready":
return "ready";
case "paused":
return "paused";
}
}
export function withDynamicAction(
stored: DynamicStoredRectificationCase,
action: DynamicNextAction,
nextVersion: number,
): DynamicStoredRectificationCase {
return {
...stored,
dynamicTurnState: {
...stored.dynamicTurnState,
turnVersion: nextVersion,
nextAction: action,
progress: { ...stored.dynamicTurnState.progress, phase: progressPhase(action) },
},
};
}
export function answerTransition(input: {
readonly stored: DynamicStoredRectificationCase;
readonly option: PersistedDynamicChoiceQuestion["options"][number];
readonly answeredAt: string;
readonly jobId: string;
readonly nextVersion: number;
}): DynamicStoredRectificationCase {
const { stored, option } = input;
const question = stored.currentChoiceQuestion;
if (question === null) return stored;
const answeredCount = stored.dynamicControl.answeredCount + 1;
const effective = option.kind === "primary";
const effectiveAnswerCount = stored.dynamicControl.effectiveAnswerCount + (effective ? 1 : 0);
const answer = {
questionId: question.questionId,
optionId: option.optionId,
kind: option.kind,
opportunityId: question.opportunityId,
answeredAt: input.answeredAt,
};
const evidence = option.kind === "primary" ? [{
questionId: question.questionId,
opportunityId: question.opportunityId,
partitionId: option.partitionId,
dimensionCode: question.dimensionCode,
candidateScores: option.candidateScores,
informationGain: question.estimatedInformationGain,
}] : [];
const nextAction: DynamicNextAction = option.kind === "primary"
? { kind: "score_pending", jobId: input.jobId }
: option.kind === "unknown"
? { kind: "generate_dynamic_question" }
: { kind: "clarify_unmatched_answer", questionId: question.questionId };
const cleared = option.kind === "unmatched" ? question : null;
const dismissed = option.kind === "primary" || option.kind === "unmatched"
? stored.dynamicControl.dismissedOpportunityIds
: [...stored.dynamicControl.dismissedOpportunityIds, question.opportunityId];
const updated = withDynamicAction(stored, nextAction, input.nextVersion);
return {
...updated,
currentChoiceQuestion: cleared,
choiceAnswers: [...stored.choiceAnswers, answer],
choiceEvidence: [...stored.choiceEvidence, ...evidence],
dynamicControl: {
...stored.dynamicControl,
answeredCount,
effectiveAnswerCount,
dismissedOpportunityIds: dismissed,
},
dynamicTurnState: {
...updated.dynamicTurnState,
progress: {
...updated.dynamicTurnState.progress,
answeredCount,
effectiveAnswerCount,
},
},
};
}
export function completeDynamicScoreTransition(input: {
readonly stored: DynamicStoredRectificationCase;
readonly candidate: CandidateResult;
readonly usefulOpportunityCount: number;
readonly repeatedOnly: boolean;
readonly nextVersion: number;
readonly candidateModel?: Readonly<Record<string, unknown>>;
}): DynamicStoredRectificationCase {
const stored = input.stored;
const decision = decideDynamicStop({
result: input.candidate,
effectiveAnswer: true,
previousResult: stored.candidateResult ?? null,
priorPlateauCount: stored.dynamicControl.plateauCount,
usefulOpportunityCount: input.usefulOpportunityCount,
repeatedOnly: input.repeatedOnly,
effectiveAnswerCount: stored.dynamicControl.effectiveAnswerCount,
forcedReason: null,
});
const action: DynamicNextAction = input.candidate.confidence === "high"
? { kind: "request_candidate_confirmation", resultId: input.candidate.resultId }
: decision.kind === "continue"
? { kind: "generate_dynamic_question" }
: input.candidate.confidence === "medium"
? { kind: "present_medium_result", resultId: input.candidate.resultId }
: { kind: "present_low_result", resultId: input.candidate.resultId };
const priorRange = stored.dynamicTurnState.progress.currentRange;
const segment = input.candidate.winningSegment;
const currentRange = segment === null
? priorRange
: { startTime: segment.startTime, endTime: segment.endTime };
const updated = withDynamicAction(stored, action, input.nextVersion);
return {
...updated,
snapshot: withCandidateResult(stored.snapshot, input.candidate),
candidateResult: input.candidate,
candidateModel: input.candidateModel ?? stored.candidateModel,
dynamicControl: {
...stored.dynamicControl,
plateauCount: decision.plateauCount,
recentRanges: [...stored.dynamicControl.recentRanges, currentRange],
},
dynamicTurnState: {
...updated.dynamicTurnState,
progress: {
...updated.dynamicTurnState.progress,
currentRange,
previousRange: priorRange,
plateauCount: decision.plateauCount,
},
permissions: { canConfirmCandidate: input.candidate.confidence === "high" },
},
};
}
+16 -17
View File
@@ -6,7 +6,7 @@ import type { CandidateVargaSample } from "./birth-time-question-planner.ts";
import { projectJourneyResponse, storedDynamicJourneyResponse, storedJourneyResponse } from "./birth-time-journey-response.ts";
import type { JourneyTurnState } from "./birth-time-journey-turn.ts";
import type { DynamicJourneyTurnState } from "./birth-time-journey-turn-protocol.ts";
import type { ScoringJobClaim, ScoringJobIdentity, ScoringJobSpec } from "./birth-time-scoring-job.ts";
import type { DynamicScoringJobIdentity, DynamicScoringJobSpec, ScoringJobClaim, ScoringJobIdentity, ScoringJobSpec } from "./birth-time-scoring-job.ts";
import { createBirthTimeScoringService } from "./birth-time-scoring-service.ts";
import { scanAssessment } from "./birth-time-journey-assessment.ts";
import type { GuidedCandidateCommit } from "./birth-time-guided-candidate.ts";
@@ -16,6 +16,7 @@ import type { CandidateDifferenceBuild, DynamicChoiceScoringResult, ServerChoice
import type { TimeRange } from "./birth-time-dynamic-choice.ts";
import type { DynamicStoredFields, LegacyStoredFields } from "./birth-time-journey-stored-protocol.ts";
import { RectificationCaseNotFoundError, RectificationQuestionsUnavailableError } from "./birth-time-journey-errors.ts";
import { createDynamicJourneyMethods } from "./birth-time-dynamic-service-methods.ts";
export { RectificationCaseNotFoundError, RectificationQuestionsUnavailableError };
@@ -86,9 +87,7 @@ export type DifferencePacketInput = {
readonly candidateModel: Readonly<Record<string, unknown>> | null;
};
export type DynamicChoiceScoreInput = Pick<DifferencePacketInput,
"birthDate" | "startTime" | "endTime" | "lat" | "lon" | "tz" | "evidence"
>;
export type DynamicChoiceScoreInput = Pick<DifferencePacketInput, "birthDate" | "startTime" | "endTime" | "lat" | "lon" | "tz" | "evidence">;
export interface BirthTimeJourneyEngine {
scan(input: JourneyScanInput): Promise<{ readonly questionnaire: RectificationQuestionnaire }>;
@@ -98,9 +97,7 @@ export interface BirthTimeJourneyEngine {
scoreChoices(input: DynamicChoiceScoreInput): Promise<DynamicChoiceScoringResult>;
}
export type LegacyBirthTimeJourneyEngine = Pick<BirthTimeJourneyEngine,
"scan" | "score" | "scoreEvents"
>;
export type LegacyBirthTimeJourneyEngine = Pick<BirthTimeJourneyEngine, "scan" | "score" | "scoreEvents">;
export type PersistedJourneyAssessment = {
readonly userId: string;
@@ -127,14 +124,11 @@ type StoredRectificationCaseBase = {
readonly candidateResult?: CandidateResult | null;
};
export type LegacyStoredRectificationCase = StoredRectificationCaseBase
& LegacyStoredFields;
export type LegacyStoredRectificationCase = StoredRectificationCaseBase & LegacyStoredFields;
export type DynamicStoredRectificationCase = StoredRectificationCaseBase
& DynamicStoredFields;
export type DynamicStoredRectificationCase = StoredRectificationCaseBase & DynamicStoredFields;
export type StoredRectificationCase = LegacyStoredRectificationCase
| DynamicStoredRectificationCase;
export type StoredRectificationCase = LegacyStoredRectificationCase | DynamicStoredRectificationCase;
export type DynamicScoringJobCommand = {
readonly expectedVersion: number;
@@ -143,8 +137,7 @@ export type DynamicScoringJobCommand = {
readonly algorithmVersion: string;
};
export type DynamicScoringJobFailureCommand = DynamicScoringJobCommand
& { readonly failureCode: string };
export type DynamicScoringJobFailureCommand = DynamicScoringJobCommand & { readonly failureCode: string };
export interface BirthTimeJourneyStore {
saveAssessment(value: PersistedJourneyAssessment): Promise<string>;
@@ -154,6 +147,8 @@ export interface BirthTimeJourneyStore {
saveDynamicTurn(value: DynamicStoredRectificationCase, expectedVersion: number, actionId: string): Promise<DynamicStoredRectificationCase>;
completeDynamicScoringJob(value: DynamicStoredRectificationCase, command: DynamicScoringJobCommand): Promise<DynamicStoredRectificationCase>;
failDynamicScoringJob(value: DynamicStoredRectificationCase, command: DynamicScoringJobFailureCommand): Promise<DynamicStoredRectificationCase>;
createDynamicScoringJob?(value: DynamicStoredRectificationCase, expectedVersion: number, actionId: string, questionId: string, job: DynamicScoringJobSpec): Promise<DynamicStoredRectificationCase>;
claimDynamicScoringJob?(identity: DynamicScoringJobIdentity): Promise<ScoringJobClaim>;
upgradeLegacyActiveCase(value: LegacyStoredRectificationCase): Promise<StoredRectificationCase>;
createScoringJob(value: LegacyStoredRectificationCase, expectedVersion: number, actionId: string, job: ScoringJobSpec): Promise<StoredRectificationCase>;
claimScoringJob(identity: ScoringJobIdentity): Promise<ScoringJobClaim>;
@@ -167,7 +162,7 @@ export interface BirthTimeJourneyStore {
export type BirthTimeJourneyPorts = {
readonly store: BirthTimeJourneyStore;
readonly engine: LegacyBirthTimeJourneyEngine;
readonly engine: BirthTimeJourneyEngine | LegacyBirthTimeJourneyEngine;
readonly now?: () => Date;
};
@@ -197,8 +192,9 @@ export function createBirthTimeJourneyService(ports: BirthTimeJourneyPorts) {
const scoringActions = createBirthTimeScoringService(ports);
const guidedCandidates = createGuidedCandidateActions(ports);
const draftRevisions = createGuidedDraftRevisionActions(ports, turnActions.proposeEvidenceDraft);
const dynamicMethods = createDynamicJourneyMethods(ports);
return {
async assess(userId: string, assessment: BirthTimeAssessment): Promise<VersionedJourneyResponse> {
async assess(userId: string, assessment: BirthTimeAssessment): Promise<VersionedJourneyResponse | DynamicVersionedJourneyResponse> {
const scan = await scanAssessment(ports.engine, assessment);
const snapshot = assessBirthTime(assessment, scan.stability);
const persisted = {
@@ -209,6 +205,8 @@ export function createBirthTimeJourneyService(ports: BirthTimeJourneyPorts) {
candidateScan: scan.questionnaire,
} satisfies PersistedJourneyAssessment;
const caseId = await ports.store.saveAssessment(persisted);
const stored = await ports.store.loadCase(userId, caseId);
if (stored?.journeyProtocol === "dynamic-choice-v2") return storedDynamicJourneyResponse(stored);
return projectJourneyResponse({
caseId,
snapshot,
@@ -275,5 +273,6 @@ export function createBirthTimeJourneyService(ports: BirthTimeJourneyPorts) {
saveGuidedCandidate: guidedCandidates.save,
confirmGuidedCandidate: guidedCandidates.confirm,
pollScoringJob: scoringActions.pollScoringJob,
...dynamicMethods,
};
}
@@ -19,6 +19,7 @@ import {
type DynamicRpcClient,
} from "./birth-time-journey-dynamic-persistence.ts";
import { saveDynamicAssessment } from "./birth-time-journey-dynamic-case.ts";
import { createDynamicScoringJobStore } from "./birth-time-dynamic-scoring-job-store.ts";
export { BirthTimeJourneyStoreError } from "./birth-time-journey-turn-persistence.ts";
@@ -41,6 +42,7 @@ export function createSupabaseBirthTimeJourneyStore(
() => now().toISOString().slice(0, 10),
);
const scoringJobs = createSupabaseScoringJobStore(supabase, loadCase);
const dynamicScoringJobs = createDynamicScoringJobStore(dynamicRpc, loadCase);
const guidedCandidates = createSupabaseGuidedCandidateStore(supabase, loadCase);
return {
async saveAssessment(value) {
@@ -51,6 +53,7 @@ export function createSupabaseBirthTimeJourneyStore(
saveTurn: turns.saveTurn,
...dynamicTurns,
...scoringJobs,
...dynamicScoringJobs,
...guidedCandidates,
async saveScoring(value) {
@@ -1,8 +1,10 @@
import { createHash } from "node:crypto";
import { z } from "zod";
import type { LifeEvent } from "./birth-time-evidence.ts";
import type { ServerChoiceEvidence } from "./birth-time-dynamic-choice-internal.ts";
export const birthTimeScoringAlgorithmVersion = "birth-time-event-scoring-v1" as const;
export const dynamicChoiceScoringAlgorithmVersion = "birth-time-choice-scoring-v2" as const;
export const scoringJobDurationMs = 15 * 60_000;
export const scoringProcessingLeaseMs = 60_000;
@@ -31,6 +33,17 @@ export type ScoringJobIdentity = {
readonly now: string;
};
export type DynamicScoringJobSpec = {
readonly jobId: string;
readonly evidenceFingerprint: string;
readonly algorithmVersion: typeof dynamicChoiceScoringAlgorithmVersion;
readonly expiresAt: string;
};
export type DynamicScoringJobIdentity = Omit<ScoringJobIdentity, "algorithmVersion"> & {
readonly algorithmVersion: typeof dynamicChoiceScoringAlgorithmVersion;
};
export type ScoringJobClaim =
| { readonly kind: "claimed"; readonly algorithmVersion: string }
| { readonly kind: "processing"; readonly algorithmVersion: string }
@@ -77,3 +90,41 @@ export function createScoringJobSpec(
expiresAt: new Date(now.getTime() + scoringJobDurationMs).toISOString(),
};
}
function canonicalChoiceEvidence(evidence: readonly ServerChoiceEvidence[]): string {
return JSON.stringify([...evidence]
.sort((left, right) => left.questionId.localeCompare(right.questionId))
.map((item) => ({
questionId: item.questionId,
opportunityId: item.opportunityId,
partitionId: item.partitionId,
dimensionCode: item.dimensionCode,
informationGain: item.informationGain,
candidateScores: Object.fromEntries(
Object.entries(item.candidateScores).sort(([left], [right]) => left.localeCompare(right)),
),
})));
}
export function dynamicEvidenceFingerprint(
evidence: readonly ServerChoiceEvidence[],
): string {
return createHash("sha256")
.update(dynamicChoiceScoringAlgorithmVersion)
.update("\u0000")
.update(canonicalChoiceEvidence(evidence))
.digest("hex");
}
export function createDynamicScoringJobSpec(
jobId: string,
evidence: readonly ServerChoiceEvidence[],
now: Date,
): DynamicScoringJobSpec {
return {
jobId,
evidenceFingerprint: dynamicEvidenceFingerprint(evidence),
algorithmVersion: dynamicChoiceScoringAlgorithmVersion,
expiresAt: new Date(now.getTime() + scoringJobDurationMs).toISOString(),
};
}