Skip to content

feat(training): full quiz/exam parity in the portal app, with the bot as single source of truth (bd-2673) - #214

Merged
hatafatif merged 1 commit into
developfrom
bd-2673-14425
Aug 13, 2026
Merged

feat(training): full quiz/exam parity in the portal app, with the bot as single source of truth (bd-2673)#214
hatafatif merged 1 commit into
developfrom
bd-2673-14425

Conversation

@hatafatif

Copy link
Copy Markdown
Collaborator

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:

  1. A capstone 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 — pass bar (bd-2483), progress write (bd-2450), eligibility proxy (bd-2447). Re-opening without removing the duplication would restart that cycle.

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:

Rule Before Now
multi-answer key + set normalisation bot, and two portal copies paper-marking.service.js only
marking a paper bot, and twice in portal.routes.js POST /internal/training/mark-paper
level-exam pass bar bot, and read inline from training_vendors with a hardcoded 100 fallback decideExamPassexam-verdict
serving policy bot only; portal built its own paper serve-paper
capstone rubric private to the bot service scoreAnswer exported

The 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 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. 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. LevelExamCard routes exam_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, _whatsappOnly and all four call sites are gone; 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.

Test results

Suite Result
Root npm test 3054 passing, 0 failing assertions
Portal npm test 107/107
Portal build clean
Lint 68 problems vs 71 on develop

24 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, and certificate-fetch-or-mint was confirmed failing on untouched develop under the same load.

New tests, each proven red first:

  • paper-marking-shared — the marking contract, incl. msq set equality and position-not-order_index
  • 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's refusal, full submit incl. certificate minting and every refusal path
  • CapstoneExamForm.test.tsx — the inputs, counter and Submit gating that were broken
  • assessments-are-whatsapp-onlyassessments-run-in-portal — the suite that pinned the gate shut now pins it open and pins eligibility still closed

Also 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

  • This widens an existing coupling. The bot was already an availability floor for portal gating (accepted 2026-08-02); it is now one for submitting too. That is the correct trade — a gate that opens when it cannot reach the authority is not a gate — but it is a conscious yes, not a side effect.
  • A correction to my own earlier analysis: I reported the module sequencing lock as missing from the portal. It is not — bd-2481 ported it, and it is enforced at all four module routes including the two this PR un-gates. Verified, no work needed.

Not in scope

Urdu/RTL on training. Training is hardcoded English, but so is every portal page (nothing under src/portal/ imports react-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

…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>
@hatafatif
hatafatif merged commit 9e50908 into develop Aug 13, 2026
1 of 2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant