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.
Live in-lecture quiz portal
FastAPI + WebSocket + SQLite quiz portal designed for ~40 students per class session. Single-process, in-memory room manager, vanilla HTML/JS front-end, Caddy in front for TLS.
Quick local run
python3 -m venv .venv
. .venv/bin/activate
pip install -e '.[dev]'
cp .env.example .env # edit QUIZ_SECRET_KEY + QUIZ_ADMIN_PASSWORD
uvicorn app.main:app --host 127.0.0.1 --port 8001 --reload
Open http://127.0.0.1:8001/admin/, log in, create a quiz pool from a
JSON pool file (see examples/pool_example.json for the schema), create
a session, and share the join URL.
VPS deploy (one-shot)
On a fresh Ubuntu 24.04 LTS root SSH:
curl -fsSL https://gitea.ahkhan.me/apps/quiz/raw/branch/master/deploy/bootstrap.sh | bash
The bootstrap:
- apt-installs Caddy + Python venv tooling
- Creates a
quizsystem user (no shell, no SSH) - Clones this repo to
/opt/quiz - Builds the venv and installs the app
- Generates
QUIZ_SECRET_KEY, prompts forQUIZ_ADMIN_PASSWORD - Drops the systemd unit and Caddyfile
- Starts both services
- Curl-checks
127.0.0.1:8001/healthz
After: quiz.ahkhan.me is live with auto-Let's-Encrypt cert. To override
the domain or repo URL, set DOMAIN= or REPO_URL= in the environment
before running the script.
Class-day workflow
- Provision Aliyun Intl HK ECS pay-as-you-go (
ecs.t6-c2m1.large, Ubuntu 24.04 LTS). - Point DNS A-record
quiz.ahkhan.meat the new IP. - SSH in as root, run the curl|bash one-liner above.
- Open
quiz.ahkhan.me/admin/, log in, upload the week's pool JSON, create a session. - Share the QR / join URL with the class.
- After class:
scp root@<ip>:/opt/quiz/quiz.db ./backups/quiz-YYYY-MM-DD.db - Destroy the instance.
Quiz pool files
Real pool JSON files contain answer keys and must not be committed
to this repo. .gitignore excludes examples/*_pool.json (only
examples/pool_example.json may be tracked). Author pools elsewhere
(e.g., your course-material directory) and upload at runtime via the
admin UI.
Tests
pytest -q
pytest --cov=app
For the WebSocket adversarial stress harness (Node.js + Playwright,
runs in a tmux loop), see tests/stress/README.md.
Spec
SPEC.md documents the locked v1.0 design (state machine, scoring,
identity flow, all WS message types).