quiz answers carry a confidence slider — raw probability, snapped 0/30/50/70/90/100

Owner design round 2026-10-01: every multiple-choice quiz answer now
carries the learner's stated probability that his selected answer is
correct — calibration data for the prober and teacher, the flow itself
unchanged.

Definition (owner ruling, after weighing chance-normalization): RAW
probability. Sub-chance reports are diagnostic gold, not dead space —
'picked an option, believes it wrong' is a real state. The agents get
the sophistication: new shared/25-confidence.md composes for both
agents (chance level, sub-chance = distrusts the pick, correct+low =
fragile, wrong+high = committed misconception, IDK = genuine gap).

- question-core: AnswerRequest/QuestionAnswerData.confidence (quiz
  only, optional — old records valid), buildAnswer clamps and drops it
  on dontKnow, formatAnswerMessage states the line, CONFIDENCE_STOPS +
  DEFAULT_CONFIDENCE_INDEX exported.
- question-dock: snapped slider below 'I don't know', above 'Your
  thinking' — native range over the stop INDEX so ←/→ step the uneven
  grid cleanly, Tab chain rows → slider → thinking, Enter submits from
  the slider too. Rests at 50 and always sends (owner: 50 must mean a
  real coin flip, never 'untouched'). 'I don't know' GRAYS it out —
  layout holds, no value sent.

Deliberately untouched: explain free-recall card (quiz-only scope),
answered-card rendering of confidence, ask flow.
This commit is contained in:
amosblomqvist
2026-10-02 00:24:05 +03:00
parent d8cca61d1d
commit 9b9f6372fa
5 changed files with 137 additions and 6 deletions
+74 -5
View File
@@ -9,6 +9,8 @@ import { cn } from "@/lib/utils";
import {
buildAnswer,
coerceCorrectAnswer,
CONFIDENCE_STOPS,
DEFAULT_CONFIDENCE_INDEX,
emptyDockSelection,
normalizeOptions,
resolveCorrect,
@@ -264,6 +266,12 @@ function QuestionDockForm({
const [thinking, setThinking] = useState("");
const [other, setOther] = useState("");
const [text, setText] = useState("");
// Confidence (owner, 2026-10-01): an index into CONFIDENCE_STOPS,
// resting at 50 — a real coin flip by definition, untouched or
// deliberate (the same number, the same meaning). The dock unmounts
// between questions, so each question starts fresh at 50.
const [confIdx, setConfIdx] = useState<number>(DEFAULT_CONFIDENCE_INDEX);
const confidenceRef = useRef<HTMLInputElement>(null);
const selectMode: SelectMode = qd.mode === "multi-select" ? "multi-select" : "single-select";
// The PRIMARY dictation input (owner, STT round 1): while a question
@@ -297,6 +305,9 @@ function QuestionDockForm({
other: sel.otherSelected ? other : undefined,
text: qd.mode === "text" ? text : undefined,
note: qd.mode !== "text" ? thinking.trim() || undefined : undefined,
// Always sent on quiz answers (owner: 50 must mean 50, never
// "untouched"); buildAnswer drops it on "I don't know".
confidence: kind === "quiz" ? CONFIDENCE_STOPS[confIdx] : undefined,
};
onSubmit(buildAnswer(qd, req));
};
@@ -368,14 +379,19 @@ function QuestionDockForm({
// Alt/Cmd/Ctrl combos are never ours (Alt+arrows cycle sessions
// globally — they must not ALSO walk the question rows).
const modded = e.altKey || e.metaKey || e.ctrlKey;
// Plain Tab toggles focus: rows → thinking, anything else → the
// walked (or first) row. Shift+Tab falls through (the global
// thinking-level cycle owns it), so the shift check matters.
// Plain Tab walks the dock's focus chain forward: rows →
// confidence slider (quiz, when enabled) → thinking. Shift+Tab
// falls through (the global thinking-level cycle owns it), so the
// shift check matters.
if (e.key === "Tab" && !e.shiftKey && !modded) {
e.preventDefault();
e.stopPropagation();
if (t?.closest?.("[data-kbdrow]")) thinkingRef.current?.focus();
else {
if (t?.closest?.("[data-confidence]")) thinkingRef.current?.focus();
else if (t?.closest?.("[data-kbdrow]")) {
const slider = confidenceRef.current;
if (slider && !slider.disabled) slider.focus();
else thinkingRef.current?.focus();
} else {
const idx = kbdIdx ?? 0;
setKbdIdx(idx);
focusRow(idx);
@@ -573,6 +589,59 @@ function QuestionDockForm({
)}
</div>
{/* Confidence (owner, 2026-10-01): a snapped slider below the options
and above "Your thinking" — the learner's stated probability
that his selected answer is correct. ←/→ move it (native range
semantics over the stop INDEX, so the uneven grid steps
cleanly). "I don't know" GRAYS it out — the layout holds, no
value is sent, the IDK signal stands alone. */}
{kind === "quiz" && (
<div className="border-t border-border/60 px-4 py-2.5">
<div className="flex items-center gap-3">
<span
className={cn(
"shrink-0 text-xs font-medium uppercase tracking-wide",
sel.dontKnow ? "text-muted-foreground/50" : "text-muted-foreground",
)}
>
Confidence
</span>
<input
ref={confidenceRef}
type="range"
min={0}
max={CONFIDENCE_STOPS.length - 1}
step={1}
value={confIdx}
disabled={sel.dontKnow}
aria-label="Confidence — the probability your selected answer is correct"
title="The probability your selected answer is correct — 50% is a coin flip"
data-testid="confidence-slider"
data-confidence=""
onChange={(e) => setConfIdx(Number(e.target.value))}
onKeyDown={(e) => {
// Enter submits from here too (dock-wide intuition);
// arrows stay native — ←/→ step the stop index.
if (e.key === "Enter" && canSubmit) {
e.preventDefault();
submit();
}
}}
className="h-1.5 min-w-0 flex-1 accent-primary disabled:opacity-40"
/>
<span
data-testid="confidence-value"
className={cn(
"w-9 shrink-0 text-right font-mono text-sm tabular-nums",
sel.dontKnow && "text-muted-foreground/50",
)}
>
{sel.dontKnow ? "—" : `${CONFIDENCE_STOPS[confIdx]}%`}
</span>
</div>
</div>
)}
{/* The learner's half of the dock — a composer TWIN: a labeled, bordered
field with Submit docked inside, exactly like the chat composer's
send. Select modes get "Your thinking" (Enter makes a newline — it