diff --git a/app/csv_export.py b/app/csv_export.py index 47dc458..0829a1e 100644 --- a/app/csv_export.py +++ b/app/csv_export.py @@ -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 "", diff --git a/app/pool.py b/app/pool.py index b75e271..8b5ac0d 100644 --- a/app/pool.py +++ b/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(): diff --git a/app/room.py b/app/room.py index 72c2525..dd54c2b 100644 --- a/app/room.py +++ b/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 diff --git a/static/projector.css b/static/projector.css index d974baa..bc13da4 100644 --- a/static/projector.css +++ b/static/projector.css @@ -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: ""; diff --git a/static/projector.js b/static/projector.js index 92276f3..7d80a8e 100644 --- a/static/projector.js +++ b/static/projector.js @@ -383,7 +383,7 @@ function renderQuestion(s, revealed) { -
    +
      ${["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 `
    1. - ${k} ${escapeText(q.options?.[k] || "")} ${v}${pct}% diff --git a/static/quiz.js b/static/quiz.js index 2e19709..c8ea306 100644 --- a/static/quiz.js +++ b/static/quiz.js @@ -372,37 +372,43 @@ function renderQuestion(message) {

      ${escapeText(message.text)}

      - ${["A","B","C","D"].map((k) => ` - - `).join("")} + ${["A","B","C","D"].map((k) => { + const text = message.options[k] || ""; + return ` + + `; + }).join("")}
      `); - 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) { ${won ? "Correct" : (yourAnswer ? "Wrong" : "Missed")} ${q?.text ? `

      ${escapeText(q.text)}

      ` : ""} -
        +
          ${["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 `
        1. - ${k}${isCorrect ? " ✓" : ""}${isYours && !isCorrect ? " ✗" : ""} ${escapeText(q?.options?.[k] || "")} ${message.histogram[k] || 0} (${Math.round(100 * (message.histogram[k] || 0) / denom)}%)
        2. diff --git a/static/style.css b/static/style.css index 42e37b6..bcb785a 100644 --- a/static/style.css +++ b/static/style.css @@ -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
        3. . */ +.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; diff --git a/tests/test_anti_cheat.py b/tests/test_anti_cheat.py index 3849a31..1b58cc4 100644 --- a/tests/test_anti_cheat.py +++ b/tests/test_anti_cheat.py @@ -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 diff --git a/tests/test_csv_export.py b/tests/test_csv_export.py index 1a86f4f..31a8bba 100644 --- a/tests/test_csv_export.py +++ b/tests/test_csv_export.py @@ -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")