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:
51
app/room.py
51
app/room.py
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user