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:
parent
7ba37561e4
commit
3a94d89e7c
6 changed files with 119 additions and 7 deletions
|
|
@ -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
58
docs/adaptive-sessions.md
Normal 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.
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in a new issue