# 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 (<precondition>)` 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.

**Model is admin-configurable.** `grading.grading_model()` (`get_setting("quiz_grading_model", "") or
INTERNAL_MODEL`) resolves the model passed to `gateway_complete`, edited at `/admin/settings` (there is
no dedicated quiz service, so this lives alongside `correction_model`/`modifier_model` in
`AdminSettingsForm` rather than a `ConfigField`). Blank falls back to the gateway default `molodetz`;
the gateway URL itself is never configurable per feature.

**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`) |

**The player delays auto-advance so the grade is actually seen.** `AppQuizPlayer._advance` used to call `_showSlide(index + 1)` synchronously in the same tick as rendering the grade, so on any quiz with more than one question the just-answered slide was hidden before a single frame ever painted its `.quiz-grade` - the grade existed in the DOM but the user (and Playwright) never saw it. It now schedules the advance via `setTimeout(..., ADVANCE_DELAY_MS)` (1200ms) so the "Correct"/"Not correct" verdict is visible for a beat before the next question slides in. A single-question quiz never hits this path at all (`index >= this._slides.length - 1` short-circuits), which is why the bug was invisible to any test built around a one-question quiz. `_showSlide` (called by the pending timeout, by manual Previous/Next, and by `disconnectedCallback`) always clears any outstanding `this._advanceTimeout` first, and `_advance` clears one before scheduling a new one - without both, a stale timer from an earlier answer could fire after the user manually navigated away, silently snapping the view back, or two timers scheduled in quick succession (answering out of order via manual nav) could race and land on the wrong slide.

**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 `<form method="post">` posting to the answer route, and the answer
route redirects back to the attempt page. Ordering uses one `<select>` per position and matching
one `<select>` 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.
