From fc1aaf5dcab8332dedaf8fe44463dcd77c500233 Mon Sep 17 00:00:00 2001 From: Daniel Date: Fri, 11 Sep 2026 23:49:24 +0200 Subject: [PATCH] fix: a score is out of what you answered, not out of what was set MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Unanswered questions were counted as wrong in every percentage the site reports. That made leaving an exam early look like failing it, and made the figure say more about how far you got than about how well you did — and how far you got is already the number sitting beside it. An unanswered question is not a wrong answer. It is not an answer. score_percent() and answered_counts() give the rule one definition, used by all seven places that reported a percentage: submission, attempt history, per-quiz history, the overall average, per-quiz stats, one attempt's detail, and the session analysis. The list endpoints count in one query rather than one per row. The review dialog said unanswered questions count as incorrect, which was true and is not any more. It now says they will not be marked wrong, and will not be marked. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN --- backend/app/routers/attempts.py | 46 +++++++++++++++++++++++++++------ frontend/src/pages/QuizPage.jsx | 8 +++++- 2 files changed, 45 insertions(+), 9 deletions(-) diff --git a/backend/app/routers/attempts.py b/backend/app/routers/attempts.py index f6abe6e..a78791c 100644 --- a/backend/app/routers/attempts.py +++ b/backend/app/routers/attempts.py @@ -182,8 +182,9 @@ def submit_attempt( except Exception: logger.warning("Failed to clear quiz progress from Redis", exc_info=True) - percentage = (score / attempt.total_questions * 100) if attempt.total_questions > 0 else 0 - + # Out of what was answered, not out of what was set. An unanswered + # question is not a wrong answer. + percentage = score_percent(score, answered_counts(db, [attempt.id]).get(attempt.id, 0)) return AttemptDetail( id=attempt.id, @@ -212,6 +213,8 @@ def list_attempts( if quiz_id: query = query.filter(QuizAttempt.quiz_id == quiz_id) attempts = query.order_by(QuizAttempt.started_at.desc()).all() + # One query for the lot rather than one per row. + answered = answered_counts(db, [a.id for a in attempts]) return [ AttemptResponse( @@ -219,7 +222,7 @@ def list_attempts( quiz_id=a.quiz_id, score=a.score, total_questions=a.total_questions, - percentage=round((a.score / a.total_questions * 100) if a.total_questions > 0 else 0, 1), + percentage=score_percent(a.score, answered.get(a.id, 0)), started_at=a.started_at, completed_at=a.completed_at, ) @@ -546,10 +549,11 @@ def get_quiz_history( # Group by quiz from collections import defaultdict + answered = answered_counts(db, [a.id for a in completed]) by_quiz: dict = defaultdict(list) quiz_titles: dict = {} for a in completed: - pct = round((a.score / a.total_questions * 100) if a.total_questions > 0 else 0, 1) + pct = score_percent(a.score, answered.get(a.id, 0)) by_quiz[a.quiz_id].append({ "attempt_id": a.id, "date": a.completed_at.isoformat(), @@ -599,9 +603,10 @@ def get_dashboard_stats( ) total_attempts = len(completed_attempts) + answered = answered_counts(db, [a.id for a in completed_attempts]) avg_score = 0.0 if completed_attempts: - scores = [(a.score / a.total_questions * 100) if a.total_questions > 0 else 0 for a in completed_attempts] + scores = [score_percent(a.score, answered.get(a.id, 0)) for a in completed_attempts] avg_score = round(sum(scores) / len(scores), 1) # Per-quiz stats — based on quizzes the user has attempted, not created @@ -611,7 +616,7 @@ def get_dashboard_stats( for quiz in quizzes: quiz_attempts = [a for a in completed_attempts if a.quiz_id == quiz.id] if quiz_attempts: - pcts = [(a.score / a.total_questions * 100) if a.total_questions > 0 else 0 for a in quiz_attempts] + pcts = [score_percent(a.score, answered.get(a.id, 0)) for a in quiz_attempts] quiz_stats.append(QuizStats( quiz_id=quiz.id, quiz_title=quiz.title, @@ -691,7 +696,8 @@ def get_attempt( figures=figures.get(q.id, []), )) - percentage = (attempt.score / attempt.total_questions * 100) if attempt.total_questions > 0 else 0 + percentage = score_percent(attempt.score, + answered_counts(db, [attempt.id]).get(attempt.id, 0)) return AttemptDetail( id=attempt.id, quiz_id=attempt.quiz_id, @@ -787,6 +793,30 @@ class _LiveAnswer: self.seconds_spent = None +def answered_counts(db: Session, attempt_ids: list[int]) -> dict[int, int]: + """How many questions were actually answered in each attempt.""" + if not attempt_ids: + return {} + rows = (db.query(AttemptAnswer.attempt_id, func.count(AttemptAnswer.id)) + .filter(AttemptAnswer.attempt_id.in_(attempt_ids), + AttemptAnswer.user_answer.isnot(None), + AttemptAnswer.user_answer != "") + .group_by(AttemptAnswer.attempt_id).all()) + return {attempt_id: count for attempt_id, count in rows} + + +def score_percent(score: int, answered: int) -> float: + """What you got right, out of what you answered. + + An unanswered question is not a wrong answer, it is not an answer — so it + is not in the denominator. Counting it as wrong made leaving an exam early + look like failing it, and made the figure say more about how far you got + than about how well you did. How far you got is the separate number + beside it. + """ + return round(score / answered * 100, 1) if answered else 0.0 + + def _rows_from_progress(db: Session, user_id: int, attempt: QuizAttempt) -> list: """Grade what is saved for a live attempt, so it can be analysed mid-session.""" try: @@ -927,7 +957,7 @@ def attempt_analysis( "total": len(rows), "answered": answered, "score": score, - "percent": (round(100 * score / len(rows)) if rows else 0) if graded else None, + "percent": (round(score_percent(score, answered)) if rows else 0) if graded else None, # False while an exam is still running: the interface shows progress # and says why there is no score yet, rather than showing a nought. "graded": graded, diff --git a/frontend/src/pages/QuizPage.jsx b/frontend/src/pages/QuizPage.jsx index ed77a11..5b4be9c 100644 --- a/frontend/src/pages/QuizPage.jsx +++ b/frontend/src/pages/QuizPage.jsx @@ -1284,7 +1284,13 @@ const timerStarted = timeLeft !== null global note still lives on the dashboard. */} {tool && setTool(null)} />} {showReview && setShowReview(false)}> -

{answeredCount} of {totalCount} questions answered. Unanswered questions count as incorrect.

+ {/* They are not counted as wrong: the score is out of what you + answered. But they are questions you did not do, and that is worth + saying before you hand it in. */} +

{answeredCount} of {totalCount} questions answered.{' '} + {answeredCount < totalCount + ? `${totalCount - answeredCount} will be left unanswered — they are not marked wrong, but they are not marked.` + : 'Everything is answered.'}

{questions.map((question, index) => )}