feat: orchestrate dynamic rectification turns
This commit is contained in:
@@ -0,0 +1,83 @@
|
||||
# Task 6 Report: Dynamic Journey Orchestration
|
||||
|
||||
## Outcome
|
||||
|
||||
Implemented the `dynamic-choice-v2` journey as a persisted, Agent-driven state machine rather
|
||||
than a fixed questionnaire loop. A primary click resolves only its server-owned private
|
||||
partition, records one canonical evidence item, and creates one idempotent scoring job in the
|
||||
same versioned transition. Unknown and unmatched choices remain non-evidence actions; unmatched
|
||||
free context is bounded, stored separately for the Agent, and never enters scoring.
|
||||
|
||||
Question generation uses the full difference packet and persists the chosen question/private
|
||||
binding before exposing it. Repeated generation fingerprints fail closed to a terminal low
|
||||
result. Scoring claims the durable job, validates identity/fingerprint/algorithm, scores once,
|
||||
and saves the result and deterministic stop decision in the same turn. Continuation depends on
|
||||
available information and plateau/confidence state, not a fixed round count. High confidence
|
||||
still requires explicit candidate confirmation and never applies a minute during orchestration.
|
||||
|
||||
Pause stores the exact current dynamic action and resume reconstructs that persisted action.
|
||||
Terminal low, medium, confirmation, and ready turns are one-way: answer, generation, retry,
|
||||
pause, and finish mutations cannot restart them. New assessments reload and return their
|
||||
persisted v2 generation turn instead of projecting the former legacy baseline question.
|
||||
|
||||
## Persistence Amendment
|
||||
|
||||
Added service-role-only `create_birth_time_dynamic_scoring_job` and
|
||||
`claim_birth_time_dynamic_scoring_job` RPCs. Creation owner-locks the v2 case and atomically
|
||||
validates/persists the public turn, private state, canonical receipt, and pending job. Claiming
|
||||
checks ownership, job identity, evidence fingerprint, algorithm version, current action, and
|
||||
lease; completed replay requires a coherent persisted result/action. Production store methods
|
||||
use these RPCs directly and do not call legacy scoring wrappers or expose the private row.
|
||||
|
||||
## Main Files
|
||||
|
||||
- `frontend/src/lib/birth-time-dynamic-actions.ts`
|
||||
- `frontend/src/lib/birth-time-dynamic-transitions.ts`
|
||||
- `frontend/src/lib/birth-time-dynamic-scoring-service.ts`
|
||||
- `frontend/src/lib/birth-time-dynamic-scoring-job-store.ts`
|
||||
- `frontend/src/lib/birth-time-dynamic-engine-input.ts`
|
||||
- `frontend/src/lib/birth-time-dynamic-service-methods.ts`
|
||||
- `frontend/src/lib/birth-time-journey-service.ts`
|
||||
- `frontend/src/lib/birth-time-journey-store.ts`
|
||||
- `frontend/src/lib/birth-time-scoring-job.ts`
|
||||
- `frontend/supabase/migrations/20260718094000_dynamic_choice_scoring_job_lifecycle.sql`
|
||||
- `frontend/tests/birth-time-dynamic-actions.test.ts`
|
||||
- `frontend/tests/birth-time-dynamic-scoring.test.ts`
|
||||
- `frontend/tests/birth-time-dynamic-scoring-store.test.ts`
|
||||
- `frontend/tests/birth-time-dynamic-terminal.test.ts`
|
||||
- `tests/test_birth_time_dynamic_scoring_job_contract.py`
|
||||
|
||||
## TDD Evidence
|
||||
|
||||
- Initial action RED: dynamic answer/generation methods and modules were absent.
|
||||
- Scoring RED: the first implementation exposed legacy mutation behavior and later failed
|
||||
completed-job replay because it inspected a now-terminal action before claiming the job.
|
||||
- Persistence RED: all SQL contract cases failed before the ordered v2 job migration existed.
|
||||
- Assessment regression RED: a newly persisted v2 case returned a response with no
|
||||
`journeyProtocol`; GREEN now reloads and returns the stored dynamic generation turn.
|
||||
|
||||
Final verification:
|
||||
|
||||
- Focused Task 6 TypeScript: **22/22 passed**.
|
||||
- Route/telemetry regression subset after v2 assessment routing: **28/28 passed**.
|
||||
- Full frontend TypeScript tests: **344/344 passed**.
|
||||
- Dynamic scoring-job SQL contracts: **3/3 passed**.
|
||||
- ESLint: **0 errors**, with the two pre-existing `page.tsx` hook warnings.
|
||||
- `git diff --check`: passed.
|
||||
- Every changed/new Task 6 TypeScript, test, and migration module is at most 250 pure LOC.
|
||||
- TypeScript check reports only the known unrelated baseline at
|
||||
`frontend/tests/profile-persistence.test.ts:7` (`TS1501`, ES2018 regex under the existing
|
||||
target).
|
||||
|
||||
Live PostgreSQL execution was unavailable from the inherited Task 5 environment because the
|
||||
Docker daemon socket was absent. The database claim is therefore limited to static SQL
|
||||
contracts plus executable TypeScript RPC fakes; no live-database pass is claimed.
|
||||
|
||||
## Handoff
|
||||
|
||||
Task 7 should expose the new v2 commands through authenticated request/response schemas and
|
||||
browser coordination. In particular, v2 polling must call `pollDynamicScoringJob`; the legacy
|
||||
`pollScoringJob` remains legacy-only. The route change in this task only permits a fresh v2
|
||||
assessment response through the existing telemetry wrapper.
|
||||
|
||||
Commit message: `feat: orchestrate dynamic rectification turns`
|
||||
@@ -906,7 +906,30 @@ Task 6:
|
||||
|
||||
### Task 6: Journey Actions, Scoring Jobs, and Anti-Loop Transitions
|
||||
|
||||
#### Task 6 persistence amendment
|
||||
|
||||
Task 5 intentionally exposed only typed dynamic scoring completion/failure wrappers. Its
|
||||
legacy scoring protocol guards make the existing public create/claim RPCs unavailable to
|
||||
`dynamic-choice-v2`, so Task 6 must also close the v2 job lifecycle rather than bypassing the
|
||||
private-state boundary or leaving browser polling unable to complete.
|
||||
|
||||
- Add one ordered migration at or below 250 pure lines for
|
||||
`create_birth_time_dynamic_scoring_job(...)` and
|
||||
`claim_birth_time_dynamic_scoring_job(...)`.
|
||||
- Creation owner-locks a v2 case, validates expected version/action/question/job/fingerprint/
|
||||
algorithm, atomically persists the advanced public turn, private dynamic state, canonical
|
||||
action receipt, and one pending job, and replays only the identical completed action.
|
||||
- Claim owner-locks the v2 case, validates job identity, fingerprint, algorithm, current
|
||||
`score_pending`/`retry_scoring` action, and the processing lease. Completed replay is allowed
|
||||
only when the stored candidate result and dynamic terminal/continuation action agree.
|
||||
- Add typed production store methods and executable fake/store tests. Do not call the
|
||||
legacy-guarded public wrappers or write the service-only private table from orchestration.
|
||||
- If live PostgreSQL is unavailable, record that limitation explicitly and retain executable
|
||||
TypeScript RPC-fake evidence plus static SQL contract/syntax checks without claiming a live
|
||||
database pass.
|
||||
|
||||
**Files:**
|
||||
- Create: `frontend/supabase/migrations/20260718094000_dynamic_choice_scoring_job_lifecycle.sql`
|
||||
- Create: `frontend/src/lib/birth-time-dynamic-transitions.ts`
|
||||
- Create: `frontend/src/lib/birth-time-dynamic-actions.ts`
|
||||
- Create: `frontend/src/lib/birth-time-dynamic-scoring-service.ts`
|
||||
@@ -916,6 +939,7 @@ Task 6:
|
||||
- Test: `frontend/tests/birth-time-dynamic-actions.test.ts`
|
||||
- Test: `frontend/tests/birth-time-dynamic-scoring.test.ts`
|
||||
- Test: `frontend/tests/birth-time-dynamic-terminal.test.ts`
|
||||
- Test: `tests/test_birth_time_dynamic_scoring_job_contract.py`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces `answerDynamicChoice`, `submitUnmatchedContext`, `generateDynamicQuestion`, `pauseDynamic`, `resumeDynamic`, and `finishDynamic` service actions.
|
||||
|
||||
@@ -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" },
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
begin;
|
||||
|
||||
create function public.create_birth_time_dynamic_scoring_job(
|
||||
p_user_id uuid, p_case_id uuid, p_job_id uuid,
|
||||
p_expected_version bigint, p_action_id uuid, p_question_id text,
|
||||
p_evidence_fingerprint text, p_algorithm_version text,
|
||||
p_expires_at timestamptz, p_public_turn_state jsonb,
|
||||
p_snapshot jsonb, p_private_state jsonb
|
||||
) returns bigint language plpgsql security definer set search_path = '' as $$
|
||||
declare
|
||||
v_case public.birth_time_rectification_cases%rowtype;
|
||||
v_job public.birth_time_rectification_scoring_jobs%rowtype;
|
||||
v_new_version bigint;
|
||||
begin
|
||||
select c.* into v_case from public.birth_time_rectification_cases c
|
||||
where c.id = p_case_id and c.user_id = p_user_id
|
||||
and c.journey_protocol = 'dynamic-choice-v2' for update;
|
||||
if not found then raise exception 'birth_time_dynamic_case_not_found'; end if;
|
||||
|
||||
if p_action_id = any(v_case.processed_action_ids) then
|
||||
select j.* into v_job from public.birth_time_rectification_scoring_jobs j
|
||||
where j.id = p_job_id and j.case_id = p_case_id and j.user_id = p_user_id;
|
||||
if not found
|
||||
or v_case.turn_version is distinct from p_expected_version + 1
|
||||
or v_case.turn_state #>> '{nextAction,kind}' is distinct from 'score_pending'
|
||||
or v_case.turn_state #>> '{nextAction,jobId}' is distinct from p_job_id::text
|
||||
or v_job.evidence_fingerprint is distinct from p_evidence_fingerprint
|
||||
or v_job.algorithm_version is distinct from p_algorithm_version then
|
||||
raise exception 'stale_birth_time_dynamic_scoring_job';
|
||||
end if;
|
||||
return v_case.turn_version;
|
||||
end if;
|
||||
|
||||
if v_case.turn_version is distinct from p_expected_version
|
||||
or v_case.turn_state #>> '{nextAction,kind}' is distinct from 'ask_dynamic_choice'
|
||||
or v_case.turn_state #>> '{nextAction,question,questionId}' is distinct from p_question_id
|
||||
or p_algorithm_version is distinct from 'birth-time-choice-scoring-v2'
|
||||
or coalesce(p_evidence_fingerprint, '') = ''
|
||||
or p_expires_at <= now()
|
||||
or p_public_turn_state ->> 'journeyProtocol' is distinct from 'dynamic-choice-v2'
|
||||
or (p_public_turn_state ->> 'turnVersion')::bigint is distinct from p_expected_version + 1
|
||||
or p_public_turn_state #>> '{nextAction,kind}' is distinct from 'score_pending'
|
||||
or p_public_turn_state #>> '{nextAction,jobId}' is distinct from p_job_id::text
|
||||
or jsonb_typeof(p_private_state) is distinct from 'object'
|
||||
or p_private_state -> 'currentChoiceQuestion' is distinct from 'null'::jsonb
|
||||
or p_private_state #>> '{choiceAnswers,-1,questionId}' is distinct from p_question_id
|
||||
or jsonb_path_exists(p_public_turn_state, '$.**.partitionId')
|
||||
or jsonb_path_exists(p_public_turn_state, '$.**.candidateScores')
|
||||
or jsonb_path_exists(p_public_turn_state, '$.**.agentContext') then
|
||||
raise exception 'birth_time_dynamic_scoring_turn_invalid';
|
||||
end if;
|
||||
|
||||
insert into public.birth_time_rectification_scoring_jobs (
|
||||
id, case_id, user_id, evidence_fingerprint, algorithm_version,
|
||||
status, expires_at
|
||||
) values (
|
||||
p_job_id, p_case_id, p_user_id, p_evidence_fingerprint,
|
||||
p_algorithm_version, 'pending', p_expires_at
|
||||
);
|
||||
|
||||
update public.birth_time_rectification_cases
|
||||
set status = 'rectifying', journey_snapshot = p_snapshot,
|
||||
turn_version = p_expected_version + 1, turn_state = p_public_turn_state,
|
||||
evidence_draft = null,
|
||||
processed_action_ids = case
|
||||
when cardinality(processed_action_ids) >= 100
|
||||
then processed_action_ids[2:100] || p_action_id
|
||||
else processed_action_ids || p_action_id end,
|
||||
updated_at = now()
|
||||
where id = p_case_id and user_id = p_user_id
|
||||
and journey_protocol = 'dynamic-choice-v2'
|
||||
and turn_version = p_expected_version
|
||||
returning turn_version into v_new_version;
|
||||
if v_new_version is null then
|
||||
raise exception 'stale_birth_time_dynamic_scoring_job';
|
||||
end if;
|
||||
perform public.persist_birth_time_dynamic_private_state(
|
||||
p_case_id, p_user_id, p_private_state
|
||||
);
|
||||
return v_new_version;
|
||||
end;
|
||||
$$;
|
||||
|
||||
create function public.claim_birth_time_dynamic_scoring_job(
|
||||
p_user_id uuid, p_case_id uuid, p_job_id uuid,
|
||||
p_evidence_fingerprint text, p_algorithm_version text, p_now timestamptz
|
||||
) returns table (claim_state text, algorithm_version text)
|
||||
language plpgsql security definer set search_path = '' as $$
|
||||
declare
|
||||
v_case public.birth_time_rectification_cases%rowtype;
|
||||
v_job public.birth_time_rectification_scoring_jobs%rowtype;
|
||||
v_action text;
|
||||
v_confidence text;
|
||||
v_result_id text;
|
||||
v_updated_id uuid;
|
||||
begin
|
||||
select c.* into v_case from public.birth_time_rectification_cases c
|
||||
where c.id = p_case_id and c.user_id = p_user_id
|
||||
and c.journey_protocol = 'dynamic-choice-v2' for update;
|
||||
if not found then raise exception 'birth_time_dynamic_case_not_found'; end if;
|
||||
select j.* into v_job from public.birth_time_rectification_scoring_jobs j
|
||||
where j.id = p_job_id and j.case_id = p_case_id and j.user_id = p_user_id
|
||||
for update;
|
||||
if not found then raise exception 'birth_time_dynamic_scoring_job_not_found'; end if;
|
||||
if v_job.evidence_fingerprint is distinct from p_evidence_fingerprint then
|
||||
raise exception 'birth_time_dynamic_scoring_fingerprint_mismatch';
|
||||
end if;
|
||||
if v_job.algorithm_version is distinct from p_algorithm_version then
|
||||
raise exception 'birth_time_dynamic_scoring_algorithm_mismatch';
|
||||
end if;
|
||||
|
||||
v_action = v_case.turn_state #>> '{nextAction,kind}';
|
||||
if v_job.status = 'completed' then
|
||||
v_confidence = v_job.result ->> 'confidence';
|
||||
v_result_id = v_job.result ->> 'resultId';
|
||||
if v_job.result is null
|
||||
or v_case.candidate_result is distinct from v_job.result
|
||||
or (v_job.result ->> 'algorithmVersion') is distinct from p_algorithm_version
|
||||
or (v_case.turn_state ->> 'turnVersion')::bigint is distinct from v_case.turn_version
|
||||
or (v_confidence = 'low' and v_action not in ('generate_dynamic_question', 'present_low_result'))
|
||||
or (v_confidence = 'medium' and v_action not in ('generate_dynamic_question', 'present_medium_result'))
|
||||
or (v_confidence = 'high' and v_action is distinct from 'request_candidate_confirmation')
|
||||
or v_confidence not in ('low', 'medium', 'high')
|
||||
or (v_action <> 'generate_dynamic_question'
|
||||
and v_case.turn_state #>> '{nextAction,resultId}' is distinct from v_result_id) then
|
||||
raise exception 'birth_time_dynamic_scoring_result_inconsistent';
|
||||
end if;
|
||||
return query select 'completed'::text, v_job.algorithm_version;
|
||||
return;
|
||||
end if;
|
||||
|
||||
if v_case.turn_state #>> '{nextAction,jobId}' is distinct from p_job_id::text
|
||||
or v_action not in ('score_pending', 'retry_scoring') then
|
||||
raise exception 'birth_time_dynamic_scoring_turn_invalid';
|
||||
end if;
|
||||
if v_job.status = 'processing'
|
||||
and v_job.updated_at > p_now - interval '60 seconds' then
|
||||
return query select 'processing'::text, v_job.algorithm_version;
|
||||
return;
|
||||
end if;
|
||||
update public.birth_time_rectification_scoring_jobs
|
||||
set status = 'processing', failure_code = null,
|
||||
expires_at = p_now + interval '15 minutes', updated_at = p_now
|
||||
where id = p_job_id and status = v_job.status and updated_at = v_job.updated_at
|
||||
and status in ('pending', 'failed', 'processing')
|
||||
and (v_job.status <> 'processing'
|
||||
or v_job.updated_at <= p_now - interval '60 seconds')
|
||||
returning id into v_updated_id;
|
||||
if v_updated_id is null then
|
||||
return query select 'processing'::text, v_job.algorithm_version;
|
||||
return;
|
||||
end if;
|
||||
return query select 'claimed'::text, v_job.algorithm_version;
|
||||
end;
|
||||
$$;
|
||||
|
||||
revoke all on function public.create_birth_time_dynamic_scoring_job(uuid, uuid, uuid, bigint, uuid, text, text, text, timestamptz, jsonb, jsonb, jsonb) from public, anon, authenticated;
|
||||
revoke all on function public.claim_birth_time_dynamic_scoring_job(uuid, uuid, uuid, text, text, timestamptz) from public, anon, authenticated;
|
||||
grant execute on function public.create_birth_time_dynamic_scoring_job(uuid, uuid, uuid, bigint, uuid, text, text, text, timestamptz, jsonb, jsonb, jsonb) to service_role;
|
||||
grant execute on function public.claim_birth_time_dynamic_scoring_job(uuid, uuid, uuid, text, text, timestamptz) to service_role;
|
||||
|
||||
commit;
|
||||
@@ -0,0 +1,207 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { createBirthTimeJourneyService } from "../src/lib/birth-time-journey-service.ts";
|
||||
import { StaleJourneyTurnError } from "../src/lib/birth-time-journey-turn-persistence.ts";
|
||||
import { dynamicCase, ownerId, persistedQuestion } from "./birth-time-dynamic-persistence-fixture.ts";
|
||||
import { memoryStore } from "./birth-time-journey-memory-store.ts";
|
||||
import { dynamicJobStore } from "./birth-time-dynamic-job-memory-store.ts";
|
||||
import { differenceBuild } from "./fixtures/birth-time-dynamic-question-fixture.ts";
|
||||
import { createInitialDynamicState } from "../src/lib/birth-time-journey-dynamic-state.ts";
|
||||
import { approximateAssessment, scanWithSigns } from "./birth-time-journey-test-support.ts";
|
||||
|
||||
const actionId = "9a921af8-ddcc-4d20-b4c8-fbbb3e6a814d";
|
||||
const secondActionId = "700406ad-1ca6-437d-9f77-61354ba8e36a";
|
||||
|
||||
function dynamicFlow(initial = dynamicCase()) {
|
||||
const memory = memoryStore(initial);
|
||||
const jobs = dynamicJobStore(memory.store, () => {
|
||||
const value = memory.savedCase();
|
||||
return value?.journeyProtocol === "dynamic-choice-v2" ? value : null;
|
||||
});
|
||||
const service = createBirthTimeJourneyService({
|
||||
store: jobs.store,
|
||||
engine: {
|
||||
async scan() { throw new Error("unexpected scan"); },
|
||||
async score() { throw new Error("unexpected score"); },
|
||||
async scoreEvents() { throw new Error("unexpected event score"); },
|
||||
async buildDifferencePacket() { return differenceBuild; },
|
||||
async scoreChoices() { throw new Error("unexpected choice score"); },
|
||||
},
|
||||
});
|
||||
return { memory, service, jobs };
|
||||
}
|
||||
|
||||
test("a primary click resolves private evidence and enters score_pending", async () => {
|
||||
const flow = dynamicFlow();
|
||||
const result = await flow.service.answerDynamicChoice(ownerId, {
|
||||
caseId: dynamicCase().id,
|
||||
actionId,
|
||||
turnVersion: 7,
|
||||
questionId: persistedQuestion.questionId,
|
||||
optionId: persistedQuestion.options[0].optionId,
|
||||
});
|
||||
|
||||
assert.equal(result.nextAction.kind, "score_pending");
|
||||
const saved = flow.memory.savedCase();
|
||||
assert.equal(saved?.journeyProtocol, "dynamic-choice-v2");
|
||||
assert.equal(saved?.choiceAnswers.length, 1);
|
||||
assert.equal(saved?.choiceEvidence[0]?.partitionId, "window-a");
|
||||
assert.equal(saved?.dynamicControl.effectiveAnswerCount, 2);
|
||||
assert.equal(flow.jobs.count(), 1);
|
||||
});
|
||||
|
||||
test("new assessments return the persisted v2 generation turn", async () => {
|
||||
const memory = memoryStore();
|
||||
const service = createBirthTimeJourneyService({
|
||||
store: {
|
||||
...memory.store,
|
||||
async saveAssessment(value) {
|
||||
const initial = createInitialDynamicState(value.snapshot, "2026-07-18");
|
||||
const fixture = dynamicCase();
|
||||
memory.replaceCase({
|
||||
...fixture,
|
||||
userId: value.userId,
|
||||
snapshot: value.snapshot,
|
||||
questionnaire: value.questionnaire,
|
||||
dynamicTurnState: initial.turn,
|
||||
...initial.privateState,
|
||||
});
|
||||
return fixture.id;
|
||||
},
|
||||
},
|
||||
engine: {
|
||||
async scan() { return scanWithSigns(["Cancer", "Leo"]); },
|
||||
async score() { throw new Error("unexpected score"); },
|
||||
async scoreEvents() { throw new Error("unexpected event score"); },
|
||||
async buildDifferencePacket() { throw new Error("unexpected packet"); },
|
||||
async scoreChoices() { throw new Error("unexpected choice score"); },
|
||||
},
|
||||
});
|
||||
|
||||
const result = await service.assess(ownerId, approximateAssessment);
|
||||
|
||||
assert.equal(result.journeyProtocol, "dynamic-choice-v2");
|
||||
assert.equal(result.nextAction.kind, "generate_dynamic_question");
|
||||
assert.equal(result.turnVersion, 0);
|
||||
});
|
||||
|
||||
test("generation returns an engine packet and commits one persisted question", async () => {
|
||||
const initial = {
|
||||
...dynamicCase(),
|
||||
eventContext: { birthDate: "1993-04-17", lat: 31.23, lon: 121.47, tz: 8 },
|
||||
currentChoiceQuestion: null,
|
||||
dynamicTurnState: {
|
||||
...dynamicCase().dynamicTurnState,
|
||||
nextAction: { kind: "generate_dynamic_question" as const },
|
||||
},
|
||||
};
|
||||
const flow = dynamicFlow(initial);
|
||||
const command = {
|
||||
caseId: initial.id,
|
||||
actionId,
|
||||
turnVersion: 7,
|
||||
unmatchedNote: null,
|
||||
};
|
||||
const build = await flow.service.generateDynamicQuestion(ownerId, command);
|
||||
const nextQuestion = {
|
||||
...persistedQuestion,
|
||||
questionId: "af34edbf-b4b0-4ebf-9a07-5c177bc73add",
|
||||
opportunityId: "next-opportunity",
|
||||
questionFingerprint: "next-question-fingerprint",
|
||||
candidatePartitionFingerprint: "next-partition-fingerprint",
|
||||
};
|
||||
const committed = await flow.service.commitDynamicQuestion(
|
||||
ownerId,
|
||||
command,
|
||||
nextQuestion,
|
||||
);
|
||||
|
||||
assert.equal(build.packet.caseId, differenceBuild.packet.caseId);
|
||||
assert.equal(committed.nextAction.kind, "ask_dynamic_choice");
|
||||
assert.equal(flow.memory.savedCase()?.currentChoiceQuestion?.questionId, nextQuestion.questionId);
|
||||
assert.deepEqual(flow.memory.savedCase()?.dynamicControl?.questionFingerprints, [
|
||||
persistedQuestion.questionFingerprint,
|
||||
nextQuestion.questionFingerprint,
|
||||
]);
|
||||
});
|
||||
|
||||
test("unmatched context is trimmed separately and generates without scoring", async () => {
|
||||
const flow = dynamicFlow();
|
||||
const unmatched = persistedQuestion.options.find((option) => option.kind === "unmatched");
|
||||
if (!unmatched) throw new Error("missing unmatched option");
|
||||
const clarification = await flow.service.answerDynamicChoice(ownerId, {
|
||||
caseId: dynamicCase().id,
|
||||
actionId,
|
||||
turnVersion: 7,
|
||||
questionId: persistedQuestion.questionId,
|
||||
optionId: unmatched.optionId,
|
||||
});
|
||||
const reframed = await flow.service.submitUnmatchedContext(ownerId, {
|
||||
caseId: dynamicCase().id,
|
||||
actionId: secondActionId,
|
||||
turnVersion: clarification.turnVersion,
|
||||
questionId: persistedQuestion.questionId,
|
||||
note: " 更像是 2017 年 ",
|
||||
});
|
||||
|
||||
assert.equal(reframed.nextAction.kind, "generate_dynamic_question");
|
||||
assert.deepEqual(flow.memory.savedCase()?.agentContext, ["用户只记得大概阶段", "更像是 2017 年"]);
|
||||
assert.equal(flow.memory.savedCase()?.currentChoiceQuestion, null);
|
||||
assert.deepEqual(flow.memory.savedCase()?.choiceEvidence, []);
|
||||
assert.equal(flow.jobs.count(), 0);
|
||||
});
|
||||
|
||||
test("pause and resume restore the exact persisted question", async () => {
|
||||
const flow = dynamicFlow();
|
||||
const paused = await flow.service.pauseDynamic(ownerId, dynamicCase().id, actionId, 7);
|
||||
const resumed = await flow.service.resumeDynamic(ownerId, dynamicCase().id);
|
||||
|
||||
assert.equal(paused.nextAction.kind, "paused");
|
||||
assert.deepEqual(resumed.nextAction, dynamicCase().dynamicTurnState.nextAction);
|
||||
assert.equal(flow.memory.savedCase()?.dynamicControl?.pausedAction, null);
|
||||
assert.equal(resumed.turnVersion, 9);
|
||||
});
|
||||
|
||||
test("a stale or forged option cannot affect private evidence", async () => {
|
||||
const primary = persistedQuestion.options.find((option) => option.kind === "primary");
|
||||
if (!primary) throw new Error("missing primary option");
|
||||
for (const command of [
|
||||
{ turnVersion: 6, optionId: primary.optionId },
|
||||
{ turnVersion: 7, optionId: "forged-option" },
|
||||
]) {
|
||||
const flow = dynamicFlow();
|
||||
await assert.rejects(
|
||||
flow.service.answerDynamicChoice(ownerId, {
|
||||
caseId: dynamicCase().id,
|
||||
actionId,
|
||||
questionId: persistedQuestion.questionId,
|
||||
...command,
|
||||
}),
|
||||
StaleJourneyTurnError,
|
||||
);
|
||||
assert.deepEqual(flow.memory.savedCase()?.choiceEvidence, []);
|
||||
}
|
||||
});
|
||||
|
||||
test("unknown and unmatched increment only answered count and never score", async () => {
|
||||
for (const kind of ["unknown", "unmatched"] as const) {
|
||||
const flow = dynamicFlow();
|
||||
const option = persistedQuestion.options.find((candidate) => candidate.kind === kind);
|
||||
if (!option) throw new Error("missing special option");
|
||||
const result = await flow.service.answerDynamicChoice(ownerId, {
|
||||
caseId: dynamicCase().id,
|
||||
actionId,
|
||||
turnVersion: 7,
|
||||
questionId: persistedQuestion.questionId,
|
||||
optionId: option.optionId,
|
||||
});
|
||||
|
||||
assert.equal(result.progress.answeredCount, 2);
|
||||
assert.equal(result.progress.effectiveAnswerCount, 1);
|
||||
assert.deepEqual(flow.memory.savedCase()?.choiceEvidence, []);
|
||||
assert.equal(flow.jobs.count(), 0);
|
||||
assert.equal(result.nextAction.kind, kind === "unknown"
|
||||
? "generate_dynamic_question"
|
||||
: "clarify_unmatched_answer");
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
import type {
|
||||
BirthTimeJourneyStore,
|
||||
DynamicStoredRectificationCase,
|
||||
} from "../src/lib/birth-time-journey-service.ts";
|
||||
import type { DynamicScoringJobSpec } from "../src/lib/birth-time-scoring-job.ts";
|
||||
import { BirthTimeScoringJobError } from "../src/lib/birth-time-scoring-job.ts";
|
||||
import { StaleJourneyTurnError } from "../src/lib/birth-time-journey-turn-persistence.ts";
|
||||
|
||||
type DynamicMemoryJob = DynamicScoringJobSpec & {
|
||||
readonly caseId: string;
|
||||
readonly userId: string;
|
||||
readonly status: "pending" | "processing" | "completed" | "failed";
|
||||
};
|
||||
|
||||
export function dynamicJobStore(
|
||||
base: BirthTimeJourneyStore,
|
||||
read: () => DynamicStoredRectificationCase | null,
|
||||
) {
|
||||
const jobs = new Map<string, DynamicMemoryJob>();
|
||||
const store: BirthTimeJourneyStore = {
|
||||
...base,
|
||||
async createDynamicScoringJob(value, expectedVersion, actionId, _questionId, spec) {
|
||||
const current = read();
|
||||
if (!current) throw new BirthTimeScoringJobError("unavailable");
|
||||
const receipt = actionId.toLowerCase();
|
||||
if (current.processedActionIds.includes(receipt)) return current;
|
||||
if (current.turnVersion !== expectedVersion) {
|
||||
throw new StaleJourneyTurnError(value.id, expectedVersion, current.turnVersion);
|
||||
}
|
||||
jobs.set(spec.jobId, {
|
||||
...spec,
|
||||
caseId: value.id,
|
||||
userId: value.userId,
|
||||
status: "pending",
|
||||
});
|
||||
return base.saveDynamicTurn(value, expectedVersion, receipt);
|
||||
},
|
||||
async claimDynamicScoringJob(identity) {
|
||||
const job = jobs.get(identity.jobId);
|
||||
if (!job || job.caseId !== identity.caseId || job.userId !== identity.userId
|
||||
|| job.evidenceFingerprint !== identity.evidenceFingerprint) {
|
||||
throw new BirthTimeScoringJobError("unavailable");
|
||||
}
|
||||
if (job.algorithmVersion !== identity.algorithmVersion) {
|
||||
throw new BirthTimeScoringJobError("algorithm_mismatch");
|
||||
}
|
||||
if (job.status === "completed") {
|
||||
return { kind: "completed", algorithmVersion: job.algorithmVersion };
|
||||
}
|
||||
if (job.status === "processing") {
|
||||
return { kind: "processing", algorithmVersion: job.algorithmVersion };
|
||||
}
|
||||
jobs.set(job.jobId, { ...job, status: "processing" });
|
||||
return { kind: "claimed", algorithmVersion: job.algorithmVersion };
|
||||
},
|
||||
async completeDynamicScoringJob(value, command) {
|
||||
const job = jobs.get(command.jobId);
|
||||
if (!job || job.status !== "processing") {
|
||||
throw new BirthTimeScoringJobError("invalid_turn");
|
||||
}
|
||||
const saved = await base.completeDynamicScoringJob(value, command);
|
||||
jobs.set(job.jobId, { ...job, status: "completed" });
|
||||
return saved;
|
||||
},
|
||||
async failDynamicScoringJob(value, command) {
|
||||
const job = jobs.get(command.jobId);
|
||||
if (!job || job.status !== "processing") {
|
||||
throw new BirthTimeScoringJobError("invalid_turn");
|
||||
}
|
||||
const saved = await base.failDynamicScoringJob(value, command);
|
||||
jobs.set(job.jobId, { ...job, status: "failed" });
|
||||
return saved;
|
||||
},
|
||||
};
|
||||
return {
|
||||
store,
|
||||
count: () => jobs.size,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { createDynamicScoringJobStore } from "../src/lib/birth-time-dynamic-scoring-job-store.ts";
|
||||
import { createDynamicScoringJobSpec } from "../src/lib/birth-time-scoring-job.ts";
|
||||
import { answerTransition } from "../src/lib/birth-time-dynamic-transitions.ts";
|
||||
import type { DynamicStoredRectificationCase } from "../src/lib/birth-time-journey-service.ts";
|
||||
import {
|
||||
dynamicCase,
|
||||
ownerId,
|
||||
persistedQuestion,
|
||||
} from "./birth-time-dynamic-persistence-fixture.ts";
|
||||
|
||||
const actionId = "ab2d936b-5ce7-45d8-a0fb-33f48f960f36";
|
||||
|
||||
function freshCase(): DynamicStoredRectificationCase {
|
||||
const stored = dynamicCase();
|
||||
return {
|
||||
...stored,
|
||||
eventContext: { birthDate: "1993-04-17", lat: 31.23, lon: 121.47, tz: 8 },
|
||||
dynamicControl: {
|
||||
...stored.dynamicControl,
|
||||
answeredCount: 0,
|
||||
effectiveAnswerCount: 0,
|
||||
},
|
||||
dynamicTurnState: {
|
||||
...stored.dynamicTurnState,
|
||||
progress: {
|
||||
...stored.dynamicTurnState.progress,
|
||||
answeredCount: 0,
|
||||
effectiveAnswerCount: 0,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test("dynamic job store sends exact private create and typed claim RPCs", async () => {
|
||||
const jobId = "85b22d7e-3adc-473d-81e1-6ad29e9b06f4";
|
||||
const now = new Date("2026-07-18T08:00:00.000Z");
|
||||
const option = persistedQuestion.options.find((candidate) => candidate.kind === "primary");
|
||||
if (!option) throw new Error("missing primary option");
|
||||
const pending = answerTransition({
|
||||
stored: freshCase(),
|
||||
option,
|
||||
answeredAt: now.toISOString(),
|
||||
jobId,
|
||||
nextVersion: 8,
|
||||
});
|
||||
const spec = createDynamicScoringJobSpec(jobId, pending.choiceEvidence, now);
|
||||
let loaded = freshCase();
|
||||
const calls: { readonly name: string; readonly args: Readonly<Record<string, unknown>> }[] = [];
|
||||
const store = createDynamicScoringJobStore({
|
||||
async rpc(name, args) {
|
||||
calls.push({ name, args });
|
||||
if (name === "create_birth_time_dynamic_scoring_job") {
|
||||
loaded = {
|
||||
...pending,
|
||||
turnVersion: 8,
|
||||
dynamicTurnState: { ...pending.dynamicTurnState, turnVersion: 8 },
|
||||
processedActionIds: [actionId],
|
||||
};
|
||||
return { data: 8, error: null };
|
||||
}
|
||||
return {
|
||||
data: [{
|
||||
claim_state: "claimed",
|
||||
algorithm_version: "birth-time-choice-scoring-v2",
|
||||
}],
|
||||
error: null,
|
||||
};
|
||||
},
|
||||
}, async () => loaded);
|
||||
|
||||
const created = await store.createDynamicScoringJob(
|
||||
pending,
|
||||
7,
|
||||
actionId,
|
||||
persistedQuestion.questionId,
|
||||
spec,
|
||||
);
|
||||
const claim = await store.claimDynamicScoringJob({
|
||||
userId: ownerId,
|
||||
caseId: pending.id,
|
||||
jobId,
|
||||
evidenceFingerprint: spec.evidenceFingerprint,
|
||||
algorithmVersion: spec.algorithmVersion,
|
||||
now: now.toISOString(),
|
||||
});
|
||||
|
||||
assert.equal(created.turnVersion, 8);
|
||||
assert.deepEqual(claim, {
|
||||
kind: "claimed",
|
||||
algorithmVersion: "birth-time-choice-scoring-v2",
|
||||
});
|
||||
assert.deepEqual(calls.map((call) => call.name), [
|
||||
"create_birth_time_dynamic_scoring_job",
|
||||
"claim_birth_time_dynamic_scoring_job",
|
||||
]);
|
||||
assert.equal(calls[0]?.args.p_question_id, persistedQuestion.questionId);
|
||||
assert.equal(JSON.stringify(calls[0]?.args.p_public_turn_state).includes("candidateScores"), false);
|
||||
assert.deepEqual(
|
||||
Reflect.get(calls[0]?.args.p_private_state ?? {}, "choiceEvidence"),
|
||||
pending.choiceEvidence,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,229 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import {
|
||||
completeDynamicScoreTransition,
|
||||
} from "../src/lib/birth-time-dynamic-transitions.ts";
|
||||
import type { CandidateResult } from "../src/lib/birth-time-evidence.ts";
|
||||
import { createBirthTimeJourneyService } from "../src/lib/birth-time-journey-service.ts";
|
||||
import {
|
||||
dynamicCase,
|
||||
ownerId,
|
||||
persistedQuestion,
|
||||
} from "./birth-time-dynamic-persistence-fixture.ts";
|
||||
import { memoryStore } from "./birth-time-journey-memory-store.ts";
|
||||
import { dynamicJobStore } from "./birth-time-dynamic-job-memory-store.ts";
|
||||
|
||||
const lowCandidate: CandidateResult = {
|
||||
resultId: "11111111-1111-4111-8111-111111111111",
|
||||
confidence: "low",
|
||||
canApply: false,
|
||||
winningSegment: null,
|
||||
eventCount: 1,
|
||||
domainCount: 1,
|
||||
topScore: 10,
|
||||
secondScore: 9,
|
||||
marginPercent: 10,
|
||||
reasons: ["close"],
|
||||
evidence: [],
|
||||
algorithmVersion: "birth-time-choice-scoring-v2",
|
||||
};
|
||||
|
||||
const actionId = "ab2d936b-5ce7-45d8-a0fb-33f48f960f36";
|
||||
|
||||
function freshDynamicCase(candidateResult: CandidateResult | null = null) {
|
||||
const stored = dynamicCase();
|
||||
return {
|
||||
...stored,
|
||||
eventContext: {
|
||||
birthDate: "1993-04-17",
|
||||
lat: 31.23,
|
||||
lon: 121.47,
|
||||
tz: 8,
|
||||
},
|
||||
candidateResult,
|
||||
dynamicControl: {
|
||||
...stored.dynamicControl,
|
||||
answeredCount: 0,
|
||||
effectiveAnswerCount: 0,
|
||||
plateauCount: candidateResult === null ? 0 : 1,
|
||||
},
|
||||
dynamicTurnState: {
|
||||
...stored.dynamicTurnState,
|
||||
progress: {
|
||||
...stored.dynamicTurnState.progress,
|
||||
answeredCount: 0,
|
||||
effectiveAnswerCount: 0,
|
||||
plateauCount: candidateResult === null ? 0 : 1,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function scoringFlow(input: {
|
||||
readonly candidate?: CandidateResult;
|
||||
readonly initialCandidate?: CandidateResult | null;
|
||||
readonly failOnce?: boolean;
|
||||
} = {}) {
|
||||
const initial = freshDynamicCase(input.initialCandidate ?? null);
|
||||
const memory = memoryStore(initial);
|
||||
const jobs = dynamicJobStore(memory.store, () => {
|
||||
const value = memory.savedCase();
|
||||
return value?.journeyProtocol === "dynamic-choice-v2" ? value : null;
|
||||
});
|
||||
let scoreCalls = 0;
|
||||
let shouldFail = input.failOnce ?? false;
|
||||
const candidate = input.candidate ?? lowCandidate;
|
||||
const service = createBirthTimeJourneyService({
|
||||
store: jobs.store,
|
||||
engine: {
|
||||
async scan() { throw new Error("unexpected scan"); },
|
||||
async score() { throw new Error("unexpected score"); },
|
||||
async scoreEvents() { throw new Error("unexpected event score"); },
|
||||
async scoreChoices() {
|
||||
scoreCalls += 1;
|
||||
if (shouldFail) {
|
||||
shouldFail = false;
|
||||
throw new TypeError("offline");
|
||||
}
|
||||
return {
|
||||
candidate,
|
||||
evidenceMode: "dynamic_choice" as const,
|
||||
effectiveAnswerCount: 1,
|
||||
dimensionCount: 1,
|
||||
};
|
||||
},
|
||||
async buildDifferencePacket(value) {
|
||||
return {
|
||||
packet: {
|
||||
caseId: value.caseId,
|
||||
scoringVersion: "birth-time-choice-scoring-v2" as const,
|
||||
currentRange: { startTime: value.startTime, endTime: value.endTime },
|
||||
opportunities: [{
|
||||
opportunityId: "next-opportunity",
|
||||
dimensionCode: "relocation_change",
|
||||
neutralContext: "一次居住变化",
|
||||
estimatedInformationGain: 0.5,
|
||||
candidatePartitionFingerprint: "next-partition",
|
||||
fallbackPrompt: "哪一段更接近一次居住变化?",
|
||||
partitions: [
|
||||
{ partitionId: "early", descriptor: "early", fallbackLabel: "较早" },
|
||||
{ partitionId: "late", descriptor: "late", fallbackLabel: "较晚" },
|
||||
],
|
||||
}],
|
||||
askedQuestionFingerprints: value.questionFingerprints,
|
||||
candidatePartitionFingerprints: value.partitionFingerprints,
|
||||
recentRangeHistory: value.recentRanges,
|
||||
},
|
||||
candidateModel: { version: "after-score" },
|
||||
scoringPartitions: {},
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
return { memory, jobs, service, scoreCalls: () => scoreCalls };
|
||||
}
|
||||
|
||||
test("score completion continues only when stop policy allows it", () => {
|
||||
const stored = dynamicCase();
|
||||
const result = completeDynamicScoreTransition({
|
||||
stored: { ...stored, currentChoiceQuestion: null },
|
||||
candidate: lowCandidate,
|
||||
usefulOpportunityCount: 1,
|
||||
repeatedOnly: false,
|
||||
nextVersion: 8,
|
||||
});
|
||||
|
||||
assert.equal(result.dynamicTurnState.nextAction.kind, "generate_dynamic_question");
|
||||
assert.equal(result.dynamicControl.plateauCount, 0);
|
||||
});
|
||||
|
||||
test("high confidence requires explicit confirmation without applying a time", () => {
|
||||
const stored = dynamicCase();
|
||||
const candidate = {
|
||||
...lowCandidate,
|
||||
resultId: "097b7b4c-60f3-4ed8-b290-64b2084182e7",
|
||||
confidence: "high" as const,
|
||||
canApply: true,
|
||||
winningSegment: {
|
||||
startTime: "05:10",
|
||||
endTime: "05:12",
|
||||
representativeTime: "05:11",
|
||||
widthMinutes: 2,
|
||||
},
|
||||
};
|
||||
const result = completeDynamicScoreTransition({
|
||||
stored: { ...stored, currentChoiceQuestion: null },
|
||||
candidate,
|
||||
usefulOpportunityCount: 1,
|
||||
repeatedOnly: false,
|
||||
nextVersion: 8,
|
||||
});
|
||||
|
||||
assert.deepEqual(result.dynamicTurnState.nextAction, {
|
||||
kind: "request_candidate_confirmation",
|
||||
resultId: candidate.resultId,
|
||||
});
|
||||
assert.equal(result.snapshot.activeTime, null);
|
||||
assert.equal(result.dynamicTurnState.permissions.canConfirmCandidate, true);
|
||||
});
|
||||
|
||||
test("dynamic scoring claims once, completes atomically, and replays", async () => {
|
||||
const flow = scoringFlow();
|
||||
const pending = await flow.service.answerDynamicChoice(ownerId, {
|
||||
caseId: dynamicCase().id,
|
||||
actionId,
|
||||
turnVersion: 7,
|
||||
questionId: persistedQuestion.questionId,
|
||||
optionId: persistedQuestion.options[0].optionId,
|
||||
});
|
||||
if (pending.nextAction.kind !== "score_pending") throw new Error("expected pending score");
|
||||
|
||||
const first = await flow.service.pollDynamicScoringJob(ownerId, dynamicCase().id, pending.nextAction.jobId);
|
||||
const replay = await flow.service.pollDynamicScoringJob(ownerId, dynamicCase().id, pending.nextAction.jobId);
|
||||
|
||||
assert.equal(first.nextAction.kind, "generate_dynamic_question");
|
||||
assert.deepEqual(replay.nextAction, first.nextAction);
|
||||
assert.equal(flow.scoreCalls(), 1);
|
||||
assert.deepEqual(flow.memory.savedCase()?.candidateModel, { version: "after-score" });
|
||||
});
|
||||
|
||||
test("dynamic scoring failure retries the same job without duplicating evidence", async () => {
|
||||
const flow = scoringFlow({ failOnce: true });
|
||||
const pending = await flow.service.answerDynamicChoice(ownerId, {
|
||||
caseId: dynamicCase().id,
|
||||
actionId,
|
||||
turnVersion: 7,
|
||||
questionId: persistedQuestion.questionId,
|
||||
optionId: persistedQuestion.options[0].optionId,
|
||||
});
|
||||
if (pending.nextAction.kind !== "score_pending") throw new Error("expected pending score");
|
||||
const jobId = pending.nextAction.jobId;
|
||||
|
||||
const failed = await flow.service.pollDynamicScoringJob(ownerId, dynamicCase().id, jobId);
|
||||
const completed = await flow.service.pollDynamicScoringJob(ownerId, dynamicCase().id, jobId);
|
||||
|
||||
assert.deepEqual(failed.nextAction, { kind: "retry_scoring", jobId });
|
||||
assert.equal(completed.nextAction.kind, "generate_dynamic_question");
|
||||
assert.equal(flow.memory.savedCase()?.choiceEvidence?.length, 1);
|
||||
assert.equal(flow.memory.savedCase()?.dynamicControl?.effectiveAnswerCount, 1);
|
||||
assert.equal(flow.scoreCalls(), 2);
|
||||
});
|
||||
|
||||
test("the second plateau is terminal and resume stays terminal", async () => {
|
||||
const medium = { ...lowCandidate, confidence: "medium" as const };
|
||||
const flow = scoringFlow({ initialCandidate: medium, candidate: medium });
|
||||
const pending = await flow.service.answerDynamicChoice(ownerId, {
|
||||
caseId: dynamicCase().id,
|
||||
actionId,
|
||||
turnVersion: 7,
|
||||
questionId: persistedQuestion.questionId,
|
||||
optionId: persistedQuestion.options[0].optionId,
|
||||
});
|
||||
if (pending.nextAction.kind !== "score_pending") throw new Error("expected pending score");
|
||||
|
||||
const terminal = await flow.service.pollDynamicScoringJob(ownerId, dynamicCase().id, pending.nextAction.jobId);
|
||||
const resumed = await flow.service.resumeDynamic(ownerId, dynamicCase().id);
|
||||
|
||||
assert.equal(terminal.nextAction.kind, "present_medium_result");
|
||||
assert.deepEqual(resumed.nextAction, terminal.nextAction);
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { createBirthTimeJourneyService } from "../src/lib/birth-time-journey-service.ts";
|
||||
import { BirthTimeDynamicActionError } from "../src/lib/birth-time-dynamic-actions.ts";
|
||||
import type { DynamicStoredRectificationCase } from "../src/lib/birth-time-journey-service.ts";
|
||||
import { dynamicCase, ownerId, persistedQuestion } from "./birth-time-dynamic-persistence-fixture.ts";
|
||||
import { memoryStore } from "./birth-time-journey-memory-store.ts";
|
||||
|
||||
const actionId = "38dd8315-7d6f-4af8-b2e4-a4062926f5ca";
|
||||
|
||||
function terminalCase() {
|
||||
const stored = dynamicCase();
|
||||
return {
|
||||
...stored,
|
||||
currentChoiceQuestion: null,
|
||||
dynamicTurnState: {
|
||||
...stored.dynamicTurnState,
|
||||
nextAction: { kind: "present_medium_result" as const, resultId: "result-1" },
|
||||
progress: { ...stored.dynamicTurnState.progress, phase: "result" as const },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function journeyFlow(initial: DynamicStoredRectificationCase = terminalCase()) {
|
||||
const memory = memoryStore(initial);
|
||||
const service = createBirthTimeJourneyService({
|
||||
store: memory.store,
|
||||
engine: {
|
||||
async scan() { throw new Error("unexpected scan"); },
|
||||
async score() { throw new Error("unexpected score"); },
|
||||
async scoreEvents() { throw new Error("unexpected event score"); },
|
||||
async buildDifferencePacket() { throw new Error("unexpected packet"); },
|
||||
async scoreChoices() { throw new Error("unexpected choice score"); },
|
||||
},
|
||||
});
|
||||
return { memory, service };
|
||||
}
|
||||
|
||||
test("terminal resume returns the stored action byte-for-byte", async () => {
|
||||
const flow = journeyFlow();
|
||||
const resumed = await flow.service.resumeDynamic(ownerId, dynamicCase().id);
|
||||
|
||||
assert.deepEqual(resumed.nextAction, terminalCase().dynamicTurnState.nextAction);
|
||||
assert.equal(flow.memory.committedTurnWrites(), 0);
|
||||
});
|
||||
|
||||
test("terminal answer pause finish and generation commits are rejected", async () => {
|
||||
const operations = [
|
||||
(service: ReturnType<typeof journeyFlow>["service"]) => service.answerDynamicChoice(ownerId, {
|
||||
caseId: dynamicCase().id,
|
||||
actionId,
|
||||
turnVersion: 7,
|
||||
questionId: persistedQuestion.questionId,
|
||||
optionId: persistedQuestion.options[0].optionId,
|
||||
}),
|
||||
(service: ReturnType<typeof journeyFlow>["service"]) => service.pauseDynamic(ownerId, dynamicCase().id, actionId, 7),
|
||||
(service: ReturnType<typeof journeyFlow>["service"]) => service.finishDynamic(ownerId, dynamicCase().id, actionId, 7),
|
||||
(service: ReturnType<typeof journeyFlow>["service"]) => service.generateDynamicQuestion(ownerId, {
|
||||
caseId: dynamicCase().id,
|
||||
actionId,
|
||||
turnVersion: 7,
|
||||
unmatchedNote: null,
|
||||
}),
|
||||
(service: ReturnType<typeof journeyFlow>["service"]) => service.commitDynamicQuestion(ownerId, {
|
||||
caseId: dynamicCase().id,
|
||||
actionId,
|
||||
turnVersion: 7,
|
||||
unmatchedNote: null,
|
||||
}, persistedQuestion),
|
||||
];
|
||||
for (const operation of operations) {
|
||||
const flow = journeyFlow();
|
||||
await assert.rejects(operation(flow.service), BirthTimeDynamicActionError);
|
||||
assert.equal(flow.memory.committedTurnWrites(), 0);
|
||||
}
|
||||
});
|
||||
|
||||
test("explicit finish preserves the current range and cannot restart on resume", async () => {
|
||||
const initial = dynamicCase();
|
||||
const flow = journeyFlow(initial);
|
||||
const finished = await flow.service.finishDynamic(
|
||||
ownerId,
|
||||
initial.id,
|
||||
actionId,
|
||||
initial.turnVersion,
|
||||
);
|
||||
const resumed = await flow.service.resumeDynamic(ownerId, initial.id);
|
||||
|
||||
assert.deepEqual(finished.nextAction, { kind: "present_low_result", resultId: null });
|
||||
assert.deepEqual(finished.progress.currentRange, initial.dynamicTurnState.progress.currentRange);
|
||||
assert.deepEqual(resumed.nextAction, finished.nextAction);
|
||||
assert.equal(flow.memory.committedTurnWrites(), 1);
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
MIGRATION = (
|
||||
Path(__file__).resolve().parents[1]
|
||||
/ "frontend"
|
||||
/ "supabase"
|
||||
/ "migrations"
|
||||
/ "20260718094000_dynamic_choice_scoring_job_lifecycle.sql"
|
||||
)
|
||||
|
||||
|
||||
def _sql() -> str:
|
||||
assert MIGRATION.exists(), "dynamic scoring lifecycle migration is required"
|
||||
return " ".join(MIGRATION.read_text(encoding="utf-8").lower().split())
|
||||
|
||||
|
||||
def _function(sql: str, name: str, next_name: str | None = None) -> str:
|
||||
body = sql.split(f"create function public.{name}", 1)[1]
|
||||
marker = f"create function public.{next_name}" if next_name else "commit;"
|
||||
return body.split(marker, 1)[0]
|
||||
|
||||
|
||||
def test_dynamic_job_creation_is_atomic_private_and_replay_safe() -> None:
|
||||
sql = _sql()
|
||||
body = _function(
|
||||
sql,
|
||||
"create_birth_time_dynamic_scoring_job",
|
||||
"claim_birth_time_dynamic_scoring_job",
|
||||
)
|
||||
for invariant in (
|
||||
"security definer",
|
||||
"set search_path = ''",
|
||||
"journey_protocol = 'dynamic-choice-v2'",
|
||||
"for update",
|
||||
"p_action_id = any(v_case.processed_action_ids)",
|
||||
"p_question_id",
|
||||
"p_evidence_fingerprint",
|
||||
"p_algorithm_version",
|
||||
"insert into public.birth_time_rectification_scoring_jobs",
|
||||
"update public.birth_time_rectification_cases",
|
||||
"perform public.persist_birth_time_dynamic_private_state",
|
||||
):
|
||||
assert invariant in body
|
||||
assert "processed_action_ids" in body
|
||||
assert "'score_pending'" in body
|
||||
|
||||
|
||||
def test_dynamic_job_claim_locks_v2_and_validates_completed_replay() -> None:
|
||||
sql = _sql()
|
||||
body = _function(sql, "claim_birth_time_dynamic_scoring_job")
|
||||
for invariant in (
|
||||
"security definer",
|
||||
"set search_path = ''",
|
||||
"journey_protocol = 'dynamic-choice-v2'",
|
||||
"for update",
|
||||
"v_job.evidence_fingerprint is distinct from p_evidence_fingerprint",
|
||||
"v_job.algorithm_version is distinct from p_algorithm_version",
|
||||
"v_job.status = 'completed'",
|
||||
"v_case.candidate_result is distinct from v_job.result",
|
||||
"v_case.turn_state #>> '{nextaction,kind}'",
|
||||
"'processing'",
|
||||
"interval '60 seconds'",
|
||||
):
|
||||
assert invariant in body
|
||||
assert "claim_state text" in body
|
||||
assert "algorithm_version text" in body
|
||||
|
||||
|
||||
def test_dynamic_job_functions_are_service_role_only_and_reviewable() -> None:
|
||||
sql = _sql()
|
||||
for name in (
|
||||
"create_birth_time_dynamic_scoring_job",
|
||||
"claim_birth_time_dynamic_scoring_job",
|
||||
):
|
||||
assert f"revoke all on function public.{name}" in sql
|
||||
assert f"grant execute on function public.{name}" in sql
|
||||
pure_lines = [
|
||||
line for line in MIGRATION.read_text(encoding="utf-8").splitlines()
|
||||
if line.strip() and not line.lstrip().startswith("--")
|
||||
]
|
||||
assert len(pure_lines) <= 250
|
||||
Reference in New Issue
Block a user