Files
quiz/app/pool.py
ameer 168cffea8b feat(options): letterless student/projector UI + text-on-wire submit
Both student-facing surfaces (the per-device join page and the
front-of-room projector) now render options as text only, no A/B/C/D
chips, no numeric prefixes. The letter-namespace remains internal:
canonical A..D in pool.json + submissions storage; canonical position
1..4 in the CSV export. Admin dashboard keeps letters because it is the
instructor's private console.

Why this lands now: the discussion of per-student option shuffling
flagged that A/B/C/D as discussion handles ("the answer is B") is itself
a leaky channel. Removing the labels closes that channel for the
non-shuffled case and keeps it closed if shuffling is added later.

Wire protocol: the submit message carries the option's full text
("answer": "Pipelining"). Server's submit_answer resolves text -> letter
via app.pool.resolve_option_key, which also accepts a canonical letter
so internal callers and tests stay readable. A non-matching string is
recorded as a zero-score submission with answer=NULL, locked in via the
PK + existing_submit_ack short-circuit. So an attempted UI bypass that
posts a fabricated string just produces a wrong answer; no retry.

CSV: A=1 .. D=2 .. C=3 .. D=4 in the answer column. Empty when no option
matched. Header unchanged so downstream pandas readers don't break, but
the value type is int instead of letter.

Histogram: the failsafe "submitted-but-no-match" row buckets into
"missed" alongside genuine misses — both yield zero credit and the
instructor cares about the same thing.

Tests:
- test_submit_accepts_option_text_resolves_to_canonical: production
  wire format produces correct grading + canonical-letter storage.
- test_submit_failsafe_locks_in_zero_score_on_garbage_text: a non-
  matching string is recorded at score=0 and a follow-up correct
  submission cannot overwrite it.

71/71 green.
2026-05-04 17:31:12 +08:00

170 lines
6.4 KiB
Python

"""Question pool validation."""
from __future__ import annotations
import json
from pathlib import Path
from typing import Any
from app.scoring import SCORE_FNS
OPTION_KEYS = {"A", "B", "C", "D"}
class PoolValidationError(ValueError):
pass
def parse_pool_json(pool_json: str | dict[str, Any]) -> dict[str, Any]:
if isinstance(pool_json, str):
try:
data = json.loads(pool_json)
except json.JSONDecodeError as exc:
raise PoolValidationError(f"Invalid JSON: {exc.msg}") from exc
else:
data = pool_json
return validate_pool(data)
def load_pool_from_file(path: str | Path) -> dict[str, Any]:
p = Path(path)
if not p.exists():
raise PoolValidationError(f"Pool file not found: {p}")
try:
raw = p.read_text(encoding="utf-8")
except OSError as exc:
raise PoolValidationError(f"Could not read pool file {p}: {exc}") from exc
return parse_pool_json(raw)
def validate_pool(data: dict[str, Any]) -> dict[str, Any]:
if not isinstance(data, dict):
raise PoolValidationError("Pool must be a JSON object")
title = data.get("title")
if not isinstance(title, str) or not title.strip():
raise PoolValidationError("Pool title is required")
score_fn = data.get("score_fn", "linear_decay")
if score_fn not in SCORE_FNS:
raise PoolValidationError(f"Unknown score function: {score_fn}")
time_limit_default = _positive_int(data.get("time_limit_default", 60), "time_limit_default")
questions = data.get("questions")
if not isinstance(questions, list) or not questions:
raise PoolValidationError("Pool must include at least one question")
normalized_questions: list[dict[str, Any]] = []
for index, question in enumerate(questions):
if not isinstance(question, dict):
raise PoolValidationError(f"Question {index} must be an object")
normalized_questions.append(_validate_question(question, index, time_limit_default))
out: dict[str, Any] = {
"title": title.strip(),
"score_fn": score_fn,
"time_limit_default": time_limit_default,
"questions": normalized_questions,
}
session_id = data.get("session_id")
if session_id is not None:
if not isinstance(session_id, str) or not session_id.strip():
raise PoolValidationError("session_id, if present, must be a non-empty string")
out["session_id"] = session_id.strip()
return out
def question_count(pool: dict[str, Any]) -> int:
return len(pool["questions"])
def get_question(pool: dict[str, Any], question_idx: int) -> dict[str, Any]:
try:
return pool["questions"][question_idx]
except IndexError as exc:
raise PoolValidationError("Question index out of range") from exc
def question_time_limit(pool: dict[str, Any], question_idx: int) -> int:
question = get_question(pool, question_idx)
return int(question.get("time_limit") or pool["time_limit_default"])
def public_question_payload(pool: dict[str, Any], question_idx: int) -> dict[str, Any]:
question = get_question(pool, question_idx)
return {
"question_idx": question_idx,
"text": question["text"],
"options": question["options"],
"time_limit": question_time_limit(pool, question_idx),
}
def resolve_option_key(question: dict[str, Any], answer: Any) -> str | None:
"""Map a submitted answer back to its canonical letter (A..D).
Accepts either:
- a canonical letter (legacy + internal callers)
- the option's full text (production wire format — students send
what they saw on the button, never a letter, so even if a leaked
"answer is B" message arrives via chat the recipient's button is
labelled with text only and the correlation is lost).
Returns the canonical letter on match, or None when nothing matches.
None is the failsafe: callers turn it into a recorded submission with
score=0 (locked in via PK), so attempted circumvention by sending a
different string just produces a wrong answer.
"""
if not isinstance(answer, str):
return None
if answer in OPTION_KEYS:
return answer
for key in ("A", "B", "C", "D"):
if question["options"].get(key) == answer:
return key
return None
# Canonical 1-indexed position used in the CSV export and any downstream
# analysis. The pool's option keys are fixed at A..D, so the mapping is
# stable across pools and across re-runs of the same pool.
CANONICAL_POSITION = {"A": 1, "B": 2, "C": 3, "D": 4}
def _validate_question(question: dict[str, Any], index: int, default_limit: int) -> dict[str, Any]:
qid = question.get("id")
if not isinstance(qid, str) or not qid.strip():
raise PoolValidationError(f"Question {index} id is required")
text = question.get("text")
if not isinstance(text, str) or not text.strip():
raise PoolValidationError(f"Question {index} text is required")
options = question.get("options")
if not isinstance(options, dict) or set(options) != OPTION_KEYS:
raise PoolValidationError(f"Question {index} options must be exactly A, B, C, D")
for key, value in options.items():
if not isinstance(value, str) or not value.strip():
raise PoolValidationError(f"Question {index} option {key} is required")
correct = question.get("correct")
if correct not in OPTION_KEYS:
raise PoolValidationError(f"Question {index} correct must be one of A, B, C, D")
normalized = {
"id": qid.strip(),
"text": text.strip(),
"options": {key: options[key].strip() for key in sorted(OPTION_KEYS)},
"correct": correct,
}
if "time_limit" in question and question["time_limit"] is not None:
normalized["time_limit"] = _positive_int(question["time_limit"], f"Question {index} time_limit")
else:
normalized["time_limit"] = default_limit
explanation = question.get("explanation")
if explanation is not None:
if not isinstance(explanation, str):
raise PoolValidationError(f"Question {index} explanation must be text")
normalized["explanation"] = explanation.strip()
return normalized
def _positive_int(value: Any, label: str) -> int:
if isinstance(value, bool) or not isinstance(value, int) or value <= 0:
raise PoolValidationError(f"{label} must be a positive integer")
return value