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.
This commit is contained in:
ameer
2026-05-04 17:31:12 +08:00
parent 464c6ee1cb
commit 168cffea8b
9 changed files with 158 additions and 30 deletions

View File

@@ -97,6 +97,37 @@ def public_question_payload(pool: dict[str, Any], question_idx: int) -> dict[str
}
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():