feat: adaptive weighs the paper as well as the learner, and Session
analysis is a real tab Adaptive selection knew what you were weak at and nothing about what the exam is made of, so being weak at something worth 5% of the paper ranked the same as being weak at something worth 1%. Every score is now multiplied by the weight the board publishes for that topic's domain — the same `exam_blueprints.weight` behind the Relevance column. A topic the blueprint does not cover takes the median published weight. A zero would make unmapped material unreachable and the highest would make it the priority; neither is a claim the blueprint supports. With no study objective the multiplier is absent and selection is about weakness alone, exactly as before. Weight scales weakness, it does not replace it: a topic you are certain of does not surface because it is worth 5% of the paper, because (1 − accuracy) is near zero and no multiplier rescues that. docs/adaptive- sessions.md says all of this, including what is still open. Session analysis is the third tab rather than a link out of the page — two of the three used to change what you were reading and the third took you somewhere else. The tab bar is one component both routes wear, AnalysisSessionPage's body is a component the tab renders in place, and the tab lives in the address so a link opens where it says. Two things that were wrong turned up in that work: a session nobody had sat showed 0% in the figures and "0% correct" in the donut — two separate statements of a score on a session that had none — and the old third tab disappeared entirely for anyone with no attempts, so the strip silently changed shape. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
This commit is contained in:
parent
7c41dd20ac
commit
dd71bed184
11 changed files with 372 additions and 72 deletions
|
|
@ -1,5 +1,6 @@
|
||||||
"""Permission-safe, saved general-bank tests and category selection."""
|
"""Permission-safe, saved general-bank tests and category selection."""
|
||||||
import random
|
import random
|
||||||
|
import statistics
|
||||||
from typing import Literal
|
from typing import Literal
|
||||||
|
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
|
|
@ -247,10 +248,46 @@ def create_saved_test(db, user, data, question_ids):
|
||||||
return {"id": quiz.id, "title": quiz.title, "questions_count": quiz.questions_count}
|
return {"id": quiz.id, "title": quiz.title, "questions_count": quiz.questions_count}
|
||||||
|
|
||||||
|
|
||||||
|
def blueprint_weights(db, user) -> dict[int, float]:
|
||||||
|
"""Each category mapped to the share of the paper its domain accounts for.
|
||||||
|
|
||||||
|
Empty when the learner has no study objective, or when the objective has no
|
||||||
|
published weights — in which case selection falls back to being about
|
||||||
|
weakness alone, which is what it always was.
|
||||||
|
|
||||||
|
`categories_for` already returns the descendants of a mapped category, so a
|
||||||
|
leaf topic under a weighted domain is in here too.
|
||||||
|
"""
|
||||||
|
exam_id = getattr(user, "active_exam_id", None)
|
||||||
|
if not exam_id:
|
||||||
|
return {}
|
||||||
|
from app.services import exam_blueprint
|
||||||
|
|
||||||
|
weights: dict[int, float] = {}
|
||||||
|
for line in exam_blueprint.domains(db, exam_id):
|
||||||
|
if line.weight is None:
|
||||||
|
continue
|
||||||
|
for category_id in exam_blueprint.categories_for(db, line.id):
|
||||||
|
weights[category_id] = float(line.weight)
|
||||||
|
return weights
|
||||||
|
|
||||||
|
|
||||||
def adaptive_select(db, user, count, category_ids, state, difficulty):
|
def adaptive_select(db, user, count, category_ids, state, difficulty):
|
||||||
"""AMBOSS-style adaptive selection: prefer unanswered questions, then recycle
|
"""Adaptive selection: the questions most likely to raise the learner's score.
|
||||||
(older incorrect first), pick the weakest category each step and damp that
|
|
||||||
category so the session moves between weak areas."""
|
Three rules, in order. Unanswered before answered, because a question never
|
||||||
|
seen teaches more than one already met. Among those, weakest topic first.
|
||||||
|
When the unanswered run out, recycle — wrong ones before right ones, oldest
|
||||||
|
first — damping each category as it is drawn from so a session of twenty
|
||||||
|
does not become twenty questions from the single worst subject.
|
||||||
|
|
||||||
|
Weakness is then multiplied by the topic's share of the real paper, which
|
||||||
|
the examining board publishes and `exam_blueprints.weight` holds. Being weak
|
||||||
|
at something worth 5% of the exam is worth more study than being equally
|
||||||
|
weak at something worth 1%, and until this was added the two ranked the
|
||||||
|
same. A topic the blueprint does not cover takes the median weight, so it
|
||||||
|
is neither promoted nor made unreachable.
|
||||||
|
"""
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
query = filtered_bank_query(db, user, category_ids, state)
|
query = filtered_bank_query(db, user, category_ids, state)
|
||||||
if difficulty:
|
if difficulty:
|
||||||
|
|
@ -285,17 +322,34 @@ def adaptive_select(db, user, count, category_ids, state, difficulty):
|
||||||
def accuracy(category):
|
def accuracy(category):
|
||||||
total, correct = cat_correct[category]
|
total, correct = cat_correct[category]
|
||||||
return correct / total if total else 0.5
|
return correct / total if total else 0.5
|
||||||
|
|
||||||
|
weights = blueprint_weights(db, user)
|
||||||
|
# The middle of what the board publishes, for a topic it does not mention.
|
||||||
|
# A zero would make unmapped material unreachable; the highest would make
|
||||||
|
# it the priority. Neither is a claim the blueprint supports.
|
||||||
|
neutral = statistics.median(weights.values()) if weights else 1.0
|
||||||
|
|
||||||
|
def weight(category):
|
||||||
|
return weights.get(category, neutral) if weights else 1.0
|
||||||
|
|
||||||
|
def impact(category):
|
||||||
|
"""How much a question here could move the score."""
|
||||||
|
return (1 - accuracy(category)) * weight(category)
|
||||||
|
|
||||||
unanswered = [r for r in rows if r[0] not in latest]
|
unanswered = [r for r in rows if r[0] not in latest]
|
||||||
recycled = [r for r in rows if r[0] in latest]
|
recycled = [r for r in rows if r[0] in latest]
|
||||||
# Older incorrect questions resurface first among recycled.
|
# Older incorrect questions resurface first among recycled.
|
||||||
recycled.sort(key=lambda r: (latest[r[0]][0], latest[r[0]][1]))
|
recycled.sort(key=lambda r: (latest[r[0]][0], latest[r[0]][1]))
|
||||||
# Step 2: prefer unanswered, ordered by weakest category first (step 3 estimate).
|
# Step 2: prefer unanswered, most impact first.
|
||||||
unanswered.sort(key=lambda r: accuracy(r[1]))
|
unanswered.sort(key=lambda r: -impact(r[1]))
|
||||||
selected = [r[0] for r in unanswered[:count]]
|
selected = [r[0] for r in unanswered[:count]]
|
||||||
if len(selected) < count:
|
if len(selected) < count:
|
||||||
damping = defaultdict(lambda: 1.0)
|
damping = defaultdict(lambda: 1.0)
|
||||||
while recycled and len(selected) < count:
|
while recycled and len(selected) < count:
|
||||||
best = max(recycled, key=lambda r: (1 - (0.85 if latest[r[0]][0] else 0.25)) * damping[r[1]])
|
# Wrong last time is worth about five times right last time, and
|
||||||
|
# the topic's share of the paper scales both.
|
||||||
|
best = max(recycled, key=lambda r: (
|
||||||
|
(1 - (0.85 if latest[r[0]][0] else 0.25)) * damping[r[1]] * weight(r[1])))
|
||||||
recycled.remove(best)
|
recycled.remove(best)
|
||||||
selected.append(best[0])
|
selected.append(best[0])
|
||||||
damping[best[1]] *= 0.5
|
damping[best[1]] *= 0.5
|
||||||
|
|
|
||||||
|
|
@ -7,17 +7,19 @@ paper.
|
||||||
"""
|
"""
|
||||||
import random
|
import random
|
||||||
import unittest
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
|
||||||
from sqlalchemy import create_engine
|
from sqlalchemy import create_engine
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from sqlalchemy.pool import StaticPool
|
from sqlalchemy.pool import StaticPool
|
||||||
|
|
||||||
import test_quiz_builder # noqa: F401 — imports every model, so the metadata resolves
|
import test_quiz_builder as fixtures # noqa: F401 — imports every model, so the metadata resolves
|
||||||
from app.database import Base
|
from app.database import Base
|
||||||
from app.models.exam import BlueprintCategoryLink, Exam, ExamBlueprint, QuestionExamLink
|
from app.models.exam import BlueprintCategoryLink, Exam, ExamBlueprint, QuestionExamLink
|
||||||
from app.models.question import Question
|
from app.models.question import Question
|
||||||
from app.models.question_category import QuestionCategory
|
from app.models.question_category import QuestionCategory
|
||||||
|
from app.services import quiz_builder
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.services import exam_blueprint
|
from app.services import exam_blueprint
|
||||||
|
|
||||||
|
|
@ -133,3 +135,59 @@ class BlueprintTests(unittest.TestCase):
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|
||||||
|
|
||||||
|
class AdaptiveWeightedByTheBlueprintTests(unittest.TestCase):
|
||||||
|
"""Weakness is scaled by how much of the real paper a topic accounts for.
|
||||||
|
|
||||||
|
Being weak at something worth 5% of the exam is worth more study than being
|
||||||
|
equally weak at something worth 1%. Until this was added the two ranked the
|
||||||
|
same, because selection knew about the learner and nothing about the paper.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.bank = fixtures.BuilderTests()
|
||||||
|
self.bank.setUp()
|
||||||
|
self.db = self.bank.db
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self.bank.tearDown()
|
||||||
|
|
||||||
|
def weigh(self, mapping):
|
||||||
|
"""Patch the blueprint so the test states the weights it is about."""
|
||||||
|
return patch.object(quiz_builder, "blueprint_weights", lambda db, user: mapping)
|
||||||
|
|
||||||
|
def test_the_heavier_topic_is_drawn_first_when_both_are_untouched(self):
|
||||||
|
# Categories 1 and 2 both unanswered, so weakness is equal at 0.5.
|
||||||
|
# Question 1 sits under category 1, question 2 under category 2.
|
||||||
|
with self.weigh({1: 10.0, 2: 1.0}):
|
||||||
|
ids = quiz_builder.adaptive_select(self.db, self.bank.owner, 1, [], "all", None)
|
||||||
|
self.assertEqual(ids, [1])
|
||||||
|
|
||||||
|
with self.weigh({1: 1.0, 2: 10.0}):
|
||||||
|
ids = quiz_builder.adaptive_select(self.db, self.bank.owner, 1, [], "all", None)
|
||||||
|
self.assertEqual(ids, [2])
|
||||||
|
|
||||||
|
def test_weakness_still_beats_weight_when_it_is_lopsided(self):
|
||||||
|
# Right in the heavy topic, wrong in the light one: the light topic is
|
||||||
|
# where the learner actually is weak, and 10× the weight does not undo
|
||||||
|
# being certain about the other.
|
||||||
|
self.bank.answer(1, True, day=0)
|
||||||
|
self.bank.answer(2, False, day=1)
|
||||||
|
with self.weigh({1: 10.0, 2: 1.0}):
|
||||||
|
ids = quiz_builder.adaptive_select(self.db, self.bank.owner, 1, [], "all", None)
|
||||||
|
# 3 and 6 are unanswered, so they come before anything recycled.
|
||||||
|
self.assertTrue(set(ids) <= {3, 6}, ids)
|
||||||
|
|
||||||
|
def test_a_topic_the_blueprint_does_not_cover_is_neither_promoted_nor_lost(self):
|
||||||
|
# Only category 1 is mapped. Category 2 takes the median, so a question
|
||||||
|
# under it is still reachable — a zero would make it invisible.
|
||||||
|
with self.weigh({1: 4.0}):
|
||||||
|
ids = quiz_builder.adaptive_select(self.db, self.bank.owner, 4, [], "all", None)
|
||||||
|
self.assertIn(2, ids)
|
||||||
|
|
||||||
|
def test_with_no_study_objective_it_is_about_weakness_alone(self):
|
||||||
|
# No exam, no weights: selection is what it always was.
|
||||||
|
self.assertEqual(quiz_builder.blueprint_weights(self.db, self.bank.owner), {})
|
||||||
|
ids = quiz_builder.adaptive_select(self.db, self.bank.owner, 2, [], "all", None)
|
||||||
|
self.assertEqual(len(ids), 2)
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ session* card on Analysis (`frontend/src/pages/AnalysisPage.jsx`).
|
||||||
|
|
||||||
## What it does today
|
## What it does today
|
||||||
|
|
||||||
Three rules, in order.
|
Three rules, then one multiplier.
|
||||||
|
|
||||||
**1. Unanswered first.** A question you have never seen teaches more than one
|
**1. Unanswered first.** A question you have never seen teaches more than one
|
||||||
you have. Everything you have not answered is taken before anything you have.
|
you have. Everything you have not answered is taken before anything you have.
|
||||||
|
|
@ -27,28 +27,36 @@ is worth roughly five times one you got right. After each pick, that category's
|
||||||
damping halves, which stops a session of twenty becoming twenty cardiology
|
damping halves, which stops a session of twenty becoming twenty cardiology
|
||||||
questions because cardiology happens to be your worst subject.
|
questions because cardiology happens to be your worst subject.
|
||||||
|
|
||||||
|
**4. All of it is scaled by the topic's share of the real paper.** Weakness
|
||||||
|
alone said that being weak at something worth 5% of the exam and something
|
||||||
|
worth 1% were the same problem. They are not. Every score above is multiplied
|
||||||
|
by the weight the examining board publishes for that topic's domain, which
|
||||||
|
`exam_blueprints.weight` holds and `blueprint_weights()` loads.
|
||||||
|
|
||||||
|
A topic the blueprint does not cover takes the **median** of the published
|
||||||
|
weights. A zero would make unmapped material unreachable; the highest would
|
||||||
|
make it the priority. Neither is a claim the blueprint supports.
|
||||||
|
|
||||||
|
With no study objective set, or an objective carrying no weights, the
|
||||||
|
multiplier is absent and selection is about weakness alone — which is what it
|
||||||
|
was before this existed.
|
||||||
|
|
||||||
Only completed, non-expired, non-course attempts count, and only your most
|
Only completed, non-expired, non-course attempts count, and only your most
|
||||||
recent answer to each question.
|
recent answer to each question.
|
||||||
|
|
||||||
## What it is *not*
|
## What it is still *not*
|
||||||
|
|
||||||
It is **not** weighted by the exam blueprint. Impact, here, means *what you are
|
Weight scales weakness; it does not replace it. A topic you are certain of does
|
||||||
weakest at*, not *what the paper is mostly made of*. Those are different
|
not surface because it is worth 5% of the paper — `(1 − accuracy)` is near zero
|
||||||
questions and we answer only the first one.
|
and no multiplier rescues it. That is deliberate: this picks what to *study*,
|
||||||
|
not what the paper is made of.
|
||||||
|
|
||||||
The blueprint is a separate algorithm — `algorithm: "blueprint"`,
|
Dealing a paper shaped like the real exam is a different algorithm —
|
||||||
`_blueprint_test` → `services/exam_blueprint.py` — which deals a paper shaped
|
`algorithm: "blueprint"`, `_blueprint_test` → `services/exam_blueprint.py`.
|
||||||
like the real exam using the published domain weights. Nothing currently
|
|
||||||
combines the two.
|
|
||||||
|
|
||||||
## The obvious next refinement
|
## Refinements still open
|
||||||
|
|
||||||
Multiply the adaptive score by the domain's published weight, so being weak at
|
In the order they are worth doing:
|
||||||
something worth 5% of the paper outranks being weak at something worth 1%. The
|
|
||||||
weights are already in `exam_blueprints.weight` and already drive the Relevance
|
|
||||||
column on Analysis. This is not built.
|
|
||||||
|
|
||||||
Two smaller ones, in the order they are worth doing:
|
|
||||||
|
|
||||||
- **Use every category a question is filed under, not only its primary.**
|
- **Use every category a question is filed under, not only its primary.**
|
||||||
Step 2 reads `question_category_id` alone, so a question's extra links do not
|
Step 2 reads `question_category_id` alone, so a question's extra links do not
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,17 @@
|
||||||
|
|
||||||
.ax-main { min-width: 0; padding: 8px 0 48px; }
|
.ax-main { min-width: 0; padding: 8px 0 48px; }
|
||||||
|
|
||||||
|
/* Section tabs. They live here because both routes wear them, and a page that
|
||||||
|
only sometimes has them is how the third one ended up looking like a link. */
|
||||||
|
.an-tabs { display: flex; gap: 4px; border-bottom: 1px solid var(--border); margin-bottom: 16px; overflow-x: auto; scrollbar-width: none; }
|
||||||
|
.an-tabs::-webkit-scrollbar { display: none; }
|
||||||
|
.an-tab {
|
||||||
|
background: none; border: none; border-bottom: 2px solid transparent;
|
||||||
|
padding: 9px 14px; font-size: 0.9rem; font-weight: 600;
|
||||||
|
color: var(--text-muted); cursor: pointer; white-space: nowrap;
|
||||||
|
}
|
||||||
|
.an-tab[aria-selected='true'] { color: var(--primary); border-bottom-color: var(--primary); }
|
||||||
|
|
||||||
/* ── Rail ─────────────────────────────────────────────────────────── */
|
/* ── Rail ─────────────────────────────────────────────────────────── */
|
||||||
.ax-rail {
|
.ax-rail {
|
||||||
/* Stuck below the header, exactly as tall as what is left of the window.
|
/* Stuck below the header, exactly as tall as what is left of the window.
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,32 @@ import SessionRail from './SessionRail'
|
||||||
import api from '../api/client'
|
import api from '../api/client'
|
||||||
import './AnalysisShell.css'
|
import './AnalysisShell.css'
|
||||||
|
|
||||||
|
export const ANALYSIS_TABS = [
|
||||||
|
['performance', 'Performance'],
|
||||||
|
['recommendations', 'Recommendations'],
|
||||||
|
['session', 'Session analysis'],
|
||||||
|
]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The three readings of the same answers, in one bar.
|
||||||
|
*
|
||||||
|
* Session analysis used to be a link out of the page, which is why it never
|
||||||
|
* felt like a tab: two of the three changed what you were reading and the
|
||||||
|
* third took you somewhere else. What selecting does is left to the caller,
|
||||||
|
* since the session's own route has to hand you back to the page holding the
|
||||||
|
* other two — but from the outside all three are the same control.
|
||||||
|
*/
|
||||||
|
export function AnalysisTabs({ active, onSelect }) {
|
||||||
|
return (
|
||||||
|
<div className="an-tabs" role="tablist">
|
||||||
|
{ANALYSIS_TABS.map(([key, label]) => (
|
||||||
|
<button key={key} type="button" role="tab" className="an-tab"
|
||||||
|
aria-selected={active === key} onClick={() => onSelect(key)}>{label}</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The frame both analysis views sit in: the session rail, then the content.
|
* The frame both analysis views sit in: the session rail, then the content.
|
||||||
*
|
*
|
||||||
|
|
|
||||||
|
|
@ -6,15 +6,6 @@
|
||||||
.an-header h1 { margin: 0 0 4px; font-size: 1.35rem; }
|
.an-header h1 { margin: 0 0 4px; font-size: 1.35rem; }
|
||||||
.an-header p { margin: 0; color: var(--text-muted); font-size: 0.87rem; }
|
.an-header p { margin: 0; color: var(--text-muted); font-size: 0.87rem; }
|
||||||
|
|
||||||
.an-tabs { display: flex; gap: 4px; border-bottom: 1px solid var(--border); margin-bottom: 16px; overflow-x: auto; scrollbar-width: none; }
|
|
||||||
.an-tabs::-webkit-scrollbar { display: none; }
|
|
||||||
.an-tab {
|
|
||||||
background: none; border: none; border-bottom: 2px solid transparent;
|
|
||||||
padding: 9px 14px; font-size: 0.9rem; font-weight: 600;
|
|
||||||
color: var(--text-muted); cursor: pointer; white-space: nowrap;
|
|
||||||
}
|
|
||||||
.an-tab[aria-selected='true'] { color: var(--primary); border-bottom-color: var(--primary); }
|
|
||||||
|
|
||||||
/* ── Summary tiles ────────────────────────────────────────────────── */
|
/* ── Summary tiles ────────────────────────────────────────────────── */
|
||||||
.an-summary { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 10px; margin-bottom: 14px; }
|
.an-summary { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 10px; margin-bottom: 14px; }
|
||||||
.an-stat { background: var(--card-bg); border: 1px solid var(--border); border-radius: 10px; padding: 13px 15px; }
|
.an-stat { background: var(--card-bg); border: 1px solid var(--border); border-radius: 10px; padding: 13px 15px; }
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
import { useState, useEffect, useCallback } from 'react'
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
import { Link, useNavigate } from 'react-router-dom'
|
import { Link, useNavigate, useSearchParams } from 'react-router-dom'
|
||||||
import api from '../api/client'
|
import api from '../api/client'
|
||||||
import CategoryPerformance from '../components/CategoryPerformance'
|
import CategoryPerformance from '../components/CategoryPerformance'
|
||||||
import Donut from '../components/Donut'
|
import Donut from '../components/Donut'
|
||||||
import LineChart from '../components/LineChart'
|
import LineChart from '../components/LineChart'
|
||||||
import AnalysisShell from '../components/AnalysisShell'
|
import AnalysisShell, { AnalysisTabs, ANALYSIS_TABS } from '../components/AnalysisShell'
|
||||||
|
import { SessionAnalysis } from './AnalysisSessionPage'
|
||||||
import './AnalysisPage.css'
|
import './AnalysisPage.css'
|
||||||
|
|
||||||
const STATUS_LABEL = { focus: 'Focus area', proficient: 'Proficient', no_data: 'No data yet' }
|
const STATUS_LABEL = { focus: 'Focus area', proficient: 'Proficient', no_data: 'No data yet' }
|
||||||
|
|
@ -371,7 +372,17 @@ function FocusRow({ row, onPractise }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AnalysisPage() {
|
export default function AnalysisPage() {
|
||||||
const [tab, setTab] = useState('recommendations')
|
// Which tab is on lives in the URL, so a reader can be sent to one and so
|
||||||
|
// the session's own route can hand them back to the tab they came from.
|
||||||
|
const [params, setParams] = useSearchParams()
|
||||||
|
const view = params.get('view')
|
||||||
|
const tab = ANALYSIS_TABS.some(([key]) => key === view) ? view : 'recommendations'
|
||||||
|
const setTab = (key) => {
|
||||||
|
const next = new URLSearchParams(params)
|
||||||
|
if (key === 'recommendations') next.delete('view')
|
||||||
|
else next.set('view', key)
|
||||||
|
setParams(next, { replace: true })
|
||||||
|
}
|
||||||
const [group, setGroup] = useState('articles')
|
const [group, setGroup] = useState('articles')
|
||||||
const [data, setData] = useState(null)
|
const [data, setData] = useState(null)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
@ -413,27 +424,20 @@ export default function AnalysisPage() {
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="an-tabs" role="tablist">
|
<AnalysisTabs active={tab} onSelect={setTab} />
|
||||||
<button className="an-tab" role="tab" aria-selected={tab === 'performance'} onClick={() => setTab('performance')}>
|
|
||||||
Performance
|
|
||||||
</button>
|
|
||||||
<button className="an-tab" role="tab" aria-selected={tab === 'recommendations'} onClick={() => setTab('recommendations')}>
|
|
||||||
Recommendations
|
|
||||||
</button>
|
|
||||||
{/* A session's own analysis is a session, and the rail beside this
|
|
||||||
page is the list of them — so this points at the last one rather
|
|
||||||
than being a third empty tab. */}
|
|
||||||
{data?.last_attempt_id && (
|
|
||||||
<Link className="an-tab" role="tab" aria-selected="false"
|
|
||||||
to={`/sessions/${data.last_attempt_id}`}>Session analysis</Link>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error && <p className="an-empty" role="alert">{error}</p>}
|
{error && <p className="an-empty" role="alert">{error}</p>}
|
||||||
|
|
||||||
{loading && !data ? (
|
{loading && !data ? (
|
||||||
<div className="loading"><div className="spinner" /> Loading…</div>
|
<div className="loading"><div className="spinner" /> Loading…</div>
|
||||||
) : !data ? null : (
|
) : !data ? null : tab === 'session' ? (
|
||||||
|
/* One sitting is its own subject. The lifetime tiles and the adaptive
|
||||||
|
button below them are about the whole bank, and repeating them over
|
||||||
|
a single session's figures would invite the two to be read as one
|
||||||
|
set. Which session: the one most recently worked on, and the rail
|
||||||
|
beside it is how you get to any of the others. */
|
||||||
|
<SessionAnalysis attemptId={data.last_attempt_id} />
|
||||||
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="an-summary">
|
<div className="an-summary">
|
||||||
<div className="an-stat">
|
<div className="an-stat">
|
||||||
|
|
|
||||||
|
|
@ -86,6 +86,70 @@ it('switches to the performance tab', async () => {
|
||||||
expect(screen.queryByText('Cardiology')).not.toBeInTheDocument()
|
expect(screen.queryByText('Cardiology')).not.toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('opens on the tab the address names', async () => {
|
||||||
|
// Which tab is on is in the URL, so one can be linked to — which is how the
|
||||||
|
// session's own route hands a reader back to the other two.
|
||||||
|
render(<MemoryRouter initialEntries={['/sessions?view=performance']}><AnalysisPage /></MemoryRouter>)
|
||||||
|
expect(await screen.findByTestId('category-performance')).toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('tab', { name: 'Performance' })).toHaveAttribute('aria-selected', 'true')
|
||||||
|
})
|
||||||
|
|
||||||
|
const SESSION = {
|
||||||
|
attempt_id: 91, quiz_id: 3, title: 'My Custom Test', mode: 'learning',
|
||||||
|
completed_at: '2026-09-11T10:00:00', graded: true,
|
||||||
|
total: 2, answered: 2, score: 1, percent: 50,
|
||||||
|
seconds_total: 180, seconds_per_question: 90,
|
||||||
|
questions: [{ position: 1, question_id: 1, excerpt: 'A 4-month-old…', status: 'correct',
|
||||||
|
difficulty: 'medium', category: 'Neonatology', seconds_spent: 90, peer_percent: 60, peer_sample: 5 }],
|
||||||
|
recommendations: [], plan: null, not_started: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
const withSession = (session) => api.get.mockImplementation(url => {
|
||||||
|
if (url === '/quizzes/sessions') return Promise.resolve({ data: [] })
|
||||||
|
if (url.endsWith('/analysis')) return Promise.resolve({ data: session })
|
||||||
|
if (url.startsWith('/attempts/')) return Promise.resolve({ data: { rows: [] } })
|
||||||
|
return Promise.resolve({ data: payload({ last_attempt_id: 91 }) })
|
||||||
|
})
|
||||||
|
|
||||||
|
const sessionTab = async () => {
|
||||||
|
render(<MemoryRouter><AnalysisPage /></MemoryRouter>)
|
||||||
|
await userEvent.click(await screen.findByRole('tab', { name: 'Session analysis' }))
|
||||||
|
}
|
||||||
|
|
||||||
|
it('reads a session on the third tab, in place, with the rail still beside it', async () => {
|
||||||
|
withSession(SESSION)
|
||||||
|
await sessionTab()
|
||||||
|
// The third tab used to be a link to a page of its own, so choosing it left
|
||||||
|
// the analysis rather than changing which part of it you were reading.
|
||||||
|
expect(await screen.findByRole('heading', { name: /Your performance for My Custom Test/ }))
|
||||||
|
.toBeInTheDocument()
|
||||||
|
expect(api.get).toHaveBeenCalledWith('/attempts/91/analysis')
|
||||||
|
expect(screen.getByRole('tab', { name: 'Performance' })).toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('complementary', { name: 'Sessions' })).toBeInTheDocument()
|
||||||
|
const figures = document.querySelector('.an-figures')
|
||||||
|
expect(within(figures).getByText('50%')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('offers the way back into a session still being sat', async () => {
|
||||||
|
withSession({ ...SESSION, completed_at: null, answered: 1 })
|
||||||
|
await sessionTab()
|
||||||
|
expect(await screen.findByRole('link', { name: 'Resume session' }))
|
||||||
|
.toHaveAttribute('href', '/study/3?start=1')
|
||||||
|
expect(screen.queryByRole('link', { name: 'Review session' })).toBeNull()
|
||||||
|
// And no figures, because a session half sat has no result — only a 50% that
|
||||||
|
// would be read as one.
|
||||||
|
expect(within(document.querySelector('.an-figures')).getAllByText('—')).toHaveLength(4)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('says there is nothing to analyse rather than showing an empty session', async () => {
|
||||||
|
await sessionTab()
|
||||||
|
// Not the rail's "No sessions yet" repeated: this says what the tab would
|
||||||
|
// have shown, which is the question the reader just asked.
|
||||||
|
expect(await screen.findByText(/sit one and this is where it is picked apart/))
|
||||||
|
.toBeInTheDocument()
|
||||||
|
expect(api.get).not.toHaveBeenCalledWith('/attempts/undefined/analysis')
|
||||||
|
})
|
||||||
|
|
||||||
it('reports completion over a window, and changes the window', async () => {
|
it('reports completion over a window, and changes the window', async () => {
|
||||||
const completion = (over = {}) => ({
|
const completion = (over = {}) => ({
|
||||||
days: 30, answered: 120, bank_total: 330, correct: 78,
|
days: 30, answered: 120, bank_total: 330, correct: 78,
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
import { Link, useNavigate, useParams } from 'react-router-dom'
|
import { Link, useNavigate, useParams } from 'react-router-dom'
|
||||||
import AnalysisShell from '../components/AnalysisShell'
|
import AnalysisShell, { AnalysisTabs } from '../components/AnalysisShell'
|
||||||
import Donut from '../components/Donut'
|
import Donut from '../components/Donut'
|
||||||
import RepeatSession from '../components/RepeatSession'
|
import RepeatSession from '../components/RepeatSession'
|
||||||
import api from '../api/client'
|
import api from '../api/client'
|
||||||
|
|
@ -34,11 +34,12 @@ const SORTS = {
|
||||||
* right, how much was reached at all, how long each question took, and how long
|
* right, how much was reached at all, how long each question took, and how long
|
||||||
* the sitting was. Time per question is the one that says whether you are
|
* the sitting was. Time per question is the one that says whether you are
|
||||||
* reading carefully or stalling.
|
* reading carefully or stalling.
|
||||||
|
*
|
||||||
|
* It takes the session as props and brings no frame of its own, because the
|
||||||
|
* Analysis page shows it on a tab and the route below shows it on its own —
|
||||||
|
* the same reading either way, from one piece of markup.
|
||||||
*/
|
*/
|
||||||
export default function AnalysisSessionPage() {
|
export function SessionAnalysis({ attemptId, quizId }) {
|
||||||
// Two ways in: an attempt to analyse, or — when nothing has been sat yet —
|
|
||||||
// the quiz itself, so the page can say what is missing instead of 404ing.
|
|
||||||
const { attemptId, quizId } = useParams()
|
|
||||||
const [data, setData] = useState(null)
|
const [data, setData] = useState(null)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
|
|
@ -51,9 +52,12 @@ export default function AnalysisSessionPage() {
|
||||||
// table and the peer statistics beside it.
|
// table and the peer statistics beside it.
|
||||||
const [group, setGroup] = useState('disciplines')
|
const [group, setGroup] = useState('disciplines')
|
||||||
const [recs, setRecs] = useState(null)
|
const [recs, setRecs] = useState(null)
|
||||||
const navigate = useNavigate()
|
|
||||||
|
|
||||||
const load = useCallback(() => {
|
const load = useCallback(() => {
|
||||||
|
// Reached with neither on the Analysis page before a first session exists.
|
||||||
|
// Asking for /attempts/undefined/analysis is how that came back as a
|
||||||
|
// failure rather than as a beginning.
|
||||||
|
if (!attemptId && !quizId) { setLoading(false); return }
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
const url = attemptId ? `/attempts/${attemptId}/analysis` : `/attempts/quiz/${quizId}/analysis`
|
const url = attemptId ? `/attempts/${attemptId}/analysis` : `/attempts/quiz/${quizId}/analysis`
|
||||||
api.get(url)
|
api.get(url)
|
||||||
|
|
@ -82,11 +86,18 @@ export default function AnalysisSessionPage() {
|
||||||
const shown = rows.slice(page * PER_PAGE, page * PER_PAGE + PER_PAGE)
|
const shown = rows.slice(page * PER_PAGE, page * PER_PAGE + PER_PAGE)
|
||||||
useEffect(() => { setPage(0) }, [sort])
|
useEffect(() => { setPage(0) }, [sort])
|
||||||
|
|
||||||
|
if (!attemptId && !quizId) {
|
||||||
|
return (
|
||||||
|
<div className="an-empty">
|
||||||
|
No session to read yet — sit one and this is where it is picked apart.
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <AnalysisShell><div className="loading"><div className="spinner" /></div></AnalysisShell>
|
return <div className="loading"><div className="spinner" /></div>
|
||||||
}
|
}
|
||||||
if (!data) {
|
if (!data) {
|
||||||
return <AnalysisShell><div className="an-empty">{error || 'Session not found.'}</div></AnalysisShell>
|
return <div className="an-empty">{error || 'Session not found.'}</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
// An exam still running is not marked, so there is nothing to split the
|
// An exam still running is not marked, so there is nothing to split the
|
||||||
|
|
@ -95,13 +106,18 @@ export default function AnalysisSessionPage() {
|
||||||
const graded = data.graded !== false
|
const graded = data.graded !== false
|
||||||
// Nothing left to answer is finished, whether or not it was handed in.
|
// Nothing left to answer is finished, whether or not it was handed in.
|
||||||
const finished = !!data.completed_at || (data.total > 0 && data.answered >= data.total)
|
const finished = !!data.completed_at || (data.total > 0 && data.answered >= data.total)
|
||||||
const correct = graded ? data.score : 0
|
// A sitting in progress has no result, and zeros are read as one: 0% is a
|
||||||
const incorrect = graded ? data.answered - data.score : 0
|
// score of nought, not "not answered yet". So an unfinished session reports
|
||||||
const answeredOnly = graded ? 0 : data.answered
|
// how far through it is and nothing else — which is also why the button
|
||||||
|
// beneath it is the way back in rather than the way through the answers.
|
||||||
|
const marked = graded && finished
|
||||||
|
const correct = marked ? data.score : 0
|
||||||
|
const incorrect = marked ? data.answered - data.score : 0
|
||||||
|
const answeredOnly = marked ? 0 : data.answered
|
||||||
const skipped = data.total - data.answered
|
const skipped = data.total - data.answered
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AnalysisShell>
|
<>
|
||||||
<div className="an-main">
|
<div className="an-main">
|
||||||
<div className="an-head">
|
<div className="an-head">
|
||||||
<h1>Your performance for <span>{data.title}</span></h1>
|
<h1>Your performance for <span>{data.title}</span></h1>
|
||||||
|
|
@ -148,7 +164,7 @@ export default function AnalysisSessionPage() {
|
||||||
['⏲', hours(data.seconds_total), 'total time spent'],
|
['⏲', hours(data.seconds_total), 'total time spent'],
|
||||||
].map(([icon, value, label]) => (
|
].map(([icon, value, label]) => (
|
||||||
<div key={label} className="an-figure">
|
<div key={label} className="an-figure">
|
||||||
<strong>{value}</strong>
|
<strong>{finished ? value : '—'}</strong>
|
||||||
<span><span aria-hidden="true">{icon}</span> {label}</span>
|
<span><span aria-hidden="true">{icon}</span> {label}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
@ -159,9 +175,9 @@ export default function AnalysisSessionPage() {
|
||||||
<h2>{data.title}</h2>
|
<h2>{data.title}</h2>
|
||||||
<div className="an-donut-wrap">
|
<div className="an-donut-wrap">
|
||||||
<Donut correct={correct} incorrect={answeredOnly ? 0 : incorrect}
|
<Donut correct={correct} incorrect={answeredOnly ? 0 : incorrect}
|
||||||
answered={answeredOnly} skipped={skipped} graded={graded} />
|
answered={answeredOnly} skipped={skipped} graded={marked} />
|
||||||
<ul className="an-legend">
|
<ul className="an-legend">
|
||||||
{graded ? (
|
{marked ? (
|
||||||
<>
|
<>
|
||||||
<li><i className="is-right" />{correct} correct</li>
|
<li><i className="is-right" />{correct} correct</li>
|
||||||
<li><i className="is-wrong" />{incorrect} incorrect</li>
|
<li><i className="is-wrong" />{incorrect} incorrect</li>
|
||||||
|
|
@ -331,6 +347,30 @@ export default function AnalysisSessionPage() {
|
||||||
<RepeatSession title={data.title} rows={data.questions}
|
<RepeatSession title={data.title} rows={data.questions}
|
||||||
onClose={() => setRepeating(false)} />
|
onClose={() => setRepeating(false)} />
|
||||||
)}
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One session at its own address.
|
||||||
|
*
|
||||||
|
* The rail hands out a link per row and people have kept the ones they were
|
||||||
|
* sent, so the route stays; it lands on the Session analysis tab with that
|
||||||
|
* session showing, which is the same view the tab gives on its own. The other
|
||||||
|
* two tabs are about the whole bank rather than this sitting, so choosing one
|
||||||
|
* goes back to the page that holds them.
|
||||||
|
*/
|
||||||
|
export default function AnalysisSessionPage() {
|
||||||
|
// Two ways in: an attempt to analyse, or — when nothing has been sat yet —
|
||||||
|
// the quiz itself, so the page can say what is missing instead of 404ing.
|
||||||
|
const { attemptId, quizId } = useParams()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AnalysisShell>
|
||||||
|
<AnalysisTabs active="session"
|
||||||
|
onSelect={key => { if (key !== 'session') navigate(`/sessions?view=${key}`) }} />
|
||||||
|
<SessionAnalysis attemptId={attemptId} quizId={quizId} />
|
||||||
</AnalysisShell>
|
</AnalysisShell>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
import { render, screen, waitFor, within } from '@testing-library/react'
|
import { render, screen, waitFor, within } from '@testing-library/react'
|
||||||
import userEvent from '@testing-library/user-event'
|
import userEvent from '@testing-library/user-event'
|
||||||
import { MemoryRouter, Route, Routes } from 'react-router-dom'
|
import { MemoryRouter, Route, Routes, useSearchParams } from 'react-router-dom'
|
||||||
import AnalysisSessionPage from './AnalysisSessionPage'
|
import AnalysisSessionPage from './AnalysisSessionPage'
|
||||||
import api from '../api/client'
|
import api from '../api/client'
|
||||||
|
|
||||||
|
|
@ -71,11 +71,13 @@ describe('a session nobody has sat', () => {
|
||||||
|
|
||||||
it('is the same page at zero, not a different kind of page', async () => {
|
it('is the same page at zero, not a different kind of page', async () => {
|
||||||
mountQuiz()
|
mountQuiz()
|
||||||
// The real figures, all zero. Scoped to the figure strip: the title and
|
// Nothing sat is not a result of nought. The figures used to read
|
||||||
// the percentage also appear in the rail and the donut.
|
// "0%, 0/20", which is a mark on an empty page; a session with nothing in
|
||||||
const figures = (await screen.findByText('0/20')).closest('.an-figures')
|
// it has no figures, and the donut says how far through it is instead.
|
||||||
expect(within(figures).getByText('0%')).toBeInTheDocument()
|
await screen.findByText(/Nothing answered yet/)
|
||||||
expect(within(figures).getByText('0/20')).toBeInTheDocument()
|
const figures = document.querySelector('.an-figures')
|
||||||
|
expect(within(figures).getAllByText('—')).toHaveLength(4)
|
||||||
|
expect(within(figures).queryByText('0%')).toBeNull()
|
||||||
// And the real table, every row skipped.
|
// And the real table, every row skipped.
|
||||||
// Ten rows a page, every one of them skipped.
|
// Ten rows a page, every one of them skipped.
|
||||||
expect(screen.getAllByText('skipped')).toHaveLength(10)
|
expect(screen.getAllByText('skipped')).toHaveLength(10)
|
||||||
|
|
@ -149,6 +151,37 @@ describe('a session whose only attempt is still in progress', () => {
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('a session reached by its own URL', () => {
|
||||||
|
const Overall = () => <p>overall analysis: {useSearchParams()[0].get('view')}</p>
|
||||||
|
|
||||||
|
const mountRouted = () => render(
|
||||||
|
<MemoryRouter initialEntries={['/sessions/91']}>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/sessions" element={<Overall />} />
|
||||||
|
<Route path="/sessions/:attemptId" element={<AnalysisSessionPage />} />
|
||||||
|
</Routes>
|
||||||
|
</MemoryRouter>)
|
||||||
|
|
||||||
|
beforeEach(() => { vi.clearAllMocks(); mock(SAT) })
|
||||||
|
|
||||||
|
it('lands on the session tab rather than on a page of its own', async () => {
|
||||||
|
mountRouted()
|
||||||
|
// People were sent these links before there were tabs, and the rail hands
|
||||||
|
// out one per row. The address still works, and it arrives somewhere that
|
||||||
|
// looks like the rest of the analysis.
|
||||||
|
expect(await screen.findByRole('tab', { name: 'Session analysis' }))
|
||||||
|
.toHaveAttribute('aria-selected', 'true')
|
||||||
|
expect(await screen.findByRole('heading', { name: /Your performance for My Custom Test/ }))
|
||||||
|
.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('hands the reader back to the two tabs it does not hold', async () => {
|
||||||
|
mountRouted()
|
||||||
|
await userEvent.click(await screen.findByRole('tab', { name: 'Performance' }))
|
||||||
|
expect(screen.getByText('overall analysis: performance')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe('the session rail', () => {
|
describe('the session rail', () => {
|
||||||
it('gives the content the whole width when the rail is put away', async () => {
|
it('gives the content the whole width when the rail is put away', async () => {
|
||||||
mountAttempt()
|
mountAttempt()
|
||||||
|
|
@ -237,6 +270,17 @@ describe('a session left part way', () => {
|
||||||
expect(screen.getByRole('link', { name: 'Resume session' })).toHaveAttribute('href', '/study/3?start=1')
|
expect(screen.getByRole('link', { name: 'Resume session' })).toHaveAttribute('href', '/study/3?start=1')
|
||||||
expect(screen.queryByRole('link', { name: 'Repeat session' })).not.toBeInTheDocument()
|
expect(screen.queryByRole('link', { name: 'Repeat session' })).not.toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('withholds the figures until there is a sitting behind them', async () => {
|
||||||
|
mountAttempt()
|
||||||
|
await screen.findByText(/1 of 2 questions still unanswered/)
|
||||||
|
// Half a session answered would score 50%, which is the sort of figure a
|
||||||
|
// learner remembers and the sort a page should not offer: it is the score
|
||||||
|
// of a session that does not exist yet.
|
||||||
|
const figures = document.querySelector('.an-figures')
|
||||||
|
expect(within(figures).getAllByText('—')).toHaveLength(4)
|
||||||
|
expect(within(figures).queryByText('50%')).toBeNull()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('ranks this session\'s topics, and asks the same answers three ways', async () => {
|
it('ranks this session\'s topics, and asks the same answers three ways', async () => {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue