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:
parent
6abe3cca11
commit
fc1aaf5dca
2 changed files with 45 additions and 9 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue