Source code for nbgrader_jupyterquiz.grader._review

"""
Static HTML review rendering for graded quizzes.

The rendered output is scoped, self-contained HTML + inline CSS —
suitable for display in any browser without a running Jupyter server
or kernel.  Emitted by
:meth:`~nbgrader_jupyterquiz.grader.autograde.QuizResult.display_review`
from within the auto-generated hidden-tests block; ``nbgrader
generate_feedback`` propagates it into the per-student feedback HTML.
"""

from __future__ import annotations
from html import escape
from typing import TYPE_CHECKING, Any


if TYPE_CHECKING:  # pragma: no cover - import cycle guard
    from .autograde import QuestionResult, QuizResult


_REVIEW_CSS = """<style>
.jq-review { font-family: sans-serif; margin: 10px 0; padding: 10px 14px;
             border: 1px solid #ccc; border-radius: 6px;
             background: #fafafa; color: #222; max-width: 720px; }
.jq-review-score { font-weight: bold; font-size: 1.05em; margin-bottom: 8px; }
.jq-review-question { margin: 12px 0; padding-top: 6px;
                      border-top: 1px solid #e5e5e5; }
.jq-review-qhead { font-weight: 600; margin-bottom: 6px; }
.jq-review-row { margin: 4px 0; }
.jq-review-choice { display: inline-block; padding: 5px 10px; margin: 3px 4px 3px 0;
                    border-radius: 4px; border: 1px solid #ccc;
                    background: #f0f0f0; font-size: 0.95em; }
.jq-review-choice.correct-picked { background: #d1e7dd; border-color: #a3cfbb;
                                   color: #0a3622; }
.jq-review-choice.correct-missed { background: #fff3cd; border-color: #e7cf88;
                                   color: #664d03; }
.jq-review-choice.wrong-picked { background: #f8d7da; border-color: #eba5ab;
                                 color: #58151c; }
.jq-review-tag { font-size: 0.8em; opacity: 0.85; margin-left: 6px;
                 font-style: italic; }
.jq-review-numeric code { background: #eee; padding: 1px 5px; border-radius: 3px; }
.jq-review-ok { color: #0a7c2f; font-weight: 600; }
.jq-review-bad { color: #b02a37; font-weight: 600; }
.jq-review-muted { color: #666; font-style: italic; }
.jq-review-ptag { font-size: 0.85em; font-weight: 500; margin-left: 8px;
                  padding: 1px 8px; border-radius: 10px;
                  background: #e7e9ec; color: #333; }
</style>"""


[docs] def fmt_pts(value: float) -> str: """ Format a point value for display, collapsing binary-float noise. Rounds to three decimal places and strips trailing zeros so that accumulation artefacts such as ``0.3 + 0.3 + 0.4 == 0.9999999999999999`` don't bleed into the visible review text. Three decimals preserve common fractional weights exactly — halves (0.5), quarters (0.25), eighths (0.125), tenths (0.1) — which a two-decimal rounding would mangle (``round(0.125, 2) == 0.12`` under banker's rounding). Parameters ---------- value : float Point value to format. Returns ------- str Canonical short textual form, e.g. ``"1"``, ``"0.5"``, ``"0.125"``. """ rounded = round(float(value), 3) if rounded == int(rounded): return str(int(rounded)) return f"{rounded:g}"
[docs] def render_review_html(result: QuizResult) -> str: """ Render a full quiz review as a scoped HTML string. Parameters ---------- result : QuizResult Grading outcome to render. Returns ------- str Complete ``<style>`` + ``<div>`` block ready for :class:`IPython.display.HTML`. """ parts = [_REVIEW_CSS, '<div class="jq-review">'] parts.append(f'<div class="jq-review-score">Your score: {fmt_pts(result.score)} / {fmt_pts(result.max_score)}</div>') for d in result.details: qtext = escape(d.question.get("question", "") or "") header_points = f' <span class="jq-review-ptag">{fmt_pts(d.earned)}/{fmt_pts(d.points)} pts</span>' parts.append(f'<div class="jq-review-question"><div class="jq-review-qhead">Q{d.qnum + 1}. {qtext}{header_points}</div>') qtype = d.question_type if qtype in ("multiple_choice", "many_choice"): parts.append(_render_review_choice(d)) elif qtype == "numeric": parts.append(_render_review_numeric(d)) elif qtype == "string": parts.append(_render_review_string(d)) else: parts.append(f'<div class="jq-review-row">(unrecognised question type: {escape(qtype)})</div>') parts.append("</div>") parts.append("</div>") return "\n".join(parts)
def _extract_picked(recorded: Any) -> list[str]: """ Normalise the student's recorded MC response to a list of strings. Parameters ---------- recorded : Any Raw recorded payload from the sidecar. Returns ------- list of str Possibly empty list of selected answer texts. """ if not isinstance(recorded, dict): return [] selected = recorded.get("selected") if isinstance(selected, list): return [s for s in selected if isinstance(s, str)] if isinstance(selected, str): return [selected] return [] def _render_review_choice(detail: QuestionResult) -> str: """ Render the review row for a single-choice / many-choice question. Parameters ---------- detail : QuestionResult Per-question grading outcome. Returns ------- str HTML fragment listing each answer option with correctness markers. """ picked = _extract_picked(detail.recorded) rows = [] for answer in detail.question.get("answers", []): text = answer.get("answer", "") is_correct = bool(answer.get("correct")) is_picked = text in picked cls = "jq-review-choice" tag = "" if is_correct and is_picked: cls += " correct-picked" tag = '<span class="jq-review-tag">✓ your choice — correct</span>' elif is_correct and not is_picked: cls += " correct-missed" tag = '<span class="jq-review-tag">correct answer — not selected</span>' elif (not is_correct) and is_picked: cls += " wrong-picked" tag = '<span class="jq-review-tag">✗ your choice — incorrect</span>' rows.append(f'<span class="{cls}">{escape(str(text))}</span>{tag}') if not picked: rows.insert(0, '<div class="jq-review-row jq-review-muted">No answer recorded.</div>') return '<div class="jq-review-row">' + " ".join(rows) + "</div>" def _render_review_numeric(detail: QuestionResult) -> str: """ Render the review row for a numeric question. Parameters ---------- detail : QuestionResult Per-question grading outcome. Returns ------- str HTML fragment showing the student's numeric answer and the expected value. """ expected = detail.expected expected_fmt = ", ".join(_fmt_numeric_expected(e) for e in (expected or [])) if not isinstance(detail.recorded, dict): return ( f'<div class="jq-review-row jq-review-numeric jq-review-muted">No answer recorded (expected <code>{escape(expected_fmt)}</code>).</div>' ) raw = detail.recorded.get("raw", "") parsed = detail.recorded.get("parsed", "") raw_show = escape(str(raw)) parsed_show = escape(str(parsed)) extra = f" (parsed as <code>{parsed_show}</code>)" if str(raw) != str(parsed) else "" mark = '<span class="jq-review-ok">✓ correct</span>' if detail.correct else '<span class="jq-review-bad">✗ incorrect</span>' return ( f'<div class="jq-review-row jq-review-numeric">' f"You answered: <code>{raw_show}</code>{extra}. " f"{mark} (expected <code>{escape(expected_fmt)}</code>)." f"</div>" ) def _render_review_string(detail: QuestionResult) -> str: """ Render the review row for a string question. Parameters ---------- detail : QuestionResult Per-question grading outcome. Returns ------- str HTML fragment showing the student's string answer and the expected value. """ expected = detail.expected or [] expected_fmt = ", ".join(escape(str(e)) for e in expected) if expected else "—" if not isinstance(detail.recorded, dict): return f'<div class="jq-review-row jq-review-muted">No answer recorded (expected <code>{expected_fmt}</code>).</div>' value = detail.recorded.get("value", "") mark = '<span class="jq-review-ok">✓ correct</span>' if detail.correct else '<span class="jq-review-bad">✗ incorrect</span>' return f'<div class="jq-review-row">You answered: <code>{escape(str(value))}</code>. {mark} (expected <code>{expected_fmt}</code>).</div>' def _fmt_numeric_expected(e: Any) -> str: """ Format a single expected numeric value or range for display. Parameters ---------- e : Any Either a scalar value or a ``(min, max)`` tuple. Returns ------- str ``"[min, max)"`` for a range, ``str(value)`` otherwise. """ if isinstance(e, tuple): return f"[{e[0]}, {e[1]})" return str(e)