v3: AI SDK pivot — pi removed, Next.js 16 + AI SDK 7 + AI Elements teacher app
The teaching agent no longer runs on pi. Stack: - ToolLoopAgent (ai@7) + OpenRouter (OPENROUTER_API_KEY), model via LH_MODEL, default anthropic/claude-sonnet-4.5 with reasoning tokens - quiz/ask_user_question = client-side tools (no server execute): the call lands as a typed tool part in input-available state; the card grades via src/core/question-core.ts; addToolOutput sends the verdict; the teacher's reaction continues the same assistant message - resume-mid-quiz is free: the pending tool part persists in the transcript - persistence: one JSON per chat (data/chats/), full UIMessage transcripts, saved onFinish (also on abort) - UI: AI Elements (owned copy-paste components, pruned to the five in use) + button-based QuizCard/AnsweredCard (ask answers never graded) - pi fully removed: TUI, extensions, Fastify+WS server, bridge, custom protocol all deleted (recoverable from git history) - tests: question-core units carry over; chat-flow integration drives handleChatRequest with MockLanguageModelV4 (48 tests, zero tokens); render smoke tests guard the cards (renderToStaticMarkup) Carried over unchanged: teacher system prompt, question-core, schema/ ledger/derive/extract pipeline, data model.
This commit is contained in:
@@ -0,0 +1,306 @@
|
||||
/**
|
||||
* question-core — pure logic for quiz/ask questions. No framework imports,
|
||||
* no UI: everything here is deterministic and unit-testable.
|
||||
*
|
||||
* Used by:
|
||||
* - the TUI quiz extension (extensions/quiz.ts) — blocking terminal UI
|
||||
* - the web app (AI SDK client-side tools) — the browser validates,
|
||||
* shuffles, grades with these functions, then sends the verdict back
|
||||
* as the tool output via addToolOutput
|
||||
*
|
||||
* The web shape (AI SDK):
|
||||
* 1. The teacher model calls the `quiz` tool (question + options +
|
||||
* correctAnswer + explanation). No server execute → the call lands in
|
||||
* the UI as a typed tool part in `input-available` state.
|
||||
* 2. The learner answers in the card; the client grades with this module.
|
||||
* 3. addToolOutput sends the verdict (selection + correctness + note —
|
||||
* never the explanation, the model authored that) back to the model.
|
||||
*
|
||||
* Because the pending question IS a persisted message part, a session can be
|
||||
* exited and resumed mid-question for free.
|
||||
*/
|
||||
|
||||
// ─── 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;
|
||||
}
|
||||
Reference in New Issue
Block a user