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

@@ -6,6 +6,7 @@ import csv
from io import StringIO
from app.db import connect
from app.pool import CANONICAL_POSITION
async def export_session_csv(db_path: str, sid: str) -> str:
@@ -17,6 +18,9 @@ async def export_session_csv(db_path: str, sid: str) -> str:
"student_id",
"name",
"question_idx",
# Canonical 1-indexed position of the chosen option in the
# pool's option list (A=1, B=2, C=3, D=4). Empty when the
# student didn't submit anything that matched an option.
"answer",
"elapsed_ms",
"score",
@@ -53,13 +57,14 @@ async def export_session_csv(db_path: str, sid: str) -> str:
counts.setdefault(row["student_id"], {})[row["kind"]] = int(row["c"])
for row in rows:
per = counts.get(row["student_id"], {})
answer_pos = CANONICAL_POSITION.get(row["answer"]) if row["answer"] else None
writer.writerow(
[
row["sid"],
row["student_id"],
row["name"],
"" if row["question_idx"] is None else row["question_idx"],
row["answer"] or "",
"" if answer_pos is None else answer_pos,
"" if row["elapsed_ms"] is None else row["elapsed_ms"],
"" if row["score"] is None else row["score"],
row["status"] or "",

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():

View File

@@ -17,7 +17,14 @@ from fastapi import WebSocket, WebSocketDisconnect
from app.config import Settings
from app.db import connect
from app.pool import get_question, parse_pool_json, public_question_payload, question_count, question_time_limit
from app.pool import (
get_question,
parse_pool_json,
public_question_payload,
question_count,
question_time_limit,
resolve_option_key,
)
from app.scoring import SCORE_FNS
@@ -525,12 +532,20 @@ class RoomManager:
await self.broadcast_instructors(sid, await self.full_leaderboard_message(sid))
async def submit_answer(self, sid: str, student_id: str, question_idx: Any, answer: Any) -> dict[str, Any]:
"""Record a student's answer and grade it.
`answer` accepts either the option's full text (production wire
format from the letterless student UI) or a canonical letter
(internal callers + tests). Anything that doesn't resolve to one
of the four options is recorded as a zero-score submission and
locked in via the PK — circumvention attempts can't retry.
"""
try:
qidx = int(question_idx)
except (TypeError, ValueError):
return {"type": "error", "code": "bad_question", "message": "Invalid question index"}
if not isinstance(answer, str) or answer not in {"A", "B", "C", "D"}:
return {"type": "error", "code": "bad_answer", "message": "Answer must be A, B, C, or D"}
if not isinstance(answer, str):
return {"type": "error", "code": "bad_answer", "message": "Answer must be a string"}
async with self.locks[sid]:
session = await self.get_session(sid)
if session["state"] != "question_open" or session["current_question_idx"] != qidx:
@@ -546,9 +561,20 @@ class RoomManager:
return {"type": "error", "code": "time_expired", "message": "Question time has expired"}
pool = await self.get_pool_for_session(sid)
question = get_question(pool, qidx)
correct = answer == question["correct"]
score_fn = SCORE_FNS[pool["score_fn"]]
score = score_fn(correct, elapsed_ms, time_limit_ms)
resolved = resolve_option_key(question, answer)
if resolved is None:
# Failsafe: option didn't match any of the four texts.
# Lock in a zero-score submission rather than erroring,
# so an attempt to circumvent the UI by sending arbitrary
# text doesn't get a free retry.
score = 0.0
stored_answer: str | None = None
correct = False
else:
correct = resolved == question["correct"]
score_fn = SCORE_FNS[pool["score_fn"]]
score = score_fn(correct, elapsed_ms, time_limit_ms)
stored_answer = resolved
submitted_at = iso_now()
async with connect(self.settings.db_path) as db:
await db.execute(
@@ -557,13 +583,13 @@ class RoomManager:
VALUES (?, ?, ?, ?, ?, ?, ?, 'submitted')
ON CONFLICT(sid, student_id, question_idx) DO NOTHING
""",
(sid, student_id, qidx, answer, submitted_at, elapsed_ms, score),
(sid, student_id, qidx, stored_answer, submitted_at, elapsed_ms, score),
)
await db.commit()
await self.broadcast_instructors(sid, await self.live_histogram_message(sid, qidx))
await self.broadcast_presence(sid)
await self.broadcast_projectors(sid)
return {"type": "submit_ack", "question_idx": qidx, "answer": answer, "score": score, "elapsed_ms": elapsed_ms}
return {"type": "submit_ack", "question_idx": qidx, "answer": stored_answer, "score": score, "elapsed_ms": elapsed_ms}
def _schedule_autoclose(self, sid: str, question_idx: int, time_limit: int) -> None:
previous = self.autoclose_tasks.pop((sid, question_idx), None)
@@ -715,9 +741,16 @@ class RoomManager:
for row in rows:
if row["status"] == "missed":
result["missed"] += row["count"]
elif row["answer"] in result:
elif row["answer"] in {"A", "B", "C", "D"}:
result[row["answer"]] += row["count"]
submitted += row["count"]
else:
# status='submitted' but answer didn't match any option
# (failsafe path in submit_answer). For aggregate display
# we bucket alongside legitimate "missed" — both yield
# zero credit and the instructor cares about the same
# thing: this student didn't pick a real option.
result["missed"] += row["count"]
if pending:
result["pending"] = max(0, int(total_row["count"]) - submitted - result["missed"])
return result