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:
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
@@ -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", [
|
||||||
|
|||||||
@@ -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
@@ -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)");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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|×|✗/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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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?");
|
||||||
|
|||||||
@@ -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)", () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user