/** * question-core — pure logic shared by the terminal (blocking) and web * (dispatch) quiz/ask extensions. No pi imports, no UI: everything here is * deterministic and unit-testable. * * The WEB dispatch shape: * 1. Tool execute() validates + shuffles + resolves the correct answer, * appends an `lh_question` custom entry (NOT in LLM context — safe to * store correctAnswer/explanation there) and returns immediately. * 2. The agent ends its turn. The server pushes the question to the client. * 3. The learner answers; the server calls back into the extension bridge. * 4. The extension grades, appends `lh_question_answered` (gold-standard * structured data for the extractor), and sends the graded answer into * the session as a user message so the agent can continue. * * Because nothing blocks, a session can be exited and resumed mid-question: * the pending state lives in the session file itself (lh_question entries * with no matching lh_question_answered). */ // ─── shared types ─────────────────────────────────────────────────────────── export interface QuizOption { label: string; value: string; description?: string; } /** A real selected answer, in the order the options were displayed. */ export interface OptionAnswer { label: string; value: string; index: number; // 1-based display index } export type QuestionKind = "quiz" | "ask"; export type SelectMode = "single-select" | "multi-select"; export type QuestionMode = SelectMode | "text"; /** Full question data — stored in the `lh_question` custom entry. */ export interface QuestionData { id: string; kind: QuestionKind; question: string; context?: string; mode: QuestionMode; options: QuizOption[]; // display order (post-shuffle) // quiz only. NEVER send to the client before answering: correctIndices?: number[]; explanation?: string; createdAt: string; } /** The learner's answer — full structured record, stored in `lh_question_answered`. */ export interface QuestionAnswerData { questionId: string; kind: QuestionKind; questionText: string; options?: { index: number; label: string }[]; // snapshot for rendering dontKnow: boolean; answers: OptionAnswer[]; other?: string; // ask: "Other" free text text?: string; // ask: text-mode answer note?: string; correct?: boolean; // quiz only correctIndices?: number[]; // quiz only (rendering) explanation?: string; // quiz only (rendering) dismissed?: boolean; answeredAt: string; } /** Client-facing question payload — deliberately omits the answer. */ export interface ClientQuestion { id: string; kind: QuestionKind; question: string; context?: string; mode: QuestionMode; options: QuizOption[]; } /** What the server passes to the bridge when the learner answers. */ export interface AnswerRequest { dontKnow?: boolean; values?: string[]; // option values other?: string; // ask "Other" text?: string; // ask text-mode note?: string; } export const QUESTION_ENTRY = "lh_question"; export const QUESTION_ANSWERED_ENTRY = "lh_question_answered"; // ─── options ──────────────────────────────────────────────────────────────── export function normalizeOptions( options: Array<{ label: string; value?: string; description?: string }> | undefined, ): QuizOption[] { const seen = new Set(); return (options || []) .map((option) => ({ label: option.label.trim(), value: option.value?.trim() || option.label.trim(), description: option.description?.trim() || undefined, })) .filter((option) => { if (option.label.length === 0) return false; if (seen.has(option.value)) throw new Error(`duplicate option value "${option.value}"`); seen.add(option.value); return true; }); } /** Fisher-Yates over a copy. correctAnswer is keyed by value, so display * order can be shuffled safely before resolving indices. */ export function shuffleOptions(options: QuizOption[]): QuizOption[] { const out = [...options]; for (let i = out.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); const a = out[i]!; const b = out[j]!; out[i] = b; out[j] = a; } return out; } // ─── quiz grading ─────────────────────────────────────────────────────────── /** The harness sometimes delivers a multi-select correctAnswer array as a * JSON-stringified string because the schema union lists String first. */ export function coerceCorrectAnswer(correctAnswer: string | string[]): string[] { if (Array.isArray(correctAnswer)) return correctAnswer; const trimmed = correctAnswer.trim(); if (trimmed.startsWith("[") && trimmed.endsWith("]")) { try { const parsed = JSON.parse(trimmed); if (Array.isArray(parsed)) return parsed.map((v) => String(v)); } catch { // not valid JSON — treat as a single literal value } } return [correctAnswer]; } /** Resolve author-supplied value(s) to 1-based display indices. Keying by * value (not position) makes a typo a hard error instead of a silent wrong * grade. Must run AFTER shuffling. */ export function resolveCorrect( correctAnswer: string | string[] | undefined, options: QuizOption[], ): { indices: number[]; error?: string } { if (correctAnswer === undefined) return { indices: [], error: "correctAnswer is required" }; const arr = coerceCorrectAnswer(correctAnswer); if (arr.length === 0) return { indices: [], error: "correctAnswer is required" }; const byValue = new Map(options.map((o, i) => [o.value, i + 1])); const indices: number[] = []; for (const raw of arr) { const v = typeof raw === "string" ? raw.trim() : raw; const idx = byValue.get(v); if (idx === undefined) { const known = options.map((o) => `"${o.value}"`).join(", "); return { indices: [], error: `correctAnswer "${v}" does not match any option value (${known})` }; } indices.push(idx); } return { indices: Array.from(new Set(indices)).sort((a, b) => a - b) }; } /** Exact-set match: same number of selections, same indices. */ export function isCorrect(selectedIndices: number[], correctIndices: number[]): boolean { if (selectedIndices.length !== correctIndices.length) return false; const a = [...selectedIndices].sort((x, y) => x - y); const b = [...correctIndices].sort((x, y) => x - y); return a.every((v, i) => v === b[i]); } // ─── arming + answering ───────────────────────────────────────────────────── export function makeId(): string { return `q_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`; } export function toClientQuestion(qd: QuestionData): ClientQuestion { return { id: qd.id, kind: qd.kind, question: qd.question, context: qd.context, mode: qd.mode, options: qd.options, }; } function formatOptionRef(options: QuizOption[], index: number): string { const opt = options.find((o, i) => i + 1 === index); return `${index}. ${opt ? opt.label : "(unknown)"}`; } /** * Turn an AnswerRequest into the full answer record + the user message the * agent will see. Quiz answers are graded here; ask answers are not. */ export function buildAnswer(qd: QuestionData, req: AnswerRequest): QuestionAnswerData { const base: QuestionAnswerData = { questionId: qd.id, kind: qd.kind, questionText: qd.question, options: qd.options.map((o, i) => ({ index: i + 1, label: o.label })), dontKnow: req.dontKnow === true, answers: [], note: req.note?.trim() || undefined, answeredAt: new Date().toISOString(), }; // resolve selected option values → OptionAnswer (in display order) if (req.values && req.values.length > 0) { const byValue = new Map(qd.options.map((o, i) => [o.value, { ...o, index: i + 1 }])); for (const v of req.values) { const opt = byValue.get(v); if (!opt) continue; // unknown value — ignore base.answers.push({ label: opt.label, value: opt.value, index: opt.index }); } base.answers.sort((a, b) => a.index - b.index); } if (req.other?.trim()) base.other = req.other.trim(); if (req.text?.trim()) base.text = req.text.trim(); if (qd.kind === "quiz") { base.correctIndices = qd.correctIndices ?? []; base.explanation = qd.explanation; base.correct = base.dontKnow ? false : isCorrect(base.answers.map((a) => a.index), qd.correctIndices ?? []); } return base; } /** The user message that carries the answer into the agent's context. */ export function formatAnswerMessage(ad: QuestionAnswerData): string { const label = ad.kind === "quiz" ? "Quiz answer" : "Answer to your question"; const head = `[${label}]`; const lines: string[] = [head]; if (ad.dismissed) { lines.push("The learner dismissed this question without answering."); return lines.join("\n"); } if (ad.kind === "quiz") { const correctStr = (ad.correctIndices ?? []).map((i) => formatOptionRefSnapshot(ad, i)).join(", "); if (ad.dontKnow) { lines.push( `The learner selected "I don't know" — they did NOT attempt an answer (a genuine knowledge gap, not a wrong guess).`, ); } else { const verdict = ad.correct ? "CORRECT" : "INCORRECT"; const selectedStr = ad.answers.map((a) => `${a.index}. ${a.label}`).join(", "); lines.push(`Selected: ${selectedStr || "(none)"}`); lines.push(`Result: ${verdict}`); } lines.push(`Correct: ${correctStr}`); } else { if (ad.text) { lines.push(`The learner answered: ${ad.text}`); } else if (ad.other) { lines.push(`The learner answered (Other): ${ad.other}`); } else if (ad.answers.length > 0) { const selectedStr = ad.answers.map((a) => `${a.index}. ${a.label}`).join(", "); lines.push(`Selected: ${selectedStr}`); } else { lines.push("(no answer)"); } } if (ad.note) lines.push(`Note: ${ad.note}`); return lines.join("\n"); } function formatOptionRefSnapshot(ad: QuestionAnswerData, index: number): string { const opt = (ad.options ?? []).find((o) => o.index === index); return `${index}. ${opt ? opt.label : "(unknown)"}`; } /** * Derive the pending (oldest unanswered) question from a session's entries. * Used on resume/backfill — pending state lives in the session file itself. */ export function findPendingQuestion( entries: Array<{ type: string; customType?: string; data?: unknown }>, ): ClientQuestion | null { const answered = new Set(); for (const e of entries) { if (e.type === "custom" && e.customType === QUESTION_ANSWERED_ENTRY) { const d = e.data as QuestionAnswerData | undefined; if (d?.questionId) answered.add(d.questionId); } } for (const e of entries) { if (e.type === "custom" && e.customType === QUESTION_ENTRY) { const d = e.data as QuestionData | undefined; if (d && !answered.has(d.id)) return toClientQuestion(d); } } return null; }