diff --git a/backend/app/services/quiz_builder.py b/backend/app/services/quiz_builder.py index 8654f24..68566d5 100644 --- a/backend/app/services/quiz_builder.py +++ b/backend/app/services/quiz_builder.py @@ -1,5 +1,6 @@ """Permission-safe, saved general-bank tests and category selection.""" import random +import statistics from typing import Literal 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} +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): - """AMBOSS-style adaptive selection: prefer unanswered questions, then recycle - (older incorrect first), pick the weakest category each step and damp that - category so the session moves between weak areas.""" + """Adaptive selection: the questions most likely to raise the learner's score. + + 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 query = filtered_bank_query(db, user, category_ids, state) if difficulty: @@ -285,17 +322,34 @@ def adaptive_select(db, user, count, category_ids, state, difficulty): def accuracy(category): total, correct = cat_correct[category] 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] recycled = [r for r in rows if r[0] in latest] # Older incorrect questions resurface first among recycled. 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). - unanswered.sort(key=lambda r: accuracy(r[1])) + # Step 2: prefer unanswered, most impact first. + unanswered.sort(key=lambda r: -impact(r[1])) selected = [r[0] for r in unanswered[:count]] if len(selected) < count: damping = defaultdict(lambda: 1.0) 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) selected.append(best[0]) damping[best[1]] *= 0.5 diff --git a/backend/tests/test_exam_blueprint.py b/backend/tests/test_exam_blueprint.py index df98e20..55aa4c3 100644 --- a/backend/tests/test_exam_blueprint.py +++ b/backend/tests/test_exam_blueprint.py @@ -7,17 +7,19 @@ paper. """ import random import unittest +from unittest.mock import patch from decimal import Decimal from sqlalchemy import create_engine from sqlalchemy.orm import Session 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.models.exam import BlueprintCategoryLink, Exam, ExamBlueprint, QuestionExamLink from app.models.question import Question from app.models.question_category import QuestionCategory +from app.services import quiz_builder from app.models.user import User from app.services import exam_blueprint @@ -133,3 +135,59 @@ class BlueprintTests(unittest.TestCase): if __name__ == "__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) diff --git a/docs/adaptive-sessions.md b/docs/adaptive-sessions.md index d0dd242..e36d03b 100644 --- a/docs/adaptive-sessions.md +++ b/docs/adaptive-sessions.md @@ -10,7 +10,7 @@ session* card on Analysis (`frontend/src/pages/AnalysisPage.jsx`). ## 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 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 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 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 -weakest at*, not *what the paper is mostly made of*. Those are different -questions and we answer only the first one. +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 — `(1 − accuracy)` is near zero +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"`, -`_blueprint_test` → `services/exam_blueprint.py` — which deals a paper shaped -like the real exam using the published domain weights. Nothing currently -combines the two. +Dealing a paper shaped like the real exam is a different algorithm — +`algorithm: "blueprint"`, `_blueprint_test` → `services/exam_blueprint.py`. -## The obvious next refinement +## Refinements still open -Multiply the adaptive score by the domain's published weight, so being weak at -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: +In the order they are worth doing: - **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 diff --git a/frontend/src/components/AnalysisShell.css b/frontend/src/components/AnalysisShell.css index f132d8d..1eff383 100644 --- a/frontend/src/components/AnalysisShell.css +++ b/frontend/src/components/AnalysisShell.css @@ -23,6 +23,17 @@ .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 ─────────────────────────────────────────────────────────── */ .ax-rail { /* Stuck below the header, exactly as tall as what is left of the window. diff --git a/frontend/src/components/AnalysisShell.jsx b/frontend/src/components/AnalysisShell.jsx index 0e3db46..0b6b7b4 100644 --- a/frontend/src/components/AnalysisShell.jsx +++ b/frontend/src/components/AnalysisShell.jsx @@ -3,6 +3,32 @@ import SessionRail from './SessionRail' import api from '../api/client' 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 ( +
{error}
} {loading && !data ? (overall analysis: {useSearchParams()[0].get('view')}
+ + const mountRouted = () => render( +