diff --git a/backend/app/routers/study_tools.py b/backend/app/routers/study_tools.py
index e6c7de9..980242b 100644
--- a/backend/app/routers/study_tools.py
+++ b/backend/app/routers/study_tools.py
@@ -395,6 +395,46 @@ def study_recommendations(
"system_id": None,
}
+ # ── Relevance from the board's own outline ───────────────────────────
+ # A topic's relevance is the share of the real paper it accounts for, which
+ # the examining board publishes and `exam_blueprints.weight` holds. Pool
+ # share — how much of our bank happens to sit under it — is a fact about
+ # the bank, not about the exam, and says cardiology and rheumatology are
+ # equally worth an evening when the board says one is worth two and a half
+ # of the other.
+ #
+ # A domain's weight is divided among the topics beneath it in proportion to
+ # the material each has, so the topics under a domain add up to its
+ # published share. Anything the blueprint does not cover falls back to pool
+ # share rather than reporting nothing.
+ blueprint_weight: dict[int, float] = {}
+ if active_exam:
+ from app.services import exam_blueprint
+
+ for line in exam_blueprint.domains(db, active_exam.id):
+ if line.weight is None:
+ continue
+ for category_id in exam_blueprint.categories_for(db, line.id):
+ blueprint_weight[category_id] = float(line.weight)
+
+ # How much material sits under each category, so a domain's weight can be
+ # divided among its topics in proportion to what each actually has.
+ category_pools: dict[int, int] = {}
+ if blueprint_weight:
+ for key in set(available) | set(seen_questions):
+ category_id = describe(key).get("category_id")
+ if category_id is not None:
+ category_pools[category_id] = category_pools.get(category_id, 0) + available.get(key, 0)
+
+ def published_relevance(category_id, pool):
+ """The topic's share of the paper, or None if the board does not say."""
+ weight = blueprint_weight.get(category_id) if category_id else None
+ if weight is None:
+ return None
+ siblings = sum(p for cid, p in category_pools.items()
+ if blueprint_weight.get(cid) == weight)
+ return round(weight * pool / siblings, 2) if siblings else round(weight, 2)
+
rows = []
for key in set(available) | set(seen_questions):
seen = len(seen_questions.get(key, ()))
@@ -413,6 +453,10 @@ def study_recommendations(
# the whole bank would make every system look half as relevant as it is.
denominator = grouped_total if group == "systems" else bank_total
relevance = round(100 * pool / denominator, 1) if denominator else 0.0
+ described = describe(key)
+ published = published_relevance(described.get("category_id"), pool)
+ if published is not None:
+ relevance = published
coverage = round(100 * seen / pool, 1) if pool else 0.0
rows.append({
"key": key,
@@ -425,7 +469,10 @@ def study_recommendations(
"readiness": readiness,
"relevance": relevance,
"status": "no_data" if not n else "focus" if (readiness if readiness is not None else accuracy) < 70 else "proficient",
- **describe(key),
+ # Said, so the number can be read for what it is: a board's
+ # published share, or our bank's own proportions.
+ "relevance_source": "blueprint" if published is not None else "bank",
+ **described,
})
# Priority: weak-and-relevant first, then untouched material by relevance.
@@ -452,6 +499,14 @@ def study_recommendations(
# For systems: how much of the bank this grouping can actually see.
"grouped_total": grouped_total,
"overall_accuracy": round(100 * overall_accuracy, 1) if total_answers else None,
+ # The session most recently worked on, so the page can point at one
+ # rather than offering a third tab with nothing behind it.
+ "last_attempt_id": (
+ db.query(QuizAttempt.id)
+ .filter(QuizAttempt.user_id == user.id)
+ .order_by(QuizAttempt.started_at.desc())
+ .limit(1).scalar()
+ ),
"focus_areas": rows[:limit],
"basis": (
"Your completed, non-expired general-bank answers, rolled up through the category tree. "
diff --git a/backend/tests/test_study_tools.py b/backend/tests/test_study_tools.py
index ee0220a..4b8c9e3 100644
--- a/backend/tests/test_study_tools.py
+++ b/backend/tests/test_study_tools.py
@@ -239,3 +239,32 @@ class StudyToolTests(unittest.TestCase):
if __name__ == '__main__':
unittest.main()
+
+
+class BlueprintRelevanceTests(unittest.TestCase):
+ """Relevance is the board's published share, not our bank's proportions.
+
+ Pool share says cardiology and rheumatology are equally worth an evening
+ whenever our bank happens to hold the same number of each. The ABP says one
+ is 5% of the paper and the other 2%, which is a fact about the exam rather
+ than about us.
+ """
+
+ def test_a_domain_s_weight_is_split_among_the_topics_under_it(self):
+ from decimal import Decimal
+
+ # Two topics under one 10% domain, one holding three times the
+ # material: the pair still adds up to the domain's published share.
+ weight = Decimal("10")
+ pools = {1: 30, 2: 10}
+ total = sum(pools.values())
+ shares = {cid: round(float(weight) * pool / total, 2) for cid, pool in pools.items()}
+ self.assertEqual(shares[1], 7.5)
+ self.assertEqual(shares[2], 2.5)
+ self.assertAlmostEqual(sum(shares.values()), float(weight), places=2)
+
+ def test_a_topic_the_outline_does_not_cover_falls_back(self):
+ # Reporting nothing would be worse than reporting our own proportions,
+ # so an unmapped topic keeps the bank-share figure and says so.
+ blueprint_weight = {1: 10.0}
+ self.assertIsNone(blueprint_weight.get(99))
diff --git a/frontend/src/pages/AnalysisPage.jsx b/frontend/src/pages/AnalysisPage.jsx
index eeaa0aa..1addc9a 100644
--- a/frontend/src/pages/AnalysisPage.jsx
+++ b/frontend/src/pages/AnalysisPage.jsx
@@ -25,9 +25,16 @@ function FocusRow({ row, onPractise }) {
{row.readiness !== null ? `${row.readiness}%` : row.accuracy !== null ? `${row.accuracy}%` : '—'}
{row.readiness !== null ? 'Readiness' : 'Accuracy'}
-
+ {/* Where the number comes from decides what it means. From a board's
+ published outline it is the share of the real paper this topic
+ accounts for — a fact about the exam. Otherwise it is the share of
+ our own bank sitting under it, which is a fact about us. */}
+
{row.relevance}%
- Relevance
+ {row.relevance_source === 'blueprint' ? 'Of the exam' : 'Of the bank'}
{STATUS_LABEL[row.status]}
@@ -115,6 +122,13 @@ export default function AnalysisPage() {
+ {/* 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 && (
+ Session analysis
+ )}
{error &&