Files
devplacepy/devplacepy/services/quiz/CLAUDE.md
T
retoorandClaude Sonnet 5 572e022584 Add thread notifications, SEO topic pages, and fix quiz auto-advance
Notifications: a new "thread" type notifies every other commenter on a
post whenever anyone comments on it, disregarding reply hierarchy -
excluding the actor and whoever already got a comment/reply
notification for that same event, so no one is double-notified.
Implemented via a background-deferred fan-out mirroring the existing
mention-notification pattern.

SEO: discussion_forum_posting() now embeds up to 20 of a post's
comments as nested schema.org Comment entities (not just an aggregate
count), and a new /topics hub plus /topics/{topic} pages give the
feed's topic filter real, independently crawlable/indexable URLs -
/feed?topic=X was never indexable since its canonical strips the
query string back to bare /feed. Both are wired end to end (schemas,
Devii actions, docs API, sitemap, locustfile load-test coverage).

Quiz player: the auto-advance to the next question used to hide the
just-answered slide in the same tick as rendering the grade, so on
any multi-question quiz the Correct/Not correct feedback was never
actually visible before the view moved on. Delayed via setTimeout,
with the pending timer cleared on manual navigation and on
disconnect so it can't race or fire on a removed component.

Also includes other local changes already in progress in this
working tree before this session (messaging, push delivery,
deepsearch jobs, game economy, quiz builder) - verified by the full
suite passing (3467 tests) but not authored or individually reviewed
in this session.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VL9Xn57W5UR3HZbbuuzxdK
2026-09-03 08:47:57 +02:00

17 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)

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.