feat(rectification): add adaptive question selector

This commit is contained in:
Jesse_Chen
2026-09-03 23:49:42 +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({
@@ -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");
}
});