feat(training): full quiz/exam parity in the portal app, with the bot as single source of truth (bd-2673) - #214
Merged
Merged
Conversation
…with the bot as the single source of truth (bd-2673)
Goal: a teacher can do in the portal/App everything they can do on the
WhatsApp bot — module quizzes (single and multi answer) and level exams
(multiple choice and written capstone) — with every gating and grading rule
living ONLY on the bot.
WHY THIS WAS TWO PROBLEMS, NOT ONE
----------------------------------
bd-2490 switched the portal's assessment surface off for two stated reasons.
Both had to be fixed before it could come back on:
1. A capstone paper is free text. The portal rendered every exam through the
radio-per-option path, so a Beacon House teacher opening her level exam
saw eight questions, no inputs, a counter stuck on 0/8 and a Submit that
could never enable.
2. Every assessment rule the portal owned had drifted from the bot's and been
fixed separately — the pass bar (bd-2483), the progress write (bd-2450),
the eligibility proxy (bd-2447). Re-opening the surface without removing
the duplication would just restart that cycle.
So (2) is fixed first and separately: the bot becomes the only place a rule is
written. Only then is the gate opened.
THE BOT IS NOW THE ONLY GRADER
------------------------------
Gating was already delegated (bd-2469/2480/2481). Grading was not — "which
answer is correct" existed in three places, agreeing by coincidence:
quiz-delivery.service.js isMultiKey / normalizeSet
portal.routes.js:2748-2771 the module-quiz copy
portal.routes.js:3306-3327 the grand-quiz copy
and the level-exam route additionally read training_vendors.passing_pct itself
and did the percentage arithmetic inline, with a hardcoded 100 fallback —
the same line bd-2393 had already fixed once.
- NEW bot/shared/services/training/paper-marking.service.js — the marking
rule, pure, zero requires. quiz-delivery now sources isMultiKey from it.
- NEW decideExamPass(), the level-exam sibling of decideModuleQuizPass.
- NEW internal routes: mark-paper, serve-paper, exam-verdict.
- The portal's copies are DELETED and replaced with rules-client calls that
throw (never default) when the bot is unreachable, so an unmarked paper is
never recorded as a scored one.
A BUG THE EXTRACTION FOUND
--------------------------
The two portal copies disagreed with each other about question_index: the
module route preferred order_index, the grand-quiz route used canonical
position. The bot writes current_question_index, a 0-based counter over the
served paper, and (attempt_id, question_index) is UNIQUE — so on a 1-based
corpus the module path would have written indices that diverge from the rows
WhatsApp writes for the same attempt. The shared rule uses position, pinned by
a test on a 1-based fixture.
WRITTEN CAPSTONE IN THE PORTAL
------------------------------
- capstone-delivery.service.js exports the pure half: scoreAnswer (the same
LLM rubric WhatsApp grades with), decideCapstonePass (carrying bd-2478's
refusal to score a short answer set), meetsAnswerFloor, MIN_ANSWER_CHARS.
finalizeAttempt keeps the WhatsApp delivery it is coupled to.
- NEW GET/POST /training/level/:id/capstone/{questions,attempts}.
- NEW portal/src/portal/components/CapstoneExamForm.tsx — a textarea per
question, live character count, Submit enabled only on work the server
would accept. LevelExamCard routes exam_kind 'capstone' here, so a written
exam can never reach the multiple-choice form again.
- The 400-character floor was prose in a docblock and enforced nowhere; it is
now enforced server-side, and the number is sent to the client rather than
duplicated in it (the bd-2489 rule).
THE GATE IS OPENED, IN BOTH PLACES, ONCE
----------------------------------------
- portal.routes.js: ASSESSMENTS_ON_WHATSAPP_ONLY and _whatsappOnly deleted
with all four call sites; _grandQuizState reports 'ready' again.
- portal/src/lib/assessments.ts deleted per its own removal note.
- Each route still asks the bot's gate FIRST and fails closed: eligibility,
not the surface, is what refuses. The API is the gate, not the button.
TESTS
-----
- paper-marking-shared: the marking contract, incl. msq set equality and the
position-not-order_index rule.
- no-local-grading-in-portal-routes: greps the route file so a marking or
pass-bar rule cannot grow back. Every assertion verified to fail against
the pre-change file.
- capstone-pure-rules, portal-capstone-submit: the floor, the bar, bd-2478,
and the full submit incl. certificate minting and every refusal path.
- CapstoneExamForm.test.tsx: the inputs, counter and Submit gating that were
broken.
- assessments-are-whatsapp-only -> assessments-run-in-portal: the suite that
pinned the gate shut now pins it open AND pins eligibility still closed.
- delegate-training-to-bot: wires the three new client methods to the real
bot functions, and mocks the LLM client — without it a capstone submit
test dials OpenRouter for real and hangs.
Root suite 3054 passing / 0 failing assertions (24 pre-existing env- and
load-dependent suite failures, all reproduced on untouched develop, which
fails 94). Portal 107/107. Portal builds; lint 68 problems vs 71 on develop.
Closes: bd-2673
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this does
A teacher can now do in the portal/App everything they can do on the WhatsApp bot — module quizzes (single and multi answer) and level exams (multiple choice and written capstone) — with every gating and grading rule living only on the bot.
Closes Row 2 of the ICT priority sheet (P0, "we need the complete flow of teacher training on App as well").
Why this was two problems
bd-2490 switched this surface off for two reasons. Both had to be fixed before it could come back on:
So (2) is fixed first and separately, then the gate opens.
The bot is now the only grader
Gating was already delegated (bd-2469/2480/2481). Grading was not — "which answer is correct" existed in three places, agreeing by coincidence:
paper-marking.service.jsonlyportal.routes.jsPOST /internal/training/mark-papertraining_vendorswith a hardcoded100fallbackdecideExamPass→exam-verdictserve-paperscoreAnswerexportedThe portal's copies are deleted and replaced with rules-client calls that throw rather than default when the bot is unreachable — an unmarked paper is never recorded as a scored one.
A bug the extraction found
The two portal copies disagreed with each other about
question_index: the module route preferredorder_index, the grand-quiz route used canonical position. The bot writescurrent_question_index— a 0-based counter over the served paper — and(attempt_id, question_index)is UNIQUE. On a 1-based corpus the module path would have written indices that diverge from the rows WhatsApp writes for the same attempt. The shared rule uses position, pinned by a test on a 1-based fixture.Written capstone
New
CapstoneExamForm.tsx— a textarea per question, live character count, Submit enabled only on work the server would accept.LevelExamCardroutesexam_kind: 'capstone'here, so a written exam can never reach the MCQ form again.The 400-character floor was prose in a docblock and enforced nowhere. It is now enforced server-side, and sent to the client rather than duplicated in it (the bd-2489 rule).
The gate is opened in both places, once
ASSESSMENTS_ON_WHATSAPP_ONLY,_whatsappOnlyand all four call sites are gone;portal/src/lib/assessments.tsdeleted per its own removal note. Each route still asks the bot's gate first and fails closed — eligibility, not the surface, is what refuses. The API is the gate, not the button.Test results
npm testnpm test24 suite-level failures remain, all env- and load-dependent and reproduced on untouched
develop(which fails 94 tests to this branch's 71). The two certificate suites that flake under full-suite load pass 36/36 in isolation across repeated runs, andcertificate-fetch-or-mintwas confirmed failing on untoucheddevelopunder the same load.New tests, each proven red first:
paper-marking-shared— the marking contract, incl. msq set equality and position-not-order_indexno-local-grading-in-portal-routes— greps the route file so a marking or pass-bar rule cannot grow back; every assertion verified to fail against the pre-change filecapstone-pure-rules+portal-capstone-submit— the floor, the bar, bd-2478's refusal, full submit incl. certificate minting and every refusal pathCapstoneExamForm.test.tsx— the inputs, counter and Submit gating that were brokenassessments-are-whatsapp-only→assessments-run-in-portal— the suite that pinned the gate shut now pins it open and pins eligibility still closedAlso fixed: the shared delegation fixture now mocks the LLM client. Without it a capstone submit test dials OpenRouter for real and hangs.
Two things to know before merging further
Not in scope
Urdu/RTL on training. Training is hardcoded English, but so is every portal page (nothing under
src/portal/importsreact-i18next), so that is the portal's baseline rather than a training regression, and a portal-wide job that should not ride in on a P0.🤖 Generated with Claude Code