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) { -