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.
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 naiveSUMover 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, onlypublishedquizzes. - Ties resolve by
quizzes_completedthenuser_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.