From 3a94d89e7ca5f4136ccf48844e19d0b165d110b1 Mon Sep 17 00:00:00 2001 From: Daniel Date: Sat, 12 Sep 2026 05:47:30 +0200 Subject: [PATCH] feat: a solid highlight, a five-minute warning, and the adaptive algorithm written down MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The yellow was a gradient stripe under the x-height, which reads as an underline rather than a highlight and all but vanishes on a wrapped line. It is a solid band now, the way a highlighter leaves one. A block that ends without warning ends on whatever question you happened to be reading. Five minutes out it says so — once, because a warning that returns every second is a warning nobody reads. And docs/adaptive-sessions.md, because "prioritised by impact" was a phrase with no written meaning. It says where the code is, what the three rules are — unanswered first, weakest topic among those, then wrong ones oldest first with the category damped so twenty questions do not all come from your worst subject — and, as plainly, what it does not do: it is not weighted by the exam blueprint. Weakest and most-of-the-paper are different questions and we answer only the first. The refinement that would combine them is written down too, with the column that already holds the weights. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN --- ...stile.py => test_login_without_captcha.py} | 0 docs/README.md | 2 + docs/adaptive-sessions.md | 58 +++++++++++++++++++ frontend/src/index.css | 12 ++-- frontend/src/pages/QuizPage.jsx | 28 ++++++++- frontend/src/pages/QuizPage.test.jsx | 26 ++++++++- 6 files changed, 119 insertions(+), 7 deletions(-) rename backend/tests/{test_login_without_turnstile.py => test_login_without_captcha.py} (100%) create mode 100644 docs/adaptive-sessions.md diff --git a/backend/tests/test_login_without_turnstile.py b/backend/tests/test_login_without_captcha.py similarity index 100% rename from backend/tests/test_login_without_turnstile.py rename to backend/tests/test_login_without_captcha.py diff --git a/docs/README.md b/docs/README.md index 80eeb11..f3d5afb 100644 --- a/docs/README.md +++ b/docs/README.md @@ -11,6 +11,8 @@ Deep technical documentation for the PedsHub pediatric learning platform. | [Services](services.md) | Backend service layer: AI extraction, embedding, vector search, PDF processing, email, reminders | | [Frontend](frontend.md) | React app structure, pages, components, state patterns, runtime config | | [Deployment](deployment.md) | Docker setup, environment variables, HTTPS, rebuilding, monitoring, troubleshooting, scaling | +| [Adaptive sessions](adaptive-sessions.md) | What the Adaptive toggle selects, where the code is, and what it deliberately ignores | +| [Study recommendations](study-recommendations.md) | How focus areas are ranked and what readiness means | ## Quick Links diff --git a/docs/adaptive-sessions.md b/docs/adaptive-sessions.md new file mode 100644 index 0000000..d0dd242 --- /dev/null +++ b/docs/adaptive-sessions.md @@ -0,0 +1,58 @@ +# Adaptive sessions — what "prioritised by impact" means here + +**Code:** `backend/app/services/quiz_builder.py`, function `adaptive_select` +(the `algorithm: "adaptive"` branch of `generate_test`). +**Reached from:** the Adaptive toggle on the custom-session builder +(`frontend/src/pages/CustomQuizPage.jsx`), and the *Next step: adaptive +session* card on Analysis (`frontend/src/pages/AnalysisPage.jsx`). +**Tests:** `backend/tests/test_quiz_builder.py`, +`test_adaptive_selection_prefers_unanswered_then_recycles_weakest`. + +## What it does today + +Three rules, in order. + +**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. + +**2. Among the unanswered, weakest topic first.** Each candidate is ordered by +your accuracy in its primary category. A category you have never answered in +scores 0.5 — treated as neither known nor unknown, so it sorts between your +strong and weak areas rather than jumping the queue. + +**3. When the unanswered run out, recycle — wrong ones first, oldest first.** +Each recycled candidate is scored `(1 − p) × damping`, where `p` is 0.85 if you +got it right last time and 0.25 if you got it wrong. So a question you missed +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. + +Only completed, non-expired, non-course attempts count, and only your most +recent answer to each question. + +## What it is *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. + +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. + +## The obvious next refinement + +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: + +- **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 + influence which topic it counts as. +- **Let time answer.** A question answered correctly in ten seconds is not the + same as one answered correctly in four minutes, and `seconds_spent` is + recorded on every answer already. diff --git a/frontend/src/index.css b/frontend/src/index.css index 14d725d..56587da 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -575,7 +575,12 @@ html, body { overflow-x: hidden; } * that said it was a link. The band now starts below the x-height and is * light enough to read through, which is what a highlighter actually does. */ .manual-highlight-active { - background: linear-gradient(transparent 58%, var(--marker, rgba(253, 224, 71, 0.55)) 58%); + /* A solid band, the way a real highlighter leaves one and the way every + question bank draws it. It was a gradient stripe under the x-height, + which read as an underline rather than a highlight and disappeared + entirely against a wrapped line. */ + background: var(--marker, #fff07a); + color: #1e293b; border-radius: 2px; cursor: context-menu; box-decoration-break: clone; @@ -599,9 +604,8 @@ html, body { overflow-x: hidden; } -webkit-box-decoration-break: clone; } .manual-highlight-active.speech-highlight-active { - background: - linear-gradient(transparent 58%, var(--marker, rgba(253, 224, 71, 0.55)) 58%), - rgba(96, 165, 250, 0.18); + background: var(--marker, #fff07a); + box-shadow: inset 0 -2px 0 rgba(59, 130, 246, 0.72); } /* On a dark ground the same yellow glares. Dimmer, and warmer, so the text on diff --git a/frontend/src/pages/QuizPage.jsx b/frontend/src/pages/QuizPage.jsx index 09a9aa9..4195cd1 100644 --- a/frontend/src/pages/QuizPage.jsx +++ b/frontend/src/pages/QuizPage.jsx @@ -18,6 +18,10 @@ import '../components/Feedback.css' import QuizTools, { QuizDialog } from '../components/QuizTools' import './QuizPlayer.css' +//: Seconds left when the block says so. Long enough to finish the question in +//: front of you and go back for one more; short enough to mean something. +const FIVE_MINUTES = 300 + const TeachChat = lazyPage(() => import('../components/TeachChat')) const OPTION_LETTERS = ['A', 'B', 'C', 'D', 'E', 'F'] @@ -437,6 +441,10 @@ export default function QuizPage() { // are saved and it can be resumed — but it is not what a mis-aimed thumb // should do either. const [leaving, setLeaving] = useState(false) + // Said once, when five minutes are left. A block that ends without warning + // ends on whatever question you happened to be reading. + const [fiveLeft, setFiveLeft] = useState(false) + const warnedRef = useRef(false) // The clock ran out: the answers are in, and the analysis waits behind an // acknowledgement rather than replacing the exam without a word. const [timeUp, setTimeUp] = useState(false) @@ -827,7 +835,14 @@ const timerStarted = timeLeft !== null clearInterval(timerRef.current) if (document.hidden || clockStopped) return timerRef.current = setInterval(() => { - setTimeLeft(t => { if (t <= 1) { clearInterval(timerRef.current); return 0 } return t - 1 }) + setTimeLeft(t => { + if (t <= 1) { clearInterval(timerRef.current); return 0 } + if (t - 1 <= FIVE_MINUTES && !warnedRef.current) { + warnedRef.current = true + setFiveLeft(true) + } + return t - 1 + }) }, 1000) } start() @@ -1372,6 +1387,17 @@ const timerStarted = timeLeft !== null {submitError &&
{submitError}
} {progressError &&
{progressError}
} + {fiveLeft && !timeUp && ( +
+
+

Block Time Warning

+

This block will end in 5 minutes.

+ +
+
+ )} + {timeUp && (
diff --git a/frontend/src/pages/QuizPage.test.jsx b/frontend/src/pages/QuizPage.test.jsx index db6a06d..fa3f790 100644 --- a/frontend/src/pages/QuizPage.test.jsx +++ b/frontend/src/pages/QuizPage.test.jsx @@ -1,5 +1,5 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' -import { fireEvent, render, screen, waitFor, within } from '@testing-library/react' +import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { MemoryRouter, Route, Routes } from 'react-router-dom' import QuizPage from './QuizPage' @@ -29,9 +29,10 @@ beforeEach(() => { localStorage.clear() mode = 'exam' quizModeVar = 'timed' + timeLimit = null api.get.mockImplementation(url => { if (url.startsWith('/quizzes/10')) return Promise.resolve({ data: { - id: 10, title: 'Personal test', mode: quizModeVar, questions_count: 2, user_id: 1, time_limit_minutes: null, + id: 10, title: 'Personal test', mode: quizModeVar, questions_count: 2, user_id: 1, time_limit_minutes: timeLimit, attempt_mode: url.includes('attempt_id=') ? mode : null, questions: questions.map(q => url.includes('attempt_id=') && mode === 'study' ? { ...q, correct_answer: q.options[0], explanation: 'Full explanation, preserved without shortening.' } : q), } }) @@ -82,6 +83,8 @@ async function beginWithTip() { await screen.findByRole('button', { name: 'stridor' }) } +let timeLimit = null + describe('quiz player', () => { it('reveals a rail excerpt only once the question has been reached', async () => { await begin() @@ -565,6 +568,25 @@ describe('quiz player', () => { expect(await screen.findByText('Submitted results')).toBeInTheDocument() }) + it('says so when five minutes are left, once', async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }) + try { + timeLimit = 6 // Six minutes, so the warning is a minute away. + await begin(false) + expect(screen.queryByText(/This block will end in 5 minutes/)).not.toBeInTheDocument() + await act(async () => { vi.advanceTimersByTime(61_000) }) + expect(await screen.findByText(/This block will end in 5 minutes/)).toBeInTheDocument() + + await userEvent.click(screen.getByRole('button', { name: 'Return to exam' })) + // Once. A warning that returns every second is a warning nobody reads. + await act(async () => { vi.advanceTimersByTime(5_000) }) + expect(screen.queryByText(/This block will end in 5 minutes/)).not.toBeInTheDocument() + } finally { + vi.useRealTimers() + timeLimit = null + } + }) + it('starts timed quizzes in exam mode without a mode prompt', async () => { quizModeVar = 'timed' await begin(false)