feat(rectification): add adaptive question selector
This commit is contained in:
@@ -45,6 +45,7 @@ const optionSchema = z.object({
|
||||
const questionSchema = z.object({
|
||||
id: z.string().trim().min(1),
|
||||
prompt: z.string().trim().min(1),
|
||||
domain: z.string().trim().min(1).optional(),
|
||||
round: z.number().int().min(1).optional(),
|
||||
options: z.array(optionSchema).optional(),
|
||||
}).passthrough();
|
||||
@@ -90,6 +91,9 @@ const scoringSchema = z.object({
|
||||
})),
|
||||
next_round: z.number().int().min(1).nullable().default(null),
|
||||
next_round_questions: z.array(questionSchema).default([]),
|
||||
next_round_selection: z.object({
|
||||
selected_questions: z.array(questionSchema).default([]),
|
||||
}).nullable().optional(),
|
||||
}).passthrough();
|
||||
|
||||
const eventDomainSchema = z.enum([
|
||||
@@ -232,6 +236,7 @@ function normalizeQuestion(question: z.infer<typeof questionSchema>): Rectificat
|
||||
return {
|
||||
id: question.id,
|
||||
prompt: question.prompt,
|
||||
...(question.domain ? { domain: question.domain } : {}),
|
||||
...(question.round ? { round: question.round } : {}),
|
||||
...(question.options ? { options: question.options } : {}),
|
||||
};
|
||||
@@ -239,11 +244,14 @@ function normalizeQuestion(question: z.infer<typeof questionSchema>): Rectificat
|
||||
|
||||
export function parseRectificationScoring(value: unknown): RectificationScoringResult {
|
||||
const parsed = scoringSchema.parse(value);
|
||||
const selectedQuestions = parsed.next_round_selection?.selected_questions ?? [];
|
||||
return {
|
||||
answeredCount: parsed.answered_count,
|
||||
candidateClusterRankings: parsed.candidate_cluster_rankings,
|
||||
nextRound: parsed.next_round,
|
||||
nextRoundQuestions: parsed.next_round_questions.map(normalizeQuestion),
|
||||
nextRoundQuestions: (selectedQuestions.length > 0
|
||||
? selectedQuestions
|
||||
: parsed.next_round_questions).map(normalizeQuestion),
|
||||
raw: parsed,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -51,6 +51,7 @@ export function projectJourneyResponse(
|
||||
questionnaire: response.questionnaire,
|
||||
persistedProgress,
|
||||
candidateResult: response.candidateResult,
|
||||
serverSelectedQuestion: response.scoring?.nextRoundQuestions[0] ?? null,
|
||||
lifeEvents: response.lifeEvents,
|
||||
}),
|
||||
};
|
||||
@@ -93,6 +94,7 @@ function projectStoredTurn(
|
||||
questionnaire: stored.questionnaire,
|
||||
persistedProgress: stored.persistedProgress,
|
||||
candidateResult: stored.candidateResult ?? null,
|
||||
serverSelectedQuestion: stored.scoring?.nextRoundQuestions[0] ?? null,
|
||||
lifeEvents: stored.lifeEvents ?? [],
|
||||
});
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ function completedTurn(
|
||||
questionnaire: stored.questionnaire,
|
||||
persistedProgress: stored.persistedProgress,
|
||||
candidateResult,
|
||||
serverSelectedQuestion: stored.scoring?.nextRoundQuestions[0] ?? null,
|
||||
lifeEvents: stored.lifeEvents ?? [],
|
||||
});
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ export type RectificationAnswer = "A" | "B" | "C" | "D";
|
||||
export type RectificationQuestion = {
|
||||
readonly id: string;
|
||||
readonly prompt: string;
|
||||
readonly domain?: string;
|
||||
readonly round?: number;
|
||||
readonly options?: readonly {
|
||||
readonly key: RectificationAnswer;
|
||||
|
||||
@@ -174,9 +174,40 @@ export type JourneyTurnProjectionInput = {
|
||||
readonly askedDomains: readonly EvidenceDomain[];
|
||||
} | null;
|
||||
readonly candidateResult: CandidateResult | null;
|
||||
readonly serverSelectedQuestion?: { readonly id: string; readonly domain?: string } | null;
|
||||
readonly lifeEvents: readonly LifeEvent[];
|
||||
};
|
||||
|
||||
const serverDomainMap: Readonly<Record<string, EvidenceDomain>> = {
|
||||
education: "education",
|
||||
relocation: "relocation",
|
||||
residence: "relocation",
|
||||
relationship: "relationship",
|
||||
career: "career",
|
||||
career_learning: "career",
|
||||
public_work: "career",
|
||||
finance: "finance",
|
||||
health_pressure: "health_pressure",
|
||||
};
|
||||
|
||||
function serverAdaptiveQuestion(
|
||||
question: JourneyTurnProjectionInput["serverSelectedQuestion"],
|
||||
adaptiveRound: number,
|
||||
): QuestionSpec | null {
|
||||
if (!question?.domain) return null;
|
||||
const domain = serverDomainMap[question.domain];
|
||||
if (!domain) return null;
|
||||
return {
|
||||
questionId: question.id,
|
||||
phase: "adaptive",
|
||||
domain,
|
||||
requestedPrecision: ["year", "month"],
|
||||
allowUnknown: true,
|
||||
purposeCode: `candidate_difference_${domain}`,
|
||||
plannerVersion: "server-next-round-selection-v1",
|
||||
};
|
||||
}
|
||||
|
||||
function projectionPhase(input: JourneyTurnProjectionInput, progress: JourneyProgress): JourneyProgress["phase"] {
|
||||
if (input.snapshot.state === "ready") return "ready";
|
||||
if (input.candidateResult) {
|
||||
@@ -219,7 +250,7 @@ export function projectJourneyTurn(input: JourneyTurnProjectionInput): JourneyTu
|
||||
const decisionProgress = input.candidateResult?.confidence === "low"
|
||||
? { ...projectedProgress, adaptiveRound }
|
||||
: projectedProgress;
|
||||
const nextQuestion = phase === "baseline" || phase === "adaptive"
|
||||
const plannedQuestion = phase === "baseline" || phase === "adaptive"
|
||||
? planEvidenceQuestion({
|
||||
phase,
|
||||
samples: input.questionnaire?.samples ?? [],
|
||||
@@ -228,6 +259,9 @@ export function projectJourneyTurn(input: JourneyTurnProjectionInput): JourneyTu
|
||||
adaptiveRound,
|
||||
})
|
||||
: null;
|
||||
const nextQuestion = phase === "adaptive"
|
||||
? serverAdaptiveQuestion(input.serverSelectedQuestion, adaptiveRound) ?? plannedQuestion
|
||||
: plannedQuestion;
|
||||
return {
|
||||
turnVersion: input.turnVersion,
|
||||
nextAction: deriveNextAction({
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { parseRectificationScoring } from "../src/lib/birth-time-journey-adapters.ts";
|
||||
import { projectJourneyTurn } from "../src/lib/birth-time-journey-turn.ts";
|
||||
import type { CandidateResult } from "../src/lib/birth-time-evidence.ts";
|
||||
|
||||
const snapshot = {
|
||||
state: "rectifying",
|
||||
assistantIntent: "continue_rectification_questions",
|
||||
input: "rectification_questions",
|
||||
route: "rectification",
|
||||
confidence: "low",
|
||||
canApply: false,
|
||||
activeTime: null,
|
||||
reportedRange: { label: "10:00—11:00", startTime: "10:00", endTime: "11:00" },
|
||||
} as const;
|
||||
|
||||
const lowResult: CandidateResult = {
|
||||
resultId: "1d8ee348-61a3-433d-8907-ff6d281b9992",
|
||||
confidence: "low",
|
||||
canApply: false,
|
||||
winningSegment: null,
|
||||
eventCount: 3,
|
||||
domainCount: 3,
|
||||
topScore: 8,
|
||||
secondScore: 7,
|
||||
marginPercent: 12.5,
|
||||
reasons: [],
|
||||
evidence: [],
|
||||
algorithmVersion: "birth-time-event-scoring-v1",
|
||||
};
|
||||
|
||||
const questionnaire = { samples: [
|
||||
{ d4Sign: "Aries", d9Sign: "Leo", d10Sign: "Virgo", d24Sign: "Gemini", d30Sign: "Pisces" },
|
||||
{ d4Sign: "Aries", d9Sign: "Leo", d10Sign: "Libra", d24Sign: "Gemini", d30Sign: "Pisces" },
|
||||
] } as const;
|
||||
|
||||
const lifeEvents = [
|
||||
{ id: "5cb071d6-6d99-46be-85dc-a9bf59ef6ac5", domain: "education", date: "2011", precision: "year" },
|
||||
{ id: "0790866c-ad5e-4a45-b2b4-a5c73f6be6ea", domain: "relocation", date: "2019", precision: "year" },
|
||||
{ id: "0ef52e51-ab5f-453b-81e5-adb44a929224", domain: "health_pressure", date: "2021", precision: "year" },
|
||||
] as const;
|
||||
|
||||
test("scoring adapter prefers the server-selected next question", () => {
|
||||
const scoring = parseRectificationScoring({
|
||||
answered_count: 1,
|
||||
candidate_cluster_rankings: [],
|
||||
next_round: 2,
|
||||
next_round_questions: [{ id: "fallback", domain: "career", prompt: "fallback" }],
|
||||
next_round_selection: {
|
||||
selected_questions: [{ id: "selected", domain: "relationship", prompt: "selected" }],
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(scoring.nextRoundQuestions[0]?.id, "selected");
|
||||
assert.equal(scoring.nextRoundQuestions[0]?.domain, "relationship");
|
||||
});
|
||||
|
||||
test("adaptive projection prefers a valid server-selected question", () => {
|
||||
const turn = projectJourneyTurn({
|
||||
turnVersion: 1,
|
||||
snapshot,
|
||||
questionnaire,
|
||||
persistedProgress: { adaptiveRound: 0, askedDomains: [] },
|
||||
candidateResult: lowResult,
|
||||
serverSelectedQuestion: { id: "relationship_followup", domain: "relationship" },
|
||||
lifeEvents,
|
||||
});
|
||||
|
||||
assert.equal(turn.nextAction.kind, "ask_adaptive_evidence");
|
||||
if (turn.nextAction.kind === "ask_adaptive_evidence") {
|
||||
assert.equal(turn.nextAction.question.questionId, "relationship_followup");
|
||||
assert.equal(turn.nextAction.question.domain, "relationship");
|
||||
}
|
||||
});
|
||||
|
||||
test("invalid server domains fall back locally and baseline stays local", () => {
|
||||
const adaptive = projectJourneyTurn({
|
||||
turnVersion: 1,
|
||||
snapshot,
|
||||
questionnaire,
|
||||
persistedProgress: { adaptiveRound: 0, askedDomains: [] },
|
||||
candidateResult: lowResult,
|
||||
serverSelectedQuestion: { id: "invalid", domain: "unsupported" },
|
||||
lifeEvents,
|
||||
});
|
||||
assert.equal(adaptive.nextAction.kind, "ask_adaptive_evidence");
|
||||
if (adaptive.nextAction.kind === "ask_adaptive_evidence") {
|
||||
assert.equal(adaptive.nextAction.question.domain, "career");
|
||||
}
|
||||
|
||||
const baseline = projectJourneyTurn({
|
||||
turnVersion: 0,
|
||||
snapshot: { ...snapshot, confidence: null },
|
||||
questionnaire,
|
||||
persistedProgress: { adaptiveRound: 0, askedDomains: [] },
|
||||
candidateResult: null,
|
||||
serverSelectedQuestion: { id: "relationship_followup", domain: "relationship" },
|
||||
lifeEvents: lifeEvents.slice(0, 1),
|
||||
});
|
||||
assert.equal(baseline.nextAction.kind, "ask_baseline_evidence");
|
||||
if (baseline.nextAction.kind === "ask_baseline_evidence") {
|
||||
assert.equal(baseline.nextAction.question.domain, "career");
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user