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 io import StringIO
from app.db import connect from app.db import connect
from app.pool import CANONICAL_POSITION
async def export_session_csv(db_path: str, sid: str) -> str: 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", "student_id",
"name", "name",
"question_idx", "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", "answer",
"elapsed_ms", "elapsed_ms",
"score", "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"]) counts.setdefault(row["student_id"], {})[row["kind"]] = int(row["c"])
for row in rows: for row in rows:
per = counts.get(row["student_id"], {}) per = counts.get(row["student_id"], {})
answer_pos = CANONICAL_POSITION.get(row["answer"]) if row["answer"] else None
writer.writerow( writer.writerow(
[ [
row["sid"], row["sid"],
row["student_id"], row["student_id"],
row["name"], row["name"],
"" if row["question_idx"] is None else row["question_idx"], "" 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["elapsed_ms"] is None else row["elapsed_ms"],
"" if row["score"] is None else row["score"], "" if row["score"] is None else row["score"],
row["status"] or "", 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]: def _validate_question(question: dict[str, Any], index: int, default_limit: int) -> dict[str, Any]:
qid = question.get("id") qid = question.get("id")
if not isinstance(qid, str) or not qid.strip(): 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.config import Settings
from app.db import connect 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 from app.scoring import SCORE_FNS
@@ -525,12 +532,20 @@ class RoomManager:
await self.broadcast_instructors(sid, await self.full_leaderboard_message(sid)) 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]: 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: try:
qidx = int(question_idx) qidx = int(question_idx)
except (TypeError, ValueError): except (TypeError, ValueError):
return {"type": "error", "code": "bad_question", "message": "Invalid question index"} return {"type": "error", "code": "bad_question", "message": "Invalid question index"}
if not isinstance(answer, str) or answer not in {"A", "B", "C", "D"}: if not isinstance(answer, str):
return {"type": "error", "code": "bad_answer", "message": "Answer must be A, B, C, or D"} return {"type": "error", "code": "bad_answer", "message": "Answer must be a string"}
async with self.locks[sid]: async with self.locks[sid]:
session = await self.get_session(sid) session = await self.get_session(sid)
if session["state"] != "question_open" or session["current_question_idx"] != qidx: 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"} return {"type": "error", "code": "time_expired", "message": "Question time has expired"}
pool = await self.get_pool_for_session(sid) pool = await self.get_pool_for_session(sid)
question = get_question(pool, qidx) question = get_question(pool, qidx)
correct = answer == question["correct"] 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_fn = SCORE_FNS[pool["score_fn"]]
score = score_fn(correct, elapsed_ms, time_limit_ms) score = score_fn(correct, elapsed_ms, time_limit_ms)
stored_answer = resolved
submitted_at = iso_now() submitted_at = iso_now()
async with connect(self.settings.db_path) as db: async with connect(self.settings.db_path) as db:
await db.execute( await db.execute(
@@ -557,13 +583,13 @@ class RoomManager:
VALUES (?, ?, ?, ?, ?, ?, ?, 'submitted') VALUES (?, ?, ?, ?, ?, ?, ?, 'submitted')
ON CONFLICT(sid, student_id, question_idx) DO NOTHING 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 db.commit()
await self.broadcast_instructors(sid, await self.live_histogram_message(sid, qidx)) await self.broadcast_instructors(sid, await self.live_histogram_message(sid, qidx))
await self.broadcast_presence(sid) await self.broadcast_presence(sid)
await self.broadcast_projectors(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: def _schedule_autoclose(self, sid: str, question_idx: int, time_limit: int) -> None:
previous = self.autoclose_tasks.pop((sid, question_idx), None) previous = self.autoclose_tasks.pop((sid, question_idx), None)
@@ -715,9 +741,16 @@ class RoomManager:
for row in rows: for row in rows:
if row["status"] == "missed": if row["status"] == "missed":
result["missed"] += row["count"] result["missed"] += row["count"]
elif row["answer"] in result: elif row["answer"] in {"A", "B", "C", "D"}:
result[row["answer"]] += row["count"] result[row["answer"]] += row["count"]
submitted += 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: if pending:
result["pending"] = max(0, int(total_row["count"]) - submitted - result["missed"]) result["pending"] = max(0, int(total_row["count"]) - submitted - result["missed"])
return result return result

View File

@@ -541,6 +541,11 @@
opacity 280ms ease, opacity 280ms ease,
color 280ms ease; color 280ms ease;
} }
/* Letterless variant — drop the leading key column, give the text more
* room. Histogram bars + counts continue to anchor each row visually. */
.big-options.letterless li {
grid-template-columns: 1fr clamp(110px, 14vw, 200px) clamp(74px, 9vw, 110px);
}
.big-options li::before { .big-options li::before {
/* tiny "this is a row" tick on the left, like an editorial bullet */ /* tiny "this is a row" tick on the left, like an editorial bullet */
content: ""; content: "";

View File

@@ -383,7 +383,7 @@ function renderQuestion(s, revealed) {
</div> </div>
</div> </div>
<ol class="big-options ${revealed ? "revealed" : ""} ${preVote ? "pre-vote" : ""}"> <ol class="big-options letterless ${revealed ? "revealed" : ""} ${preVote ? "pre-vote" : ""}">
${["A","B","C","D"].map((k) => { ${["A","B","C","D"].map((k) => {
const v = hist[k] || 0; const v = hist[k] || 0;
const pct = Math.round(100 * v / total); const pct = Math.round(100 * v / total);
@@ -395,7 +395,6 @@ function renderQuestion(s, revealed) {
].filter(Boolean).join(" "); ].filter(Boolean).join(" ");
return ` return `
<li class="${cls}"> <li class="${cls}">
<span class="opt-key">${k}</span>
<span class="opt-text">${escapeText(q.options?.[k] || "")}</span> <span class="opt-text">${escapeText(q.options?.[k] || "")}</span>
<span class="opt-bar"><span class="opt-bar-fill" style="width:${pct}%"></span></span> <span class="opt-bar"><span class="opt-bar-fill" style="width:${pct}%"></span></span>
<span class="opt-count">${v}<small>${pct}%</small></span> <span class="opt-count">${v}<small>${pct}%</small></span>

View File

@@ -372,37 +372,43 @@ function renderQuestion(message) {
<div class="qbar"><span id="qbar-fill"></span></div> <div class="qbar"><span id="qbar-fill"></span></div>
<h1 class="question-text">${escapeText(message.text)}</h1> <h1 class="question-text">${escapeText(message.text)}</h1>
<div class="answer-grid"> <div class="answer-grid">
${["A","B","C","D"].map((k) => ` ${["A","B","C","D"].map((k) => {
<button class="answer-btn" data-answer="${k}"> const text = message.options[k] || "";
<span class="answer-key">${k}</span> return `
<span class="answer-text">${escapeText(message.options[k] || "")}</span> <button class="answer-btn" data-option="${escapeText(k)}" data-answer-text="${escapeText(text)}">
<span class="answer-text">${escapeText(text)}</span>
</button> </button>
`).join("")} `;
}).join("")}
</div> </div>
</article> </article>
`); `);
document.querySelectorAll("[data-answer]").forEach((btn) => { document.querySelectorAll("[data-option]").forEach((btn) => {
btn.addEventListener("click", () => submitAnswer(btn.dataset.answer)); btn.addEventListener("click", () => submitAnswer(btn.dataset.option, btn.dataset.answerText));
}); });
startCountdown(); startCountdown();
} }
function submitAnswer(answer) { function submitAnswer(optionKey, optionText) {
if (!store.currentQuestion || store.submitted || store.pickedAnswer) return; if (!store.currentQuestion || store.submitted || store.pickedAnswer) return;
// Drop the click silently if the WS isn't open right now (mid-reconnect // Drop the click silently if the WS isn't open right now (mid-reconnect
// or already torn down). On reconnect the server replays question_open // or already torn down). On reconnect the server replays question_open
// for the same qidx, which re-renders the card with buttons re-enabled, // for the same qidx, which re-renders the card with buttons re-enabled,
// so the student just clicks again. // so the student just clicks again.
if (!store.ws || store.ws.readyState !== WebSocket.OPEN) return; if (!store.ws || store.ws.readyState !== WebSocket.OPEN) return;
store.pickedAnswer = answer; store.pickedAnswer = optionKey;
document.querySelectorAll("[data-answer]").forEach((btn) => { document.querySelectorAll("[data-option]").forEach((btn) => {
btn.disabled = true; btn.disabled = true;
if (btn.dataset.answer === answer) btn.classList.add("picked"); if (btn.dataset.option === optionKey) btn.classList.add("picked");
}); });
// The wire format carries the option's full text. The server resolves
// it back to the canonical letter; if the text doesn't match (e.g. a
// student tries to circumvent the UI and send a fabricated string)
// the submission is recorded with score=0 and locked in.
store.ws.send(JSON.stringify({ store.ws.send(JSON.stringify({
type: "submit", type: "submit",
question_idx: store.currentQuestion.question_idx, question_idx: store.currentQuestion.question_idx,
answer, answer: optionText,
})); }));
} }
@@ -435,7 +441,7 @@ function renderReveal(message) {
<span class="state-badge ${won ? "state-correct" : "state-wrong"}">${won ? "Correct" : (yourAnswer ? "Wrong" : "Missed")}</span> <span class="state-badge ${won ? "state-correct" : "state-wrong"}">${won ? "Correct" : (yourAnswer ? "Wrong" : "Missed")}</span>
</div> </div>
${q?.text ? `<h2 class="question-text small">${escapeText(q.text)}</h2>` : ""} ${q?.text ? `<h2 class="question-text small">${escapeText(q.text)}</h2>` : ""}
<ol class="options reveal student-reveal"> <ol class="options reveal student-reveal letterless">
${["A","B","C","D"].map((k) => { ${["A","B","C","D"].map((k) => {
const isCorrect = k === correct; const isCorrect = k === correct;
const isYours = k === yourAnswer; const isYours = k === yourAnswer;
@@ -445,7 +451,6 @@ function renderReveal(message) {
if (isYours) cls += " yours"; if (isYours) cls += " yours";
return ` return `
<li class="${cls}"> <li class="${cls}">
<span class="key">${k}${isCorrect ? " ✓" : ""}${isYours && !isCorrect ? " ✗" : ""}</span>
<span class="opt-text">${escapeText(q?.options?.[k] || "")}</span> <span class="opt-text">${escapeText(q?.options?.[k] || "")}</span>
<span class="opt-count muted">${message.histogram[k] || 0} (${Math.round(100 * (message.histogram[k] || 0) / denom)}%)</span> <span class="opt-count muted">${message.histogram[k] || 0} (${Math.round(100 * (message.histogram[k] || 0) / denom)}%)</span>
</li> </li>

View File

@@ -1061,7 +1061,9 @@ h2.question-text.small {
} }
.answer-btn { .answer-btn {
display: grid; display: grid;
grid-template-columns: 36px 1fr; /* Letterless: option text fills the row. Generous padding compensates
* for the missing letter chip so the button still reads as a tile. */
grid-template-columns: 1fr;
gap: 18px; gap: 18px;
align-items: center; align-items: center;
text-align: left; text-align: left;
@@ -1127,10 +1129,22 @@ h2.question-text.small {
.answer-btn .answer-text { .answer-btn .answer-text {
font-family: var(--font-display); font-family: var(--font-display);
font-weight: 500; font-weight: 500;
font-size: 1.1rem; font-size: 1.18rem;
line-height: 1.35; line-height: 1.35;
} }
/* Student-reveal letterless variant: drop the 32px key column from the
* options row. The "Your pick" ribbon (.options.student-reveal li.yours)
* and correct/wrong tinting still work because they target the <li>. */
.options.letterless {
/* options li uses display:grid; redefine the columns when letterless. */
}
.options.letterless li {
grid-template-columns: 1fr auto;
padding-left: 18px;
padding-right: 18px;
}
.big-score { .big-score {
font-family: var(--font-mono); font-family: var(--font-mono);
font-size: 4.2rem; font-size: 4.2rem;

View File

@@ -110,6 +110,38 @@ def test_post_recovery_old_cookie_is_dead(client, sid):
assert response.status_code == 401 assert response.status_code == 401
def test_submit_accepts_option_text_resolves_to_canonical(client, sid):
"""The wire format is letterless: the student sends the option's
full text. Server resolves to the canonical letter for storage and
grading. CSV export shows the canonical position (1..4)."""
join_student(client, sid, "s1", "Alice")
rooms = client.app.state.rooms
client.portal.call(rooms.open_question, sid, 0, 5)
# Q0 in conftest: A=Alpha, B=Beta, C=Gamma, D=Delta, correct=B.
ack = client.portal.call(rooms.submit_answer, sid, "s1", 0, "Beta")
assert ack["type"] == "submit_ack"
assert ack["answer"] == "B" # server resolved to canonical letter
assert ack["score"] > 0
def test_submit_failsafe_locks_in_zero_score_on_garbage_text(client, sid):
"""Sending a string that isn't one of the four option texts records
a zero-score 'submitted' row and locks the student in (PK constraint
+ existing_submit_ack short-circuit). A second attempt — even with
the correct text — returns the original zero-score ack."""
join_student(client, sid, "s1", "Alice")
rooms = client.app.state.rooms
client.portal.call(rooms.open_question, sid, 0, 5)
first = client.portal.call(rooms.submit_answer, sid, "s1", 0, "not-an-option")
assert first["type"] == "submit_ack"
assert first["answer"] is None
assert first["score"] == 0
# Locked in: a follow-up retry returns the original zero ack.
second = client.portal.call(rooms.submit_answer, sid, "s1", 0, "Beta")
assert second["answer"] is None
assert second["score"] == 0
def test_submit_lockout_is_server_enforced(client, sid): def test_submit_lockout_is_server_enforced(client, sid):
"""Server-side: a second submit for the same (sid, student_id, qidx) """Server-side: a second submit for the same (sid, student_id, qidx)
returns the *original* ack rather than overwriting the answer. The returns the *original* ack rather than overwriting the answer. The

View File

@@ -14,7 +14,11 @@ def test_csv_export_contains_one_row_per_submission(client, sid):
lines = response.text.strip().splitlines() lines = response.text.strip().splitlines()
assert lines[0] == "sid,student_id,name,question_idx,answer,elapsed_ms,score,status,blur_count,hidden_count,duplicate_join_attempts" assert lines[0] == "sid,student_id,name,question_idx,answer,elapsed_ms,score,status,blur_count,hidden_count,duplicate_join_attempts"
assert len(lines) == 2 assert len(lines) == 2
assert ",s1,Student One,0,B," in lines[1] # The CSV stores the canonical 1-indexed position of the chosen
# option (A=1, B=2, C=3, D=4) rather than the letter — the student
# UI is letterless and a number is unambiguous for downstream
# analysis.
assert ",s1,Student One,0,2," in lines[1]
# Default audit-event counts are 0 for a clean run (no blur events, # Default audit-event counts are 0 for a clean run (no blur events,
# no duplicate-join attempts). # no duplicate-join attempts).
assert lines[1].endswith(",0,0,0") assert lines[1].endswith(",0,0,0")