feat(rectification): add adaptive question selector

This commit is contained in:
Jesse_Chen
2026-09-03 23:48:14 +08:00
parent 49cd66c537
commit 0be6fcfb4c
12 changed files with 741 additions and 32 deletions
@@ -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;
+35 -1
View File
@@ -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({