# Quizzes (`devplacepy/services/quiz/`, `devplacepy/routers/quizzes/`) This file documents the quiz subsystem. Claude Code auto-loads it when a file under `devplacepy/services/quiz/` is read or edited. ## Overview A quiz is user-generated content exactly like a gist: owner, slug, description, comments, votes, bookmarks, reactions, soft delete, sitemap entry. Every signed-in member authors quizzes, every member plays them, guests read published ones. `/quizzes` is the hub (three-column `.feed-layout`: filters left, list centre, cross-quiz scoreboard right); `/quizzes/{slug}` is the detail page; `/quizzes/{slug}/edit` is the builder; `/quizzes/{slug}/attempts/{uid}` is the player. Customer-facing documentation is the `docs_api` **Quizzes** group (`docs_api/groups/quizzes.py`) and the prose guide `templates/docs/quizzes.html`. Keep both in lockstep with `scoring.py` whenever a kind, formula, or endpoint changes. ## Package layout ``` services/quiz/ scoring.py pure rules: the eight kinds, every formula. No DB, no network. grading.py AI free-text grading + the deterministic fallback. store/ common.py table accessors, QuizError, guard_editable, can_view_quiz quizzes.py create/edit/publish/list/validation_errors/recompute_quiz_totals questions.py add/edit/delete/reorder questions and their options attempts.py start/answer/finish/expire - the atomic transitions scoreboard.py cross-quiz board, per-viewer listing state, per-quiz leaderboard serialize.py serialize_quiz/question/attempt/result - owns answer-key withholding documents.py import/export of the full quiz JSON document ``` `store/` is the only place that touches the tables. `scoring.py` never imports `store`. Routers never issue SQL. ## Publishing is terminal (the central invariant) A draft is fully editable. The moment its owner publishes it, the quiz, its questions and its options are frozen forever. There is no unpublish, no post-publish edit, no admin override. The only remaining operation is delete (owner or admin, soft, full cascade). This is what makes a score comparable and the scoreboard honest. **The lock is one data-layer guard, `store/common.py::guard_editable(quiz_uid)`, on the first line of every mutating store entrypoint** (`edit_quiz`, `add_question`, `edit_question`, `delete_question`, `reorder_questions`, and every option write). Any new mutating entrypoint MUST call it. Routers never re-check `status` themselves - a router that did would be a second source of truth and would drift. `guard_editable` is **atomic, not advisory**: it is a conditional `UPDATE quizzes SET content_version = COALESCE(content_version,0) + 1 WHERE uid = :uid AND status = 'draft' AND deleted_at IS NULL` whose `rowcount` is the decision. `publish_quiz` reads `content_version` BEFORE validating and pins it in its own CAS (`WHERE status='draft' AND COALESCE(content_version,0) = :seen_version`). So the two real orderings of a concurrent publish-vs-edit both resolve correctly: - edit claims first -> publish's pinned version is stale -> publish loses -> a fully edited draft. - publish wins first -> the edit's claim finds `status='published'` -> the edit is refused before writing anything -> an untouched published quiz. **Accepted residual (tiny, documented like the Code Farm steal-cooldown overlap):** a publish that lands in the microseconds between `guard_editable` returning and that same call's row writes will produce a published quiz carrying that one fully-applied edit. It is never a *half*-applied edit - `recompute_quiz_totals` runs after the writes, so `question_count`/`total_points` always match the live rows (fuzz-verified). Closing it entirely would need a lease/lock, which this codebase does not otherwise use. `store/quizzes.py::validation_errors(quiz_uid) -> list[str]` is the single function producing the pre-publish checklist; the builder renders it live and the publish route calls the same function. One quantity, one function. ## Atomicity: every transition is one conditional UPDATE Under `uvicorn --workers N` a read-check-write is a TOCTOU race across processes. Every transition goes through `database/atomic.py::conditional_update_row(table, uid, set_clause, where_clause, params)` - one `UPDATE ... WHERE uid = :uid AND ()` whose real driver `rowcount` is the decision. That primitive used to live in `services/game/store/common.py`; it was moved to `devplacepy/database/atomic.py` and the Code Farm module imports it from there, so there is exactly one copy. | Transition | Precondition | Losing rowcount 0 means | |------------|--------------|-------------------------| | Answer | `answered_at IS NULL OR answered_at = ''` | already answered - `QuizError`, credit nothing | | Credit the score | `status = 'in_progress'` (only the CAS winner runs it) | finished or expired in between; finish recomputes | | Finish | `status = 'in_progress'` | already finished - same result, no second XP, no second notification, no second `attempt_count` | | Expire | `status = 'in_progress'`, evaluated lazily on read | someone else expired it first - harmless | | Publish | `status = 'draft' AND content_version = :seen` | already published, or the quiz changed mid-publish | | Claim an edit | `status = 'draft' AND deleted_at IS NULL` | the quiz is published - refuse before writing | **The score is recomputed from the answer rows at finish** (`SUM(awarded_points)` over live answers) and written in the same statement as the status transition. The running `score_points` counter is the live HUD value; the finish recomputation is the authority, so a lost credit increment is self-correcting rather than a permanently wrong final score. **One in-progress attempt per `(user_uid, quiz_uid)`.** `start_attempt` inserts, then re-reads the earliest live `in_progress` attempt for the pair; if it lost, it soft-deletes its own attempt and its blank answers and returns the winner. **One blank `quiz_answers` row per question is created at attempt start.** This is load-bearing: it turns answering into a conditional UPDATE on an existing row instead of an insert, which is what makes the double-submit race closeable with a single statement. **No background tick.** The time limit is `expires_at` on the attempt row, evaluated by `expire_if_due` on every read and in every mutator. There is no reconciler, no service, no scheduled sweep - the state is a pure function of stored timestamps and the clock, exactly like the Code Farm. ## The answer key is withheld by the serializer `store/serialize.py` omits `is_correct`/`match_value` on options and `correct_boolean`/`expected_answer`/`grading_criteria`/`numeric_value`/`numeric_tolerance` on the question **unless** the viewer owns the quiz, or the question has already been answered in this attempt and the quiz has `reveal_answers` on. The serializer decides this, not the template - a JSON client must not be able to fetch the answer key. `documents.export_document(uid, include_answers)` applies the same rule: `include_answers` is true only for the owner. **`matching` is the one exception that needs care.** Its right-hand values ARE the player's visible choices, so `serialize_question` emits them as a question-level `match_choices` list, shuffled and deduplicated, while the per-option `match_value` (the actual pairing) stays withheld. ## Grading Seven kinds are graded by the pure `scoring.grade_answer(question, options, submission)`, which raises `NeedsAiGrading` for `free_text`. `store/attempts.answer` catches it and calls `await asyncio.to_thread(grading.grade_free_text, api_key, question, answer_text)`. **`asyncio.to_thread` is mandatory, not stylistic.** The gateway lives in this same process on localhost; a blocking call on the loop thread deadlocks the worker against its own gateway request until the httpx timeout. Same trap as `correction.py` sync mode and `SeoMetaService.process`. **Attribution:** the bearer token is the answering member's own `users.api_key`, so the spend lands on that member in the existing gateway ledger. No new usage table. **Never trust the model.** `grading.build_result` clamps the score into `[0, 1]`, derives `is_correct` from the *clamped* score against `QUIZ_AI_CORRECT_THRESHOLD` (never from the model's boolean, so `correct: true, score: 0.0` cannot happen), clamps the confidence, and HTML-strips and truncates the feedback. `awarded_points` always comes from `scoring.awarded_points`. **Visible degradation, never silent.** On any gateway failure, timeout, empty output, unparsable body, or missing api_key, `grade_free_text` returns `scoring.fallback_result(...)` - a deterministic token-overlap score stamped `graded_by="fallback"` with feedback saying automatic review was unavailable. The row stores it, `QuizAnswerOut.graded_by` carries it into JSON, the results screen renders `.quiz-grade-fallback`, and the route records `quiz.grade.failed` via `audit.record_system(result="failure")`. This is the DeepSearch `synthesis="heuristic"` rule on a smaller surface. ## The scoreboard is best-attempt-per-quiz `store/scoreboard.py` is one windowed SQL aggregate plus one `get_users_by_uids`, behind a 15s module-level display `TTLCache` (deliberately NOT wired into the cross-worker `cache_state` versioning - 15s of staleness on a ranking is cosmetic). Rules that decide whether the board is honest: - **Best attempt per quiz, never the sum of attempts** (`MAX(score_points)` grouped by `(user_uid, quiz_uid)`). Replaying can raise a member's contribution to their personal best and never beyond it. A naive `SUM` over all completed attempts would make replaying one easy quiz the dominant strategy - the same class of defect as the uncapped coin term the Code Farm leaderboard had to fix. - Only live rows, only `status='completed'` attempts, only `published` quizzes. - Ties resolve by `quizzes_completed` then `user_uid`, so two renders never reshuffle a tie. `store.clear_cache()` is called after publish, finish and delete so the board is fresh where it matters. **An author's attempts on their own quiz DO count.** There is no author-exclusion clause in the aggregate. An earlier version filtered them out (`q.user_uid != a.user_uid`), which made a member who had written and played their own quiz see an empty board and an all-zero progress card while the quiz card said `Completed - 100%` - reported as a bug and reversed by explicit decision. If self-scoring is ever revisited, change the aggregate, `progress_for`, and the docs together. **The "Your progress" rail card MUST use the same basis as the board.** `progress_for(user_uid)` derives `completed`, `avg_percent`, `total_points`, `rank` and `perfect_count` from the single `standing_for` call, and `todo` from the total published-quiz count minus that same ranked count. A first version counted completions with a second query on a different basis, so the card contradicted both itself and the board. Never add a second completion query on a different basis. **Per-viewer listing state is one batch query.** `attempt_states_for(user_uid, quiz_uids)` returns `{quiz_uid: {state, best_percent, best_points, attempt_uid, completed_at}}` and drives the `todo` and `done` filters, the card badges, the "Your progress" card, and the `QuizListItemOut.viewer_*` fields at once. Guests get an empty map with **zero queries**. Never derive the same state a second way in a template. ## One quantity, one pure function Every number a player sees - the per-question score, the awarded points, the running total, the percentage, the remaining seconds - comes from exactly one function in `scoring.py`, called with identical arguments by the serializer and by the mutator. If the results screen and the grading path ever compute the same number from different inputs, that is the defect, not a rounding difference. `shuffle` is seeded and deterministic (`question_order` on the attempt uid, `option_order` on `attempt:question`) so a resumed attempt never disagrees with itself. ## Tables `quizzes`, `quiz_questions`, `quiz_options`, `quiz_attempts`, `quiz_answers` - **all five are in `SOFT_DELETE_TABLES`** (they are user content, like `polls`/`poll_options`/`poll_votes`, unlike the Code Farm tables which are mutable game state). Every column set and index is ensured in `init_db` BEFORE the indexes are created, including `deleted_at`/`deleted_by`, or the live-partial `idx_quizzes_live_created` fails to build on a fresh database. Every insert goes through `store/common.py::born_live`, which writes `created_at`, `updated_at` and the `deleted_at: None, deleted_by: None` pair. Every table has `updated_at` because `conditional_update_row` writes it in every statement. `quiz_options` serves all five option-bearing kinds - choices, ordering items, matching pairs and fill-in blanks share one shape, so every read, cascade and serializer stays single. Do not add a second table. **Cascade:** `content.delete_content_item` calls `store.cascade_questions(quiz_uid, actor, stamp)` for `target_type == "quiz"`, soft-deleting questions, options, attempts and answers under the **same stamp** as the quiz itself, so `/admin/trash` restores or purges the whole quiz as one event. `quizzes` is registered in `routers/admin/trash.py::TRASH_TABLES`. ## Fan-out | Layer | Where | |-------|-------| | Polymorphic engagement | `"quiz"` in `VOTABLE_TARGETS`/`STAR_TARGETS`, `routers/votes.VOTABLE`, `routers/reactions.REACTABLE`, `routers/bookmarks.BOOKMARKABLE`, `content.BOOKMARKABLE_TYPES`/`REACTABLE_TYPES`/`VOTE_NOTIFY_TYPES`, `CommentForm.target_type`, `docs_api/_shared` target lists, `resolve_object_url` | | AI correction | `CORRECTABLE_FIELDS["quizzes"] = ("title", "description")`. Question prompts and submitted answers are deliberately excluded - rewriting a graded artifact would change its meaning | | SEO metadata | `"quiz"` in `SEO_META_TYPES`, `seo_meta.TABLE_TO_TYPE`/`TYPE_TABLES`/`TITLE_FIELD`/`BODY_FIELD` | | SEO | `seo.quiz_schema`, `/quizzes` + the newest published quizzes in the sitemap; hub and detail `index,follow`, builder/player/results `noindex,follow`, a draft detail `noindex,nofollow` | | Notifications | `quiz_attempt`, fired once on the finish transition to the author, never per answer | | Gamification | `XP_QUIZ`/`XP_QUIZ_PUBLISH`/`XP_QUIZ_COMPLETE`, achievements `quiz_publish`/`quiz_complete`/`quiz_perfect`, the **Quizzes** badge group | | Audit | prefix `quiz` -> category `content`; thirteen keys in `events.md` | | Devii | `actions/catalog/quizzes.py`; `publish_quiz`, `delete_quiz` and `delete_quiz_question` are in `dispatcher.CONFIRM_REQUIRED` and each declares a `confirm` param | | CLI | `devplace quiz prune` - hard-deletes abandoned and expired attempts older than `QUIZ_ATTEMPT_RETENTION_DAYS`. Completed attempts are never pruned; they are the player's record | | Frontend | `dp-quiz-player`/`dp-quiz-builder` (light DOM, adopt the server-rendered markup), `static/css/quiz.css` (adds only what is new - the layout and card chrome come from `feed.css`/`sidebar.css`) | **Generic Devii prompt seeding.** The hub's *Create quiz with Devii* button uses the platform-wide `data-devii-prompt` attribute: `DeviiTerminal.bindTriggers` reads it and passes it to `open(prompt)`, which calls `devii-terminal.prefill(text)`. It never auto-sends - the member reads the request and presses Enter, so the assistant's first action stays user-initiated. This is available on every page, not just quizzes. ## No-JS path Every question slide is a real `
` posting to the answer route, and the answer route redirects back to the attempt page. Ordering uses one `` per left-hand item, so both work with a keyboard and a screen reader - there is no drag-only interaction and no third-party JS.