Files
learnharness/extensions/core/question-core.ts
T
Amos 25063b2a3e v1 web interface: dispatch-mode quiz/ask, Fastify+WS server, React frontend, deterministic test suite
- extensions/core/question-core.ts: pure quiz/ask logic (normalize/shuffle/resolve/grade/format) shared by both interfaces
- extensions/core/bridge.ts: in-process server<->extension bridge (answer/dismiss/re-arm)
- extensions/quiz-web.ts + ask-web.ts: web dispatch mode — tool returns immediately, graded answer arrives as next user message; sessions resumable mid-question via lh_question/lh_question_answered custom entries (never in LLM context)
- extensions/quiz.ts: refactored onto shared core (terminal blocking mode unchanged)
- src/server/index.ts: Fastify + WebSocket; REST (list/create/resume), entry sync (diff-based, all message kinds), streaming deltas, pending-question push
- web/: Vite + React — home view (graph placeholder, session list, new-session input), session view (markdown+KaTeX+mermaid transcript, quiz cards, streaming)
- src/testing/scripted-model.ts: deterministic fake LLM (scripted model runtime injected via factory overrides)
- tests/: 48 deterministic tests (unit: question-core + bridge; integration: full pi loop with scripted model incl. resume re-arm)
- scripts/e2e.ts: live smoke test (manual, real LLM, never a gate)
- teacher/system-prompt.md: end-your-turn rule for question tools
2026-08-22 00:19:49 +03:00

305 lines
11 KiB
TypeScript

/**
* 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<string>();
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<string>();
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;
}