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

@@ -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