Files
devplacepy/devplacepy/services/quiz/CLAUDE.md
T
retoor 571a0485c5
DevPlace CI / test (push) Failing after 58m59s
Fix circular import, primary-admin NULL trap, and add gateway quota reset
Restores a working import graph and closes two data-correctness bugs, plus
adds a reset for the AI gateway's rolling 24h spend.

Circular import: database/__init__ -> engagement -> content -> utils ->
database made the package unimportable. get_project_devlog moves out of
database/engagement.py into content.py, where enrich_items already lives.

Primary administrator: _can_hold_primary_admin read is_active with
bool(row.get("is_active")), so an admin row whose is_active column is SQL
NULL (any row predating the column) was treated as deactivated and skipped.
Every other site defaults an unknown is_active to active; this one now does
too.

Profile JSON: xp_next_level and xp_progress_pct were computed but only put on
the top-level context, never on profile_user, so they serialised as null even
though UserOut declares them and the API docs document them as embedded there.

Gateway quota reset: a cap previously lifted only with the passage of time.
quota.reset upserts a watermark row into gateway_quota_resets, scoped by the
same three nullable dimensions as a quota rule, and spent_24h sums from
max(24h cutoff, watermark). No ledger row is deleted, so the cost analytics on
/admin/ai-usage stay intact. Reaches every surface: POST
/admin/gateway/quota-resets, a per-rule Reset spend button, the Devii tool
gateway_quota_reset (confirm-gated), devplace gateway quota reset, and the API
docs. Admin's Reset all quotas now stamps a global gateway watermark too,
which is what a caller stuck on "AI gateway daily quota exceeded" needed.

Startup: _backfill_gamification swept every xp=0 user on every boot in every
worker and could never converge, since a user with no content earns no XP.
It now intersects pending users with _milestone_candidates(). db.tables is a
live reflection, so it is hoisted out of the loops that probed it per row.

Docker: the dependency layer now depends on pyproject.toml only, so a source
edit no longer reinstalls every dependency and re-downloads Chromium.
Adds start_interval so the healthcheck probes during the start period, and a
docker-reload target, since docker-up does not restart an unchanged container.

Adds events.md, the audit event catalogue that README, CLAUDE.md, the quiz
docs and the tooling all referenced but which never existed: 288 keys across
28 categories, including the families built from a variable at the call site.

Test fixes: both devlog helpers dated post 0 as the newest while the tests
assumed post 2 was; a profile login posted username= to a form that takes
email=; a devlog assertion matched six buttons under strict mode; and the
primary-admin tests seeded founders newer than the back-dated fixture admin,
so they only passed without the api tier.

Full suite: 2989 passed, 1 skipped.
2026-07-27 11:17:48 +02:00

16 KiB

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.

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 <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.