From 7aa800b846e9015cbbd765680972bf17d737f042 Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Sun, 19 Jul 2026 08:25:33 +0800 Subject: [PATCH] feat: orchestrate dynamic rectification turns --- .superpowers/sdd/task-6-report.md | 83 ++++++ ...dynamic-choice-birth-time-rectification.md | 24 ++ .../src/app/api/birth-time-journey/route.ts | 10 +- .../src/lib/birth-time-dynamic-actions.ts | 264 ++++++++++++++++++ .../lib/birth-time-dynamic-choice-internal.ts | 26 +- .../lib/birth-time-dynamic-engine-input.ts | 41 +++ .../birth-time-dynamic-scoring-job-store.ts | 123 ++++++++ .../lib/birth-time-dynamic-scoring-service.ts | 140 ++++++++++ .../lib/birth-time-dynamic-service-methods.ts | 15 + .../src/lib/birth-time-dynamic-transitions.ts | 205 ++++++++++++++ .../src/lib/birth-time-journey-service.ts | 33 ++- frontend/src/lib/birth-time-journey-store.ts | 3 + frontend/src/lib/birth-time-scoring-job.ts | 51 ++++ ...0_dynamic_choice_scoring_job_lifecycle.sql | 162 +++++++++++ .../tests/birth-time-dynamic-actions.test.ts | 207 ++++++++++++++ .../birth-time-dynamic-job-memory-store.ts | 79 ++++++ .../birth-time-dynamic-scoring-store.test.ts | 104 +++++++ .../tests/birth-time-dynamic-scoring.test.ts | 229 +++++++++++++++ .../tests/birth-time-dynamic-terminal.test.ts | 93 ++++++ ...birth_time_dynamic_scoring_job_contract.py | 82 ++++++ 20 files changed, 1946 insertions(+), 28 deletions(-) create mode 100644 .superpowers/sdd/task-6-report.md create mode 100644 frontend/src/lib/birth-time-dynamic-actions.ts create mode 100644 frontend/src/lib/birth-time-dynamic-engine-input.ts create mode 100644 frontend/src/lib/birth-time-dynamic-scoring-job-store.ts create mode 100644 frontend/src/lib/birth-time-dynamic-scoring-service.ts create mode 100644 frontend/src/lib/birth-time-dynamic-service-methods.ts create mode 100644 frontend/src/lib/birth-time-dynamic-transitions.ts create mode 100644 frontend/supabase/migrations/20260718094000_dynamic_choice_scoring_job_lifecycle.sql create mode 100644 frontend/tests/birth-time-dynamic-actions.test.ts create mode 100644 frontend/tests/birth-time-dynamic-job-memory-store.ts create mode 100644 frontend/tests/birth-time-dynamic-scoring-store.test.ts create mode 100644 frontend/tests/birth-time-dynamic-scoring.test.ts create mode 100644 frontend/tests/birth-time-dynamic-terminal.test.ts create mode 100644 tests/test_birth_time_dynamic_scoring_job_contract.py diff --git a/.superpowers/sdd/task-6-report.md b/.superpowers/sdd/task-6-report.md new file mode 100644 index 00000000..48616063 --- /dev/null +++ b/.superpowers/sdd/task-6-report.md @@ -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` diff --git a/docs/superpowers/plans/2026-07-18-dynamic-choice-birth-time-rectification.md b/docs/superpowers/plans/2026-07-18-dynamic-choice-birth-time-rectification.md index b177cb73..dc5608fd 100644 --- a/docs/superpowers/plans/2026-07-18-dynamic-choice-birth-time-rectification.md +++ b/docs/superpowers/plans/2026-07-18-dynamic-choice-birth-time-rectification.md @@ -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. diff --git a/frontend/src/app/api/birth-time-journey/route.ts b/frontend/src/app/api/birth-time-journey/route.ts index 6d79cd43..fb7e0532 100644 --- a/frontend/src/app/api/birth-time-journey/route.ts +++ b/frontend/src/app/api/birth-time-journey/route.ts @@ -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 { } async function responseWithJourneyMetric( - action: Promise, + action: Promise, name: Extract, ): Promise { 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); } diff --git a/frontend/src/lib/birth-time-dynamic-actions.ts b/frontend/src/lib/birth-time-dynamic-actions.ts new file mode 100644 index 00000000..3736a035 --- /dev/null +++ b/frontend/src/lib/birth-time-dynamic-actions.ts @@ -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; +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 { + 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)); + }, + }; +} diff --git a/frontend/src/lib/birth-time-dynamic-choice-internal.ts b/frontend/src/lib/birth-time-dynamic-choice-internal.ts index 0bcf8129..97bc52ed 100644 --- a/frontend/src/lib/birth-time-dynamic-choice-internal.ts +++ b/frontend/src/lib/birth-time-dynamic-choice-internal.ts @@ -51,7 +51,23 @@ export type CandidateDifferenceBuild = { readonly scoringPartitions: Readonly>; }; -export type PersistedDynamicChoiceQuestion = PublicDynamicChoiceQuestion & { +export type PersistedDynamicChoiceOption = + | { + readonly optionId: string; + readonly label: string; + readonly kind: "primary"; + readonly partitionId: string; + readonly candidateScores: Readonly>; + } + | { + readonly optionId: string; + readonly label: string; + readonly kind: "unknown" | "unmatched"; + readonly partitionId: null; + readonly candidateScores: null; + }; + +export type PersistedDynamicChoiceQuestion = Omit & { 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> | null; - }[]; + readonly options: readonly PersistedDynamicChoiceOption[]; }; export type StoredChoiceAnswer = { diff --git a/frontend/src/lib/birth-time-dynamic-engine-input.ts b/frontend/src/lib/birth-time-dynamic-engine-input.ts new file mode 100644 index 00000000..c4632181 --- /dev/null +++ b/frontend/src/lib/birth-time-dynamic-engine-input.ts @@ -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, + }; +} diff --git a/frontend/src/lib/birth-time-dynamic-scoring-job-store.ts b/frontend/src/lib/birth-time-dynamic-scoring-job-store.ts new file mode 100644 index 00000000..21dfa2c4 --- /dev/null +++ b/frontend/src/lib/birth-time-dynamic-scoring-job-store.ts @@ -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>, + ) => PromiseLike; +}; + +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>; + +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, + userId: string, + caseId: string, +): Promise { + 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, +): 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, + }; + }, + }; +} diff --git a/frontend/src/lib/birth-time-dynamic-scoring-service.ts b/frontend/src/lib/birth-time-dynamic-scoring-service.ts new file mode 100644 index 00000000..a5b58a05 --- /dev/null +++ b/frontend/src/lib/birth-time-dynamic-scoring-service.ts @@ -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 { + if (!("buildDifferencePacket" in ports.engine) || !("scoreChoices" in ports.engine)) { + throw new BirthTimeScoringJobError("unavailable"); + } + return ports.engine; +} + +function requirePending( + value: Awaited>, + 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>, +): 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); + }, + }; +} diff --git a/frontend/src/lib/birth-time-dynamic-service-methods.ts b/frontend/src/lib/birth-time-dynamic-service-methods.ts new file mode 100644 index 00000000..56d17819 --- /dev/null +++ b/frontend/src/lib/birth-time-dynamic-service-methods.ts @@ -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, + }; +} diff --git a/frontend/src/lib/birth-time-dynamic-transitions.ts b/frontend/src/lib/birth-time-dynamic-transitions.ts new file mode 100644 index 00000000..0a8a5261 --- /dev/null +++ b/frontend/src/lib/birth-time-dynamic-transitions.ts @@ -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([ + "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>; +}): 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" }, + }, + }; +} diff --git a/frontend/src/lib/birth-time-journey-service.ts b/frontend/src/lib/birth-time-journey-service.ts index c46dea0d..cdb90fee 100644 --- a/frontend/src/lib/birth-time-journey-service.ts +++ b/frontend/src/lib/birth-time-journey-service.ts @@ -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> | null; }; -export type DynamicChoiceScoreInput = Pick; +export type DynamicChoiceScoreInput = Pick; export interface BirthTimeJourneyEngine { scan(input: JourneyScanInput): Promise<{ readonly questionnaire: RectificationQuestionnaire }>; @@ -98,9 +97,7 @@ export interface BirthTimeJourneyEngine { scoreChoices(input: DynamicChoiceScoreInput): Promise; } -export type LegacyBirthTimeJourneyEngine = Pick; +export type LegacyBirthTimeJourneyEngine = Pick; 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; @@ -154,6 +147,8 @@ export interface BirthTimeJourneyStore { saveDynamicTurn(value: DynamicStoredRectificationCase, expectedVersion: number, actionId: string): Promise; completeDynamicScoringJob(value: DynamicStoredRectificationCase, command: DynamicScoringJobCommand): Promise; failDynamicScoringJob(value: DynamicStoredRectificationCase, command: DynamicScoringJobFailureCommand): Promise; + createDynamicScoringJob?(value: DynamicStoredRectificationCase, expectedVersion: number, actionId: string, questionId: string, job: DynamicScoringJobSpec): Promise; + claimDynamicScoringJob?(identity: DynamicScoringJobIdentity): Promise; upgradeLegacyActiveCase(value: LegacyStoredRectificationCase): Promise; createScoringJob(value: LegacyStoredRectificationCase, expectedVersion: number, actionId: string, job: ScoringJobSpec): Promise; claimScoringJob(identity: ScoringJobIdentity): Promise; @@ -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 { + async assess(userId: string, assessment: BirthTimeAssessment): Promise { 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, }; } diff --git a/frontend/src/lib/birth-time-journey-store.ts b/frontend/src/lib/birth-time-journey-store.ts index d4020351..1f5571c8 100644 --- a/frontend/src/lib/birth-time-journey-store.ts +++ b/frontend/src/lib/birth-time-journey-store.ts @@ -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) { diff --git a/frontend/src/lib/birth-time-scoring-job.ts b/frontend/src/lib/birth-time-scoring-job.ts index 2d6ebb24..44df2a66 100644 --- a/frontend/src/lib/birth-time-scoring-job.ts +++ b/frontend/src/lib/birth-time-scoring-job.ts @@ -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 & { + 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(), + }; +} diff --git a/frontend/supabase/migrations/20260718094000_dynamic_choice_scoring_job_lifecycle.sql b/frontend/supabase/migrations/20260718094000_dynamic_choice_scoring_job_lifecycle.sql new file mode 100644 index 00000000..6288f2f4 --- /dev/null +++ b/frontend/supabase/migrations/20260718094000_dynamic_choice_scoring_job_lifecycle.sql @@ -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; diff --git a/frontend/tests/birth-time-dynamic-actions.test.ts b/frontend/tests/birth-time-dynamic-actions.test.ts new file mode 100644 index 00000000..62080f2c --- /dev/null +++ b/frontend/tests/birth-time-dynamic-actions.test.ts @@ -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"); + } +}); diff --git a/frontend/tests/birth-time-dynamic-job-memory-store.ts b/frontend/tests/birth-time-dynamic-job-memory-store.ts new file mode 100644 index 00000000..5245b18b --- /dev/null +++ b/frontend/tests/birth-time-dynamic-job-memory-store.ts @@ -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(); + 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, + }; +} diff --git a/frontend/tests/birth-time-dynamic-scoring-store.test.ts b/frontend/tests/birth-time-dynamic-scoring-store.test.ts new file mode 100644 index 00000000..a23f5021 --- /dev/null +++ b/frontend/tests/birth-time-dynamic-scoring-store.test.ts @@ -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> }[] = []; + 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, + ); +}); diff --git a/frontend/tests/birth-time-dynamic-scoring.test.ts b/frontend/tests/birth-time-dynamic-scoring.test.ts new file mode 100644 index 00000000..903ac217 --- /dev/null +++ b/frontend/tests/birth-time-dynamic-scoring.test.ts @@ -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); +}); diff --git a/frontend/tests/birth-time-dynamic-terminal.test.ts b/frontend/tests/birth-time-dynamic-terminal.test.ts new file mode 100644 index 00000000..5249c7b2 --- /dev/null +++ b/frontend/tests/birth-time-dynamic-terminal.test.ts @@ -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["service"]) => service.answerDynamicChoice(ownerId, { + caseId: dynamicCase().id, + actionId, + turnVersion: 7, + questionId: persistedQuestion.questionId, + optionId: persistedQuestion.options[0].optionId, + }), + (service: ReturnType["service"]) => service.pauseDynamic(ownerId, dynamicCase().id, actionId, 7), + (service: ReturnType["service"]) => service.finishDynamic(ownerId, dynamicCase().id, actionId, 7), + (service: ReturnType["service"]) => service.generateDynamicQuestion(ownerId, { + caseId: dynamicCase().id, + actionId, + turnVersion: 7, + unmatchedNote: null, + }), + (service: ReturnType["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); +}); diff --git a/tests/test_birth_time_dynamic_scoring_job_contract.py b/tests/test_birth_time_dynamic_scoring_job_contract.py new file mode 100644 index 00000000..f51655de --- /dev/null +++ b/tests/test_birth_time_dynamic_scoring_job_contract.py @@ -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