diff --git a/backend/app/routers/study_tools.py b/backend/app/routers/study_tools.py index 7a23d13..30365ec 100644 --- a/backend/app/routers/study_tools.py +++ b/backend/app/routers/study_tools.py @@ -422,6 +422,116 @@ def performance_over_time( } +#: Below this many other learners on the same questions there is no cohort to +#: compare against, only a couple of strangers. +PEER_MIN_LEARNERS = 3 +#: And below this many questions held in common, the comparison is about which +#: questions each of you happened to sit. +PEER_MIN_SHARED = 10 + + +def _latest_answers(db: Session, user_id: int): + """The most recent answer to each question this learner has answered. + + Repetitions, course quizzes, expired attempts and blanks are left out, as + everywhere that measures rather than counts practice. The most recent + answer is the one that says what is known now; the earlier attempt at the + same question says what was known then, which is a different question and + not the one a readiness score is asking. + """ + rows = db.query( + AttemptAnswer.question_id, AttemptAnswer.is_correct, QuizAttempt.completed_at, + ).join(QuizAttempt, QuizAttempt.id == AttemptAnswer.attempt_id + ).join(Quiz, Quiz.id == QuizAttempt.quiz_id + ).filter( + QuizAttempt.user_id == user_id, + QuizAttempt.completed_at.isnot(None), + or_(QuizAttempt.expired == 0, QuizAttempt.expired.is_(None)), + Quiz.course_id.is_(None), + or_(Quiz.is_repetition == 0, Quiz.is_repetition.is_(None)), + AttemptAnswer.user_answer != "", + ).order_by(QuizAttempt.completed_at.asc(), QuizAttempt.id.asc()).all() + latest: dict[int, bool] = {} + for row in rows: + latest[row.question_id] = bool(row.is_correct) + return latest + + +@router.get("/readiness") +def readiness( + db: Session = Depends(get_db), + user: User = Depends(get_current_user), +): + """Two figures, each refusing to appear before it means anything. + + **Your score** is the share of questions you got right at your most recent + attempt at each. Not an equated score: we do not have the psychometrics to + equate one, and a number dressed up as one would be a claim we cannot + support. + + **Against everyone else** compares that with how other learners did on the + very questions you answered, rather than with their scores on whatever they + happened to sit. Someone who worked through the hardest fifty in the bank + should not read as weaker than someone who did fifty easy ones, and a + percentile over different question sets says exactly that. + """ + mine = _latest_answers(db, user.id) + answered = len(mine) + correct = sum(1 for right in mine.values() if right) + unlocked = answered >= READINESS_UNLOCK_ANSWERS + + peers = {} + if mine: + rows = db.query( + AttemptAnswer.question_id, + func.count(func.distinct(QuizAttempt.user_id)).label("learners"), + func.count(AttemptAnswer.id).label("answers"), + func.sum(case((AttemptAnswer.is_correct.is_(True), 1), else_=0)).label("correct"), + ).join(QuizAttempt, QuizAttempt.id == AttemptAnswer.attempt_id + ).join(Quiz, Quiz.id == QuizAttempt.quiz_id + ).filter( + QuizAttempt.user_id != user.id, + QuizAttempt.completed_at.isnot(None), + or_(QuizAttempt.expired == 0, QuizAttempt.expired.is_(None)), + Quiz.course_id.is_(None), + or_(Quiz.is_repetition == 0, Quiz.is_repetition.is_(None)), + AttemptAnswer.user_answer != "", + AttemptAnswer.question_id.in_(list(mine)), + ).group_by(AttemptAnswer.question_id).all() + peers = {row.question_id: row for row in rows} + + shared = [qid for qid in mine if qid in peers] + # Distinct learners cannot be summed across questions without counting the + # same person once per question they answered, so the cohort reported is + # the most any one shared question saw — a floor, not a guess. + cohort_size = max((peers[qid].learners for qid in shared), default=0) + peer_unlocked = len(shared) >= PEER_MIN_SHARED and cohort_size >= PEER_MIN_LEARNERS + + expected = None + yours_on_shared = None + if shared: + expected = round(100 * sum( + (peers[qid].correct or 0) / peers[qid].answers for qid in shared) / len(shared), 1) + yours_on_shared = round(100 * sum(1 for qid in shared if mine[qid]) / len(shared), 1) + + return { + "answered": answered, + "score": round(100 * correct / answered, 1) if answered else None, + "unlocked": unlocked, + "answers_needed": max(0, READINESS_UNLOCK_ANSWERS - answered), + "peer": { + "unlocked": peer_unlocked, + "shared_questions": len(shared), + "shared_needed": max(0, PEER_MIN_SHARED - len(shared)), + "cohort": cohort_size, + "cohort_needed": max(0, PEER_MIN_LEARNERS - cohort_size), + "expected": expected, + "yours": yours_on_shared, + "delta": None if expected is None else round(yours_on_shared - expected, 1), + }, + } + + @router.get("/recommendations") def study_recommendations( group: Literal["articles", "disciplines", "systems"] = "disciplines", diff --git a/backend/tests/test_study_tools.py b/backend/tests/test_study_tools.py index 67bfc7d..5050d72 100644 --- a/backend/tests/test_study_tools.py +++ b/backend/tests/test_study_tools.py @@ -455,3 +455,79 @@ class PerformanceOverTimeTests(CompletionTests): self.assertEqual(data['points'], []) self.assertFalse(data['unlocked']) self.assertEqual(data['total_answered'], 0) + + +class ReadinessTests(CompletionTests): + """Two figures that refuse to appear before they mean anything.""" + + def read(self): + response = self.client.get('/study-tools/readiness') + self.assertEqual(response.status_code, 200, response.text) + return response.json() + + def peer_sat(self, user_id, qid, correct, quiz_id=1): + from datetime import datetime + + from app.models.attempt import AttemptAnswer, QuizAttempt + from app.models.user import User + if not self.bank.db.get(User, user_id): + self.bank.db.add(User(id=user_id, name=f'Learner {user_id}', + email=f'learner{user_id}@example.test', + hashed_password='unused')) + self.bank.db.flush() + attempt = QuizAttempt(user_id=user_id, quiz_id=quiz_id, total_questions=1, + score=int(correct), completed_at=datetime.utcnow()) + self.bank.db.add(attempt) + self.bank.db.flush() + self.bank.db.add(AttemptAnswer(attempt_id=attempt.id, question_id=qid, + is_correct=correct, user_answer='yes', seconds_spent=30)) + self.bank.db.commit() + + def test_the_score_is_the_most_recent_answer_to_each_question(self): + self.sat(1, False, ago_days=9, seconds=30) + self.sat(1, True, ago_days=1, seconds=30) + self.sat(2, False, ago_days=1, seconds=30) + data = self.read() + # Two questions known about, one of them right: 50%, not the 33% a + # count of every answer ever given would report. + self.assertEqual(data['answered'], 2) + self.assertEqual(data['score'], 50.0) + + def test_a_score_on_four_answers_is_not_shown_as_a_score(self): + for qid in (1, 2, 3): + self.sat(qid, True, ago_days=1, seconds=30) + data = self.read() + self.assertFalse(data['unlocked']) + self.assertEqual(data['answers_needed'], 37) + # The figure is still computed; the page decides whether to show it. + self.assertEqual(data['score'], 100.0) + + def test_the_comparison_is_against_the_same_questions_not_the_same_people(self): + # Everyone answers question 1; the learner gets it right and two of the + # three peers get it wrong. + self.sat(1, True, ago_days=1, seconds=30) + self.peer_sat(2, 1, False) + self.peer_sat(3, 1, False) + self.peer_sat(4, 1, True) + peer = self.read()['peer'] + self.assertEqual(peer['shared_questions'], 1) + self.assertEqual(peer['cohort'], 3) + self.assertEqual(peer['expected'], 33.3) + self.assertEqual(peer['yours'], 100.0) + self.assertEqual(peer['delta'], 66.7) + + def test_no_cohort_means_no_comparison_and_it_says_what_is_missing(self): + self.sat(1, True, ago_days=1, seconds=30) + data = self.read()['peer'] + self.assertFalse(data['unlocked']) + self.assertEqual(data['shared_questions'], 0) + self.assertEqual(data['cohort_needed'], 3) + self.assertIsNone(data['expected']) + self.assertIsNone(data['delta']) + + def test_nothing_answered_reports_nothing(self): + data = self.read() + self.assertEqual(data['answered'], 0) + self.assertIsNone(data['score']) + self.assertFalse(data['unlocked']) + self.assertFalse(data['peer']['unlocked']) diff --git a/docs/TODO.md b/docs/TODO.md index 8309dd1..2a4f6d4 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -308,16 +308,19 @@ Analysis**, which has three tabs. ### Performance -- [ ] **Readiness** — two cards. One is the headline score (AMBOSS calls it - EPC, an equated percent correct); the other is peer comparison. Both say - what is still needed before they mean anything — "complete 32 more - questions" — rather than showing a number built on four answers. -- [ ] **Next step: adaptive session** — a card with a question-count select and - one button. It repeats on the Recommendations tab; it is the thing the - whole page is for. -- [ ] **Performance over time** — a line of the headline score by date, with an - honest empty state ("complete more questions to unlock this chart") - rather than a chart drawn through two points. +- [x] **Readiness** — done 2026-09-12. `GET /study-tools/readiness`. Your + score is the most recent answer to each question, and it is *not* called + an equated score: we have no psychometrics to equate one. The peer card + compares you with other learners **on the questions you have in common**, + not with their scores on whatever they happened to sit — sitting the + hardest fifty should not read as weakness. Both stay dashes until they + mean something and say which of the two things they are waiting for. +- [x] **Next step: adaptive session** — already sat above the tab switch, so + it shows on both. The readiness-locked note did too, and has been moved + down to the table it is actually about. +- [x] **Performance over time** — done 2026-09-12. The running score across + everything answered up to each day, not the session on its own. Locked + below 40 answers or 3 sessions, saying which. - [x] **Analysis panel beside it** — done 2026-09-12. `GET /study-tools/answer-split` counts the same answers twice: every answer ever given, and the most recent answer to each question. The donut is diff --git a/frontend/src/pages/AnalysisPage.css b/frontend/src/pages/AnalysisPage.css index 5990238..a785896 100644 --- a/frontend/src/pages/AnalysisPage.css +++ b/frontend/src/pages/AnalysisPage.css @@ -175,3 +175,26 @@ } .an-trend-now { font-size: 1.1rem; font-weight: 700; } .an-trend .an-basis { margin-top: 10px; } + +/* ── Readiness ──────────────────────────────────────────────────────── + Two cards, each showing a dash and its own reason rather than a number + built on four answers. */ +.an-readiness { + display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + gap: 18px; margin-top: 4px; +} +.an-ready { + padding: 16px 18px 18px; + border: 1px solid var(--border); border-radius: 12px; background: var(--card-bg); +} +.an-ready h2 { margin: 0 0 8px; font-size: 1.05rem; font-weight: 650; } +.an-ready-figure { margin: 0 0 8px; font-size: 2.1rem; font-weight: 700; line-height: 1.1; } +.an-ready-figure.is-locked { color: var(--text-subtle); } +.an-ready-figure.is-ahead { color: var(--correct-fg); } +.an-ready-figure.is-behind { color: var(--wrong-fg); } +.an-ready-unit { font-size: 0.8rem; font-weight: 600; color: var(--text-muted); margin-left: 6px; } +.an-ready-note { margin: 0; font-size: 0.78rem; line-height: 1.6; color: var(--text-muted); } + +@media (max-width: 900px) { + .an-readiness { grid-template-columns: minmax(0, 1fr); } +} diff --git a/frontend/src/pages/AnalysisPage.jsx b/frontend/src/pages/AnalysisPage.jsx index 6958c46..b1c771e 100644 --- a/frontend/src/pages/AnalysisPage.jsx +++ b/frontend/src/pages/AnalysisPage.jsx @@ -94,6 +94,92 @@ function Completion() { ) } +/** + * The two figures a learner opens this page for. + * + * Neither appears before it means anything, and each says what it is waiting + * for. A score built on four answers is not a small score, it is not a score, + * and showing one because the arithmetic is possible is how a page starts + * lying politely. + */ +function Readiness() { + const [data, setData] = useState(null) + const [error, setError] = useState('') + + useEffect(() => { + let live = true + api.get('/study-tools/readiness') + .then(res => { if (live) setData(res.data) }) + .catch(() => { if (live) setError('Could not load your readiness') }) + return () => { live = false } + }, []) + + if (error) return
{error}
+ if (!data) returnLoading…
+ + const peer = data.peer || {} + const ahead = peer.delta > 0 + const level = Math.abs(peer.delta ?? 0) < 2 + + return ( +{data.score}%
++ Of the {data.answered} questions you have answered, counting your + most recent answer to each. Not an equated score — we do not have + the psychometrics to equate one, and a number dressed up as one + would be a claim we cannot support. +
+ > + ) : ( + <> +—
++ Answer {data.answers_needed} more question + {data.answers_needed === 1 ? '' : 's'} and this becomes a score. + Below that it would be arithmetic, not a measurement. +
+ > + )} ++ {level ? 'Level' : `${ahead ? '+' : ''}${peer.delta}`} + {!level && points} +
++ You scored {peer.yours}% on the {peer.shared_questions} questions + you have in common with other learners; they averaged{' '} + {peer.expected}%. Compared on the same questions rather than on + whatever each person happened to sit — working through the hardest + fifty in the bank should not read as weakness. +
+ > + ) : ( + <> +—
++ {peer.shared_needed > 0 + ? `Answer ${peer.shared_needed} more question${peer.shared_needed === 1 ? '' : 's'} that other learners have also sat.` + : `Waiting for ${peer.cohort_needed} more learner${peer.cohort_needed === 1 ? '' : 's'} to sit the questions you have.`} + {' '}Until then there is nobody to compare with, only a couple of + strangers. +
+ > + )} +