Skip to content

Commit f4ac391

Browse files
fix(mcp): submission-grader misreports what is graded — ungraded zeros, invisible overrides, decorative confidence (#567) (#574)
* fix(mcp): make submission-grader tell the truth about what is graded (#567) The per-question review list in the submission-grader widget misreported how much of a submission had actually been graded — the one thing a teacher opens it to find out. - An ungraded question (`points_earned: null`) rendered as `0/15 pts`, making "nobody has looked at this yet" indistinguishable from "the student got it wrong", and contradicting the `4/5 graded` summary a few hundred pixels above. It now renders as `—/15 pts` alongside a "Not graded" pill, and the `?? 0` coercion is gone. - The grade panel now states how many points sit in ungraded questions, so the `60 / 100` figure can no longer read as "the student lost 40 points" when 15 of those points have simply never been graded. - `is_overridden` arrived in the payload and was never rendered, so a teacher could not see which scores they had already corrected. Overridden rows now carry a "Teacher adjusted" pill. - AI confidence was plain grey text appended to a label, so 41% and 82% looked identical. Confidence below 60% now renders as an amber "review this" pill and tints the question's border, while high confidence stays quiet. The server side needed no change: `lms_get_submission` already excludes null scores from `total_points_earned` and `graded_count` (analytics.ts:382-385). The misleading part was the denominator spanning ungraded questions, which is a presentation concern and is handled in the widget. Verified against the `lms_demo_submission_grader` default fixture (ungraded Q5, overridden Q4, confidences 0.82 and 0.41) in both light and dark themes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017Zt1kZKdcWAcsiJdBgnX3D * docs(mcp): before/after shots of the submission-grader grading states (#567) Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017Zt1kZKdcWAcsiJdBgnX3D * docs(qa): re-shoot #567 grading states on the merged widget master's #578 rewrote this widget with i18n, so the PR's original screenshots no longer show the code under review. Re-shot from the demo fixture after the merge, plus a Spanish render — the three new affordances are translated now, and es is the path the old shots could not have covered. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017Zt1kZKdcWAcsiJdBgnX3D --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 5de9ad2 commit f4ac391

8 files changed

Lines changed: 88 additions & 11 deletions

File tree

167 KB
Loading
165 KB
Loading
161 KB
Loading
160 KB
Loading

docs/qa/567/after-merge-dark.png

169 KB
Loading
174 KB
Loading

docs/qa/567/after-merge-light.png

167 KB
Loading

mcp-server/resources/submission-grader/widget.tsx

Lines changed: 88 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,12 @@ const STRINGS = {
8989
incorrect: "Incorrect",
9090
aiFeedback: "AI feedback",
9191
confidenceSuffix: (pct: string) => ` (${pct} confidence)`,
92+
lowConfidence: (pct: string) => `${pct} confidence · review this`,
9293
points: (earned: string, possible: string) => `${earned}/${possible} pts`,
94+
notGraded: "Not graded",
95+
teacherAdjusted: "Teacher adjusted",
96+
ungradedNote: (points: string, questions: number) =>
97+
`${points} pts across ${questions} question${questions === 1 ? "" : "s"} not graded yet`,
9398
question: (n: number) => `Q${n}`,
9499
status: {
95100
teacher_reviewed: "Teacher reviewed",
@@ -117,7 +122,12 @@ const STRINGS = {
117122
incorrect: "Incorrecta",
118123
aiFeedback: "Comentarios de la IA",
119124
confidenceSuffix: (pct: string) => ` (confianza: ${pct})`,
125+
lowConfidence: (pct: string) => `confianza: ${pct} · revisa esta`,
120126
points: (earned: string, possible: string) => `${earned}/${possible} pts`,
127+
notGraded: "Sin calificar",
128+
teacherAdjusted: "Ajustada por el profesor",
129+
ungradedNote: (points: string, questions: number) =>
130+
`${points} pts en ${questions} pregunta${questions === 1 ? "" : "s"} sin calificar`,
121131
question: (n: number) => `P${n}`,
122132
status: {
123133
teacher_reviewed: "Revisado por el profesor",
@@ -132,6 +142,21 @@ type Strings = typeof STRINGS.en;
132142

133143
// ── Helpers ──────────────────────────────────────────────────────────────────
134144

145+
/**
146+
* Below this, the AI is guessing rather than grading — the teacher should
147+
* re-read the answer instead of trusting the suggested score.
148+
*/
149+
const LOW_CONFIDENCE = 0.6;
150+
151+
/**
152+
* `points_earned: null` means nobody has graded the question — never a zero.
153+
* Same rule `shared/severity.ts` states for progress bars: no data and a real
154+
* zero are different facts and must not render alike.
155+
*/
156+
function isUngraded(q: Props["questions"][number]): boolean {
157+
return q.points_earned == null;
158+
}
159+
135160
function statusPill(status: string, t: Strings): { classes: string; label: string } {
136161
const map: Record<string, { classes: string }> = {
137162
teacher_reviewed: {
@@ -216,6 +241,15 @@ export default function SubmissionGrader() {
216241
const studentName = studentDisplayName(submission.student_name, t.unnamedStudent);
217242
const saveDisabled = saving || (!dirty && status === "teacher_reviewed");
218243

244+
// The summary counts only graded questions into total_points_earned, but
245+
// total_points_possible spans the whole exam — so "60 / 100" can read as
246+
// "lost 40 points" when part of that gap is simply not graded yet.
247+
const ungradedQuestions = questions.filter(isUngraded);
248+
const ungradedPoints = ungradedQuestions.reduce(
249+
(total, q) => total + (q.points_possible ?? 0),
250+
0
251+
);
252+
219253
return (
220254
<McpUseProvider autoSize>
221255
<Brand />
@@ -272,6 +306,12 @@ export default function SubmissionGrader() {
272306
</div>
273307
</div>
274308

309+
{ungradedQuestions.length > 0 && (
310+
<div className="mt-2 text-[12.5px] font-medium text-amber-700 dark:text-amber-400">
311+
{t.ungradedNote(fmt.number(ungradedPoints), ungradedQuestions.length)}
312+
</div>
313+
)}
314+
275315
<label className="mt-3.5 block">
276316
<span className="mb-1.5 block text-xs font-semibold text-zinc-500 dark:text-zinc-400">
277317
{t.feedbackLabel}
@@ -311,6 +351,9 @@ export default function SubmissionGrader() {
311351
{/* Questions */}
312352
<div className="flex flex-col gap-3">
313353
{questions.map((q, i) => {
354+
const ungraded = isUngraded(q);
355+
const lowConfidence =
356+
q.ai_confidence != null && q.ai_confidence < LOW_CONFIDENCE;
314357
const correct = q.is_correct;
315358
const badge =
316359
correct === true
@@ -329,7 +372,11 @@ export default function SubmissionGrader() {
329372
return (
330373
<div
331374
key={q.question_id}
332-
className="rounded-xl border border-zinc-200 bg-white p-4 dark:border-zinc-800 dark:bg-zinc-900"
375+
className={`rounded-xl border bg-white p-4 dark:bg-zinc-900 ${
376+
lowConfidence
377+
? "border-amber-300 dark:border-amber-800"
378+
: "border-zinc-200 dark:border-zinc-800"
379+
}`}
333380
>
334381
<div className="mb-2 flex items-start justify-between gap-2.5">
335382
<div className="flex items-baseline gap-2">
@@ -340,15 +387,33 @@ export default function SubmissionGrader() {
340387
{q.type.replace(/_/g, " ")}
341388
</span>
342389
</div>
343-
<div className="flex shrink-0 items-center gap-2">
390+
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2">
344391
{q.points_possible != null && (
345-
<span className="text-xs font-semibold text-zinc-900 dark:text-zinc-100">
392+
<span
393+
className={`text-xs font-semibold ${
394+
ungraded
395+
? "text-zinc-400 dark:text-zinc-500"
396+
: "text-zinc-900 dark:text-zinc-100"
397+
}`}
398+
>
399+
{/* fmt.number renders null as an em dash, so an
400+
ungraded question reads "—/15 pts", never "0/15". */}
346401
{t.points(
347-
fmt.number(q.points_earned ?? 0),
402+
fmt.number(q.points_earned),
348403
fmt.number(q.points_possible)
349404
)}
350405
</span>
351406
)}
407+
{ungraded && (
408+
<span className="rounded-lg bg-zinc-100 px-2 py-0.5 text-[11px] font-semibold text-zinc-500 dark:bg-zinc-800 dark:text-zinc-400">
409+
{t.notGraded}
410+
</span>
411+
)}
412+
{q.is_overridden && (
413+
<span className="rounded-lg bg-[var(--brand-50)] px-2 py-0.5 text-[11px] font-semibold text-[var(--brand-600)] dark:bg-[var(--brand-950)] dark:text-[var(--brand-400)]">
414+
{t.teacherAdjusted}
415+
</span>
416+
)}
352417
{badge && (
353418
<span
354419
className={`rounded-lg px-2 py-0.5 text-[11px] font-semibold ${badge.classes}`}
@@ -401,13 +466,25 @@ export default function SubmissionGrader() {
401466
{/* AI feedback + confidence */}
402467
{q.ai_feedback && (
403468
<div className="text-[12.5px] leading-normal text-zinc-500 dark:text-zinc-400">
404-
<span className="font-semibold">
405-
{t.aiFeedback}
406-
{q.ai_confidence != null &&
407-
t.confidenceSuffix(
408-
fmt.percent(Math.round(q.ai_confidence * 100))
409-
)}
410-
</span>
469+
<div className="flex flex-wrap items-center gap-2">
470+
<span className="font-semibold">
471+
{t.aiFeedback}
472+
{/* A confident score stays inline and quiet; only a
473+
guess is promoted to a pill that says so. */}
474+
{q.ai_confidence != null &&
475+
!lowConfidence &&
476+
t.confidenceSuffix(
477+
fmt.percent(Math.round(q.ai_confidence * 100))
478+
)}
479+
</span>
480+
{q.ai_confidence != null && lowConfidence && (
481+
<span className="rounded-lg bg-amber-100 px-2 py-0.5 text-[11px] font-semibold text-amber-800 dark:bg-amber-950 dark:text-amber-300">
482+
{t.lowConfidence(
483+
fmt.percent(Math.round(q.ai_confidence * 100))
484+
)}
485+
</span>
486+
)}
487+
</div>
411488
<Markdown content={q.ai_feedback} dark={dark} fontSize={12.5} />
412489
</div>
413490
)}

0 commit comments

Comments
 (0)