fix: a score is out of what you answered, not out of what was set

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
This commit is contained in:
Daniel 2026-09-11 23:49:24 +02:00
parent 6abe3cca11
commit fc1aaf5dca
2 changed files with 45 additions and 9 deletions

View file

@ -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,

View file

@ -1284,7 +1284,13 @@ const timerStarted = timeLeft !== null
global note still lives on the dashboard. */}
{tool && <QuizTools tool={tool} onClose={() => setTool(null)} />}
{showReview && <QuizDialog title="Review & Complete" onClose={() => setShowReview(false)}>
<p>{answeredCount} of {totalCount} questions answered. Unanswered questions count as incorrect.</p>
{/* 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. */}
<p>{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.'}</p>
<div className="quiz-review-grid">{questions.map((question, index) => <button type="button" key={question.id} onClick={() => { safeNavigate(index); setShowReview(false) }}>
{index + 1} · {answers[question.id] ? 'Answered' : 'Unanswered'}{favorites.includes(question.id) ? ' · Bookmarked' : ''}
</button>)}</div>