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:
@@ -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 "",
|
||||
|
||||
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():
|
||||
|
||||
47
app/room.py
47
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"]
|
||||
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
|
||||
|
||||
@@ -541,6 +541,11 @@
|
||||
opacity 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 {
|
||||
/* tiny "this is a row" tick on the left, like an editorial bullet */
|
||||
content: "";
|
||||
|
||||
@@ -383,7 +383,7 @@ function renderQuestion(s, revealed) {
|
||||
</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) => {
|
||||
const v = hist[k] || 0;
|
||||
const pct = Math.round(100 * v / total);
|
||||
@@ -395,7 +395,6 @@ function renderQuestion(s, revealed) {
|
||||
].filter(Boolean).join(" ");
|
||||
return `
|
||||
<li class="${cls}">
|
||||
<span class="opt-key">${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-count">${v}<small>${pct}%</small></span>
|
||||
|
||||
@@ -372,37 +372,43 @@ function renderQuestion(message) {
|
||||
<div class="qbar"><span id="qbar-fill"></span></div>
|
||||
<h1 class="question-text">${escapeText(message.text)}</h1>
|
||||
<div class="answer-grid">
|
||||
${["A","B","C","D"].map((k) => `
|
||||
<button class="answer-btn" data-answer="${k}">
|
||||
<span class="answer-key">${k}</span>
|
||||
<span class="answer-text">${escapeText(message.options[k] || "")}</span>
|
||||
${["A","B","C","D"].map((k) => {
|
||||
const text = message.options[k] || "";
|
||||
return `
|
||||
<button class="answer-btn" data-option="${escapeText(k)}" data-answer-text="${escapeText(text)}">
|
||||
<span class="answer-text">${escapeText(text)}</span>
|
||||
</button>
|
||||
`).join("")}
|
||||
`;
|
||||
}).join("")}
|
||||
</div>
|
||||
</article>
|
||||
`);
|
||||
document.querySelectorAll("[data-answer]").forEach((btn) => {
|
||||
btn.addEventListener("click", () => submitAnswer(btn.dataset.answer));
|
||||
document.querySelectorAll("[data-option]").forEach((btn) => {
|
||||
btn.addEventListener("click", () => submitAnswer(btn.dataset.option, btn.dataset.answerText));
|
||||
});
|
||||
startCountdown();
|
||||
}
|
||||
|
||||
function submitAnswer(answer) {
|
||||
function submitAnswer(optionKey, optionText) {
|
||||
if (!store.currentQuestion || store.submitted || store.pickedAnswer) return;
|
||||
// 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
|
||||
// for the same qidx, which re-renders the card with buttons re-enabled,
|
||||
// so the student just clicks again.
|
||||
if (!store.ws || store.ws.readyState !== WebSocket.OPEN) return;
|
||||
store.pickedAnswer = answer;
|
||||
document.querySelectorAll("[data-answer]").forEach((btn) => {
|
||||
store.pickedAnswer = optionKey;
|
||||
document.querySelectorAll("[data-option]").forEach((btn) => {
|
||||
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({
|
||||
type: "submit",
|
||||
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>
|
||||
</div>
|
||||
${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) => {
|
||||
const isCorrect = k === correct;
|
||||
const isYours = k === yourAnswer;
|
||||
@@ -445,7 +451,6 @@ function renderReveal(message) {
|
||||
if (isYours) cls += " yours";
|
||||
return `
|
||||
<li class="${cls}">
|
||||
<span class="key">${k}${isCorrect ? " ✓" : ""}${isYours && !isCorrect ? " ✗" : ""}</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>
|
||||
</li>
|
||||
|
||||
@@ -1061,7 +1061,9 @@ h2.question-text.small {
|
||||
}
|
||||
.answer-btn {
|
||||
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;
|
||||
align-items: center;
|
||||
text-align: left;
|
||||
@@ -1127,10 +1129,22 @@ h2.question-text.small {
|
||||
.answer-btn .answer-text {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 500;
|
||||
font-size: 1.1rem;
|
||||
font-size: 1.18rem;
|
||||
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 {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 4.2rem;
|
||||
|
||||
@@ -110,6 +110,38 @@ def test_post_recovery_old_cookie_is_dead(client, sid):
|
||||
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):
|
||||
"""Server-side: a second submit for the same (sid, student_id, qidx)
|
||||
returns the *original* ack rather than overwriting the answer. The
|
||||
|
||||
@@ -14,7 +14,11 @@ def test_csv_export_contains_one_row_per_submission(client, sid):
|
||||
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 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,
|
||||
# no duplicate-join attempts).
|
||||
assert lines[1].endswith(",0,0,0")
|
||||
|
||||
Reference in New Issue
Block a user