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 &&
This block will end in 5 minutes.
+ +