diff --git a/backend/app/services/quiz_builder.py b/backend/app/services/quiz_builder.py index 68566d5..83102e3 100644 --- a/backend/app/services/quiz_builder.py +++ b/backend/app/services/quiz_builder.py @@ -356,7 +356,31 @@ def adaptive_select(db, user, count, category_ids, state, difficulty): return selected +def require_objective(db, user) -> None: + """Refuse to build a session for somebody who has not said what they study. + + The objective decides which questions exist, how relevance is weighted and + what readiness is measured against. No objective quietly means the whole + bank — a reasonable default, and a poor thing to arrive at by accident, + which is what was happening: the interface asks now, and this is the same + rule where it cannot be walked past. + + Only where there is something to choose. A deployment with no exams + configured, and the first administrator of a fresh one, must still be able + to build a session; a rule that locks an empty site is not a rule, it is a + fault. + """ + if getattr(user, "active_exam_id", None): + return + from app.models.exam import Exam + + if not db.query(Exam.id).filter(Exam.is_active == 1).first(): + return + raise HTTPException(400, "Choose what you are studying for before building a session") + + def generate_test(db, user, data): + require_objective(db, user) if data.algorithm == "blueprint": return _blueprint_test(db, user, data) if data.algorithm == "adaptive": diff --git a/backend/tests/test_quiz_builder.py b/backend/tests/test_quiz_builder.py index ad903f2..60d52c1 100644 --- a/backend/tests/test_quiz_builder.py +++ b/backend/tests/test_quiz_builder.py @@ -453,3 +453,43 @@ class ExamClockTests(unittest.TestCase): time_limit_minutes = 15 self.assertEqual(exam_minutes(Ask(), 40), 15) + + +class ObjectiveRequiredTests(unittest.TestCase): + """A session cannot be built by somebody who has not said what they study. + + The objective decides which questions exist, how relevance is weighted and + what readiness measures against. The interface asks; this is the same rule + where it cannot be walked past. + """ + + def setUp(self): + self.bank = BuilderTests() + self.bank.setUp() + self.client = self.bank.client + self.db = self.bank.db + + def tearDown(self): + self.bank.tearDown() + + def offer(self): + from app.models.exam import Exam + self.db.add(Exam(id=1, slug='pediatrics-boards', name='Pediatrics Boards', is_active=1)) + self.db.commit() + + def test_with_an_objective_on_offer_and_none_chosen_it_refuses(self): + self.offer() + response = self.bank.generate(is_shared=True, category_ids=[1]) + self.assertEqual(response.status_code, 400, response.text) + self.assertIn('studying for', response.json()['detail']) + + def test_choosing_one_lets_it_through(self): + self.offer() + self.bank.owner.active_exam_id = 1 + self.db.commit() + self.assertEqual(self.bank.generate(is_shared=True, category_ids=[1]).status_code, 200) + + def test_a_site_with_no_objectives_is_not_locked(self): + # A rule that locks an empty deployment is not a rule, it is a fault: + # the first administrator has nothing to choose from yet. + self.assertEqual(self.bank.generate(is_shared=True, category_ids=[1]).status_code, 200) diff --git a/docs/TODO.md b/docs/TODO.md index ff3c8be..86ffec4 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -332,12 +332,12 @@ Analysis**, which has three tabs. ### Recommendations -- [ ] **Your knowledge profile** — topics ranked by score, lowest marked +- [x] **Your knowledge profile** — topics ranked by score, lowest marked **FOCUS AREA**, under three tabs: **Articles, Systems, Disciplines**. Columns: Topic, score, **Relevance**, Status, Action. A row expands to questions completed with a bar, answered-correctly with a - correct/hints/incorrect bar, and a Start Qbank button for that topic. -- [ ] **Relevance is the ABP content specification weight.** This is the part + correct/hints/incorrect bar, and a Start Qbank button for that topic. — done 2026-09-12 — paginated, with the three axes and expandable rows. +- [x] **Relevance is the ABP content specification weight.** This is the part we can do properly and AMBOSS cannot explain: a topic's relevance is the share of the real paper its domain accounts for, which `exam_blueprints.weight` already holds. Cardiology at 5% and @@ -345,7 +345,7 @@ Analysis**, which has three tabs. say so from the board's own numbers rather than from a guess. ### Session Analysis - + — done — exam_blueprints.weight drives the Relevance column and now adaptive selection too. - [x] **A rail of latest sessions** — done. `AnalysisShell` owns the rail and the session list for both views. The session's recommendations now carry the Articles / Disciplines / Systems switch too, from @@ -368,13 +368,13 @@ Analysis**, which has three tabs. The whole pipeline from a PDF to a question in the bank lives on one page, and nothing reaches the bank until an administrator has read it. -- [ ] **Move the PDFs into Tools.** Upload, the document list and extraction +- [x] **Move the PDFs into Tools.** Upload, the document list and extraction all move off their own pages and onto the Tools page, because they are - one job and were three places. -- [ ] **Nextcloud is the administrator's alone.** Nobody else connects an + one job and were three places. — done — upload, the document list and extraction all live on /tools. +- [x] **Nextcloud is the administrator's alone.** Nobody else connects an account; it is an import path for whoever loads the corpus, not a - per-learner integration. It moves out of everyone's Settings. -- [ ] **Review and promote, on the same page.** A run lands as a batch of + per-learner integration. It moves out of everyone's Settings. — done. +- [x] **Review and promote, on the same page.** A run lands as a batch of drafts — already built, `draft_batches` / `draft_questions`, with their own sequence so nothing takes a question id early. What is missing is the screen: read them, fix them, reject the rubbish, then select the @@ -382,7 +382,7 @@ and nothing reaches the bank until an administrator has read it. moment a `Question` is created. ## Collections, as shown 2026-09-11 (evening) - + — done — a batch is read and accepted in place, and the question id is taken only on promotion. - [x] **A collections page** — done 2026-09-12. `/collections`, in the section bar beside Qbank. Card and Table views (the choice is remembered), sort by last used / created / name / size with a direction control, a count @@ -513,25 +513,25 @@ needs are `conversations` and `messages`. The reference is UWorld-style for the exam player and AMBOSS for analysis. ### Exam player chrome -- [ ] **Top bar**: `Item: n of m` / `Block: 1 of 1` at the left, Previous / n of m +- [x] **Top bar** — done 2026-09-12.: `Item: n of m` / `Block: 1 of 1` at the left, Previous / n of m / Next in the middle, Lab Values · Notes · Calculator · Settings at the right. -- [ ] **Bottom bar**: `Block Time Remaining: 00:01:29` at the left, Pause and +- [x] **Bottom bar** — done 2026-09-12.: `Block Time Remaining: 00:01:29` at the left, Pause and Lock in the middle, End Block at the right. -- [ ] **One button, not two.** A question with no answer offers Skip *and* +- [x] **One button, not two.** — done 2026-09-12. A question with no answer offers Skip *and* Next today. It is one boxed control: one button. -- [ ] **Proceed to Next Item on the last question ends the block** rather than +- [x] **Proceed to Next Item on the last question ends the block** — done 2026-09-12 — End Block replaced three controls under two names. rather than doing nothing. -- [ ] **Cross out an option** from a per-option control beside it (the `ab` +- [x] **Cross out an option** — already there — the `ab` control per option. from a per-option control beside it (the `ab` strike icon), not only from a menu. -- [ ] **Pause says "Exam Paused" and nothing else** — a title and a Return to +- [x] **Pause says "Exam Paused" and nothing else** — done 2026-09-12. — a title and a Return to exam button. No warning about real exams; that is AMBOSS's disclaimer, not ours. -- [ ] **End Session is a plain confirmation** — "Are you sure you want to end +- [x] **End Session is a plain confirmation** — done 2026-09-12. — "Are you sure you want to end this session?", End Session / Cancel. -- [ ] **Right-click removes a highlight**, and the yellow itself needs fixing. -- [ ] **The rail shows numbers while sitting an exam and shortened stems in - review.** It is numbers in both today. +- [x] **Right-click removes a highlight** — done 2026-09-12 — the yellow is a solid band now, not a gradient stripe., and the yellow itself needs fixing. +- [x] **The rail shows numbers while sitting an exam and shortened stems in + review.** — done 2026-09-12. It is numbers in both today. ### Player review mode — specified 12 Sep from a finished exam Reviewing a block that has ended looks like study mode, **whether or not @@ -542,20 +542,20 @@ other option struck red with the peer percentage beside it, KEY INFO / ATTENDING TIP / LABS, SHOW ALL EXPLANATIONS and HIDE STATS along the foot, and PREVIOUS / SKIP to move between them. -- [ ] **"Finished" means the attempt is closed, not that every question was +- [x] **"Finished" means the attempt is closed — done 2026-09-12., not that every question was answered.** The flag in QuizPage is `answeredCount >= totalCount`, which is wrong for exactly this case: a block that timed out with nothing answered is over, and it still shows numbers and hides explanations. -- [ ] **The player opens a completed attempt in review** rather than sending +- [x] **The player opens a completed attempt in review** — done 2026-09-12. rather than sending people to `/results/:id`, which makes that page redundant. ### Analysis -- [ ] **Session Analysis is the third tab**, inside the Analysis page with the +- [x] **Session Analysis is the third tab** — done 2026-09-12., inside the Analysis page with the session rail beside it — not a link away. Four figures, the donut, and Study recommendations under Articles / Disciplines / Systems. -- [ ] **An unfinished session shows Resume**, with the figures reading `--` +- [x] **An unfinished session shows Resume** — done 2026-09-12., with the figures reading `--` rather than a blank card. -- [ ] **Knowledge profile paginates** (`1 – 10 of 20`) and each row expands to +- [x] **Knowledge profile paginates** — done 2026-09-12. (`1 – 10 of 20`) and each row expands to two bars — questions completed, answered correctly split correct / correct-using-hints / incorrect — with Read article and Start Qbank beside them. @@ -563,7 +563,7 @@ PREVIOUS / SKIP to move between them. ### Elsewhere - [x] **Cap replaces Turnstile**, everywhere Turnstile was wired — one shared `Captcha` component, one `captcha` service, keys blank until issued. -- [ ] **The session rail scrolls** once it holds more than a screenful. +- [x] **The session rail scrolls** — already worked — measured in headless Chrome at three viewports rather than assumed. once it holds more than a screenful. ## Done this session diff --git a/frontend/src/pages/CollectionsPage.css b/frontend/src/pages/CollectionsPage.css index 36a1050..281a1ef 100644 --- a/frontend/src/pages/CollectionsPage.css +++ b/frontend/src/pages/CollectionsPage.css @@ -93,7 +93,13 @@ /* ── What is on one shelf ───────────────────────────────────────────── Opened in place: there is no browsable question list to send anyone to, and a link that goes nowhere is worse than no link. */ -.col-shelf { list-style: none; margin: 12px 0 0; padding: 10px 0 0; border-top: 1px solid var(--border); } +.col-shelf { + list-style: none; margin: 12px 0 0; padding: 10px 0 0; + border-top: 1px solid var(--border); + /* Boxed. Sixty-seven saved questions inside a card is a page that scrolls + past everything else on it before it ends. */ + max-height: 320px; overflow-y: auto; overscroll-behavior: contain; +} .col-shelf li { display: flex; align-items: flex-start; gap: 8px; padding: 5px 0; font-size: 0.82rem; } .col-shelf-stem { flex: 1; min-width: 0; color: var(--text-muted); line-height: 1.5; diff --git a/frontend/src/pages/CollectionsPage.jsx b/frontend/src/pages/CollectionsPage.jsx index c2d1a64..ba76eac 100644 --- a/frontend/src/pages/CollectionsPage.jsx +++ b/frontend/src/pages/CollectionsPage.jsx @@ -26,9 +26,11 @@ function Shelf({ rows, onDrop }) {
Counted at the moment you loaded this page, not typed into it last spring.
++ The counts could not be reached just now. Rather than show you a number we + have not checked, here is none. +
+ )} ++ Your next session: + {ADAPTIVE_STEPS.join(', ')}. + +
+ ) +} + // ── Contact form ────────────────────────────────────────────────────────────── function ContactForm() { const [form, setForm] = useState({ name: '', email: '', type: 'question', message: '' }) @@ -69,10 +310,10 @@ function ContactForm() { } if (sent) return ( -+
{form.type === 'moderator' ? "We'll review your application and get back to you." : "We'll get back to you shortly."} @@ -81,65 +322,44 @@ function ContactForm() { ) return ( -