feat: orchestrate dynamic rectification turns
This commit is contained in:
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user