feat: a solid highlight, a five-minute warning, and the adaptive algorithm

written down

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
This commit is contained in:
Daniel 2026-09-12 05:47:30 +02:00
parent 7ba37561e4
commit 3a94d89e7c
6 changed files with 119 additions and 7 deletions

View file

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

58
docs/adaptive-sessions.md Normal file
View file

@ -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.

View file

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

View file

@ -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 && <div role="alert" className="quiz-submit-error">{submitError} <button type="button" disabled={submitting} onClick={() => handleSubmit(false)}>Retry submission</button></div>}
{progressError && <div role="alert" className="quiz-submit-error">{progressError} <button type="button" onClick={() => saveProgressNow()}>Retry saving</button></div>}
{fiveLeft && !timeUp && (
<div className="quiz-away" role="dialog" aria-modal="true" aria-labelledby="fivemin-heading">
<div className="quiz-away-card">
<h2 id="fivemin-heading">Block Time Warning</h2>
<p>This block will end in 5 minutes.</p>
<button type="button" className="btn btn-primary"
onClick={() => setFiveLeft(false)}>Return to exam</button>
</div>
</div>
)}
{timeUp && (
<div className="quiz-away" role="dialog" aria-modal="true" aria-labelledby="timeup-heading">
<div className="quiz-away-card">

View file

@ -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)