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:
31
app/pool.py
31
app/pool.py
@@ -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():
|
||||
|
||||
Reference in New Issue
Block a user