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 &&

{error}

}