Scoring runs when a taker submits a session (POST /sessions/{id}/submit). The engine scores every question in the session and computes a final percentage.
score_pct = round(earned_points / total_points * 100)
passed = score_pct >= passing_score_pct (if a threshold is set)
| Type | Logic | needs_review |
|---|---|---|
multiple_choice |
selected matches the correct option id → full points |
No |
multiple_select |
Depends on the test’s multiple_select_scoring (all-or-nothing or partial) — see below |
No |
true_false |
Case-insensitive string match → full points | No |
short_text (with correct_answer.text) |
Lowercase + trim comparison → full points | No |
short_text (no correct_answer) |
Score = null | Yes |
long_text |
Score = null | Yes |
file_upload |
Score = null | Yes |
audio_prompt, video_prompt, passage, divider |
Score = 0, no points expected | No |
| Unknown type | Score = null | Yes |
multiple_select scoring is controlled per-test by the multiple_select_scoring field:
all_or_nothing (default): the selected set must be identical to the correct set; any missing or extra selection scores zero.partial: each correct selection earns points / (number of correct options), floored to an integer. Extra (wrong) selections carry no penalty.After scoring, the session’s review_status is set:
| Value | Meaning |
|---|---|
auto_scored |
All answers machine-graded; score_pct and passed are final |
awaiting_review |
At least one answer has needs_review = true; score is not final |
reviewed |
A reviewer has graded all open answers; score_pct is now final |
When any answer needs_review, score_pct and passed remain null on the session until a reviewer finishes grading.
show_correct_answers: per_question)When a test is configured with show_correct_answers: per_question, the taker can request immediate feedback after answering each question before submitting:
POST /sessions/{session_id}/check/{question_id}
Body: {"value": {...}}
Response:
{
"is_correct": true,
"needs_review": false,
"auto_score": 1,
"correct_answer": "b",
"options_json": [...]
}
Note on
correct_answershape. The canonical stored form is a plain string ("b"), plain array (["a","c"]), or{"text":"…"}for short_text. All write paths now normalise to this form (app/normalize.py), and migration007rewrites older rows. The scoring engine still tolerates the legacy wrapped envelope —{"value":"b"}/{"values":["a","c"]}— and matches option ids case-insensitively, as a safety net. See question-types.md › Two authoring formats.
is_correct is null when the question requires manual review. This endpoint is only available when show_correct_answers == "per_question" — it returns 403 otherwise.
The show_correct_answers field on Test controls when correct_answer and options_json are included in result responses:
| Value | When answers are revealed |
|---|---|
never |
Never — not included in any response |
at_end |
On the result screen after submission |
per_question |
On each /check call during the session, and also at end |
after_review |
Only after review_status == "reviewed" (Phase 5.1 — not yet fully implemented) |
The show_score field controls whether score_pct and passed are included:
| Value | When score is revealed |
|---|---|
at_end |
On the result screen |
never |
Score is hidden from the taker (admin/reviewer still sees it) |
If a taker did not answer a question (navigated past it without saving), no Answer row exists. The scoring engine creates a blank Answer with auto_score = 0 for that question, so it counts as a zero in the total.
review_status = "awaiting_review" if any open answers exist.review_status = "reviewed", score_pct is computed.Note: The full reviewer UI (Phase 5.1) is planned but not yet built. The
review_statusfield andmanual_score/reviewer_commentcolumns exist and are ready.