Files
Jyotisha/frontend/tests/birth-time-agent-flow-test-support.ts
T
2026-07-19 14:48:02 +08:00

248 lines
9.5 KiB
TypeScript

import assert from "node:assert/strict";
import { z } from "zod";
import { createBirthTimeGuideService } from "../src/lib/birth-time-guide-service.ts";
import type { BirthTimeGuideGenerator } from "../src/lib/birth-time-guide-agent.ts";
import { candidateResultSchema } from "../src/lib/birth-time-evidence.ts";
import { createGuidedCandidateActions } from "../src/lib/birth-time-guided-candidate.ts";
import {
createBirthTimeJourneyService,
type LegacyBirthTimeJourneyEngine,
type DynamicVersionedJourneyResponse,
type StoredRectificationCase,
type VersionedJourneyResponse,
} from "../src/lib/birth-time-journey-service.ts";
import { journeyMetric } from "../src/lib/birth-time-journey-telemetry.ts";
import {
guidedCase,
journeyCaseId,
memoryStore,
preserveLegacyResumeStore,
unusedJourneyEngine,
} from "./birth-time-journey-test-support.ts";
export const actionIds = [
"00000000-0000-4000-8000-000000000001",
"00000000-0000-4000-8000-000000000002",
"00000000-0000-4000-8000-000000000003",
"00000000-0000-4000-8000-000000000004",
"00000000-0000-4000-8000-000000000005",
"00000000-0000-4000-8000-000000000006",
"00000000-0000-4000-8000-000000000007",
"00000000-0000-4000-8000-000000000008",
"00000000-0000-4000-8000-000000000009",
"00000000-0000-4000-8000-000000000010",
"00000000-0000-4000-8000-000000000011",
"00000000-0000-4000-8000-000000000012",
"00000000-0000-4000-8000-000000000013",
"00000000-0000-4000-8000-000000000014",
"00000000-0000-4000-8000-000000000015",
"00000000-0000-4000-8000-000000000016",
] as const;
type Confidence = "low" | "medium" | "high";
export function candidate(confidence: Confidence, resultId: string) {
return candidateResultSchema.parse({
resultId,
confidence,
canApply: confidence === "high",
winningSegment: confidence === "low"
? null
: { startTime: "14:22", endTime: "14:26", representativeTime: "14:24", widthMinutes: 5 },
eventCount: confidence === "high" ? 4 : 3,
domainCount: confidence === "high" ? 4 : 3,
topScore: confidence === "high" ? 18 : 14,
secondScore: confidence === "high" ? 8 : 12,
marginPercent: confidence === "high" ? 55 : 14,
reasons: confidence === "low" ? ["Candidate scores remain close."] : [],
evidence: [],
algorithmVersion: "birth-time-event-scoring-v1",
});
}
export type JourneyHarness = ReturnType<typeof createHarness>;
function createFakeAgent(): BirthTimeGuideGenerator {
let nextYear = 2010;
return {
async generate(prompt: string) {
const value = z.record(z.unknown()).parse(JSON.parse(prompt));
if (value.task === "select_question_variant") return { text: '{"variant":"gentle"}' };
const domain = z.string().parse(value.requiredDomain);
const year = String(nextYear);
nextYear += 1;
return { text: JSON.stringify({ domain, precision: "year", date: year }) };
},
};
}
export function createHarness(input: {
readonly initial: StoredRectificationCase;
readonly result: ReturnType<typeof candidate>;
readonly resultAfterAdaptive?: ReturnType<typeof candidate>;
readonly failFirstScore?: boolean;
}) {
const memory = memoryStore(input.initial);
const store = preserveLegacyResumeStore(memory.store);
let scoreEventsCalls = 0;
const engine: LegacyBirthTimeJourneyEngine = {
...unusedJourneyEngine,
async scoreEvents() {
scoreEventsCalls += 1;
if (input.failFirstScore === true && scoreEventsCalls === 1) throw new Error("fake engine offline");
return scoreEventsCalls > 1 && input.resultAfterAdaptive
? input.resultAfterAdaptive
: input.result;
},
};
const service = createBirthTimeJourneyService({ store, engine });
const guide = createBirthTimeGuideService({
generator: createFakeAgent(),
loadCase: store.loadCase,
proposeEvidenceDraft: service.proposeEvidenceDraft,
});
return {
memory,
service,
guide,
candidateActions: createGuidedCandidateActions({ store }),
scoreEventsCalls: () => scoreEventsCalls,
};
}
export function assertLegalTurn(
turn: VersionedJourneyResponse | DynamicVersionedJourneyResponse,
): asserts turn is VersionedJourneyResponse {
if (turn.journeyProtocol === "dynamic-choice-v2") {
assert.fail("legacy flow unexpectedly resumed a dynamic journey");
}
assert.ok(turn.nextAction, "every legal journey response has nextAction");
assert.ok(turn.turnVersion >= 0);
switch (turn.nextAction.kind) {
case "ask_baseline_evidence":
case "ask_adaptive_evidence":
assert.ok(turn.nextAction.question.questionId.length > 0);
break;
case "review_evidence_draft":
assert.equal(turn.evidenceDraft?.draftId, turn.nextAction.draftId);
break;
case "score_pending":
case "retry_scoring":
assert.ok(turn.nextAction.jobId.length > 0);
assert.equal(turn.progress.phase, "scoring");
break;
case "present_low_result":
assert.equal(turn.candidateResult?.confidence, "low");
assert.equal(turn.permissions.canConfirmCandidate, false);
assert.equal(turn.snapshot.activeTime, null);
assert.equal(turn.nextAction.resultId, turn.candidateResult?.resultId ?? null);
break;
case "present_medium_result":
case "candidate_saved":
assert.equal(turn.candidateResult?.confidence, "medium");
assert.equal(turn.permissions.canConfirmCandidate, false);
assert.equal(turn.snapshot.activeTime, null);
assert.equal(turn.nextAction.resultId, turn.candidateResult?.resultId ?? null);
break;
case "request_candidate_confirmation":
assert.equal(turn.candidateResult?.confidence, "high");
assert.equal(turn.permissions.canConfirmCandidate, true);
assert.equal(turn.snapshot.state, "confirming");
assert.equal(turn.snapshot.activeTime, null);
assert.equal(turn.nextAction.resultId, turn.candidateResult?.resultId ?? null);
assert.ok(turn.candidateResult?.winningSegment !== null);
break;
case "ready":
assert.equal(turn.snapshot.state, "ready");
assert.equal(turn.snapshot.route, "direct_chart");
assert.equal(turn.snapshot.activeTime, turn.nextAction.activeTime);
break;
case "paused":
assert.equal(turn.progress.phase, "paused");
break;
default: {
const exhaustive: never = turn.nextAction;
assert.fail(`unexpected action ${String(exhaustive)}`);
}
}
}
export async function confirmDirectEvidence(input: {
readonly harness: JourneyHarness;
readonly turn: VersionedJourneyResponse;
readonly proposalActionId: string;
readonly confirmActionId: string;
readonly date: string;
}): Promise<VersionedJourneyResponse> {
assert.ok(
input.turn.nextAction.kind === "ask_baseline_evidence"
|| input.turn.nextAction.kind === "ask_adaptive_evidence",
);
const reviewed = await input.harness.service.proposeEvidenceDraft(
"user-1",
journeyCaseId,
input.proposalActionId,
input.turn.turnVersion,
{ domain: input.turn.nextAction.question.domain, precision: "year", date: input.date },
);
assertLegalTurn(reviewed);
assert.equal(reviewed.nextAction.kind, "review_evidence_draft");
const confirmed = await input.harness.service.confirmEvidenceDraft(
"user-1",
journeyCaseId,
input.confirmActionId,
reviewed.turnVersion,
reviewed.nextAction.draftId,
);
assertLegalTurn(confirmed);
return confirmed;
}
export async function confirmGuideEvidence(input: {
readonly harness: JourneyHarness;
readonly turn: VersionedJourneyResponse;
readonly actionId: (typeof actionIds)[number];
readonly year: string;
}): Promise<VersionedJourneyResponse> {
assert.equal(input.turn.nextAction.kind, "ask_baseline_evidence");
const guideDraft = await input.harness.guide.draftEvidence("user-1", {
caseId: journeyCaseId,
actionId: input.actionId,
turnVersion: input.turn.turnVersion,
message: `${input.year} 年发生了一次明显变化`,
});
assert.equal(guideDraft.type, "evidence_draft");
assertLegalTurn(guideDraft.turn);
assert.equal(guideDraft.turn.nextAction.kind, "review_evidence_draft");
const confirmed = await input.harness.service.confirmEvidenceDraft(
"user-1",
journeyCaseId,
actionIds[actionIds.indexOf(input.actionId) + 1] ?? actionIds[0],
guideDraft.turn.turnVersion,
guideDraft.turn.nextAction.draftId,
);
assertLegalTurn(confirmed);
return confirmed;
}
export async function reachScoring(input: {
readonly harness: JourneyHarness;
readonly useGuide?: boolean;
}): Promise<VersionedJourneyResponse> {
let turn = await input.harness.service.resume("user-1", journeyCaseId);
assertLegalTurn(turn);
if (input.useGuide === true) {
turn = await confirmGuideEvidence({ harness: input.harness, turn, actionId: actionIds[0], year: "2010" });
turn = await confirmGuideEvidence({ harness: input.harness, turn, actionId: actionIds[2], year: "2011" });
turn = await confirmGuideEvidence({ harness: input.harness, turn, actionId: actionIds[4], year: "2012" });
} else {
turn = await confirmDirectEvidence({ harness: input.harness, turn, proposalActionId: actionIds[0], confirmActionId: actionIds[1], date: "2010" });
turn = await confirmDirectEvidence({ harness: input.harness, turn, proposalActionId: actionIds[2], confirmActionId: actionIds[3], date: "2011" });
turn = await confirmDirectEvidence({ harness: input.harness, turn, proposalActionId: actionIds[4], confirmActionId: actionIds[5], date: "2012" });
}
assert.equal(turn.nextAction.kind, "score_pending");
return turn;
}
export { guidedCase, journeyCaseId, journeyMetric };