fix(mcp): submission-grader misreports what is graded — ungraded zeros, invisible overrides, decorative confidence (#567) - #574
Conversation
…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
…#567) Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017Zt1kZKdcWAcsiJdBgnX3D
#578 (widget i18n/colour semantics) and #575 (missing-data display) both rewrote submission-grader/widget.tsx, so every hunk of this branch conflicted. Resolved by resetting the file to master and re-applying the #567 fix on top of the new structure rather than reconciling markers. What changed in the re-application: - The three new affordances are now translated. "Not graded", "Teacher adjusted", the low-confidence pill and the ungraded-points note all have en/es entries in this widget's STRINGS table, matching #578's convention; a missing Spanish string is a type error. - The em dash for an ungraded question is no longer hand-written. fmt.number() from shared/i18n.tsx already renders null as an em dash, so dropping the `?? 0` is now the entire fix at that call site and the dash follows the reader's locale like every other number. - Points and confidence go through fmt.number/fmt.percent, so es renders "confianza: 41 %" rather than a hardcoded English percentage. master's own shared/severity.ts landed the same rule this issue is about — "a draft course with no lessons has nothing to complete (neutral); a student who has completed nothing is at 0% and that is the worst case" — and #575 applied it to the roster while leaving `points_earned ?? 0` in this widget untouched. The re-applied fix is that doctrine reaching the per-question list, and isUngraded() now cites it. Kept from master unchanged: the inline "(82% confidence)" suffix for a confident score, so only a guess is promoted to a pill. Verified after the merge: mcp-server tsc --noEmit clean (the 4 pre-existing useCallTool errors this branch flagged are gone — master fixed them), 66/66 mcp-server tests, and the demo fixture re-shot in dark, light and es. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017Zt1kZKdcWAcsiJdBgnX3D
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
Rebased onto master — conflict resolved
Resolved by resetting the file to master and re-applying the #567 fix on top of the new structure, rather than reconciling conflict markers — the widget's shape changed enough (string table, What changed in the re-application
Worth noting for the reviewermaster's own
#575 then applied that rule to the roster — but left One thing this merge retires from the PR description: the 4 pre-existing Re-verified after the merge
The original screenshots in the PR body show pre-#578 code, so here is the widget as it stands after the merge. Spanish is new — the old shots could not have covered it, since the widget had no i18n then. Dark / light Spanish ( All four acceptance criteria still hold: Q5 reads One follow-up I deliberately did not fold in#575 added |



What
Fixes the three ways the
submission-graderMCP widget misreported how much of a submission had actually been graded: ungraded questions rendering as zeros,is_overriddennever being shown, and AI confidence being styled as decoration.Closes #567
Why
A teacher opens this widget to answer one question — how much of this is graded, and where should I look? The per-question list was answering it wrong in three places at once.
Ungraded read as a zero.
points_earned: nullis the tool's encoding for "nobody has graded this question yet". The widget coerced it with?? 0, so an untouched question rendered0/15 pts— pixel-identical to a question the student genuinely bombed. In the demo fixture, Q5 has never been looked at and read as a zero, directly contradicting the4/5 gradedsummary a few hundred pixels above it.Overrides were invisible.
is_overriddenis declared in the widget's schema, arrives populated fromlms_get_submission, and nothing rendered it. A teacher who had already corrected the AI on a question had no way to tell which ones they'd touched — so on a second pass they either re-review everything or mistake the AI's number for their own.Confidence was decoration. 41% and 82% were the same grey text in the same label. Confidence exists to say where to look, and the 41% question was the least prominent thing on screen.
One thing the issue asked me to check turned out to be fine: the server does not sum
nullas0.analytics.ts:382-385only adds toearned/gradedwhenpoints_earned != null, sototal_points_earnedandgraded_countalready agree. What was misleading is the denominator —total_points_possiblespans the whole exam, so60 / 100reads as "the student lost 40 points" when 15 of those points have simply never been graded. That is presentation, not arithmetic, so it is fixed in the widget (which already has every question row) rather than by changing the tool or the props schema.How to QA
No
db:resetand no login needed — this runs against the built-in demo fixture, which already contains all three states (Q5 ungraded, Q4 overridden, AI confidences of 0.82 and 0.41).cd mcp-server && MCP_DEMO_WIDGETS=1 npm run dev--theme light— widgetdark:classes resolve viaprefers-color-scheme, not the.darkwrapper class, so both themes are worth a look.—/15 ptsin muted type plus a Not graded pill — never0/15.15 pts across 1 question not graded yetline, so60 / 100can no longer be read as lost points.41% confidence · review thispill with a matching border on the card, while Q3's 82% stays quiet grey text.lms_demo_submission_graderto poke at it interactively.Screenshots / GIF
No GIF: this change is purely how the widget renders a payload. Nothing about the interaction (the score input, the feedback box, the Save button) changed, so a recording would show a still frame. Before/after in both themes below.
Dark — before / after
Light — before / after
In the "before" shots, look at the bottom card:
0/15 ptson a question nobody has graded. Q4 has no marker at all despite being teacher-adjusted, and its 41% is the same grey as Q3's 82%.Notes for the reviewer
points_earnedis already the teacher's number once overridden), so that needs a schema + tool change and belongs in its own issue rather than widening this one.Awaiting reviewstatus pill.tsc --noEmitinmcp-server/reports 4useCallToolgeneric errors inflashcards,lesson-viewer,practice-playerandstudy-plan. None are in this file and none are new — flagging them as a separate cleanup.Checklist
npm run typecheckandnpm run test:unitpass — clean, 611/611 unit tests.mcp-serverVitest also green (39/39).npm run buildpasses — not run. This change touches no file in the Next.js app (mcp-server/is a separate workspace with its own build);npm run mcp:buildis the relevant build and themcp-use devbundler compiled the widget while the screenshots were taken.tenant_id— n/a, no queries added; the widget renders props it is handed.src/tool-policy.tsand that gate is unchanged.isPendingand save-error branches are untouched; the new states (ungraded, overridden, low confidence) each have an explicit render.messages/en.jsonandmessages/es.json— n/a: MCP widgets are not wired tonext-intland every existing string in this widget is hardcoded English. The new strings follow that convention.