Files
learning-platform/docs/on-demand-tests-spec.md
RaymondVerhoef 43a71e2110
All checks were successful
On Push to Main / test (push) Successful in 1m1s
On Push to Main / publish (push) Successful in 1m22s
On Push to Main / deploy-dev (push) Successful in 2m18s
feat: on-demand topic tests with shared question bank
Adds a /topic-test route where learners can take a 5-question test on any
eligible topic at any time. Questions come from a shared, admin-curated
question_bank — no LLM calls on the user path. Points feed the existing
leaderboard with a 10pt/topic/week cap (per ISO week) so the bank can't be
farmed, and repeats from a thin bank yield 0 points.

- New PB collections: question_bank, on_demand_attempts (+ migrations and
  setup-pb-collections.mjs entries).
- db.js: un-deprecates getQuizBank/setQuizBank against question_bank so the
  existing admin TestManager panel becomes functional again; adds
  saveOnDemandAttempt, getOnDemandPointsThisWeek, getUserSeenQuestionIds,
  getAllOnDemandAttempts, isoWeekKey.
- testService.js: getEligibleTopicsForOnDemand, startOnDemandTest (unseen-
  first draw), finishOnDemandTest (cap enforcement + leaderboard upsert).
- New TopicTest page, route, and nav entry; weekly test flow untouched.
- Leaderboard folds on-demand 100%s into perfect-score count and merges
  on-demand attempts into the recent activity feed.
- Spec: docs/on-demand-tests-spec.md with ADRs and extension points.
- Tests: 5 new vitest cases covering eligibility, draw fallback, scoring,
  partial cap, and exhausted cap (85/85 total).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-02 15:06:43 +02:00

6.1 KiB

On-demand topic tests

Lets a learner take a 5-question multiple-choice test on any topic they pick, at any time. Questions come from a shared, admin-curated bank — the Anthropic API is not called from the user path. Points flow to the same leaderboard as the weekly test, with a per-(user, topic) weekly cap to prevent farming.

The weekly test (/test, test_results, generateWeeklyQuiz, curriculumService.getCurrentWeekContent) is unchanged. This module sits alongside it.


Architecture

Collections

  • question_bank — one record per topic.

    • topic_id: text (required), questions: json[] (array of question objects).
    • Each question object: { id, question, topicLabel, options[4], correctIndex, explanation, difficulty }.
    • Admin-curated only — no automatic top-up from the user path.
  • on_demand_attempts — one record per completed on-demand test.

    • Keys: user_id, topic_id, plus iso_week (the "YYYY-Www" of completion) for fast weekly-cap aggregation.
    • Stores: question_ids[] (the bank IDs served — used to compute "unseen" for future attempts), score, total, percentage, time_used, unseen_correct, points_earned, capped, breakdown, completed_at.

Service entry points (src/lib/testService.js)

Function Caller Notes
getEligibleTopicsForOnDemand() TopicTest picker Filters type !== 'fact', learning_relevance !== 'exclude', and bank.length >= 5.
startOnDemandTest(userId, topicId) TopicTest Picks 5 questions, prefers unseen, tags each with isUnseen: bool.
finishOnDemandTest(userId, topicId, topicLabel, quiz, answers, timeUsed) TopicTest Computes score, enforces cap, writes on_demand_attempts, updates leaderboard.
ON_DEMAND_TEST_SIZE / WEEKLY_POINT_CAP_PER_TOPIC constants 5 and 10 respectively.

Draw strategy

bank = db.getQuestionBank(topic_id)
seen = db.getUserSeenQuestionIds(user_id, topic_id)   # union of all this user's
                                                      # prior question_ids for topic
picked = shuffle(bank - seen)[:5]
if len(picked) < 5:
    picked += shuffle(bank ∩ seen)[:5 - len(picked)]  # repeats, flagged isUnseen=false

A repeated question is shown to the learner with a Practice (no points) tag and yields 0 points regardless of correctness.

Anti-farm cap

raw_points       = unseen_correct * 2
already_earned   = sum(points_earned) for (user, topic, current_iso_week)
points_earned    = min(raw_points, max(0, 10 - already_earned))
capped           = raw_points > points_earned

Cap is enforced at write-time inside finishOnDemandTest. The user can keep playing the topic, but excess attempts within the same ISO week show a "cap reached" notice and earn 0 points.

Points flow to the leaderboard via the existing db.upsertLeaderboardEntry(userId, name, +pointsEarned, +1) — same path as the weekly test, so the points column, badges, and Contributions feed pick up on-demand activity without extra wiring.

Heatmap policy

The Leaderboard's 26-week heatmap stays weekly-test-only. On-demand attempts have no curriculum-week slot, so adding them would muddle the visual.

On-demand contributions surface in:

  • The leaderboard's "points" column (commits) via upsertLeaderboardEntry.
  • The "perfect" stat (any 100% counts, weekly or on-demand).
  • The Recent Activity feed (merged with weekly-test entries, sorted by created).

ADR-style decisions

  • Blob-per-topic over row-per-question. Existing TestManager.jsx consumes bank.length and operates on an array. Row-per-question would double the migration/CRUD surface without measurable benefit for banks < 100 questions.

  • Un-deprecation over rename. The legacy getQuizBank / setQuizBank / deleteQuestionFromBank stubs in db.js were aliased to the new question_bank collection rather than renamed. This keeps forceGenerateTopicQuestions and the admin panel working with zero UI changes.

  • Cap = 10 pt/topic/week, equal to a perfect weekly run. Keeps weekly and on-demand value-symmetric. Tunable via the WEEKLY_POINT_CAP_PER_TOPIC export.

  • Practice-allowed over block-on-thin-bank. When the bank is exhausted for a user, the test still runs with repeats but yields 0 points for repeated questions. Confirmed product preference: "oefenen mag altijd."

  • No quiz_cache for on-demand. Each on-demand test draws fresh from the bank — there's nothing to cache and per-user cache keys would only confuse the seen/unseen logic.


Aanroepconventies (für eine nachfolgende Agent)

Wat aanroepen waarvoor:

  • Een topic bevragen voor on-demand picker: getEligibleTopicsForOnDemand()[{ id, label, description, theme, bankSize, ... }].
  • Een test starten: startOnDemandTest(userId, topicId){ questions: [{...q, isUnseen}], meta }. Houd isUnseen per vraag bij in de UI; geef q ongewijzigd door aan finishOnDemandTest.
  • Een test afsluiten: finishOnDemandTest(userId, topicId, topicLabel, quiz, answers, timeUsed){ score, total, percentage, pointsEarned, capped, rawPoints, breakdown, ... }. De service handelt cap, PB-write en leaderboard-update zelf af; geen extra calls vanuit de UI.
  • Bank-CRUD vanuit admin: blijft op de bestaande forceGenerateTopicQuestions / getTopicQuestionBank / deleteQuestion exports — die routeren intern naar de question_bank collectie.

Uitbreidingspunten

  1. Cap-tuning via settings: lift WEEKLY_POINT_CAP_PER_TOPIC naar de settings-collectie en lees in finishOnDemandTest.
  2. Per-vraag stats: voeg times_served, times_correct toe aan elk bank-question object voor toekomstige difficulty-tuning / IRT.
  3. Dashboard surfacing: een "Topic Test" tile op het Dashboard die de topics met de meeste ongeziene vragen voor de huidige user toont (getEligibleTopicsForOnDemand + getUserSeenQuestionIds).
  4. Heatmap fold-in: als de heatmap ooit niet meer week-gebonden hoeft te zijn, kunnen on-demand attempts erbij worden gemixt — vergt een redesign van de visuele as.