feat: relevance is the board's published share, not our bank's proportions
The knowledge profile ranked topics by how much of *our* bank sat under each one, which is a fact about us rather than about the exam. It made cardiology and rheumatology equally worth an evening whenever we happened to hold the same number of each. The ABP publishes that one is 5% of the paper and the other 2%, and exam_blueprints.weight has held that since the blueprint landed. A domain's weight is divided among the topics beneath it in proportion to the material each holds, so the topics under a domain add up to its published share. 672 of our categories now carry one. A topic the outline does not cover keeps the bank-share figure rather than reporting nothing — and the row says which it is, because the two numbers mean different things and should not be read as the same one. Session analysis is a link to the last session rather than a third tab with nothing behind it — a session's analysis is a session, and the rail beside this page is the list of them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
This commit is contained in:
parent
b2ddee74dc
commit
1b996b0a3d
3 changed files with 101 additions and 3 deletions
|
|
@ -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. "
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -25,9 +25,16 @@ function FocusRow({ row, onPractise }) {
|
|||
{row.readiness !== null ? `${row.readiness}%` : row.accuracy !== null ? `${row.accuracy}%` : '—'}
|
||||
<small>{row.readiness !== null ? 'Readiness' : 'Accuracy'}</small>
|
||||
</span>
|
||||
<span className="an-metric an-metric-relevance">
|
||||
{/* 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. */}
|
||||
<span className="an-metric an-metric-relevance"
|
||||
title={row.relevance_source === 'blueprint'
|
||||
? 'Share of the real exam this topic accounts for, from the examining board’s published content outline'
|
||||
: 'Share of the question bank filed under this topic'}>
|
||||
{row.relevance}%
|
||||
<small>Relevance</small>
|
||||
<small>{row.relevance_source === 'blueprint' ? 'Of the exam' : 'Of the bank'}</small>
|
||||
</span>
|
||||
<span className={`an-status is-${row.status}`}>{STATUS_LABEL[row.status]}</span>
|
||||
</button>
|
||||
|
|
@ -115,6 +122,13 @@ export default function AnalysisPage() {
|
|||
<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>}
|
||||
|
|
|
|||
Loading…
Reference in a new issue