two questions per teaching unit: predict → teach → check (owner 2026-10-03)

The teaching cycle gains its motivation question. The teach quiz schema
now carries a REQUIRED type field — predict (posed before the teaching,
arrives open, the learner predicts the next step) or check (posed after,
arrives collapsed behind the reveal). The prober's quiz stays role-less.

- core: quizInputObject.type union + normalizeQuizType + the reveal law
  as pure quizNeedsReveal; QuestionData.questionType; note → thinking
  rename (legacy reads fold back); readingThinking channel on answers;
  compactAnswerForModel emits thinking / thinkingWhileReading
- tools: descriptions stripped to mechanics — QUIZ_OPTION_CRAFT and
  EXPLAIN_DOCTRINE deleted, doctrine moved to the prompt layer
  (reverses the 2026-09-27 placement, owner 2026-10-03); shared
  quizDescription/explainDescription builders differ only in deltas
- prompts: shared/20-question-tools.md rewritten (option craft,
  transcribed-speech leniency, the two thinking channels); new
  teacher/28-predictions.md (cycle, roles, skip rule, resolution law);
  20-teaching + 30-question-principles scoped to the new taxonomy
- UI: predictions render open with a Prediction eyebrow, verdict shows
  but the explanation tucks behind a toggle; the reading-thinking field
  mounts while a check waits (dictation claims it until reveal);
  answered card separates thinking vs thinking-while-reading blocks
This commit is contained in:
amosblomqvist
2026-10-04 00:53:29 +03:00
parent 571cc94053
commit 360d564a6c
19 changed files with 664 additions and 106 deletions
+47 -9
View File
@@ -1,5 +1,6 @@
"use client"; "use client";
import { useState } from "react";
import { ConfidenceRail } from "@/components/confidence-rail"; import { ConfidenceRail } from "@/components/confidence-rail";
import { GlyphMark } from "@/components/ui/glyph-mark"; import { GlyphMark } from "@/components/ui/glyph-mark";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@@ -17,6 +18,7 @@ import type { QuestionAnswerData } from "@/src/core";
* the explanation, with NO verdict chips (owner ruling 2026-09-27). * the explanation, with NO verdict chips (owner ruling 2026-09-27).
*/ */
export function AnsweredCard({ answer }: { answer: QuestionAnswerData }) { export function AnsweredCard({ answer }: { answer: QuestionAnswerData }) {
const [whyOpen, setWhyOpen] = useState(false); // the tucked explanation (predictions, owner 2026-10-03)
if (answer.dismissed) { if (answer.dismissed) {
return ( return (
<div className="rounded-xl border bg-card p-4 text-sm text-muted-foreground"> <div className="rounded-xl border bg-card p-4 text-sm text-muted-foreground">
@@ -33,7 +35,7 @@ export function AnsweredCard({ answer }: { answer: QuestionAnswerData }) {
return ( return (
<div className="rounded-xl border bg-card p-4 text-card-foreground" data-testid="answered-card"> <div className="rounded-xl border bg-card p-4 text-card-foreground" data-testid="answered-card">
<div className="mb-1 flex items-center gap-2 text-xs font-medium uppercase tracking-wide text-muted-foreground"> <div className="mb-1 flex items-center gap-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
{isQuiz ? "Quiz" : isExplain ? "Explain" : "Question"} {isQuiz ? (answer.questionType === "predict" ? "Prediction" : "Quiz") : isExplain ? "Explain" : "Question"}
{isQuiz && {isQuiz &&
(answer.dontKnow ? ( (answer.dontKnow ? (
<span className="rounded bg-muted px-1.5 py-0.5">didn't know</span> <span className="rounded bg-muted px-1.5 py-0.5">didn't know</span>
@@ -122,26 +124,62 @@ export function AnsweredCard({ answer }: { answer: QuestionAnswerData }) {
<div className="mt-2 text-sm text-muted-foreground">Answered “I don't know”.</div> <div className="mt-2 text-sm text-muted-foreground">Answered “I don't know”.</div>
)} )}
{answer.note && ( {(answer.thinking ?? answer.note) && (
<div data-testid="your-thinking" className="mt-3 border-l-2 border-primary/40 pl-3"> <div data-testid="your-thinking" className="mt-3 border-l-2 border-primary/40 pl-3">
<span className="mb-0.5 block text-xs font-medium uppercase tracking-wide text-muted-foreground"> <span className="mb-0.5 block text-xs font-medium uppercase tracking-wide text-muted-foreground">
Your thinking Your thinking
</span> </span>
<span className="lh-prose block whitespace-pre-wrap text-foreground/90">{answer.note}</span> <span className="lh-prose block whitespace-pre-wrap text-foreground/90">{answer.thinking ?? answer.note}</span>
</div>
)}
{/* The reading channel (owner 2026-10-03): thoughts captured while
reading the teaching prose — a separate block from the question
thinking, same quiet voice. */}
{answer.readingThinking && (
<div data-testid="your-reading-thinking" className="mt-3 border-l-2 border-muted-foreground/30 pl-3">
<span className="mb-0.5 block text-xs font-medium uppercase tracking-wide text-muted-foreground">
Your thinking while reading
</span>
<span className="lh-prose block whitespace-pre-wrap text-foreground/90">{answer.readingThinking}</span>
</div> </div>
)} )}
{/* px-3→removed: card p-4 + box px-3 double-inset sat the "Why" label deeper {/* px-3→removed: card p-4 + box px-3 double-inset sat the "Why" label deeper
than the option box edges (owner, round 3). Sides flush with everything than the option box edges (owner, round 3). Sides flush with everything
else; py-2 stays for vertical air. */} else; py-2 stays for vertical air. */}
{isQuiz && answer.explanation && ( {isQuiz && answer.explanation && answer.questionType === "predict" ? (
<div className="mt-3 rounded-lg bg-muted py-2 text-sm text-muted-foreground"> /* The TUCKED explanation (owner 2026-10-03): a prediction's
<span className="font-medium text-foreground">Why: </span> verdict shows, but the explanation sits collapsed — viewable,
{/* The markdown wrapper must go truly inline — a block <p> after the never center stage. The teaching that follows resolves the
"Why:" label created the oversized leading gap (owner's fix list). */} prediction; THAT is the real explanation. */
<MessageResponse className="inline [&_p]:inline [&_p]:m-0">{answer.explanation}</MessageResponse> <div className="mt-3" data-testid="tucked-explanation">
<button
type="button"
onClick={() => setWhyOpen((v) => !v)}
className="text-xs font-medium text-muted-foreground transition-colors hover:text-foreground"
>
{whyOpen ? "Hide why" : "Why"}
</button>
{whyOpen && (
<div className="mt-1.5 rounded-lg bg-muted py-2 text-sm text-muted-foreground">
<span className="font-medium text-foreground">Why: </span>
<MessageResponse className="inline [&_p]:inline [&_p]:m-0">{answer.explanation}</MessageResponse>
</div>
)}
</div> </div>
) : (
isQuiz &&
answer.explanation && (
<div className="mt-3 rounded-lg bg-muted py-2 text-sm text-muted-foreground">
<span className="font-medium text-foreground">Why: </span>
{/* The markdown wrapper must go truly inline — a block <p> after the
"Why:" label created the oversized leading gap (owner's fix list). */}
<MessageResponse className="inline [&_p]:inline [&_p]:m-0">{answer.explanation}</MessageResponse>
</div>
)
)} )}
{/* The explain reveal (owner ruling 2026-09-27): the committed answer {/* The explain reveal (owner ruling 2026-09-27): the committed answer
— one field, reasoning included — shown on submit; no verdict, — one field, reasoning included — shown on submit; no verdict,
the model's next reply IS the grading. */} the model's next reply IS the grading. */}
+55 -5
View File
@@ -41,7 +41,8 @@ import type { ArtifactPartProps, JobRecord, Phase3, QuestionAnswerData, TurnUsag
import type { LhViewMessage } from "@/src/core"; import type { LhViewMessage } from "@/src/core";
import { PhaseChip, PhaseDivider } from "@/components/phase-divider"; import { PhaseChip, PhaseDivider } from "@/components/phase-divider";
import { CoverageRing } from "@/components/extraction-coverage"; import { CoverageRing } from "@/components/extraction-coverage";
import { dismissedAnswer, findPendingToolCall, phaseAt3, phaseTimeline, toolQuestionKind } from "@/src/core"; import { dismissedAnswer, findPendingToolCall, phaseAt3, phaseTimeline, toolQuestionKind, quizNeedsReveal } from "@/src/core";
import { ReadingThinkingField } from "@/components/reading-thinking";
import { slotsConfigured, type ModelsSettings } from "@/src/core"; import { slotsConfigured, type ModelsSettings } from "@/src/core";
import { useSettingsSaved } from "@/components/settings-signal"; import { useSettingsSaved } from "@/components/settings-signal";
@@ -331,7 +332,10 @@ export function Chat({
const p = pendingRef.current; const p = pendingRef.current;
if (!p) return; if (!p) return;
const question = (p.input as { question?: string } | null)?.question ?? ""; const question = (p.input as { question?: string } | null)?.question ?? "";
void dismissQuestion(p.toolCallId, dismissedAnswer(toolQuestionKind(p.tool), question)); const reading = readingThinkingRef.current.trim() || undefined;
clearReadingThinking();
const answer = dismissedAnswer(toolQuestionKind(p.tool), question);
void dismissQuestion(p.toolCallId, reading ? { ...answer, readingThinking: reading } : answer);
}, },
[abortInFlightTurn, dismissQuestion], [abortInFlightTurn, dismissQuestion],
); );
@@ -393,7 +397,28 @@ export function Chat({
// never precede its phase's seam in the transcript (the teacher's turn // never precede its phase's seam in the transcript (the teacher's turn
// ends at the question call), so the session's current phase IS the // ends at the question call), so the session's current phase IS the
// question's phase. // question's phase.
const requiresReveal = pending !== null && pending.tool === "quiz" && phaseAt3(messages) === "teach"; const requiresReveal = pending !== null && quizNeedsReveal(pending.tool, pending.input, phaseAt3(messages));
/* The READING-THINKING channel (owner 2026-10-03): a draft field for
* thinking while reading the teaching prose, mounted while a check waits
* behind its reveal, riding the check answer (or its dismissal) as the
* separate `readingThinking` field. Reset per pending question. */
const [readingThinking, setReadingThinking] = useState("");
const readingThinkingRef = useRef("");
const applyReadingThinking = useCallback((v: string) => {
readingThinkingRef.current = v;
setReadingThinking(v);
}, []);
const clearReadingThinking = useCallback(() => {
readingThinkingRef.current = "";
setReadingThinking("");
}, []);
const pendingQuestionId = pending?.toolCallId ?? null;
useEffect(() => {
// A new pending question never inherits the previous one's draft.
readingThinkingRef.current = "";
setReadingThinking("");
}, [pendingQuestionId]);
// The phase at every message (one linear fold): the transcript draws a // The phase at every message (one linear fold): the transcript draws a
// color-coded dotted divider at each boundary; the header chip reads the // color-coded dotted divider at each boundary; the header chip reads the
@@ -1241,6 +1266,8 @@ export function Chat({
plan={hasPlan ? <PlanMarginalia messages={messages} /> : undefined} plan={hasPlan ? <PlanMarginalia messages={messages} /> : undefined}
onOpenPlan={() => setPlanOpen(true)} onOpenPlan={() => setPlanOpen(true)}
requiresReveal={requiresReveal} requiresReveal={requiresReveal}
readingThinking={readingThinking}
onReadingThinking={applyReadingThinking}
phase={phases.at(-1) ?? "probe"} phase={phases.at(-1) ?? "probe"}
turns={turns} turns={turns}
onReserve={setDockReserve} onReserve={setDockReserve}
@@ -1288,8 +1315,12 @@ export function Chat({
// the optimistic graded result (the AnsweredCard shows at // the optimistic graded result (the AnsweredCard shows at
// once) and the server's write-ahead persists it before the // once) and the server's write-ahead persists it before the
// turn; the teacher's reaction streams below it. Dismissals // turn; the teacher's reaction streams below it. Dismissals
// ride the same callback (QuestionAnswerData.dismissed). // ride the same callback (QuestionAnswerData.dismissed). The
submitAnswer(pending.tool, pending.toolCallId, output); // reading-thinking draft rides along (owner 2026-10-03) — the
// check answer is its only carrier.
const reading = readingThinkingRef.current.trim() || undefined;
clearReadingThinking();
submitAnswer(pending.tool, pending.toolCallId, reading ? { ...output, readingThinking: reading } : output);
}} }}
/> />
</Conversation> </Conversation>
@@ -1398,6 +1429,8 @@ export function shouldStampGeometryMute(nextH: number, prevH: number, pending: b
function DockOverlay({ function DockOverlay({
pending, pending,
requiresReveal, requiresReveal,
readingThinking,
onReadingThinking,
phase, phase,
turns, turns,
composer, composer,
@@ -1414,6 +1447,10 @@ function DockOverlay({
pending: ReturnType<typeof findPendingToolCall>; pending: ReturnType<typeof findPendingToolCall>;
/** Teach-phase pending question: arrives collapsed behind the reveal button (W5). */ /** Teach-phase pending question: arrives collapsed behind the reveal button (W5). */
requiresReveal: boolean; requiresReveal: boolean;
/** The reading-thinking draft (owner 2026-10-03) — owned by ChatView so
* the submit + dismissal seams (outside this overlay) can merge it. */
readingThinking: string;
onReadingThinking: (v: string) => void;
/** Session phase at the pending question — scales the thinking field. */ /** Session phase at the pending question — scales the thinking field. */
phase: Phase3; phase: Phase3;
/** The session's usage log — the stats bar beneath the composer folds it. */ /** The session's usage log — the stats bar beneath the composer folds it. */
@@ -1730,6 +1767,18 @@ function DockOverlay({
rounding starts, truncated, no background, no container. A rounding starts, truncated, no background, no container. A
line dies when its card lands (visibleQueuedSteers). */} line dies when its card lands (visibleQueuedSteers). */}
<QueuedSteersBody steers={queuedSteers} /> <QueuedSteersBody steers={queuedSteers} />
{/* The reading-thinking field (owner 2026-10-03): mounted while a
teach-phase check waits — the same cue that collapses it. It
claims dictated speech until the learner reveals the check
(the dock reclaims then). Rides the check answer on submit. */}
{pending && requiresReveal && (
<ReadingThinkingField
value={readingThinking}
onChange={onReadingThinking}
onEscape={dictationCancel}
claim={revealPending}
/>
)}
<div ref={shellRef} className="pointer-events-auto overflow-hidden rounded-2xl border bg-card shadow-lg"> <div ref={shellRef} className="pointer-events-auto overflow-hidden rounded-2xl border bg-card shadow-lg">
<div key={mode} className={mode === initialModeRef.current ? undefined : "dock-variant"}> <div key={mode} className={mode === initialModeRef.current ? undefined : "dock-variant"}>
{pending ? ( {pending ? (
@@ -1743,6 +1792,7 @@ function DockOverlay({
onExpand={expandAndReveal} onExpand={expandAndReveal}
onSubmit={onSubmitAnswer} onSubmit={onSubmitAnswer}
onEscape={dictationCancel} onEscape={dictationCancel}
dictationClaim={!revealPending}
/> />
) : ( ) : (
composer composer
+28 -4
View File
@@ -9,6 +9,7 @@ import { cn } from "@/lib/utils";
import { import {
buildAnswer, buildAnswer,
coerceCorrectAnswer, coerceCorrectAnswer,
normalizeQuizType,
CONFIDENCE_STOPS, CONFIDENCE_STOPS,
DEFAULT_CONFIDENCE_INDEX, DEFAULT_CONFIDENCE_INDEX,
emptyDockSelection, emptyDockSelection,
@@ -79,6 +80,11 @@ export interface QuestionDockProps {
/** Session phase at the question — scales the thinking field: probing /** Session phase at the question — scales the thinking field: probing
* stays quick (one row), teaching invites dumping (three rows). */ * stays quick (one row), teaching invites dumping (three rows). */
phase?: Phase3; phase?: Phase3;
/** Dictation claim (owner 2026-10-03): false while a teach-phase check
* sits UNREVEALED — the reading-thinking field owns dictated speech
* during the reading; the dock reclaims on reveal. Default true (probe
* questions, predictions, old callers). */
dictationClaim?: boolean;
} }
export function QuestionDock({ export function QuestionDock({
@@ -91,6 +97,7 @@ export function QuestionDock({
onSubmit, onSubmit,
onEscape, onEscape,
phase = "probe", phase = "probe",
dictationClaim = true,
}: QuestionDockProps) { }: QuestionDockProps) {
// Parse, don't cast (src/core/tool-parts.ts): the model authored this // Parse, don't cast (src/core/tool-parts.ts): the model authored this
// input, and only the schema the tool declared vouches for its shape. // input, and only the schema the tool declared vouches for its shape.
@@ -115,6 +122,11 @@ export function QuestionDock({
question={parsed.question} question={parsed.question}
onExpand={onExpand} onExpand={onExpand}
variant={reveal ? "reveal" : "collapsed"} variant={reveal ? "reveal" : "collapsed"}
label={
toolName === "quiz" && normalizeQuizType((parsed as { type?: unknown }).type) === "predict"
? "Prediction"
: undefined
}
/> />
)} )}
<div className={expanded ? "dock-variant" : "invisible h-0 overflow-hidden"}> <div className={expanded ? "dock-variant" : "invisible h-0 overflow-hidden"}>
@@ -126,6 +138,7 @@ export function QuestionDock({
active={expanded} active={expanded}
onSubmit={onSubmit} onSubmit={onSubmit}
onEscape={onEscape} onEscape={onEscape}
dictationClaim={dictationClaim}
/> />
</div> </div>
</> </>
@@ -154,11 +167,16 @@ export function QuestionDockBar({
question, question,
onExpand, onExpand,
variant = "collapsed", variant = "collapsed",
label,
}: { }: {
toolName: "quiz" | "ask_user_question" | "explain"; toolName: "quiz" | "ask_user_question" | "explain";
question: string; question: string;
onExpand: () => void; onExpand: () => void;
variant?: "collapsed" | "reveal"; variant?: "collapsed" | "reveal";
/** The kind chip's text (owner 2026-10-03): "Prediction" when a
* predict-type quiz minimizes by scroll — predictions never use the
* reveal variant, but the scroll-collapse strip can still hide them. */
label?: string;
}) { }) {
if (variant === "reveal") { if (variant === "reveal") {
return ( return (
@@ -189,7 +207,7 @@ export function QuestionDockBar({
className="dock-variant flex w-full items-center gap-2.5 px-4 py-2.5 text-left text-sm transition-colors hover:bg-accent/40" className="dock-variant flex w-full items-center gap-2.5 px-4 py-2.5 text-left text-sm transition-colors hover:bg-accent/40"
> >
<span className="shrink-0 text-xs font-medium uppercase tracking-wide text-muted-foreground"> <span className="shrink-0 text-xs font-medium uppercase tracking-wide text-muted-foreground">
{toolName === "quiz" ? "Quiz" : toolName === "explain" ? "Explain" : "Question"} {label ?? (toolName === "quiz" ? "Quiz" : toolName === "explain" ? "Explain" : "Question")}
</span> </span>
<span className="min-w-0 flex-1 truncate text-muted-foreground">{teaserText(question)}</span> <span className="min-w-0 flex-1 truncate text-muted-foreground">{teaserText(question)}</span>
<span className="flex shrink-0 items-center gap-1 text-xs font-medium"> <span className="flex shrink-0 items-center gap-1 text-xs font-medium">
@@ -208,6 +226,7 @@ function QuestionDockForm({
active, active,
onSubmit, onSubmit,
onEscape, onEscape,
dictationClaim = true,
}: Omit<QuestionDockProps, "expanded" | "onExpand" | "reveal">) { }: Omit<QuestionDockProps, "expanded" | "onExpand" | "reveal">) {
const kind = toolQuestionKind(toolName); const kind = toolQuestionKind(toolName);
@@ -237,6 +256,9 @@ function QuestionDockForm({
const q: QuestionData = { const q: QuestionData = {
id: toolCallId, id: toolCallId,
kind, kind,
// The role (owner 2026-10-03): predict questions gate by it in the
// view (never collapsed) and the answered card tucks the explanation.
questionType: kind === "quiz" ? normalizeQuizType((input as { type?: unknown }).type) : undefined,
question: input.question, question: input.question,
context: input.details, context: input.details,
mode, mode,
@@ -304,7 +326,9 @@ function QuestionDockForm({
}, },
[qd.mode], [qd.mode],
), ),
true, // Released while a teach check sits unrevealed (owner 2026-10-03):
// the reading-thinking field owns dictated speech during the reading.
dictationClaim,
); );
const toggle = (idx: number) => setSel(toggleOption(selectMode, sel, idx)); const toggle = (idx: number) => setSel(toggleOption(selectMode, sel, idx));
@@ -335,7 +359,7 @@ function QuestionDockForm({
values: [...sel.selected].map((i) => qd.options[i]!.value), values: [...sel.selected].map((i) => qd.options[i]!.value),
other: sel.otherSelected ? other : undefined, other: sel.otherSelected ? other : undefined,
text: qd.mode === "text" ? answerTextRef.current : undefined, text: qd.mode === "text" ? answerTextRef.current : undefined,
note: qd.mode !== "text" ? thinkingTextRef.current.trim() || undefined : undefined, thinking: qd.mode !== "text" ? thinkingTextRef.current.trim() || undefined : undefined,
// Always sent on quiz answers (owner: 50 must mean 50, never // Always sent on quiz answers (owner: 50 must mean 50, never
// "untouched"); buildAnswer drops it on "I don't know". // "untouched"); buildAnswer drops it on "I don't know".
confidence: kind === "quiz" ? CONFIDENCE_STOPS[confIdx] : undefined, confidence: kind === "quiz" ? CONFIDENCE_STOPS[confIdx] : undefined,
@@ -489,7 +513,7 @@ function QuestionDockForm({
{/* Question area — scrolls internally if very tall; the bottom row stays put. */} {/* Question area — scrolls internally if very tall; the bottom row stays put. */}
<div className="max-h-[55dvh] overflow-y-auto px-4 pt-3"> <div className="max-h-[55dvh] overflow-y-auto px-4 pt-3">
<div className="mb-1 text-xs font-medium uppercase tracking-wide text-muted-foreground"> <div className="mb-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">
{kind === "quiz" ? "Quiz" : kind === "explain" ? "Explain" : "Question"} {kind === "quiz" ? (qd.questionType === "predict" ? "Prediction" : "Quiz") : kind === "explain" ? "Explain" : "Question"}
{qd.mode === "multi-select" && " · select all that apply"} {qd.mode === "multi-select" && " · select all that apply"}
</div> </div>
<div className="mb-3 font-medium"> <div className="mb-3 font-medium">
+77
View File
@@ -0,0 +1,77 @@
"use client";
import { useCallback } from "react";
import { joinDictation, useDictationTarget } from "@/components/dictation";
/**
* The reading-thinking field (owner design 2026-10-03, "two questions per
* teaching unit"): a quiet textarea for thinking WHILE READING the
* teaching prose — the same transfer channel the question dock's "Your
* thinking" gives the answer, opened one phase earlier. It mounts while a
* teach-phase check waits behind its reveal (the same cue that collapses
* the check), rides the check answer as the SEPARATE `readingThinking`
* channel (never conflated with the quiz thinking), and disappears with
* the question.
*
* Dictation: while the check sits UNREVEALED this field is the primary
* target (the dock releases its claim — dictated reading-thoughts land
* here, not in the hidden quiz field); once the learner reveals the
* check, the dock reclaims and this field is plain typing.
*/
export function ReadingThinkingField({
value,
onChange,
onEscape,
claim,
}: {
value: string;
onChange: (v: string) => void;
/** Esc in the field stops dictation (threaded from the page's hook). */
onEscape?: () => void;
/** Primary dictation claim — true while the check is behind its reveal. */
claim: boolean;
}) {
useDictationTarget(
useCallback(
(t: string) => {
onChange(joinDictation(value, t));
},
[value, onChange],
),
claim,
);
return (
<div
data-testid="reading-thinking-field"
className="pointer-events-auto mb-2 rounded-2xl border bg-card/60 px-3 py-2 transition-colors focus-within:border-primary/50"
>
<div className="pb-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">
Thinking while reading
</div>
<textarea
value={value}
onChange={(e) => onChange(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Escape") {
// Esc stops dictation (the strip's rule) — claimed so the
// window chain doesn't also read it as abort-turn.
e.preventDefault();
e.stopPropagation();
onEscape?.();
return;
}
// The dock's dumping convention: plain Enter is a newline.
// No submit button here — the check's own submit is this
// field's carrier — so ⌘↵ just blurs.
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
e.currentTarget.blur();
}
}}
placeholder="What are you thinking as you read? Say as much as you like — it rides your next check answer to the teacher."
rows={1}
className="field-sizing-content lh-input max-h-40 w-full resize-none bg-transparent outline-none placeholder:text-muted-foreground/70"
/>
</div>
);
}
+18 -4
View File
@@ -1,13 +1,27 @@
## The question tools ## The question tools
Two instruments. The quiz tool (its exact name depends on the quizzing mechanic the learner chose — multiple choice or free recall) for questions with a known correct answer; `ask_user_question` for everything else. Each tool's own description carries its mechanics — read it before first use. Two instruments. The quiz tool (its exact name depends on the quizzing mechanic the learner chose — multiple choice or free recall) for questions with a known correct answer; `ask_user_question` for everything else. Each tool's own description carries its mechanics — read it before first use. What the questions should BE is here.
### Quiz checks — shared doctrine ### Building graded questions — the invariant craft
Applies to every graded question you ask, whatever its role:
- Multiple choice carries a separate `explanation` (why the correct option is correct, written BEFORE `correctAnswer`: reason it out first, then commit). Free recall has ONE field — `correctAnswer`, the committed answer with its reasoning inside. - Multiple choice carries a separate `explanation` (why the correct option is correct, written BEFORE `correctAnswer`: reason it out first, then commit). Free recall has ONE field — `correctAnswer`, the committed answer with its reasoning inside.
- Option-style answers may carry a free-text `note` — read it; it says what he was thinking. - **The option craft.** The giveaway is never the distractors' wrongness, it's asymmetry: the correct option carrying its reasoning while the others are bare, or key-term bolding that flags it. Build evenness in: (1) every option is a bare claim — zero "why" in any of them; all reasoning goes in the explanation, shown only after he answers; (2) write the correct claim first, then mutate it into each distractor — what would someone holding a specific misconception claim? Same skeleton, same grain, same register; (3) each distractor is a real, BELIEVABLE error — the answer he would actually give under a specific wrong model, wrong on knowledge, never obviously wrong on sight. An option a cold reader eliminates instantly is a wasted option: his pick is diagnostic only when the wrong model is what selects it; (4) no asymmetric bolding. If you can tell the right one cold without knowing the material, regenerate — don't patch.
- ONE question per turn: ask, then end your turn — his answer determines the next. - **Free-recall grading.** Grade his words strictly against the committed answer; never mark a wrong answer right to be kind — a near-miss is partial understanding: name exactly what he got and what he missed. If he genuinely doesn't know, he says so in his own words — that is a genuine gap, not a wrong answer.
### His thinking channels
His thinking is the most precise transfer of his understanding you get — read it all:
- Answers may carry `thinking` — his reasoning on the question itself.
- Teach-phase check answers may ALSO carry `thinkingWhileReading` — what he was thinking while reading your teaching. Two distinct channels; never conflate them.
- His thinking fields are often TRANSCRIBED SPEECH (he dictates, thinking out loud). Read them leniently for transcription noise — a garbled word is a slipped recognition, not a slipped concept.
### `ask_user_question` ### `ask_user_question`
Asks a single question and waits. He can always pick "Other" and type. If you recommend an option, list it first with "(Recommended)" appended. Multiple questions → multiple calls. Asks a single question and waits. He can always pick "Other" and type. If you recommend an option, list it first with "(Recommended)" appended. Multiple questions → multiple calls.
### ONE question per turn
Ask, then end your turn — his answer determines the next.
+4 -4
View File
@@ -21,13 +21,13 @@ Choose per topic and per his apparent energy:
A Socratic step is about *who* speaks first, not about grading: a question with a definite right answer is still gradable — use `quiz`, not `ask_user_question` — even when it's an open-ended prompt he answers freely, which you then frame as options. Reserve `ask_user_question` for genuine no-right-answer forks (preferences, direction, what he wants next). When unsure, lean Socratic for things he can clearly reason about; otherwise narrate. A Socratic step is about *who* speaks first, not about grading: a question with a definite right answer is still gradable — use `quiz`, not `ask_user_question` — even when it's an open-ended prompt he answers freely, which you then frame as options. Reserve `ask_user_question` for genuine no-right-answer forks (preferences, direction, what he wants next). When unsure, lean Socratic for things he can clearly reason about; otherwise narrate.
## How checks arrive — the learner's pacing ## How questions arrive — the learner's pacing
Once the plan has been presented, everything you write is teaching, and every question you ask arrives **collapsed behind a "Check your understanding" button** — the learner opens it when he's ready. Nothing interrupts him mid-reading — which puts the burden on you: Once the plan has been presented, everything you write is teaching. The two question roles arrive differently (the predict/check doctrine, above): **predictions arrive open** — they precede the teaching and gate it — while **checks arrive collapsed behind a "Check your understanding" button** — the learner opens it when he's ready, after reading. Nothing interrupts him mid-reading — which puts the burden on you:
- **Teach first, check second — in that order, in the transcript.** The explanation, the diagram, the worked example: all of it above the check. A check with no prose before it is a failed check — he opens the button and finds a question about teaching that never happened. - **Teach first, check second — in that order, in the transcript.** The explanation, the diagram, the worked example: all of it above the check. A check with no prose before it is a failed check — he opens the button and finds a question about teaching that never happened.
- **Finish the thought before you fire.** Never call a check mid-explanation. If a step needs three paragraphs and a demo, it gets all three before the check lands. - **Finish the thought before you fire.** Never call a check mid-explanation. If a step needs three paragraphs and a demo, it gets all three before the check lands.
- **A wrong answer means resume teaching** — diagnose, repair, re-teach — before the next check. The button is a gate, not a quiz-show buzzer. - **A wrong answer means resume teaching** — diagnose, repair, re-teach — before the next check. The button is a gate, not a quiz-show buzzer.
- **One step, one check.** The check fires when the step's prose is done; teaching advances in a rhythm of step → check → repair-or-continue. - **One step, one check.** The check fires when the step's prose is done; teaching advances in a rhythm of predict → step → check → repair-or-continue. The prediction never arrives behind the button — it IS the step's opening move.
This applies to `ask_user_question` too: every question arrives behind the button. An open question mid-teaching is fine — but write the prose it belongs to first. This collapse law applies to `ask_user_question` too: it arrives behind the button like a check. (Predictions are the one open arrival.) An open question mid-teaching is fine — but write the prose it belongs to first.
@@ -0,0 +1,22 @@
# The two questions — predict, then check (the teaching cycle)
Every teaching unit is a **predict → teach → check** cycle, and the two questions have entirely different jobs. Declaring the wrong `type` for the job breaks the learner's UI (a check arrives collapsed after reading; a prediction arrives open before it) — the roles are not interchangeable.
## The prediction (`type: "predict"`) — motivation
- Posed BEFORE you teach the step, as its own turn. He answers first; your teaching then resolves it.
- Its subject is the NEXT step — not yet taught. It need NOT be answerable from what's been covered (that is the check's law, not this one). Ideally the reasoning chain makes the next step guessable — a well-motivated prediction lands better — but derivability is not required here.
- Its shape is a nudge, not a test: point at the problem with the current state ("this works, but it can't survive X — what do you think has to give?"), and let him try to discover the next step himself. Direct him toward the right territory; reveal nothing more. A prediction he can't fully attempt is fine — the attempt is the point.
- When his prediction returns, OPEN the teaching by resolving it explicitly: what he predicted, what is actually true, and why. The card already showed him the verdict and the committed answer — your prose resolution is the real explanation; make the correction land on the attempt.
- Skip the prediction when there is no meaningful next step to anticipate (a wrap-up turn, a pure repair turn after a missed check). Teacher's judgment — never force one, never let skipping become the habit.
## The check (`type: "check"`) — data for you
- Posed AFTER the step's teaching is complete. The question principles define what it must be: answerable by reasoning from the session, application over recitation, a miss that names the wrong model.
- Its verdict is your continuation data: continue, repair, or reclarify. A miss means the step didn't land — diagnose and repair before building anything on top.
## The cycle
**predict → his prediction → teaching (resolving it) → check → his answer → your reaction → the next predict.**
One question per turn, every turn, in that order. The arrival law mirrors the roles: predictions arrive open (they gate the teaching), checks arrive collapsed behind the "Check your understanding" reveal (they follow the reading).
@@ -1,5 +1,7 @@
# Question principles — what a teach-phase check must be # Question principles — what a teach-phase check must be
These govern `type: "check"` questions (and explain). Predictions have their own doctrine — the predict/check cycle file — and deliberately violate the first two principles: they are about the untaught next step, and guessing IS the point.
Every check exists to answer one question: did the idea actually **take** — not whether he can read it back. The principles: Every check exists to answer one question: did the idea actually **take** — not whether he can read it back. The principles:
- **Answerable by reasoning from the session.** Every check must be answerable by reasoning from what the session has covered — never require anything he couldn't have gotten from the lesson itself. A check he has no route to is not a check, it's a coin flip. - **Answerable by reasoning from the session.** Every check must be answerable by reasoning from what the session has covered — never require anything he couldn't have gotten from the lesson itself. A check he has no route to is not a check, it's a coin flip.
+52 -54
View File
@@ -93,47 +93,61 @@ export const askUserQuestionTool: AgentTool<typeof askInputSchema> = {
}, },
}; };
// ─── the prober's question tools (no nodeId — no map exists yet) ───────────── // ─── the question tools ───────────────────────────────────────────────────────
//
// MECHANICAL DESCRIPTIONS ONLY (owner 2026-10-03): a tool's description
// carries what the tool DOES and how its fields work — nothing about what
// the questions should BE. The craft (option-building, free-recall
// grading, question roles) lives in the system prompt — a single-sourced
// chunk both agents compose (prompts/shared/20-question-tools.md and the
// teacher's question fragments), reversing the 2026-09-27 placement now
// that the question family has grown (predict/check roles, future
// practice variants): duplicates stay honest by sharing SCHEMA ATOMS
// (question-core) + PROMPT CHUNKS, and each tool declares only its
// mechanical delta.
/** The option-building craft (owner 2026-09-27: moved OUT of the system /** The quiz mechanics every variant shares; `delta` appends the variant's
* prompt into the tool description — pi-native doctrine travels with * own fields. All mechanics, zero doctrine. */
* the armed tool). Shared verbatim by both quiz variants. */ function quizDescription(delta: { nodeId: boolean; types: boolean }): string {
const QUIZ_OPTION_CRAFT = return (
"Building options — the giveaway is never the distractors' wrongness, it's asymmetry: the correct option " + "Ask the learner a GRADED question with a known correct answer, then instantly grade and give feedback. " +
"carrying its reasoning while the others are bare, or key-term bolding that flags it. Build evenness in: " + "Unlike ask_user_question (which collects preferences/decisions with no right answer), quiz always has a " +
"(1) every option is a bare claim — zero 'why' in any of them; all reasoning goes in explanation, shown only " + "correct answer supplied by you, marks the learner's selection right/wrong, reveals the correct answer, and " +
"after he answers; (2) write the correct claim first, then mutate it into each distractor — what would someone " + "can show an explanation. Options-only: single-select or multi-select, plus an automatic 'I don't know' " +
"holding a specific misconception claim? Same skeleton, same grain, same register; (3) each distractor is a real " + "choice so the learner can signal a genuine gap instead of guessing. An always-present optional thinking " +
"error he might actually make (his pick is diagnostic) yet unambiguously wrong; (4) no asymmetric bolding. If " + "field lets the learner attach his reasoning to ANY answer; it reaches you only when non-empty. " +
"you can tell the right one cold without knowing the material, regenerate — don't patch."; "No free-text answers — for non-graded questions use ask_user_question instead. " +
(delta.types
? "Every call declares `type` — the question's role in the teaching cycle; the field's own description says when each kind fires. "
: "") +
(delta.nodeId
? "Set `nodeId` (required) to the plan node this question is about — validated at call time against the session's plan map. For off-map checks use ask_user_question. "
: "") +
"ONE question per turn: call a single quiz, then end your turn — his answer determines the next."
);
}
/** The explain doctrine (owner 2026-09-27): free-recall quizzing — the /** The explain mechanics (free-recall quizzing): the commitment rule is
* sycophancy mitigation is the commitment. Carried by the DESCRIPTION * the GRADING CONTRACT — mechanics, not doctrine. The softer doctrine
* (pi-native ruling), not the system prompt. Shared by both variants; * (near-miss reading, genuine gaps) lives in the shared prompt chunk. */
* the teach variant appends the nodeId tagging paragraph. */ function explainDescription(nodeId: boolean): string {
const EXPLAIN_DOCTRINE = return (
"Ask the learner to EXPLAIN something in his own words — a graded check with NO options: he types a free-text " + "Ask the learner to EXPLAIN something in his own words — a graded check with NO options: he types a free-text " +
"answer. Unlike ask_user_question (no right answer), explain always has one. THE COMMITMENT RULE: write correctAnswer — the FULL " + "answer. Unlike ask_user_question (no right answer), explain always has one. THE COMMITMENT RULE: " +
"correct answer in your own words, reasoning included — committed before he ever sees the question. When his answer returns, grade it strictly against your committed answer in your reply: never " + "correctAnswer is the FULL correct answer in your own words, reasoning included, COMMITTED before he ever " +
"revise the commitment after seeing his words, and never mark a wrong answer right to be kind — a near-miss is " + "sees the question. When his answer returns, grade it strictly against the commitment — never revise it " +
"partial understanding; name exactly what he got and what he missed. He sees the committed answer on submit. If he genuinely doesn't know, he says so in his own words — that is a genuine " + "after seeing his words. He sees the committed answer on submit. " +
"gap, not a wrong answer. " + (nodeId
"ONE question per turn: call a single explain, then end your turn — his answer determines the next."; ? "Set `nodeId` (required) to the plan node this question is about — validated at call time against the session's plan map. "
: "") +
"ONE question per turn: call a single explain, then end your turn — his answer determines the next."
);
}
export const probeQuizTool: AgentTool<typeof probeQuizInputSchema> = { export const probeQuizTool: AgentTool<typeof probeQuizInputSchema> = {
name: "quiz", name: "quiz",
label: "quiz", label: "quiz",
description: description: quizDescription({ nodeId: false, types: false }),
"Ask the learner a GRADED question with a known correct answer, then instantly grade and give feedback. " +
"Unlike ask_user_question (which collects preferences/decisions with no right answer), quiz always has a " +
"correct answer supplied by you, marks the learner's selection right/wrong, reveals the correct answer, and " +
"can show an explanation. Use it to locate the edge of his understanding: bracket each strand the lesson will " +
"depend on — something he gets right (a floor), something he gets wrong or doesn't know (a ceiling). " +
"Options-only: single-select or multi-select, plus an automatic 'I don't know' choice so the learner can " +
"signal a genuine gap instead of guessing. An always-present optional note field lets the learner attach a " +
"free-text note to ANY answer; it reaches you only when non-empty. No free-text answers — for non-graded " +
"questions use ask_user_question instead. ONE question per turn: call a single quiz, then end your turn — " +
"his answer determines the next question to ask. " + QUIZ_OPTION_CRAFT,
parameters: probeQuizInputSchema, parameters: probeQuizInputSchema,
prepareArguments: prepareQuestionArguments, prepareArguments: prepareQuestionArguments,
executionMode: "sequential", executionMode: "sequential",
@@ -152,11 +166,7 @@ export function teachExplainToolFor(
return { return {
name: "explain", name: "explain",
label: "explain", label: "explain",
description: description: explainDescription(true),
EXPLAIN_DOCTRINE +
" Set `nodeId` (required) to the plan node this question is about — the lesson's position on the map derives " +
"from those tags; a node takes as many questions as it deserves, so don't rush forward. Off-map checks use " +
"ask_user_question.",
parameters: explainInputObject, parameters: explainInputObject,
prepareArguments: prepareQuestionArguments, prepareArguments: prepareQuestionArguments,
executionMode: "sequential", executionMode: "sequential",
@@ -175,7 +185,7 @@ export function teachExplainToolFor(
export const probeExplainTool: AgentTool<typeof explainInputBase> = { export const probeExplainTool: AgentTool<typeof explainInputBase> = {
name: "explain", name: "explain",
label: "explain", label: "explain",
description: EXPLAIN_DOCTRINE, description: explainDescription(false),
parameters: explainInputBase, parameters: explainInputBase,
prepareArguments: prepareQuestionArguments, prepareArguments: prepareQuestionArguments,
executionMode: "sequential", executionMode: "sequential",
@@ -299,19 +309,7 @@ export function teachQuizToolFor(ctx: Pick<PlanToolContext, "chatId" | "chatStor
return { return {
name: "quiz", name: "quiz",
label: "quiz", label: "quiz",
description: description: quizDescription({ nodeId: true, types: true }),
"Ask the learner a GRADED question with a known correct answer, then instantly grade and give feedback. " +
"Unlike ask_user_question (which collects preferences/decisions with no right answer), quiz always has a " +
"correct answer supplied by you, marks the learner's selection right/wrong, reveals the correct answer, and " +
"can show an explanation. Use it to (1) confirm a taught node actually landed, or (2) probe understanding " +
"whenever you're unsure. Set `nodeId` (required) to the plan node this question is about — the lesson's " +
"position on the map derives from those tags; a node takes as many questions as it deserves, so don't rush " +
"forward. Off-map checks use ask_user_question. Options-only: single-select or multi-select, " +
"plus an automatic 'I don't know' choice so the learner can signal a genuine gap instead of guessing. " +
"An always-present optional note field lets the learner attach a free-text note to ANY answer; it reaches you " +
"only when non-empty. No free-text answers — for non-graded questions use ask_user_question instead. " +
"ONE question per turn: call a single quiz, then end your turn — his answer determines the next. " +
QUIZ_OPTION_CRAFT,
parameters: quizInputObject, parameters: quizInputObject,
prepareArguments: prepareQuestionArguments, prepareArguments: prepareQuestionArguments,
executionMode: "sequential", executionMode: "sequential",
+91 -15
View File
@@ -189,17 +189,50 @@ export const quizInputBase = Type.Object({
), ),
}); });
// The TEACH-side quiz object: the shared base + the plan-position field. // The TEACH-side quiz object: the shared base + the plan-position field
// `nodeId` means "this question is ABOUT this node" — a position signal, // + the ROLE field (owner design 2026-10-03, "two questions per teaching
// never a completion signal (docs/probe-teach-split.md §3). The cross-field // unit"): `type` declares the question's role in the cycle. The ROLE
// authoring contract (the Gate-3 hardening: a quiz whose correctAnswer // DOCTRINE lives in the system prompt (owner: tool descriptions stay
// can't be graded NEVER reaches the learner — schema-valid ⇔ gradeable, the // purely MECHANICAL — no instructions on what the questions should be);
// rules exactly what the grading path accepts) runs at the CALL BOUNDARY in // the enum's one-liners below say only WHEN each kind fires.
// the tool's execute: quizInputProblems here, the per-session plan-nodeId // nodeId means "this question is ABOUT this node" — a position signal,
// validation in tools.ts (it reads the live chat record, which this core // never a completion signal (docs/probe-teach-split.md §3). BOTH teaching
// never touches). // types carry it. The cross-field authoring contract (the Gate-3
// hardening: a quiz whose correctAnswer can't be graded NEVER reaches the
// learner — schema-valid ⇔ gradeable, the rules exactly what the grading
// path accepts) runs at the CALL BOUNDARY in the tool's execute:
// quizInputProblems here, the per-session plan-nodeId validation in
// tools.ts (it reads the live chat record, which this core never touches).
export const QUIZ_QUESTION_TYPES = ["predict", "check"] as const;
export type QuizQuestionType = (typeof QUIZ_QUESTION_TYPES)[number];
/** Normalize a persisted quiz `type` (display seam): old records carry
* none, and model wobble degrades to undefined — callers render "Quiz"
* and the check-style card. Only exact literals count. */
export function normalizeQuizType(raw: unknown): QuizQuestionType | undefined {
return raw === "predict" ? "predict" : raw === "check" ? "check" : undefined;
}
/** The reveal law as a pure predicate (owner 2026-10-03): a teach-phase
* CHECK collapses behind the "Check your understanding" reveal — a
* prediction NEVER does (it precedes the teaching and gates it;
* collapsing it would invert the cycle), and neither do probe/plan
* questions, ask, or explain. chat.tsx calls this for its pending
* question; the reading-thinking field mounts on the same truth. */
export function quizNeedsReveal(tool: string, rawInput: unknown, phase: string): boolean {
if (tool !== "quiz" || phase !== "teach") return false;
return normalizeQuizType((rawInput as { type?: unknown } | null)?.type) !== "predict";
}
export const quizInputObject = Type.Object({ export const quizInputObject = Type.Object({
...quizInputBase.properties, ...quizInputBase.properties,
type: Type.Union([Type.Literal("predict"), Type.Literal("check")], {
description:
"REQUIRED — the question's role in the teaching cycle. \"predict\": posed BEFORE you teach the step " +
"(its own turn; the learner answers, then your teaching resolves it — arrives open, never collapsed). " +
"\"check\": posed AFTER the step's teaching is complete (arrives collapsed behind the \"Check your " +
"understanding\" reveal). ONE question per turn either way.",
}),
nodeId: Type.String({ nodeId: Type.String({
description: description:
"REQUIRED plan-tree node id this question is about — must EXACTLY match one of this session's " + "REQUIRED plan-tree node id this question is about — must EXACTLY match one of this session's " +
@@ -212,11 +245,26 @@ export const quizInputObject = Type.Object({
}); });
/** The PROBE-side quiz declaration: the shared authoring contract, no /** The PROBE-side quiz declaration: the shared authoring contract, no
* nodeId field at all — the session's map doesn't exist yet. Structurally * nodeId and no `type` field at all — the session's map doesn't exist
* identical to quizInputBase (the old superRefine never reached the wire — * yet, and probing has exactly ONE question role (owner 2026-10-03: the
* it ran at the call boundary, where quizInputProblems runs now). */ * prober keeps its own tool; the teaching cycle's types stay with the
* teacher, so nothing invites misuse). Structurally identical to
* quizInputBase (the old superRefine never reached the wire — it ran at
* the call boundary, where quizInputProblems runs now). */
export const probeQuizInputSchema = quizInputBase; export const probeQuizInputSchema = quizInputBase;
/** The DISPLAY parse for persisted quiz calls (tool-parts.ts): the base
* + `type` as an OPTIONAL plain string (old records carry none, and
* Value.Clean keeps a key only when the schema knows it — the dock would
* otherwise lose the role it renders and gates by). Normalization is
* normalizeQuizType at the read seam; nodeId stays stripped (unknown
* key). The PROBE quiz parses against the bare base — probe calls carry
* no type. */
export const quizDisplayInputSchema = Type.Object({
...quizInputBase.properties,
type: Type.Optional(Type.String()),
});
// ─── the explain tool (free-recall quizzing, owner 2026-09-27) ───────────── // ─── the explain tool (free-recall quizzing, owner 2026-09-27) ─────────────
// //
// Quiz mechanics WITHOUT options: the learner answers in his own words. // Quiz mechanics WITHOUT options: the learner answers in his own words.
@@ -326,6 +374,11 @@ export type QuestionMode = SelectMode | "text";
export interface QuestionData { export interface QuestionData {
id: string; id: string;
kind: QuestionKind; kind: QuestionKind;
/** quiz only (teach phase, owner 2026-10-03): the question's role in
* the cycle — predict (before the teaching, never collapsed) or check
* (after it, behind the reveal). Absent on probe quizzes and old
* records — everything check-shaped. */
questionType?: QuizQuestionType;
question: string; question: string;
context?: string; context?: string;
mode: QuestionMode; mode: QuestionMode;
@@ -344,13 +397,27 @@ export interface QuestionData {
* toolCallId restated — the toolResult message already carries it. */ * toolCallId restated — the toolResult message already carries it. */
export interface QuestionAnswerData { export interface QuestionAnswerData {
kind: QuestionKind; kind: QuestionKind;
/** quiz only (teach phase): the role snapshot for rendering (a
* prediction tucks its explanation) and later calibration splits.
* Absent on old records and probe answers. */
questionType?: QuizQuestionType;
questionText: string; questionText: string;
options?: { index: number; label: string }[]; // snapshot for rendering options?: { index: number; label: string }[]; // snapshot for rendering
dontKnow: boolean; dontKnow: boolean;
answers: OptionAnswer[]; answers: OptionAnswer[];
other?: string; // ask: "Other" free text other?: string; // ask: "Other" free text
text?: string; // ask/explain: text-mode answer text?: string; // ask/explain: text-mode answer
/** The learner's reasoning on the QUESTION (renamed from `note`, owner
* 2026-10-03 — "that's what it is"). */
thinking?: string;
/** @deprecated the pre-rename key — never written again; old records
* replay through the same types, so it stays declared. Read via
* `thinking ?? note`. */
note?: string; note?: string;
/** The learner's thinking WHILE READING the teaching prose (owner
* 2026-10-03) — a separate channel from `thinking`, captured by the
* reading field and riding the check answer. Absent when empty. */
readingThinking?: string;
confidence?: number; // quiz only: the learner's stated probability (0-100, raw) that his selected answer is correct — never sent on "I don't know" (owner, 2026-10-01) confidence?: number; // quiz only: the learner's stated probability (0-100, raw) that his selected answer is correct — never sent on "I don't know" (owner, 2026-10-01)
correct?: boolean; // quiz only correct?: boolean; // quiz only
correctIndices?: number[]; // quiz only (rendering) correctIndices?: number[]; // quiz only (rendering)
@@ -376,7 +443,8 @@ export interface AnswerRequest {
values?: string[]; // option values values?: string[]; // option values
other?: string; // ask "Other" other?: string; // ask "Other"
text?: string; // ask text-mode text?: string; // ask text-mode
note?: string; thinking?: string; // the reasoning-on-the-question channel (was `note`)
readingThinking?: string; // the thinking-while-reading channel (check answers)
confidence?: number; // quiz only: 0-100 raw probability — 50 is a real coin flip, always sent (owner, 2026-10-01) confidence?: number; // quiz only: 0-100 raw probability — 50 is a real coin flip, always sent (owner, 2026-10-01)
} }
@@ -567,11 +635,13 @@ function formatOptionRef(options: QuizOption[], index: number): string {
export function buildAnswer(qd: QuestionData, req: AnswerRequest): QuestionAnswerData { export function buildAnswer(qd: QuestionData, req: AnswerRequest): QuestionAnswerData {
const base: QuestionAnswerData = { const base: QuestionAnswerData = {
kind: qd.kind, kind: qd.kind,
questionType: qd.questionType,
questionText: qd.question, questionText: qd.question,
options: qd.options.map((o, i) => ({ index: i + 1, label: o.label })), options: qd.options.map((o, i) => ({ index: i + 1, label: o.label })),
dontKnow: req.dontKnow === true, dontKnow: req.dontKnow === true,
answers: [], answers: [],
note: req.note?.trim() || undefined, thinking: req.thinking?.trim() || undefined,
readingThinking: req.readingThinking?.trim() || undefined,
answeredAt: new Date().toISOString(), answeredAt: new Date().toISOString(),
}; };
// resolve selected option values → OptionAnswer (in display order) // resolve selected option values → OptionAnswer (in display order)
@@ -647,6 +717,12 @@ export function compactAnswerForModel(ad: QuestionAnswerData): Record<string, un
// incorrect and carries no confidence: both stay implicit.) // incorrect and carries no confidence: both stay implicit.)
if (ad.correct !== undefined && !ad.dontKnow) out.correct = ad.correct; if (ad.correct !== undefined && !ad.dontKnow) out.correct = ad.correct;
if (ad.confidence !== undefined) out.confidence = ad.confidence; if (ad.confidence !== undefined) out.confidence = ad.confidence;
if (ad.note) out.note = ad.note; // Two DISTINCT thinking channels, clearly keyed so the model never
// conflates them (owner 2026-10-03): reasoning on the question vs
// thinking while reading the teaching. `note` is the pre-rename key —
// old records replay through here too, so both feed `thinking`.
const thinking = ad.thinking ?? ad.note;
if (thinking) out.thinking = thinking;
if (ad.readingThinking) out.thinkingWhileReading = ad.readingThinking;
return out; return out;
} }
+7 -2
View File
@@ -29,7 +29,7 @@
import { Value } from "typebox/value"; import { Value } from "typebox/value";
import type { TSchema } from "typebox"; import type { TSchema } from "typebox";
import { quizInputBase, askInputSchema, askOptionProblems, quizInputProblems, explainInputBase, explainInputProblems, type AskToolInput, type QuizToolInput, type ExplainToolInput } from "./question-core.ts"; import { quizInputBase, quizDisplayInputSchema, askInputSchema, askOptionProblems, quizInputProblems, explainInputBase, explainInputProblems, type AskToolInput, type QuizToolInput, type ExplainToolInput } from "./question-core.ts";
import { restoreMathEscapes } from "./math-delims.ts"; import { restoreMathEscapes } from "./math-delims.ts";
import { probeDoneInputSchema, type ProbeDoneInput } from "./contracts/probe.ts"; import { probeDoneInputSchema, type ProbeDoneInput } from "./contracts/probe.ts";
@@ -84,7 +84,12 @@ function restoreMathEscapesDeep(value: unknown): unknown {
} }
export function parseQuizInput(input: unknown): QuizToolInput | undefined { export function parseQuizInput(input: unknown): QuizToolInput | undefined {
const cleaned = boundaryParse<QuizToolInput>(quizInputBase, restoreMathEscapesDeep(input)); // quizDisplayInputSchema (question-core, owner 2026-10-03): base + the
// OPTIONAL `type` — new teach-phase records carry predict|check and the
// dock renders/gates by it (normalizeQuizType at the read seam); old and
// probe records carry none. nodeId stays an unknown key Value.Clean
// strips, exactly as before.
const cleaned = boundaryParse<QuizToolInput>(quizDisplayInputSchema, restoreMathEscapesDeep(input));
if (!cleaned) return undefined; if (!cleaned) return undefined;
if (quizInputProblems(cleaned).length > 0) return undefined; if (quizInputProblems(cleaned).length > 0) return undefined;
return cleaned; return cleaned;
+3
View File
@@ -62,6 +62,7 @@ const QUIZ_INPUT = {
], ],
correctAnswer: "Mercury", correctAnswer: "Mercury",
explanation: "Mercury is the innermost planet.", explanation: "Mercury is the innermost planet.",
type: "check",
nodeId: "planet-order", nodeId: "planet-order",
}; };
@@ -619,6 +620,7 @@ describe("teacher chat flow (scripted model)", () => {
// same turn. This pins that whole mechanism (the live Nietzsche bug). // same turn. This pins that whole mechanism (the live Nietzsche bug).
const BAD_QUIZ_INPUT = { const BAD_QUIZ_INPUT = {
question: "What is Nietzsche's genealogical claim about religion?", question: "What is Nietzsche's genealogical claim about religion?",
type: "check",
nodeId: "genealogy", nodeId: "genealogy",
options: [{ label: "A cultural diagnosis" }, { label: "Atheism as a conclusion" }], options: [{ label: "A cultural diagnosis" }, { label: "Atheism as a conclusion" }],
correctAnswer: "cultural-diagnosis", // the exact live failure shape: a slug, no match correctAnswer: "cultural-diagnosis", // the exact live failure shape: a slug, no match
@@ -695,6 +697,7 @@ describe("teacher chat flow (scripted model)", () => {
// question, and the model's retry must become the pending quiz. // question, and the model's retry must become the pending quiz.
const STRINGY_QUIZ_INPUT = { const STRINGY_QUIZ_INPUT = {
question: "Lewis's indexical analysis of 'actual' — which is it?", question: "Lewis's indexical analysis of 'actual' — which is it?",
type: "check",
nodeId: "indexicals", nodeId: "indexicals",
options: JSON.stringify([ options: JSON.stringify([
{ label: "Like 'I'", value: "indexical" }, { label: "Like 'I'", value: "indexical" },
+1 -1
View File
@@ -185,7 +185,7 @@ function fixtureMessages(): LhMessage[] {
quizPart( quizPart(
"call-3", "call-3",
{ question: "Q3", nodeId: "alpha", options: [{ label: "a" }, { label: "b" }], correctAnswer: "a", explanation: "e" }, { question: "Q3", nodeId: "alpha", options: [{ label: "a" }, { label: "b" }], correctAnswer: "a", explanation: "e" },
answer({ questionText: "Q3 text", correct: true, note: "recalled the diagram", answeredAt: "2026-08-22T02:00:00.000Z" }), answer({ questionText: "Q3 text", correct: true, thinking: "recalled the diagram", answeredAt: "2026-08-22T02:00:00.000Z" }),
), ),
]), ]),
assistant("a4", [ assistant("a4", [
+21
View File
@@ -1280,6 +1280,27 @@ describe("prompt composition", () => {
assert.ok(!prober.includes("Question principles"), "the teach-phase fragment never composes into the prober"); assert.ok(!prober.includes("Question principles"), "the teach-phase fragment never composes into the prober");
}); });
it("the predict-check cycle composes into the teacher; the craft chunk reaches BOTH agents (owner 2026-10-03)", () => {
const teacher = composeInstructions("teacher");
// the cycle doctrine (28-predictions.md) — teacher-only
assert.ok(teacher.includes("predict → teach → check"), "the cycle line is in");
assert.ok(teacher.includes('The prediction (`type: "predict"`)'), "the prediction role section is in");
assert.ok(teacher.includes("Skip the prediction"), "the skip rule is in");
assert.ok(teacher.includes('The check (`type: "check"`)'), "the check role section is in");
// the craft + thinking channels (shared/20-question-tools.md) — BOTH agents
const prober = composeInstructions("prober");
for (const [name, prompt] of [
["teacher", teacher],
["prober", prober],
] as const) {
assert.ok(prompt.includes("asymmetry"), `${name} carries the option craft (moved out of tool descriptions)`);
assert.ok(prompt.includes("TRANSCRIBED SPEECH"), `${name} reads dictated thinking leniently`);
assert.ok(prompt.includes("thinkingWhileReading"), `${name} knows the reading channel`);
}
// the cycle is teach-phase doctrine — it never composes into the prober
assert.ok(!prober.includes("predict → teach → check"), "the teaching cycle stays out of the prober");
});
it("the sources-v0 prompt lines compose: read/pdf doctrine in the teacher, the read line in the prober", () => { it("the sources-v0 prompt lines compose: read/pdf doctrine in the teacher, the read line in the prober", () => {
const teacher = composeInstructions("teacher"); const teacher = composeInstructions("teacher");
assert.ok(teacher.includes("Learner source files"), "the teacher carries the source-files section"); assert.ok(teacher.includes("Learner source files"), "the teacher carries the source-files section");
+105 -4
View File
@@ -24,6 +24,9 @@ import {
explainInputProblems, explainInputProblems,
quizInputSchema, quizInputSchema,
probeQuizInputSchema, probeQuizInputSchema,
quizDisplayInputSchema,
normalizeQuizType,
quizNeedsReveal,
resolveCorrect, resolveCorrect,
toggleOption, toggleOption,
toggleOther, toggleOther,
@@ -64,6 +67,7 @@ describe("the schema's argument order (reasoning before the pick)", () => {
"explanation", "explanation",
"correctAnswer", "correctAnswer",
"details", "details",
...(keys.includes("type") ? ["type"] : []),
...(keys.includes("nodeId") ? ["nodeId"] : []), ...(keys.includes("nodeId") ? ["nodeId"] : []),
]); ]);
} }
@@ -76,6 +80,7 @@ describe("quizInputSchema / quizInputProblems (the authoring contract)", () => {
options: [{ label: "Venus" }, { label: "Mercury" }, { label: "Mars" }], options: [{ label: "Venus" }, { label: "Mercury" }, { label: "Mars" }],
correctAnswer: "Mercury", correctAnswer: "Mercury",
explanation: "Mercury is the innermost planet.", explanation: "Mercury is the innermost planet.",
type: "check",
nodeId: "planet-order", nodeId: "planet-order",
}; };
@@ -366,8 +371,8 @@ describe("buildAnswer", () => {
}); });
it("keeps the note only when non-empty", () => { it("keeps the note only when non-empty", () => {
assert.equal(buildAnswer(quizData(), { values: ["mercury"], note: " " }).note, undefined); assert.equal(buildAnswer(quizData(), { values: ["mercury"], thinking: " " }).thinking, undefined);
assert.equal(buildAnswer(quizData(), { values: ["mercury"], note: " x " }).note, "x"); assert.equal(buildAnswer(quizData(), { values: ["mercury"], thinking: " x " }).thinking, "x");
}); });
it("ignores unknown option values", () => { it("ignores unknown option values", () => {
@@ -418,14 +423,14 @@ describe("buildAnswer", () => {
describe("compactAnswerForModel", () => { describe("compactAnswerForModel", () => {
it("quiz: the learner's selection, verdict, confidence, note — NOTHING the model authored", () => { it("quiz: the learner's selection, verdict, confidence, note — NOTHING the model authored", () => {
const ad = buildAnswer(quizData(), { values: ["mercury"], confidence: 70, note: "guessed" }); const ad = buildAnswer(quizData(), { values: ["mercury"], confidence: 70, thinking: "guessed" });
const compact = compactAnswerForModel(ad); const compact = compactAnswerForModel(ad);
assert.deepEqual(compact, { assert.deepEqual(compact, {
kind: "quiz", kind: "quiz",
answers: [{ index: 1, label: "Mercury" }], answers: [{ index: 1, label: "Mercury" }],
correct: true, correct: true,
confidence: 70, confidence: 70,
note: "guessed", thinking: "guessed",
}); });
const raw = JSON.stringify(compact); const raw = JSON.stringify(compact);
// every one of these lives in the tool CALL's arguments already // every one of these lives in the tool CALL's arguments already
@@ -615,3 +620,99 @@ describe("buildAnswer / formatAnswerMessage: explain", () => {
}); });
}); });
// ─── the question's role: predict vs check (owner 2026-10-03) ─────────────
// Teaching units are a predict → teach → check cycle. The teach quiz schema
// carries a REQUIRED `type` declaring the role; the prober's quiz never does
// (probing questions are role-less). The reveal law keys off it: only CHECKS
// collapse behind "Check your understanding" — predictions arrive open.
describe("the quiz role field (predict | check)", () => {
const valid = {
question: "Which planet is closest to the Sun?",
options: [{ label: "Venus" }, { label: "Mercury" }, { label: "Mars" }],
correctAnswer: "Mercury",
explanation: "Mercury is the innermost planet.",
type: "check",
nodeId: "planet-order",
};
it("the teach schema REQUIRES type; the probe schema has no such field", () => {
assert.equal(Value.Check(quizInputSchema, valid), true, "fixture (type: check) is boundary-valid");
assert.equal(
Value.Check(quizInputSchema, { ...valid, type: undefined }),
false,
"a teach quiz without a role is rejected at the boundary",
);
assert.equal(
"nodeId" in (probeQuizInputSchema as { properties: Record<string, unknown> }).properties === false,
true,
);
assert.equal(
"type" in (probeQuizInputSchema as { properties: Record<string, unknown> }).properties,
false,
"the probe quiz stays role-less",
);
});
it("normalizeQuizType: exact literals only — old records and wobble degrade to check-style", () => {
assert.equal(normalizeQuizType("predict"), "predict");
assert.equal(normalizeQuizType("check"), "check");
assert.equal(normalizeQuizType(undefined), undefined);
assert.equal(normalizeQuizType("prediction"), undefined);
assert.equal(normalizeQuizType(42), undefined);
});
it("quizNeedsReveal: checks collapse, predictions never do", () => {
assert.equal(quizNeedsReveal("quiz", { type: "check" }, "teach"), true);
assert.equal(quizNeedsReveal("quiz", { type: "predict" }, "teach"), false, "predictions arrive OPEN");
assert.equal(
quizNeedsReveal("quiz", undefined, "teach"),
true,
"old records (no type) render check-style",
);
assert.equal(quizNeedsReveal("quiz", { type: "predict" }, "probe"), false);
assert.equal(quizNeedsReveal("quiz", { type: "check" }, "probe"), false);
assert.equal(quizNeedsReveal("ask_user_question", {}, "teach"), false);
assert.equal(quizNeedsReveal("explain", {}, "teach"), false);
});
function quizQd(): QuestionData {
const options = normalizeOptions(valid.options);
return {
id: "q1",
kind: "quiz",
question: valid.question,
mode: "single-select",
options,
correctIndices: resolveCorrect(coerceCorrectAnswer(valid.correctAnswer), options).indices,
explanation: valid.explanation,
createdAt: new Date().toISOString(),
};
}
it("buildAnswer snapshots the question's role and carries both thinking channels", () => {
const predictQd: QuestionData = { ...quizQd(), questionType: "predict" };
const a = buildAnswer(predictQd, { values: ["Mercury"], thinking: " guessed ", readingThinking: " reading note " });
assert.equal(a.questionType, "predict");
assert.equal(a.thinking, "guessed", "thinking is trimmed");
assert.equal(a.readingThinking, "reading note");
const b = buildAnswer(quizQd(), { values: ["Mercury"] });
assert.equal(b.thinking, undefined, "empty channels stay absent, never empty strings");
assert.equal(b.readingThinking, undefined);
assert.equal(b.questionType, undefined, "a role-less QuestionData (probe / old record) answers role-less");
});
it("compactAnswerForModel: distinct keys for the two channels; legacy `note` folds into thinking", () => {
const a = buildAnswer(quizQd(), { values: ["Mercury"], thinking: "h", readingThinking: "r" });
const compact = compactAnswerForModel(a) as Record<string, unknown>;
assert.equal(compact.thinking, "h");
assert.equal(compact.thinkingWhileReading, "r");
const legacy = compactAnswerForModel({ ...a, thinking: undefined, note: "old note" }) as Record<string, unknown>;
assert.equal(legacy.thinking, "old note", "records written before the rename still reach the model");
assert.equal("note" in legacy, false, "the legacy key never re-crosses the wire");
});
it("the display parse KEEPS the role: quizDisplayInputSchema retains type where the base strips it", () => {
const cleaned = Value.Clean(quizDisplayInputSchema, { ...valid }) as Record<string, unknown>;
assert.equal(cleaned.type, "check", "role survives the display parse (the dock gates collapse + labels on it)");
});
});
+47
View File
@@ -59,6 +59,7 @@ const validQuiz = {
], ],
correctAnswer: "4", correctAnswer: "4",
explanation: "Arithmetic.", explanation: "Arithmetic.",
type: "check",
nodeId: "arith", nodeId: "arith",
}; };
@@ -383,3 +384,49 @@ describe("the prober's explain: no nodeId field at all", () => {
assert.equal(empty.success, false); assert.equal(empty.success, false);
}); });
}); });
// ─── descriptions are MECHANICS (owner 2026-10-03) ────────────────────────
// Question-design doctrine lives in the system prompt (shared craft chunk +
// teacher fragments), NOT in tool descriptions. A description that teaches
// the model how to write questions drifts the moment a new variant (prober,
// future adaptive practice) reuses the tool with a different doctrine.
describe("tool descriptions carry mechanics only, no question-craft", () => {
const teachQuiz = teachQuizToolFor({ chatId: "chat_x", chatStore: undefined as never });
for (const [name, tool] of [
["teach quiz", teachQuiz],
["probe quiz", probeQuizTool],
] as const) {
it(`${name}: no craft doctrine, mechanics intact`, () => {
const d = tool.description;
for (const banned of ["asymmetry", "distractor", "believable", "misconception"]) {
assert.ok(!d.toLowerCase().includes(banned), `${name} description leaks craft: "${banned}"`);
}
for (const mechanical of ["GRADED", "ONE question per turn", "ask_user_question"]) {
assert.ok(d.includes(mechanical), `${name} description lost mechanics: "${mechanical}"`);
}
});
}
it("the teach quiz declares the `type` field and `nodeId`; the probe quiz declares neither", () => {
assert.ok(teachQuiz.description.includes("`type`"), "teach quiz points at the role field");
assert.ok(teachQuiz.description.includes("`nodeId`"), "teach quiz points at the plan-map validation");
assert.ok(!probeQuizTool.description.includes("`type`"), "the probe quiz has no roles");
assert.ok(!probeQuizTool.description.includes("`nodeId`"), "the probe quiz has no plan map");
});
it("the explain tools keep the COMMITMENT rule (the grading contract) and drop the softer doctrine", () => {
for (const [name, tool] of [
["teach explain", teachExplainToolFor({ chatId: "chat_x", chatStore: undefined as never })],
["probe explain", probeExplainTool],
] as const) {
assert.ok(tool.description.includes("COMMITTED"), `${name} keeps the commitment rule`);
assert.ok(!tool.description.includes("near-miss"), `${name} drops the doctrine (now in the shared prompt)`);
}
});
it("the craft now lives in the shared prompt chunk (both agents compose it)", async () => {
const shared = await fs.promises.readFile(
path.join(PATHS.root, "src/agent/prompts/shared/20-question-tools.md"),
"utf-8",
);
assert.ok(shared.includes("asymmetry"), "the option craft moved here, not deleted");
assert.ok(shared.includes("TRANSCRIBED SPEECH"), "the dictation-leniency note lives here");
});
});
+80 -2
View File
@@ -23,7 +23,14 @@ import { JobsContext } from "../components/jobs-context.ts";
import { ChatActionsContext } from "../components/chat.tsx"; import { ChatActionsContext } from "../components/chat.tsx";
import type { JobRecord } from "../src/core/contracts.ts"; import type { JobRecord } from "../src/core/contracts.ts";
import { AnsweredCard } from "../components/answered-card.tsx"; import { AnsweredCard } from "../components/answered-card.tsx";
import { buildAnswer, coerceCorrectAnswer, normalizeOptions, resolveCorrect, type QuestionData } from "../src/core/question-core.ts"; import {
buildAnswer,
coerceCorrectAnswer,
normalizeOptions,
resolveCorrect,
quizNeedsReveal,
type QuestionData,
} from "../src/core/question-core.ts";
const quizInput = { const quizInput = {
question: "Which planet is closest to the sun?", question: "Which planet is closest to the sun?",
@@ -276,7 +283,7 @@ describe("render smoke tests", () => {
it("AnsweredCard promotes the learner's thinking to a first-class block", () => { it("AnsweredCard promotes the learner's thinking to a first-class block", () => {
const answer = buildAnswer(quizQD(), { const answer = buildAnswer(quizQD(), {
values: ["Venus"], values: ["Venus"],
note: "I picked Venus because it looks brightest — but now I realize brightness isn't distance.", thinking: "I picked Venus because it looks brightest — but now I realize brightness isn't distance.",
}); });
const html = renderToStaticMarkup(<AnsweredCard answer={answer} />); const html = renderToStaticMarkup(<AnsweredCard answer={answer} />);
assert.ok(html.includes('data-testid="your-thinking"'), "the thinking block exists"); assert.ok(html.includes('data-testid="your-thinking"'), "the thinking block exists");
@@ -509,3 +516,74 @@ describe("AnsweredCard: the explain variant", () => {
}); });
}); });
// ─── the two roles in the UI (predict vs check, owner 2026-10-03) ─────────
// Predictions arrive OPEN before the teaching; checks arrive collapsed after
// it. The answered card tells them apart (eyebrow) and tucks a prediction's
// explanation (the teaching that follows IS the explanation) while a check's
// stays inline.
describe("predict vs check rendering", () => {
it("a predict quiz renders the 'Prediction' eyebrow in the dock", () => {
const html = renderToStaticMarkup(
<QuestionDock
{...{
toolName: "quiz" as const,
toolCallId: "call-p1",
input: { ...quizInput, type: "predict", nodeId: "planet-order" },
}}
expanded={true}
onExpand={() => {}}
onSubmit={() => {}}
/>,
);
assert.ok(html.includes("Prediction"), `eyebrow missing: ${html.slice(0, 200)}`);
});
it("a check quiz keeps the 'Quiz' eyebrow (old records unchanged)", () => {
const html = renderToStaticMarkup(
<QuestionDock
{...{
toolName: "quiz" as const,
toolCallId: "call-c1",
input: { ...quizInput, type: "check", nodeId: "planet-order" },
}}
expanded={true}
onExpand={() => {}}
onSubmit={() => {}}
/>,
);
assert.ok(html.includes("Quiz"));
});
it("a prediction answer SHOWS the verdict but TUCKS the explanation behind a toggle", () => {
const qd: QuestionData = { ...quizQD(), questionType: "predict" };
const answer = buildAnswer(qd, { values: ["Venus"] }); // wrong on purpose
const html = renderToStaticMarkup(<AnsweredCard answer={answer} />);
assert.ok(html.includes("Prediction"), "the eyebrow distinguishes the card");
assert.ok(/incorrect|wrong|&times;|✗/i.test(html), "the verdict still shows (owner: predictions are graded)");
assert.ok(!html.includes("Why:"), "the explanation is not center stage");
assert.ok(html.includes("tucked-explanation"), "it sits behind the toggle");
});
it("a check answer keeps the inline explanation", () => {
const answer = buildAnswer(quizQD(), { values: ["Venus"] });
const html = renderToStaticMarkup(<AnsweredCard answer={answer} />);
assert.ok(html.includes("Why:"), "checks explain inline (current behavior)");
assert.ok(!html.includes("tucked-explanation"));
});
it("the reading-thinking channel renders as its own block; legacy `note` still renders", () => {
const withReading = buildAnswer(quizQD(), {
values: ["Mercury"],
thinking: "answering thought",
readingThinking: "was unsure mid-paragraph",
});
const html = renderToStaticMarkup(<AnsweredCard answer={withReading} />);
assert.ok(html.includes("Your thinking while reading"), "the reading channel is labeled distinctly");
assert.ok(html.includes("answering thought"));
assert.ok(html.includes("was unsure mid-paragraph"));
const legacy = { ...buildAnswer(quizQD(), { values: ["Mercury"] }), note: "a 2026-09 answer" };
const legacyHtml = renderToStaticMarkup(<AnsweredCard answer={legacy} />);
assert.ok(legacyHtml.includes("a 2026-09 answer"), "records from before the rename still render");
});
});
+2 -2
View File
@@ -923,7 +923,7 @@ describe("newAnswerResult two audiences", () => {
explanation: "Mercury orbits closest.", explanation: "Mercury orbits closest.",
createdAt: "2026-01-01T00:00:00Z", createdAt: "2026-01-01T00:00:00Z",
}; };
const full = buildAnswer(qd, { values: ["mercury"], confidence: 70, note: "guessed" }); const full = buildAnswer(qd, { values: ["mercury"], confidence: 70, thinking: "guessed" });
const msg = newAnswerResult("call-1", "quiz", full); const msg = newAnswerResult("call-1", "quiz", full);
const modelSees = JSON.parse((msg.content as { type: "text"; text: string }[])[0]!.text); const modelSees = JSON.parse((msg.content as { type: "text"; text: string }[])[0]!.text);
assert.deepEqual(modelSees, { assert.deepEqual(modelSees, {
@@ -931,7 +931,7 @@ describe("newAnswerResult two audiences", () => {
answers: [{ index: 1, label: "Mercury" }], answers: [{ index: 1, label: "Mercury" }],
correct: true, correct: true,
confidence: 70, confidence: 70,
note: "guessed", thinking: "guessed",
}); });
assert.equal(msg.details, full, "details is the full QuestionAnswerData verbatim"); assert.equal(msg.details, full, "details is the full QuestionAnswerData verbatim");
assert.equal((msg.details as { questionText?: string }).questionText, "Which planet is closest to the Sun?"); assert.equal((msg.details as { questionText?: string }).questionText, "Which planet is closest to the Sun?");
+2
View File
@@ -29,6 +29,7 @@ const TEACHER_QUIZ = {
], ],
correctAnswer: "truth", correctAnswer: "truth",
explanation: "Convention-T schematizes truth for the object language.", explanation: "Convention-T schematizes truth for the object language.",
type: "check", // teach-only role field — the display parse KEEPS it (the dock gates by it)
nodeId: "convention-t", // teacher-only field — the display parse must accept and strip it nodeId: "convention-t", // teacher-only field — the display parse must accept and strip it
}; };
@@ -55,6 +56,7 @@ describe("parseQuizInput", () => {
assert.ok(parsed); assert.ok(parsed);
assert.equal(parsed.question, TEACHER_QUIZ.question); assert.equal(parsed.question, TEACHER_QUIZ.question);
assert.equal((parsed as Record<string, unknown>).nodeId, undefined, "unknown keys are stripped, not trusted"); assert.equal((parsed as Record<string, unknown>).nodeId, undefined, "unknown keys are stripped, not trusted");
assert.equal((parsed as { type?: unknown }).type, "check", "the role field survives the display parse (owner 2026-10-03)");
}); });
it("parses a boundary-valid PROBER quiz (no nodeId at all)", () => { it("parses a boundary-valid PROBER quiz (no nodeId at all)", () => {