Skip to content

feat(quiz): adaptive difficulty, /api/quiz/config, stable error envelope (#540) - #547

Merged
AndresL230 merged 3 commits into
mainfrom
feat/540-quiz-preflight-a
Aug 12, 2026
Merged

feat(quiz): adaptive difficulty, /api/quiz/config, stable error envelope (#540)#547
AndresL230 merged 3 commits into
mainfrom
feat/540-quiz-preflight-a

Conversation

@AndresL230

@AndresL230AndresL230 commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator

Closes#540. Workstream A of the pre-revamp quiz repair batch (epic #537) — the P0 that ships standalone.

What

A1 — adaptive is a real difficulty. The route accepts it (REQUESTED_DIFFICULTIES), the agent picks the per-question mix under new ADAPTIVE MODE prompt rules (no ±1 clamp; every question still emits a concrete easy|medium|hard), and the attempt row stores the requested value — quiz_attempts_difficulty_check extended by migration 20260812204809. The response now carries requested_difficulty + resolved_difficulty (mode of per-question difficulties, ties break harder) so the client can tell the student what was actually chosen. Both fields are additive; the old client ignores them.

A2 — one source of truth for selector options.GET /api/quiz/config serves num_questions {min,max,options}, difficulties, question_types from services/quiz_config.py; GenerateQuizBody reads the same constants (pinned by a sync test). QuizPanel builds its selects from the endpoint with a static fallback mirroring it — the dead "15 questions" option is gone.

Cap decision (measured, not guessed):scripts/bench_quiz_question_cap.py, 2026-08-12, gemini-2.5-flash-lite:

  • cap 10 — serves fine: 4.6s/5.6s, ~260 in / ~1500 out tokens, 10/10 delivered
  • cap 15 — rejected before generation: HTTP 400 INVALID_ARGUMENT "schema produces a constraint that has too many states for serving"
  • cap 20 — same rejection

10 is a hard ceiling for a single structured call on the quiz model tier, not a preference. Raising it = model-tier change or batched generation = #537 revamp scope.

A3 — stable error envelope. Every quiz-route 4xx/5xx returns { error: { code, message, detail?, request_id } }; codes live in one enum (services/quiz_errors.py::QuizErrorCode); scoped strictly to /api/quiz/* via the main.py handlers, so no other route changes shape.

Wire-contract notes (for the current client)

  • Generate response: additive fields only (requested_difficulty, resolved_difficulty).
  • Error payloads on quiz routes: legacy top-level detail and request_id keys are kept alongside the new error object — frontend/src/lib/api.ts reads data?.detail and keeps working unchanged.
  • QuizPanel.tsx selector lists are now config-driven; defaults (5 / medium) unchanged, so the test(e2e): journey — quiz answer → mastery update (UI + DB) #393 journey is untouched.

Verification

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added adaptive quiz difficulty, selecting easy, medium, or hard per question based on performance history.
    • Added server-driven quiz configuration for question counts, difficulties, and question types.
    • Quiz generation now reports requested and resolved difficulty.
    • Added structured quiz error responses with stable error codes and details.
  • Bug Fixes

    • Improved validation for question counts, difficulty, concepts, and quiz submissions.
    • Added fallback quiz options when configuration cannot be loaded.

…ope (#540)
Workstream A of the pre-revamp quiz repair batch (epic #537):
A1 — 'adaptive' is a real difficulty. The route accepts it, the agent
picks the per-question mix (ADAPTIVE MODE prompt rules), the attempt row
records the request (CHECK extended by migration), and the response
reports requested_difficulty + resolved_difficulty (mode, ties break
harder) so the client can say what was actually chosen.
A2 — GET /api/quiz/config is the single source of truth for selector
values; the Pydantic model reads the same constants. Cap decision is
measured, not guessed: scripts/bench_quiz_question_cap.py shows 15- and
20-question schemas are rejected outright by gemini-2.5-flash-lite
("too many states for serving"), so 10 is a hard ceiling. QuizPanel now
builds its selects from the endpoint (static fallback mirrors it; the
dead "15 questions" option is gone).
A3 — every quiz-route 4xx/5xx returns {error: {code, message, detail?,
request_id}} with codes in services/quiz_errors.py::QuizErrorCode; the
legacy top-level detail key is kept so the current client still works.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@supabase

supabaseBot commented Aug 12, 2026

Copy link
Copy Markdown

This pull request has been ignored for the connected project ybgqdonkoqftwrmweuyv because there are no changes detected in supabase directory. You can change this behaviour in Project Integrations Settings ↗︎.


Preview Branches by Supabase.
Learn more about Supabase Branching ↗︎.

@coderabbitai

coderabbitaiBot commented Aug 12, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@AndresL230, you've reached your PR review limit, so we couldn't start this review.

Next review available in:19 minutes

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 12e52a35-4af8-4ba0-a17a-a7c1770ce8eb

📥 Commits

Reviewing files that changed from the base of the PR and between afc97df and 1bd3512.

📒 Files selected for processing (11)
  • backend/agents/quiz.py
  • backend/db/migrations/20260812204809_quiz_attempts_adaptive_difficulty.sql
  • backend/main.py
  • backend/routes/quiz.py
  • backend/scripts/bench_quiz_question_cap.py
  • backend/services/quiz_config.py
  • backend/services/quiz_errors.py
  • backend/tests/test_quiz_preflight_a.py
  • frontend/e2e/quiz.spec.ts
  • frontend/src/components/QuizPanel.test.tsx
  • frontend/src/components/QuizPanel.tsx
📝 Walkthrough

Walkthrough

The PR adds adaptive quiz difficulty, centralizes quiz configuration, updates question-count validation and persistence constraints, introduces structured quiz errors, and makes frontend selectors load backend-defined options with static fallbacks.

Changes

Adaptive quiz configuration

Layer / File(s)Summary
Centralized quiz configuration and persistence contracts
backend/services/quiz_config.py, backend/models/__init__.py, backend/db/migrations/..., backend/db/e2e_checks/quiz.py, frontend/src/lib/api.ts
Shared question bounds, difficulty values, question types, persistence constraints, and frontend response types now include adaptive configuration.
Adaptive generation and difficulty resolution
backend/agents/quiz.py, backend/routes/quiz.py, backend/scripts/bench_quiz_question_cap.py, backend/tests/test_quiz_preflight_a.py
Adaptive requests delegate per-question difficulty selection to the agent. Generation responses include requested and resolved difficulty. The benchmark tests question caps of 10, 15, and 20.
Quiz error envelopes and route handling
backend/services/quiz_errors.py, backend/main.py, backend/routes/quiz.py
Quiz errors use stable codes, machine-readable details, request IDs, and legacy detail fields. Non-quiz responses retain their existing format.
Configuration-driven quiz selectors
frontend/src/components/QuizPanel.tsx, frontend/src/components/QuizPanel.test.tsx
The panel loads selector options from the backend and uses static options when configuration loading fails. Adaptive difficulty is available in the selectors.

Estimated code review effort: 4 (Complex) | ~45 minutes

Possibly related issues

Possibly related PRs

Sequence Diagram(s)

sequenceDiagram
participant QuizPanel
participant QuizRoute
participant QuizAgent
participant Database
QuizPanel->>QuizRoute: Request adaptive quiz generation
QuizRoute->>Database: Read mastery and recent attempts
QuizRoute->>QuizAgent: Generate questions with adaptive instructions
QuizAgent-->>QuizRoute: Return concrete question difficulties
QuizRoute->>Database: Store quiz attempt
QuizRoute-->>QuizPanel: Return requested and resolved difficulty
Loading

Suggested reviewers:darkest-teddy

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 21.74% which is insufficient. The required threshold is 80.00%.Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check nameStatusExplanation
Title check✅ PassedThe title clearly summarizes the primary changes: adaptive difficulty, the quiz configuration endpoint, and stable error envelopes.
Description check✅ PassedThe description covers the implementation, related issues, testing, compatibility notes, benchmark results, and verification details.
Linked Issues check✅ PassedThe changes address #540 objectives for adaptive difficulty, centralized configuration, measured caps, coded quiz errors, persistence, and real-database testing.
Out of Scope Changes check✅ PassedThe migration, benchmark, tests, frontend updates, documentation, and error handling are all directly related to the linked issue objectives.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/540-quiz-preflight-a

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Aug 12, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-staging1bd3512Commit Preview URL

Branch Preview URL
Aug 12 2026, 09:41 PM

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
backend/db/e2e_checks/quiz.py (1)

35-44: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Exercise the adaptive persistence path.

Line 42 still sends "easy". This E2E flow does not verify that a successful adaptive request stores "adaptive" in quiz_attempts.difficulty. Send an adaptive request and, when generation succeeds, assert the created attempt row has difficulty == "adaptive" before submit.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/db/e2e_checks/quiz.py` around lines 35 - 44, Update the generate
request in the E2E flow to use difficulty "adaptive" instead of "easy". After
successful generation, query the created quiz_attempts row and assert its
difficulty is "adaptive" before submitting the quiz.
🧹 Nitpick comments (1)
frontend/src/components/QuizPanel.tsx (1)

129-137: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Reconcile selected values with loaded quiz configuration.

If the server removes "5" or "medium", the selectors render no selected option but start() still sends those stale values. This defeats the server-defined selector contract.

  • frontend/src/components/QuizPanel.tsx#L129-L137: after accepting a non-empty config payload, use functional state setters to retain a selected value only when it remains allowed; otherwise select the first allowed value.
  • frontend/src/components/QuizPanel.test.tsx#L45-L47: add a successful config mock that excludes "5" and "medium", then assert quiz generation uses the reconciled values.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@frontend/src/components/QuizPanel.tsx` around lines 129 - 137, The quiz
configuration load must reconcile selected count and difficulty values with the
server’s allowed options. In frontend/src/components/QuizPanel.tsx lines
129-137, after accepting a non-empty payload, use functional setters to preserve
each current selection only when allowed, otherwise choose the first allowed
value. In frontend/src/components/QuizPanel.test.tsx lines 45-47, add a
successful config mock excluding "5" and "medium", then assert quiz generation
uses the reconciled selections.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@backend/agents/quiz.py`:
- Around line 116-124: Update the fixed-difficulty rules immediately following
the adaptive-mode instructions in the quiz prompt so they apply only when the
user provides a concrete difficulty request. Prevent adaptive mode from
inheriting rules that shift from or honor a requested difficulty; preserve
adaptive’s mastery-based selection and medium baseline when no history exists.
In `@backend/db/migrations/20260812204809_quiz_attempts_adaptive_difficulty.sql`:
- Around line 9-14: Update the quiz_attempts_difficulty_check replacement in
this migration to add the constraint as NOT VALID, avoiding validation during
the migration transaction. Create a subsequent timestamped migration that
validates quiz_attempts_difficulty_check with VALIDATE CONSTRAINT.
In `@backend/services/quiz_errors.py`:
- Around line 44-51: Update the status-resolution logic using _STATUS_FALLBACK
so unlisted 4xx statuses, including 400, return QUIZ_VALIDATION_ERROR, while
unlisted 5xx statuses continue returning QUIZ_INTERNAL_ERROR. Preserve the
explicit mappings for listed statuses.
In `@backend/tests/test_quiz_preflight_a.py`:
- Around line 14-19: Update the tests in this module to remove the global
TestClient setup and route-internal patching, and use the shared Supabase and
Gemini fixtures provided by tests/conftest.py. Adjust affected test functions to
receive and use those fixtures while preserving their existing assertions and
behavior.
---
Outside diff comments:
In `@backend/db/e2e_checks/quiz.py`:
- Around line 35-44: Update the generate request in the E2E flow to use
difficulty "adaptive" instead of "easy". After successful generation, query the
created quiz_attempts row and assert its difficulty is "adaptive" before
submitting the quiz.
---
Nitpick comments:
In `@frontend/src/components/QuizPanel.tsx`:
- Around line 129-137: The quiz configuration load must reconcile selected count
and difficulty values with the server’s allowed options. In
frontend/src/components/QuizPanel.tsx lines 129-137, after accepting a non-empty
payload, use functional setters to preserve each current selection only when
allowed, otherwise choose the first allowed value. In
frontend/src/components/QuizPanel.test.tsx lines 45-47, add a successful config
mock excluding "5" and "medium", then assert quiz generation uses the reconciled
selections.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: ee118b9c-e231-4709-a0b2-ba45685d6e26

📥 Commits

Reviewing files that changed from the base of the PR and between 7681c48 and afc97df.

📒 Files selected for processing (13)
  • backend/agents/quiz.py
  • backend/db/e2e_checks/quiz.py
  • backend/db/migrations/20260812204809_quiz_attempts_adaptive_difficulty.sql
  • backend/main.py
  • backend/models/__init__.py
  • backend/routes/quiz.py
  • backend/scripts/bench_quiz_question_cap.py
  • backend/services/quiz_config.py
  • backend/services/quiz_errors.py
  • backend/tests/test_quiz_preflight_a.py
  • frontend/src/components/QuizPanel.test.tsx
  • frontend/src/components/QuizPanel.tsx
  • frontend/src/lib/api.ts

Comment threadbackend/agents/quiz.py Outdated
Comment on lines +116 to +124
"ADAPTIVE MODE (#540): when the user message says the quiz is in "
"adaptive mode, there is no user-requested difficulty — you choose "
"each question's difficulty yourself. Base the mix on mastery and "
"`recent_attempts.accuracy`: struggling/low-accuracy concepts get "
"easy-leaning questions, strong/high-accuracy ones get hard-leaning "
"questions; with no history at all, center the mix on medium. The "
"±1-step limits below do not apply in adaptive mode, but every "
"question still carries a concrete easy|medium|hard difficulty — "
"'adaptive' is never a per-question value.\n\n"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Scope the fixed-difficulty rules to non-adaptive requests.

Adaptive mode says there is no requested difficulty and defaults to medium without history. The following rules still instruct the agent to shift from, or honor, a requested difficulty. This gives adaptive generation an undefined baseline.

Limit the rules at lines 125-136 to concrete requests, or define an explicit adaptive baseline for each rule.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/agents/quiz.py` around lines 116 - 124, Update the fixed-difficulty
rules immediately following the adaptive-mode instructions in the quiz prompt so
they apply only when the user provides a concrete difficulty request. Prevent
adaptive mode from inheriting rules that shift from or honor a requested
difficulty; preserve adaptive’s mastery-based selection and medium baseline when
no history exists.

Comment on lines +9 to +14
ALTER TABLE quiz_attempts
DROP CONSTRAINT IF EXISTS quiz_attempts_difficulty_check;

ALTER TABLE quiz_attempts
ADD CONSTRAINT quiz_attempts_difficulty_check
CHECK (difficulty IN ('easy', 'medium', 'hard', 'adaptive'));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bashset -euo pipefail
migrator="$(fd -a '^migrate\.py$' backend/db | head -n1)"test -n "$migrator"
ast-grep outline "$migrator" --items all
rg -n -C 5 'BEGIN|COMMIT|transaction|execute|migrations'"$migrator"

Repository: SaplingLearn/Sapling

Length of output: 7968


🏁 Script executed:

#!/bin/bashset -euo pipefail
migration="$(fd -a '20260812204809_quiz_attempts_adaptive_difficulty\.sql$' backend/db/migrations | head -n1)"test -n "$migration"
cat -n "$migration"printf'\nConstraint migration patterns:\n'
rg -n -C 4 'ADD CONSTRAINT|DROP CONSTRAINT|NOT VALID|VALIDATE CONSTRAINT' backend/db/migrations

Repository: SaplingLearn/Sapling

Length of output: 19373


🏁 Script executed:

#!/bin/bashset -euo pipefail
python3 - <<'PY'from pathlib import Pathimport rerunner = Path("backend/db/migrate.py").read_text()migration = Path("backend/db/migrations/20260812204809_quiz_attempts_adaptive_difficulty.sql").read_text()apply = re.search( r"def apply_migration\(.*?^\s*def run\(", runner, re.MULTILINE | re.DOTALL,)assert apply, "apply_migration block not found"block = apply.group(0)checks = { "executes migration SQL": "cur.execute(path.read_text" in block, "records ledger before commit": block.find("INSERT INTO schema_migrations") < block.find("conn.commit()"), "commits once per migration": block.count("conn.commit()") == 1, "current constraint is not NOT VALID": "NOT VALID" not in migration.upper(),}for name, result in checks.items(): print(f"{name}: {result}")assert all(checks.values())PY

Repository: SaplingLearn/Sapling

Length of output: 297


🌐 Web query:

PostgreSQL documentation ALTER TABLE ADD CHECK NOT VALID lock level VALIDATE CONSTRAINT

💡 Result:

In PostgreSQL, the VALIDATE CONSTRAINT command acquires a SHARE UPDATE EXCLUSIVE lock on the table being altered [1][2]. This lock level is specifically designed to allow concurrent reads, updates, inserts, and deletes, as the validation process only needs to check pre-existing rows [1][2]. Because new rows inserted or updated after the constraint was initially added (using NOT VALID) are already being enforced by the constraint, they do not need to be scanned during the validation phase [1][2]. If the constraint being validated is a foreign key, an additional ROW SHARE lock is required on the referenced table [1][2]. When using ALTER TABLE ADD CONSTRAINT with the NOT VALID option, the command itself requires a SHARE ROW EXCLUSIVE lock (for check constraints) [3][4], which is less restrictive than the ACCESS EXCLUSIVE lock required for standard constraint addition without NOT VALID [1][2]. This allows the initial constraint definition to proceed without blocking concurrent data modifications [1][2].

Citations:


Avoid full-table blocking validation in this migration.

backend/db/migrate.py commits each migration after executing its SQL and ledger insert. The current ADD CONSTRAINT therefore holds an ACCESS EXCLUSIVE lock on quiz_attempts through the validation scan. Add the replacement constraint as NOT VALID, then validate it in a later timestamped migration with VALIDATE CONSTRAINT.

🧰 Tools
🪛 Squawk (2.61.0)

[warning] 13-14: By default new constraints require a table scan and block writes to the table while that scan occurs. Use NOT VALID with a later VALIDATE CONSTRAINT call.

(constraint-missing-not-valid)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/db/migrations/20260812204809_quiz_attempts_adaptive_difficulty.sql`
around lines 9 - 14, Update the quiz_attempts_difficulty_check replacement in
this migration to add the constraint as NOT VALID, avoiding validation during
the migration transaction. Create a subsequent timestamped migration that
validates quiz_attempts_difficulty_check with VALIDATE CONSTRAINT.

Source: Linters/SAST tools

Comment on lines +44 to +51
_STATUS_FALLBACK: dict[int, QuizErrorCode] = {
401: QuizErrorCode.QUIZ_NOT_AUTHORIZED,
403: QuizErrorCode.QUIZ_NOT_AUTHORIZED,
404: QuizErrorCode.QUIZ_ATTEMPT_NOT_FOUND,
409: QuizErrorCode.QUIZ_ATTEMPT_ALREADY_COMPLETED,
422: QuizErrorCode.QUIZ_VALIDATION_ERROR,
502: QuizErrorCode.QUIZ_GENERATION_FAILED,
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Map unlisted 4xx statuses to a client-error code.

A plain quiz-route HTTPException(status_code=400) resolves to QUIZ_INTERNAL_ERROR. This conflicts with the stable error-code contract because the response reports a client error as an internal failure.

Use QUIZ_VALIDATION_ERROR as the fallback for unlisted 4xx statuses. Keep QUIZ_INTERNAL_ERROR as the fallback for 5xx statuses.

Proposed fix
- resolved_code = code or _STATUS_FALLBACK.get(- status_code,- QuizErrorCode.QUIZ_INTERNAL_ERROR,- )+ resolved_code = code or _STATUS_FALLBACK.get(+ status_code,+ (+ QuizErrorCode.QUIZ_VALIDATION_ERROR+ if 400 <= status_code < 500+ else QuizErrorCode.QUIZ_INTERNAL_ERROR+ ),+ )

Also applies to: 87-90

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/services/quiz_errors.py` around lines 44 - 51, Update the
status-resolution logic using _STATUS_FALLBACK so unlisted 4xx statuses,
including 400, return QUIZ_VALIDATION_ERROR, while unlisted 5xx statuses
continue returning QUIZ_INTERNAL_ERROR. Preserve the explicit mappings for
listed statuses.

Comment on lines +14 to +19
from fastapi.testclient import TestClient

from main import app
from agents.quiz import Quiz, QuizQuestion

client = TestClient(app)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Use the shared backend fixtures.

This module creates its own global TestClient and patches route internals. Use the shared Supabase and Gemini fixtures from tests/conftest.py instead. This keeps backend test setup consistent and prevents fixture behavior from being bypassed.

As per coding guidelines, backend/tests/**/*.py must “use the shared Supabase and Gemini fixtures from tests/conftest.py.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/tests/test_quiz_preflight_a.py` around lines 14 - 19, Update the
tests in this module to remove the global TestClient setup and route-internal
patching, and use the shared Supabase and Gemini fixtures provided by
tests/conftest.py. Adjust affected test functions to receive and use those
fixtures while preserving their existing assertions and behavior.

Source: Coding guidelines

AndresL230and others added 2 commits August 12, 2026 17:31
… semantics, config-driven coverage
Review findings (xhigh, 15 defects):
- 422s on num_questions only get QUIZ_COUNT_OUT_OF_RANGE for actual
bounds violations; type errors stay QUIZ_VALIDATION_ERROR.
- _STATUS_FALLBACK narrowed to auth/validation; uncoded 404/405s get the
new generic QUIZ_HTTP_ERROR instead of impersonating domain states, and
a non-QuizErrorCode `code` attr can no longer crash the handler.
- The envelope-vs-legacy branch now lives once in quiz_errors.error_content;
main.py's three handlers all call it.
- The system prompt defines stored difficulty='adaptive' history rows
(judge by accuracy) so the quiz-history tool doesn't feed the stepping
rules an undefined token.
- The adaptive migration drops the old CHECK by introspection (name-robust)
and documents migrate-before-deploy ordering.
- bench_quiz_question_cap exits 1 when the baseline cap measured nothing.
- QUIZ_QUESTION_TYPES uses the schema token 'multiple_choice', not 'mcq'.
- _DIFFICULTY_RANK derived from CONCRETE_DIFFICULTIES.
- QuizPanel: humanizeError in both catches (the envelope's sentence now
actually reaches the student) and an "Adaptive · <resolved>" chip in the
active phase; vitest covers the config-driven select path both ways; a
promoted #540 journey pins config-mirroring selects + the adaptive wire
round trip in a real browser.
Skipped (documented): moving difficulty validation into a Pydantic
Literal — the 400 + QUIZ_DIFFICULTY_INVALID contract is published in #540
and changing it to a 422 buys no client anything today; revisit in #537.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…urney
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@AndresL230
AndresL230 merged commit 6081ec5 into mainAug 12, 2026
6 of 8 checks passed
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

quiz A (P0): accept 'adaptive' difficulty, single-source /api/quiz/config, stable error envelope

1 participant

@AndresL230
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
feat(quiz): adaptive difficulty, /api/quiz/config, stable error envelope (#540) by AndresL230 · Pull Request #547 · SaplingLearn/Sapling · GitHub
Skip to content

feat(quiz): adaptive difficulty, /api/quiz/config, stable error envelope (#540) - #547

Merged
AndresL230 merged 3 commits into
mainfrom
feat/540-quiz-preflight-a
Aug 12, 2026
Merged

feat(quiz): adaptive difficulty, /api/quiz/config, stable error envelope (#540)#547
AndresL230 merged 3 commits into
mainfrom
feat/540-quiz-preflight-a

Conversation

@AndresL230

@AndresL230AndresL230 commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator

Closes#540. Workstream A of the pre-revamp quiz repair batch (epic #537) — the P0 that ships standalone.

What

A1 — adaptive is a real difficulty. The route accepts it (REQUESTED_DIFFICULTIES), the agent picks the per-question mix under new ADAPTIVE MODE prompt rules (no ±1 clamp; every question still emits a concrete easy|medium|hard), and the attempt row stores the requested value — quiz_attempts_difficulty_check extended by migration 20260812204809. The response now carries requested_difficulty + resolved_difficulty (mode of per-question difficulties, ties break harder) so the client can tell the student what was actually chosen. Both fields are additive; the old client ignores them.

A2 — one source of truth for selector options.GET /api/quiz/config serves num_questions {min,max,options}, difficulties, question_types from services/quiz_config.py; GenerateQuizBody reads the same constants (pinned by a sync test). QuizPanel builds its selects from the endpoint with a static fallback mirroring it — the dead "15 questions" option is gone.

Cap decision (measured, not guessed):scripts/bench_quiz_question_cap.py, 2026-08-12, gemini-2.5-flash-lite:

  • cap 10 — serves fine: 4.6s/5.6s, ~260 in / ~1500 out tokens, 10/10 delivered
  • cap 15 — rejected before generation: HTTP 400 INVALID_ARGUMENT "schema produces a constraint that has too many states for serving"
  • cap 20 — same rejection

10 is a hard ceiling for a single structured call on the quiz model tier, not a preference. Raising it = model-tier change or batched generation = #537 revamp scope.

A3 — stable error envelope. Every quiz-route 4xx/5xx returns { error: { code, message, detail?, request_id } }; codes live in one enum (services/quiz_errors.py::QuizErrorCode); scoped strictly to /api/quiz/* via the main.py handlers, so no other route changes shape.

Wire-contract notes (for the current client)

  • Generate response: additive fields only (requested_difficulty, resolved_difficulty).
  • Error payloads on quiz routes: legacy top-level detail and request_id keys are kept alongside the new error object — frontend/src/lib/api.ts reads data?.detail and keeps working unchanged.
  • QuizPanel.tsx selector lists are now config-driven; defaults (5 / medium) unchanged, so the test(e2e): journey — quiz answer → mastery update (UI + DB) #393 journey is untouched.

Verification

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added adaptive quiz difficulty, selecting easy, medium, or hard per question based on performance history.
    • Added server-driven quiz configuration for question counts, difficulties, and question types.
    • Quiz generation now reports requested and resolved difficulty.
    • Added structured quiz error responses with stable error codes and details.
  • Bug Fixes

    • Improved validation for question counts, difficulty, concepts, and quiz submissions.
    • Added fallback quiz options when configuration cannot be loaded.

…ope (#540)
Workstream A of the pre-revamp quiz repair batch (epic #537):
A1 — 'adaptive' is a real difficulty. The route accepts it, the agent
picks the per-question mix (ADAPTIVE MODE prompt rules), the attempt row
records the request (CHECK extended by migration), and the response
reports requested_difficulty + resolved_difficulty (mode, ties break
harder) so the client can say what was actually chosen.
A2 — GET /api/quiz/config is the single source of truth for selector
values; the Pydantic model reads the same constants. Cap decision is
measured, not guessed: scripts/bench_quiz_question_cap.py shows 15- and
20-question schemas are rejected outright by gemini-2.5-flash-lite
("too many states for serving"), so 10 is a hard ceiling. QuizPanel now
builds its selects from the endpoint (static fallback mirrors it; the
dead "15 questions" option is gone).
A3 — every quiz-route 4xx/5xx returns {error: {code, message, detail?,
request_id}} with codes in services/quiz_errors.py::QuizErrorCode; the
legacy top-level detail key is kept so the current client still works.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@supabase

supabaseBot commented Aug 12, 2026

Copy link
Copy Markdown

This pull request has been ignored for the connected project ybgqdonkoqftwrmweuyv because there are no changes detected in supabase directory. You can change this behaviour in Project Integrations Settings ↗︎.


Preview Branches by Supabase.
Learn more about Supabase Branching ↗︎.

@coderabbitai

coderabbitaiBot commented Aug 12, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@AndresL230, you've reached your PR review limit, so we couldn't start this review.

Next review available in:19 minutes

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 12e52a35-4af8-4ba0-a17a-a7c1770ce8eb

📥 Commits

Reviewing files that changed from the base of the PR and between afc97df and 1bd3512.

📒 Files selected for processing (11)
  • backend/agents/quiz.py
  • backend/db/migrations/20260812204809_quiz_attempts_adaptive_difficulty.sql
  • backend/main.py
  • backend/routes/quiz.py
  • backend/scripts/bench_quiz_question_cap.py
  • backend/services/quiz_config.py
  • backend/services/quiz_errors.py
  • backend/tests/test_quiz_preflight_a.py
  • frontend/e2e/quiz.spec.ts
  • frontend/src/components/QuizPanel.test.tsx
  • frontend/src/components/QuizPanel.tsx
📝 Walkthrough

Walkthrough

The PR adds adaptive quiz difficulty, centralizes quiz configuration, updates question-count validation and persistence constraints, introduces structured quiz errors, and makes frontend selectors load backend-defined options with static fallbacks.

Changes

Adaptive quiz configuration

Layer / File(s)Summary
Centralized quiz configuration and persistence contracts
backend/services/quiz_config.py, backend/models/__init__.py, backend/db/migrations/..., backend/db/e2e_checks/quiz.py, frontend/src/lib/api.ts
Shared question bounds, difficulty values, question types, persistence constraints, and frontend response types now include adaptive configuration.
Adaptive generation and difficulty resolution
backend/agents/quiz.py, backend/routes/quiz.py, backend/scripts/bench_quiz_question_cap.py, backend/tests/test_quiz_preflight_a.py
Adaptive requests delegate per-question difficulty selection to the agent. Generation responses include requested and resolved difficulty. The benchmark tests question caps of 10, 15, and 20.
Quiz error envelopes and route handling
backend/services/quiz_errors.py, backend/main.py, backend/routes/quiz.py
Quiz errors use stable codes, machine-readable details, request IDs, and legacy detail fields. Non-quiz responses retain their existing format.
Configuration-driven quiz selectors
frontend/src/components/QuizPanel.tsx, frontend/src/components/QuizPanel.test.tsx
The panel loads selector options from the backend and uses static options when configuration loading fails. Adaptive difficulty is available in the selectors.

Estimated code review effort: 4 (Complex) | ~45 minutes

Possibly related issues

Possibly related PRs

Sequence Diagram(s)

sequenceDiagram
participant QuizPanel
participant QuizRoute
participant QuizAgent
participant Database
QuizPanel->>QuizRoute: Request adaptive quiz generation
QuizRoute->>Database: Read mastery and recent attempts
QuizRoute->>QuizAgent: Generate questions with adaptive instructions
QuizAgent-->>QuizRoute: Return concrete question difficulties
QuizRoute->>Database: Store quiz attempt
QuizRoute-->>QuizPanel: Return requested and resolved difficulty
Loading

Suggested reviewers:darkest-teddy

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 21.74% which is insufficient. The required threshold is 80.00%.Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check nameStatusExplanation
Title check✅ PassedThe title clearly summarizes the primary changes: adaptive difficulty, the quiz configuration endpoint, and stable error envelopes.
Description check✅ PassedThe description covers the implementation, related issues, testing, compatibility notes, benchmark results, and verification details.
Linked Issues check✅ PassedThe changes address #540 objectives for adaptive difficulty, centralized configuration, measured caps, coded quiz errors, persistence, and real-database testing.
Out of Scope Changes check✅ PassedThe migration, benchmark, tests, frontend updates, documentation, and error handling are all directly related to the linked issue objectives.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/540-quiz-preflight-a

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Aug 12, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-staging1bd3512Commit Preview URL

Branch Preview URL
Aug 12 2026, 09:41 PM

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
backend/db/e2e_checks/quiz.py (1)

35-44: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Exercise the adaptive persistence path.

Line 42 still sends "easy". This E2E flow does not verify that a successful adaptive request stores "adaptive" in quiz_attempts.difficulty. Send an adaptive request and, when generation succeeds, assert the created attempt row has difficulty == "adaptive" before submit.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/db/e2e_checks/quiz.py` around lines 35 - 44, Update the generate
request in the E2E flow to use difficulty "adaptive" instead of "easy". After
successful generation, query the created quiz_attempts row and assert its
difficulty is "adaptive" before submitting the quiz.
🧹 Nitpick comments (1)
frontend/src/components/QuizPanel.tsx (1)

129-137: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Reconcile selected values with loaded quiz configuration.

If the server removes "5" or "medium", the selectors render no selected option but start() still sends those stale values. This defeats the server-defined selector contract.

  • frontend/src/components/QuizPanel.tsx#L129-L137: after accepting a non-empty config payload, use functional state setters to retain a selected value only when it remains allowed; otherwise select the first allowed value.
  • frontend/src/components/QuizPanel.test.tsx#L45-L47: add a successful config mock that excludes "5" and "medium", then assert quiz generation uses the reconciled values.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@frontend/src/components/QuizPanel.tsx` around lines 129 - 137, The quiz
configuration load must reconcile selected count and difficulty values with the
server’s allowed options. In frontend/src/components/QuizPanel.tsx lines
129-137, after accepting a non-empty payload, use functional setters to preserve
each current selection only when allowed, otherwise choose the first allowed
value. In frontend/src/components/QuizPanel.test.tsx lines 45-47, add a
successful config mock excluding "5" and "medium", then assert quiz generation
uses the reconciled selections.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@backend/agents/quiz.py`:
- Around line 116-124: Update the fixed-difficulty rules immediately following
the adaptive-mode instructions in the quiz prompt so they apply only when the
user provides a concrete difficulty request. Prevent adaptive mode from
inheriting rules that shift from or honor a requested difficulty; preserve
adaptive’s mastery-based selection and medium baseline when no history exists.
In `@backend/db/migrations/20260812204809_quiz_attempts_adaptive_difficulty.sql`:
- Around line 9-14: Update the quiz_attempts_difficulty_check replacement in
this migration to add the constraint as NOT VALID, avoiding validation during
the migration transaction. Create a subsequent timestamped migration that
validates quiz_attempts_difficulty_check with VALIDATE CONSTRAINT.
In `@backend/services/quiz_errors.py`:
- Around line 44-51: Update the status-resolution logic using _STATUS_FALLBACK
so unlisted 4xx statuses, including 400, return QUIZ_VALIDATION_ERROR, while
unlisted 5xx statuses continue returning QUIZ_INTERNAL_ERROR. Preserve the
explicit mappings for listed statuses.
In `@backend/tests/test_quiz_preflight_a.py`:
- Around line 14-19: Update the tests in this module to remove the global
TestClient setup and route-internal patching, and use the shared Supabase and
Gemini fixtures provided by tests/conftest.py. Adjust affected test functions to
receive and use those fixtures while preserving their existing assertions and
behavior.
---
Outside diff comments:
In `@backend/db/e2e_checks/quiz.py`:
- Around line 35-44: Update the generate request in the E2E flow to use
difficulty "adaptive" instead of "easy". After successful generation, query the
created quiz_attempts row and assert its difficulty is "adaptive" before
submitting the quiz.
---
Nitpick comments:
In `@frontend/src/components/QuizPanel.tsx`:
- Around line 129-137: The quiz configuration load must reconcile selected count
and difficulty values with the server’s allowed options. In
frontend/src/components/QuizPanel.tsx lines 129-137, after accepting a non-empty
payload, use functional setters to preserve each current selection only when
allowed, otherwise choose the first allowed value. In
frontend/src/components/QuizPanel.test.tsx lines 45-47, add a successful config
mock excluding "5" and "medium", then assert quiz generation uses the reconciled
selections.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: ee118b9c-e231-4709-a0b2-ba45685d6e26

📥 Commits

Reviewing files that changed from the base of the PR and between 7681c48 and afc97df.

📒 Files selected for processing (13)
  • backend/agents/quiz.py
  • backend/db/e2e_checks/quiz.py
  • backend/db/migrations/20260812204809_quiz_attempts_adaptive_difficulty.sql
  • backend/main.py
  • backend/models/__init__.py
  • backend/routes/quiz.py
  • backend/scripts/bench_quiz_question_cap.py
  • backend/services/quiz_config.py
  • backend/services/quiz_errors.py
  • backend/tests/test_quiz_preflight_a.py
  • frontend/src/components/QuizPanel.test.tsx
  • frontend/src/components/QuizPanel.tsx
  • frontend/src/lib/api.ts

Comment threadbackend/agents/quiz.py Outdated
Comment on lines +116 to +124
"ADAPTIVE MODE (#540): when the user message says the quiz is in "
"adaptive mode, there is no user-requested difficulty — you choose "
"each question's difficulty yourself. Base the mix on mastery and "
"`recent_attempts.accuracy`: struggling/low-accuracy concepts get "
"easy-leaning questions, strong/high-accuracy ones get hard-leaning "
"questions; with no history at all, center the mix on medium. The "
"±1-step limits below do not apply in adaptive mode, but every "
"question still carries a concrete easy|medium|hard difficulty — "
"'adaptive' is never a per-question value.\n\n"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Scope the fixed-difficulty rules to non-adaptive requests.

Adaptive mode says there is no requested difficulty and defaults to medium without history. The following rules still instruct the agent to shift from, or honor, a requested difficulty. This gives adaptive generation an undefined baseline.

Limit the rules at lines 125-136 to concrete requests, or define an explicit adaptive baseline for each rule.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/agents/quiz.py` around lines 116 - 124, Update the fixed-difficulty
rules immediately following the adaptive-mode instructions in the quiz prompt so
they apply only when the user provides a concrete difficulty request. Prevent
adaptive mode from inheriting rules that shift from or honor a requested
difficulty; preserve adaptive’s mastery-based selection and medium baseline when
no history exists.

Comment on lines +9 to +14
ALTER TABLE quiz_attempts
DROP CONSTRAINT IF EXISTS quiz_attempts_difficulty_check;

ALTER TABLE quiz_attempts
ADD CONSTRAINT quiz_attempts_difficulty_check
CHECK (difficulty IN ('easy', 'medium', 'hard', 'adaptive'));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bashset -euo pipefail
migrator="$(fd -a '^migrate\.py$' backend/db | head -n1)"test -n "$migrator"
ast-grep outline "$migrator" --items all
rg -n -C 5 'BEGIN|COMMIT|transaction|execute|migrations'"$migrator"

Repository: SaplingLearn/Sapling

Length of output: 7968


🏁 Script executed:

#!/bin/bashset -euo pipefail
migration="$(fd -a '20260812204809_quiz_attempts_adaptive_difficulty\.sql$' backend/db/migrations | head -n1)"test -n "$migration"
cat -n "$migration"printf'\nConstraint migration patterns:\n'
rg -n -C 4 'ADD CONSTRAINT|DROP CONSTRAINT|NOT VALID|VALIDATE CONSTRAINT' backend/db/migrations

Repository: SaplingLearn/Sapling

Length of output: 19373


🏁 Script executed:

#!/bin/bashset -euo pipefail
python3 - <<'PY'from pathlib import Pathimport rerunner = Path("backend/db/migrate.py").read_text()migration = Path("backend/db/migrations/20260812204809_quiz_attempts_adaptive_difficulty.sql").read_text()apply = re.search( r"def apply_migration\(.*?^\s*def run\(", runner, re.MULTILINE | re.DOTALL,)assert apply, "apply_migration block not found"block = apply.group(0)checks = { "executes migration SQL": "cur.execute(path.read_text" in block, "records ledger before commit": block.find("INSERT INTO schema_migrations") < block.find("conn.commit()"), "commits once per migration": block.count("conn.commit()") == 1, "current constraint is not NOT VALID": "NOT VALID" not in migration.upper(),}for name, result in checks.items(): print(f"{name}: {result}")assert all(checks.values())PY

Repository: SaplingLearn/Sapling

Length of output: 297


🌐 Web query:

PostgreSQL documentation ALTER TABLE ADD CHECK NOT VALID lock level VALIDATE CONSTRAINT

💡 Result:

In PostgreSQL, the VALIDATE CONSTRAINT command acquires a SHARE UPDATE EXCLUSIVE lock on the table being altered [1][2]. This lock level is specifically designed to allow concurrent reads, updates, inserts, and deletes, as the validation process only needs to check pre-existing rows [1][2]. Because new rows inserted or updated after the constraint was initially added (using NOT VALID) are already being enforced by the constraint, they do not need to be scanned during the validation phase [1][2]. If the constraint being validated is a foreign key, an additional ROW SHARE lock is required on the referenced table [1][2]. When using ALTER TABLE ADD CONSTRAINT with the NOT VALID option, the command itself requires a SHARE ROW EXCLUSIVE lock (for check constraints) [3][4], which is less restrictive than the ACCESS EXCLUSIVE lock required for standard constraint addition without NOT VALID [1][2]. This allows the initial constraint definition to proceed without blocking concurrent data modifications [1][2].

Citations:


Avoid full-table blocking validation in this migration.

backend/db/migrate.py commits each migration after executing its SQL and ledger insert. The current ADD CONSTRAINT therefore holds an ACCESS EXCLUSIVE lock on quiz_attempts through the validation scan. Add the replacement constraint as NOT VALID, then validate it in a later timestamped migration with VALIDATE CONSTRAINT.

🧰 Tools
🪛 Squawk (2.61.0)

[warning] 13-14: By default new constraints require a table scan and block writes to the table while that scan occurs. Use NOT VALID with a later VALIDATE CONSTRAINT call.

(constraint-missing-not-valid)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/db/migrations/20260812204809_quiz_attempts_adaptive_difficulty.sql`
around lines 9 - 14, Update the quiz_attempts_difficulty_check replacement in
this migration to add the constraint as NOT VALID, avoiding validation during
the migration transaction. Create a subsequent timestamped migration that
validates quiz_attempts_difficulty_check with VALIDATE CONSTRAINT.

Source: Linters/SAST tools

Comment on lines +44 to +51
_STATUS_FALLBACK: dict[int, QuizErrorCode] = {
401: QuizErrorCode.QUIZ_NOT_AUTHORIZED,
403: QuizErrorCode.QUIZ_NOT_AUTHORIZED,
404: QuizErrorCode.QUIZ_ATTEMPT_NOT_FOUND,
409: QuizErrorCode.QUIZ_ATTEMPT_ALREADY_COMPLETED,
422: QuizErrorCode.QUIZ_VALIDATION_ERROR,
502: QuizErrorCode.QUIZ_GENERATION_FAILED,
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Map unlisted 4xx statuses to a client-error code.

A plain quiz-route HTTPException(status_code=400) resolves to QUIZ_INTERNAL_ERROR. This conflicts with the stable error-code contract because the response reports a client error as an internal failure.

Use QUIZ_VALIDATION_ERROR as the fallback for unlisted 4xx statuses. Keep QUIZ_INTERNAL_ERROR as the fallback for 5xx statuses.

Proposed fix
- resolved_code = code or _STATUS_FALLBACK.get(- status_code,- QuizErrorCode.QUIZ_INTERNAL_ERROR,- )+ resolved_code = code or _STATUS_FALLBACK.get(+ status_code,+ (+ QuizErrorCode.QUIZ_VALIDATION_ERROR+ if 400 <= status_code < 500+ else QuizErrorCode.QUIZ_INTERNAL_ERROR+ ),+ )

Also applies to: 87-90

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/services/quiz_errors.py` around lines 44 - 51, Update the
status-resolution logic using _STATUS_FALLBACK so unlisted 4xx statuses,
including 400, return QUIZ_VALIDATION_ERROR, while unlisted 5xx statuses
continue returning QUIZ_INTERNAL_ERROR. Preserve the explicit mappings for
listed statuses.

Comment on lines +14 to +19
from fastapi.testclient import TestClient

from main import app
from agents.quiz import Quiz, QuizQuestion

client = TestClient(app)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Use the shared backend fixtures.

This module creates its own global TestClient and patches route internals. Use the shared Supabase and Gemini fixtures from tests/conftest.py instead. This keeps backend test setup consistent and prevents fixture behavior from being bypassed.

As per coding guidelines, backend/tests/**/*.py must “use the shared Supabase and Gemini fixtures from tests/conftest.py.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/tests/test_quiz_preflight_a.py` around lines 14 - 19, Update the
tests in this module to remove the global TestClient setup and route-internal
patching, and use the shared Supabase and Gemini fixtures provided by
tests/conftest.py. Adjust affected test functions to receive and use those
fixtures while preserving their existing assertions and behavior.

Source: Coding guidelines

AndresL230and others added 2 commits August 12, 2026 17:31
… semantics, config-driven coverage
Review findings (xhigh, 15 defects):
- 422s on num_questions only get QUIZ_COUNT_OUT_OF_RANGE for actual
bounds violations; type errors stay QUIZ_VALIDATION_ERROR.
- _STATUS_FALLBACK narrowed to auth/validation; uncoded 404/405s get the
new generic QUIZ_HTTP_ERROR instead of impersonating domain states, and
a non-QuizErrorCode `code` attr can no longer crash the handler.
- The envelope-vs-legacy branch now lives once in quiz_errors.error_content;
main.py's three handlers all call it.
- The system prompt defines stored difficulty='adaptive' history rows
(judge by accuracy) so the quiz-history tool doesn't feed the stepping
rules an undefined token.
- The adaptive migration drops the old CHECK by introspection (name-robust)
and documents migrate-before-deploy ordering.
- bench_quiz_question_cap exits 1 when the baseline cap measured nothing.
- QUIZ_QUESTION_TYPES uses the schema token 'multiple_choice', not 'mcq'.
- _DIFFICULTY_RANK derived from CONCRETE_DIFFICULTIES.
- QuizPanel: humanizeError in both catches (the envelope's sentence now
actually reaches the student) and an "Adaptive · <resolved>" chip in the
active phase; vitest covers the config-driven select path both ways; a
promoted #540 journey pins config-mirroring selects + the adaptive wire
round trip in a real browser.
Skipped (documented): moving difficulty validation into a Pydantic
Literal — the 400 + QUIZ_DIFFICULTY_INVALID contract is published in #540
and changing it to a 422 buys no client anything today; revisit in #537.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…urney
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@AndresL230
AndresL230 merged commit 6081ec5 into mainAug 12, 2026
6 of 8 checks passed
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

quiz A (P0): accept 'adaptive' difficulty, single-source /api/quiz/config, stable error envelope

1 participant

@AndresL230
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat(quiz): adaptive difficulty, /api/quiz/config, stable error envelope (#540) by AndresL230 · Pull Request #547 · SaplingLearn/Sapling · GitHub
Skip to content

feat(quiz): adaptive difficulty, /api/quiz/config, stable error envelope (#540) - #547

Merged
AndresL230 merged 3 commits into
mainfrom
feat/540-quiz-preflight-a
Aug 12, 2026
Merged

feat(quiz): adaptive difficulty, /api/quiz/config, stable error envelope (#540)#547
AndresL230 merged 3 commits into
mainfrom
feat/540-quiz-preflight-a

Conversation

@AndresL230

@AndresL230AndresL230 commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator

Closes#540. Workstream A of the pre-revamp quiz repair batch (epic #537) — the P0 that ships standalone.

What

A1 — adaptive is a real difficulty. The route accepts it (REQUESTED_DIFFICULTIES), the agent picks the per-question mix under new ADAPTIVE MODE prompt rules (no ±1 clamp; every question still emits a concrete easy|medium|hard), and the attempt row stores the requested value — quiz_attempts_difficulty_check extended by migration 20260812204809. The response now carries requested_difficulty + resolved_difficulty (mode of per-question difficulties, ties break harder) so the client can tell the student what was actually chosen. Both fields are additive; the old client ignores them.

A2 — one source of truth for selector options.GET /api/quiz/config serves num_questions {min,max,options}, difficulties, question_types from services/quiz_config.py; GenerateQuizBody reads the same constants (pinned by a sync test). QuizPanel builds its selects from the endpoint with a static fallback mirroring it — the dead "15 questions" option is gone.

Cap decision (measured, not guessed):scripts/bench_quiz_question_cap.py, 2026-08-12, gemini-2.5-flash-lite:

  • cap 10 — serves fine: 4.6s/5.6s, ~260 in / ~1500 out tokens, 10/10 delivered
  • cap 15 — rejected before generation: HTTP 400 INVALID_ARGUMENT "schema produces a constraint that has too many states for serving"
  • cap 20 — same rejection

10 is a hard ceiling for a single structured call on the quiz model tier, not a preference. Raising it = model-tier change or batched generation = #537 revamp scope.

A3 — stable error envelope. Every quiz-route 4xx/5xx returns { error: { code, message, detail?, request_id } }; codes live in one enum (services/quiz_errors.py::QuizErrorCode); scoped strictly to /api/quiz/* via the main.py handlers, so no other route changes shape.

Wire-contract notes (for the current client)

  • Generate response: additive fields only (requested_difficulty, resolved_difficulty).
  • Error payloads on quiz routes: legacy top-level detail and request_id keys are kept alongside the new error object — frontend/src/lib/api.ts reads data?.detail and keeps working unchanged.
  • QuizPanel.tsx selector lists are now config-driven; defaults (5 / medium) unchanged, so the test(e2e): journey — quiz answer → mastery update (UI + DB) #393 journey is untouched.

Verification

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added adaptive quiz difficulty, selecting easy, medium, or hard per question based on performance history.
    • Added server-driven quiz configuration for question counts, difficulties, and question types.
    • Quiz generation now reports requested and resolved difficulty.
    • Added structured quiz error responses with stable error codes and details.
  • Bug Fixes

    • Improved validation for question counts, difficulty, concepts, and quiz submissions.
    • Added fallback quiz options when configuration cannot be loaded.

…ope (#540)
Workstream A of the pre-revamp quiz repair batch (epic #537):
A1 — 'adaptive' is a real difficulty. The route accepts it, the agent
picks the per-question mix (ADAPTIVE MODE prompt rules), the attempt row
records the request (CHECK extended by migration), and the response
reports requested_difficulty + resolved_difficulty (mode, ties break
harder) so the client can say what was actually chosen.
A2 — GET /api/quiz/config is the single source of truth for selector
values; the Pydantic model reads the same constants. Cap decision is
measured, not guessed: scripts/bench_quiz_question_cap.py shows 15- and
20-question schemas are rejected outright by gemini-2.5-flash-lite
("too many states for serving"), so 10 is a hard ceiling. QuizPanel now
builds its selects from the endpoint (static fallback mirrors it; the
dead "15 questions" option is gone).
A3 — every quiz-route 4xx/5xx returns {error: {code, message, detail?,
request_id}} with codes in services/quiz_errors.py::QuizErrorCode; the
legacy top-level detail key is kept so the current client still works.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@supabase

supabaseBot commented Aug 12, 2026

Copy link
Copy Markdown

This pull request has been ignored for the connected project ybgqdonkoqftwrmweuyv because there are no changes detected in supabase directory. You can change this behaviour in Project Integrations Settings ↗︎.


Preview Branches by Supabase.
Learn more about Supabase Branching ↗︎.

@coderabbitai

coderabbitaiBot commented Aug 12, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@AndresL230, you've reached your PR review limit, so we couldn't start this review.

Next review available in:19 minutes

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 12e52a35-4af8-4ba0-a17a-a7c1770ce8eb

📥 Commits

Reviewing files that changed from the base of the PR and between afc97df and 1bd3512.

📒 Files selected for processing (11)
  • backend/agents/quiz.py
  • backend/db/migrations/20260812204809_quiz_attempts_adaptive_difficulty.sql
  • backend/main.py
  • backend/routes/quiz.py
  • backend/scripts/bench_quiz_question_cap.py
  • backend/services/quiz_config.py
  • backend/services/quiz_errors.py
  • backend/tests/test_quiz_preflight_a.py
  • frontend/e2e/quiz.spec.ts
  • frontend/src/components/QuizPanel.test.tsx
  • frontend/src/components/QuizPanel.tsx
📝 Walkthrough

Walkthrough

The PR adds adaptive quiz difficulty, centralizes quiz configuration, updates question-count validation and persistence constraints, introduces structured quiz errors, and makes frontend selectors load backend-defined options with static fallbacks.

Changes

Adaptive quiz configuration

Layer / File(s)Summary
Centralized quiz configuration and persistence contracts
backend/services/quiz_config.py, backend/models/__init__.py, backend/db/migrations/..., backend/db/e2e_checks/quiz.py, frontend/src/lib/api.ts
Shared question bounds, difficulty values, question types, persistence constraints, and frontend response types now include adaptive configuration.
Adaptive generation and difficulty resolution
backend/agents/quiz.py, backend/routes/quiz.py, backend/scripts/bench_quiz_question_cap.py, backend/tests/test_quiz_preflight_a.py
Adaptive requests delegate per-question difficulty selection to the agent. Generation responses include requested and resolved difficulty. The benchmark tests question caps of 10, 15, and 20.
Quiz error envelopes and route handling
backend/services/quiz_errors.py, backend/main.py, backend/routes/quiz.py
Quiz errors use stable codes, machine-readable details, request IDs, and legacy detail fields. Non-quiz responses retain their existing format.
Configuration-driven quiz selectors
frontend/src/components/QuizPanel.tsx, frontend/src/components/QuizPanel.test.tsx
The panel loads selector options from the backend and uses static options when configuration loading fails. Adaptive difficulty is available in the selectors.

Estimated code review effort: 4 (Complex) | ~45 minutes

Possibly related issues

Possibly related PRs

Sequence Diagram(s)

sequenceDiagram
participant QuizPanel
participant QuizRoute
participant QuizAgent
participant Database
QuizPanel->>QuizRoute: Request adaptive quiz generation
QuizRoute->>Database: Read mastery and recent attempts
QuizRoute->>QuizAgent: Generate questions with adaptive instructions
QuizAgent-->>QuizRoute: Return concrete question difficulties
QuizRoute->>Database: Store quiz attempt
QuizRoute-->>QuizPanel: Return requested and resolved difficulty
Loading

Suggested reviewers:darkest-teddy

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 21.74% which is insufficient. The required threshold is 80.00%.Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check nameStatusExplanation
Title check✅ PassedThe title clearly summarizes the primary changes: adaptive difficulty, the quiz configuration endpoint, and stable error envelopes.
Description check✅ PassedThe description covers the implementation, related issues, testing, compatibility notes, benchmark results, and verification details.
Linked Issues check✅ PassedThe changes address #540 objectives for adaptive difficulty, centralized configuration, measured caps, coded quiz errors, persistence, and real-database testing.
Out of Scope Changes check✅ PassedThe migration, benchmark, tests, frontend updates, documentation, and error handling are all directly related to the linked issue objectives.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/540-quiz-preflight-a

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Aug 12, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-staging1bd3512Commit Preview URL

Branch Preview URL
Aug 12 2026, 09:41 PM

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
backend/db/e2e_checks/quiz.py (1)

35-44: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Exercise the adaptive persistence path.

Line 42 still sends "easy". This E2E flow does not verify that a successful adaptive request stores "adaptive" in quiz_attempts.difficulty. Send an adaptive request and, when generation succeeds, assert the created attempt row has difficulty == "adaptive" before submit.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/db/e2e_checks/quiz.py` around lines 35 - 44, Update the generate
request in the E2E flow to use difficulty "adaptive" instead of "easy". After
successful generation, query the created quiz_attempts row and assert its
difficulty is "adaptive" before submitting the quiz.
🧹 Nitpick comments (1)
frontend/src/components/QuizPanel.tsx (1)

129-137: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Reconcile selected values with loaded quiz configuration.

If the server removes "5" or "medium", the selectors render no selected option but start() still sends those stale values. This defeats the server-defined selector contract.

  • frontend/src/components/QuizPanel.tsx#L129-L137: after accepting a non-empty config payload, use functional state setters to retain a selected value only when it remains allowed; otherwise select the first allowed value.
  • frontend/src/components/QuizPanel.test.tsx#L45-L47: add a successful config mock that excludes "5" and "medium", then assert quiz generation uses the reconciled values.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@frontend/src/components/QuizPanel.tsx` around lines 129 - 137, The quiz
configuration load must reconcile selected count and difficulty values with the
server’s allowed options. In frontend/src/components/QuizPanel.tsx lines
129-137, after accepting a non-empty payload, use functional setters to preserve
each current selection only when allowed, otherwise choose the first allowed
value. In frontend/src/components/QuizPanel.test.tsx lines 45-47, add a
successful config mock excluding "5" and "medium", then assert quiz generation
uses the reconciled selections.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@backend/agents/quiz.py`:
- Around line 116-124: Update the fixed-difficulty rules immediately following
the adaptive-mode instructions in the quiz prompt so they apply only when the
user provides a concrete difficulty request. Prevent adaptive mode from
inheriting rules that shift from or honor a requested difficulty; preserve
adaptive’s mastery-based selection and medium baseline when no history exists.
In `@backend/db/migrations/20260812204809_quiz_attempts_adaptive_difficulty.sql`:
- Around line 9-14: Update the quiz_attempts_difficulty_check replacement in
this migration to add the constraint as NOT VALID, avoiding validation during
the migration transaction. Create a subsequent timestamped migration that
validates quiz_attempts_difficulty_check with VALIDATE CONSTRAINT.
In `@backend/services/quiz_errors.py`:
- Around line 44-51: Update the status-resolution logic using _STATUS_FALLBACK
so unlisted 4xx statuses, including 400, return QUIZ_VALIDATION_ERROR, while
unlisted 5xx statuses continue returning QUIZ_INTERNAL_ERROR. Preserve the
explicit mappings for listed statuses.
In `@backend/tests/test_quiz_preflight_a.py`:
- Around line 14-19: Update the tests in this module to remove the global
TestClient setup and route-internal patching, and use the shared Supabase and
Gemini fixtures provided by tests/conftest.py. Adjust affected test functions to
receive and use those fixtures while preserving their existing assertions and
behavior.
---
Outside diff comments:
In `@backend/db/e2e_checks/quiz.py`:
- Around line 35-44: Update the generate request in the E2E flow to use
difficulty "adaptive" instead of "easy". After successful generation, query the
created quiz_attempts row and assert its difficulty is "adaptive" before
submitting the quiz.
---
Nitpick comments:
In `@frontend/src/components/QuizPanel.tsx`:
- Around line 129-137: The quiz configuration load must reconcile selected count
and difficulty values with the server’s allowed options. In
frontend/src/components/QuizPanel.tsx lines 129-137, after accepting a non-empty
payload, use functional setters to preserve each current selection only when
allowed, otherwise choose the first allowed value. In
frontend/src/components/QuizPanel.test.tsx lines 45-47, add a successful config
mock excluding "5" and "medium", then assert quiz generation uses the reconciled
selections.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: ee118b9c-e231-4709-a0b2-ba45685d6e26

📥 Commits

Reviewing files that changed from the base of the PR and between 7681c48 and afc97df.

📒 Files selected for processing (13)
  • backend/agents/quiz.py
  • backend/db/e2e_checks/quiz.py
  • backend/db/migrations/20260812204809_quiz_attempts_adaptive_difficulty.sql
  • backend/main.py
  • backend/models/__init__.py
  • backend/routes/quiz.py
  • backend/scripts/bench_quiz_question_cap.py
  • backend/services/quiz_config.py
  • backend/services/quiz_errors.py
  • backend/tests/test_quiz_preflight_a.py
  • frontend/src/components/QuizPanel.test.tsx
  • frontend/src/components/QuizPanel.tsx
  • frontend/src/lib/api.ts

Comment threadbackend/agents/quiz.py Outdated
Comment on lines +116 to +124
"ADAPTIVE MODE (#540): when the user message says the quiz is in "
"adaptive mode, there is no user-requested difficulty — you choose "
"each question's difficulty yourself. Base the mix on mastery and "
"`recent_attempts.accuracy`: struggling/low-accuracy concepts get "
"easy-leaning questions, strong/high-accuracy ones get hard-leaning "
"questions; with no history at all, center the mix on medium. The "
"±1-step limits below do not apply in adaptive mode, but every "
"question still carries a concrete easy|medium|hard difficulty — "
"'adaptive' is never a per-question value.\n\n"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Scope the fixed-difficulty rules to non-adaptive requests.

Adaptive mode says there is no requested difficulty and defaults to medium without history. The following rules still instruct the agent to shift from, or honor, a requested difficulty. This gives adaptive generation an undefined baseline.

Limit the rules at lines 125-136 to concrete requests, or define an explicit adaptive baseline for each rule.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/agents/quiz.py` around lines 116 - 124, Update the fixed-difficulty
rules immediately following the adaptive-mode instructions in the quiz prompt so
they apply only when the user provides a concrete difficulty request. Prevent
adaptive mode from inheriting rules that shift from or honor a requested
difficulty; preserve adaptive’s mastery-based selection and medium baseline when
no history exists.

Comment on lines +9 to +14
ALTER TABLE quiz_attempts
DROP CONSTRAINT IF EXISTS quiz_attempts_difficulty_check;

ALTER TABLE quiz_attempts
ADD CONSTRAINT quiz_attempts_difficulty_check
CHECK (difficulty IN ('easy', 'medium', 'hard', 'adaptive'));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bashset -euo pipefail
migrator="$(fd -a '^migrate\.py$' backend/db | head -n1)"test -n "$migrator"
ast-grep outline "$migrator" --items all
rg -n -C 5 'BEGIN|COMMIT|transaction|execute|migrations'"$migrator"

Repository: SaplingLearn/Sapling

Length of output: 7968


🏁 Script executed:

#!/bin/bashset -euo pipefail
migration="$(fd -a '20260812204809_quiz_attempts_adaptive_difficulty\.sql$' backend/db/migrations | head -n1)"test -n "$migration"
cat -n "$migration"printf'\nConstraint migration patterns:\n'
rg -n -C 4 'ADD CONSTRAINT|DROP CONSTRAINT|NOT VALID|VALIDATE CONSTRAINT' backend/db/migrations

Repository: SaplingLearn/Sapling

Length of output: 19373


🏁 Script executed:

#!/bin/bashset -euo pipefail
python3 - <<'PY'from pathlib import Pathimport rerunner = Path("backend/db/migrate.py").read_text()migration = Path("backend/db/migrations/20260812204809_quiz_attempts_adaptive_difficulty.sql").read_text()apply = re.search( r"def apply_migration\(.*?^\s*def run\(", runner, re.MULTILINE | re.DOTALL,)assert apply, "apply_migration block not found"block = apply.group(0)checks = { "executes migration SQL": "cur.execute(path.read_text" in block, "records ledger before commit": block.find("INSERT INTO schema_migrations") < block.find("conn.commit()"), "commits once per migration": block.count("conn.commit()") == 1, "current constraint is not NOT VALID": "NOT VALID" not in migration.upper(),}for name, result in checks.items(): print(f"{name}: {result}")assert all(checks.values())PY

Repository: SaplingLearn/Sapling

Length of output: 297


🌐 Web query:

PostgreSQL documentation ALTER TABLE ADD CHECK NOT VALID lock level VALIDATE CONSTRAINT

💡 Result:

In PostgreSQL, the VALIDATE CONSTRAINT command acquires a SHARE UPDATE EXCLUSIVE lock on the table being altered [1][2]. This lock level is specifically designed to allow concurrent reads, updates, inserts, and deletes, as the validation process only needs to check pre-existing rows [1][2]. Because new rows inserted or updated after the constraint was initially added (using NOT VALID) are already being enforced by the constraint, they do not need to be scanned during the validation phase [1][2]. If the constraint being validated is a foreign key, an additional ROW SHARE lock is required on the referenced table [1][2]. When using ALTER TABLE ADD CONSTRAINT with the NOT VALID option, the command itself requires a SHARE ROW EXCLUSIVE lock (for check constraints) [3][4], which is less restrictive than the ACCESS EXCLUSIVE lock required for standard constraint addition without NOT VALID [1][2]. This allows the initial constraint definition to proceed without blocking concurrent data modifications [1][2].

Citations:


Avoid full-table blocking validation in this migration.

backend/db/migrate.py commits each migration after executing its SQL and ledger insert. The current ADD CONSTRAINT therefore holds an ACCESS EXCLUSIVE lock on quiz_attempts through the validation scan. Add the replacement constraint as NOT VALID, then validate it in a later timestamped migration with VALIDATE CONSTRAINT.

🧰 Tools
🪛 Squawk (2.61.0)

[warning] 13-14: By default new constraints require a table scan and block writes to the table while that scan occurs. Use NOT VALID with a later VALIDATE CONSTRAINT call.

(constraint-missing-not-valid)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/db/migrations/20260812204809_quiz_attempts_adaptive_difficulty.sql`
around lines 9 - 14, Update the quiz_attempts_difficulty_check replacement in
this migration to add the constraint as NOT VALID, avoiding validation during
the migration transaction. Create a subsequent timestamped migration that
validates quiz_attempts_difficulty_check with VALIDATE CONSTRAINT.

Source: Linters/SAST tools

Comment on lines +44 to +51
_STATUS_FALLBACK: dict[int, QuizErrorCode] = {
401: QuizErrorCode.QUIZ_NOT_AUTHORIZED,
403: QuizErrorCode.QUIZ_NOT_AUTHORIZED,
404: QuizErrorCode.QUIZ_ATTEMPT_NOT_FOUND,
409: QuizErrorCode.QUIZ_ATTEMPT_ALREADY_COMPLETED,
422: QuizErrorCode.QUIZ_VALIDATION_ERROR,
502: QuizErrorCode.QUIZ_GENERATION_FAILED,
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Map unlisted 4xx statuses to a client-error code.

A plain quiz-route HTTPException(status_code=400) resolves to QUIZ_INTERNAL_ERROR. This conflicts with the stable error-code contract because the response reports a client error as an internal failure.

Use QUIZ_VALIDATION_ERROR as the fallback for unlisted 4xx statuses. Keep QUIZ_INTERNAL_ERROR as the fallback for 5xx statuses.

Proposed fix
- resolved_code = code or _STATUS_FALLBACK.get(- status_code,- QuizErrorCode.QUIZ_INTERNAL_ERROR,- )+ resolved_code = code or _STATUS_FALLBACK.get(+ status_code,+ (+ QuizErrorCode.QUIZ_VALIDATION_ERROR+ if 400 <= status_code < 500+ else QuizErrorCode.QUIZ_INTERNAL_ERROR+ ),+ )

Also applies to: 87-90

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/services/quiz_errors.py` around lines 44 - 51, Update the
status-resolution logic using _STATUS_FALLBACK so unlisted 4xx statuses,
including 400, return QUIZ_VALIDATION_ERROR, while unlisted 5xx statuses
continue returning QUIZ_INTERNAL_ERROR. Preserve the explicit mappings for
listed statuses.

Comment on lines +14 to +19
from fastapi.testclient import TestClient

from main import app
from agents.quiz import Quiz, QuizQuestion

client = TestClient(app)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Use the shared backend fixtures.

This module creates its own global TestClient and patches route internals. Use the shared Supabase and Gemini fixtures from tests/conftest.py instead. This keeps backend test setup consistent and prevents fixture behavior from being bypassed.

As per coding guidelines, backend/tests/**/*.py must “use the shared Supabase and Gemini fixtures from tests/conftest.py.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/tests/test_quiz_preflight_a.py` around lines 14 - 19, Update the
tests in this module to remove the global TestClient setup and route-internal
patching, and use the shared Supabase and Gemini fixtures provided by
tests/conftest.py. Adjust affected test functions to receive and use those
fixtures while preserving their existing assertions and behavior.

Source: Coding guidelines

AndresL230and others added 2 commits August 12, 2026 17:31
… semantics, config-driven coverage
Review findings (xhigh, 15 defects):
- 422s on num_questions only get QUIZ_COUNT_OUT_OF_RANGE for actual
bounds violations; type errors stay QUIZ_VALIDATION_ERROR.
- _STATUS_FALLBACK narrowed to auth/validation; uncoded 404/405s get the
new generic QUIZ_HTTP_ERROR instead of impersonating domain states, and
a non-QuizErrorCode `code` attr can no longer crash the handler.
- The envelope-vs-legacy branch now lives once in quiz_errors.error_content;
main.py's three handlers all call it.
- The system prompt defines stored difficulty='adaptive' history rows
(judge by accuracy) so the quiz-history tool doesn't feed the stepping
rules an undefined token.
- The adaptive migration drops the old CHECK by introspection (name-robust)
and documents migrate-before-deploy ordering.
- bench_quiz_question_cap exits 1 when the baseline cap measured nothing.
- QUIZ_QUESTION_TYPES uses the schema token 'multiple_choice', not 'mcq'.
- _DIFFICULTY_RANK derived from CONCRETE_DIFFICULTIES.
- QuizPanel: humanizeError in both catches (the envelope's sentence now
actually reaches the student) and an "Adaptive · <resolved>" chip in the
active phase; vitest covers the config-driven select path both ways; a
promoted #540 journey pins config-mirroring selects + the adaptive wire
round trip in a real browser.
Skipped (documented): moving difficulty validation into a Pydantic
Literal — the 400 + QUIZ_DIFFICULTY_INVALID contract is published in #540
and changing it to a 422 buys no client anything today; revisit in #537.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…urney
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@AndresL230
AndresL230 merged commit 6081ec5 into mainAug 12, 2026
6 of 8 checks passed
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

quiz A (P0): accept 'adaptive' difficulty, single-source /api/quiz/config, stable error envelope

1 participant

@AndresL230
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat(quiz): adaptive difficulty, /api/quiz/config, stable error envelope (#540) by AndresL230 · Pull Request #547 · SaplingLearn/Sapling · GitHub
Skip to content

feat(quiz): adaptive difficulty, /api/quiz/config, stable error envelope (#540) - #547

Merged
AndresL230 merged 3 commits into
mainfrom
feat/540-quiz-preflight-a
Aug 12, 2026
Merged

feat(quiz): adaptive difficulty, /api/quiz/config, stable error envelope (#540)#547
AndresL230 merged 3 commits into
mainfrom
feat/540-quiz-preflight-a

Conversation

@AndresL230

@AndresL230AndresL230 commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator

Closes#540. Workstream A of the pre-revamp quiz repair batch (epic #537) — the P0 that ships standalone.

What

A1 — adaptive is a real difficulty. The route accepts it (REQUESTED_DIFFICULTIES), the agent picks the per-question mix under new ADAPTIVE MODE prompt rules (no ±1 clamp; every question still emits a concrete easy|medium|hard), and the attempt row stores the requested value — quiz_attempts_difficulty_check extended by migration 20260812204809. The response now carries requested_difficulty + resolved_difficulty (mode of per-question difficulties, ties break harder) so the client can tell the student what was actually chosen. Both fields are additive; the old client ignores them.

A2 — one source of truth for selector options.GET /api/quiz/config serves num_questions {min,max,options}, difficulties, question_types from services/quiz_config.py; GenerateQuizBody reads the same constants (pinned by a sync test). QuizPanel builds its selects from the endpoint with a static fallback mirroring it — the dead "15 questions" option is gone.

Cap decision (measured, not guessed):scripts/bench_quiz_question_cap.py, 2026-08-12, gemini-2.5-flash-lite:

  • cap 10 — serves fine: 4.6s/5.6s, ~260 in / ~1500 out tokens, 10/10 delivered
  • cap 15 — rejected before generation: HTTP 400 INVALID_ARGUMENT "schema produces a constraint that has too many states for serving"
  • cap 20 — same rejection

10 is a hard ceiling for a single structured call on the quiz model tier, not a preference. Raising it = model-tier change or batched generation = #537 revamp scope.

A3 — stable error envelope. Every quiz-route 4xx/5xx returns { error: { code, message, detail?, request_id } }; codes live in one enum (services/quiz_errors.py::QuizErrorCode); scoped strictly to /api/quiz/* via the main.py handlers, so no other route changes shape.

Wire-contract notes (for the current client)

  • Generate response: additive fields only (requested_difficulty, resolved_difficulty).
  • Error payloads on quiz routes: legacy top-level detail and request_id keys are kept alongside the new error object — frontend/src/lib/api.ts reads data?.detail and keeps working unchanged.
  • QuizPanel.tsx selector lists are now config-driven; defaults (5 / medium) unchanged, so the test(e2e): journey — quiz answer → mastery update (UI + DB) #393 journey is untouched.

Verification

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added adaptive quiz difficulty, selecting easy, medium, or hard per question based on performance history.
    • Added server-driven quiz configuration for question counts, difficulties, and question types.
    • Quiz generation now reports requested and resolved difficulty.
    • Added structured quiz error responses with stable error codes and details.
  • Bug Fixes

    • Improved validation for question counts, difficulty, concepts, and quiz submissions.
    • Added fallback quiz options when configuration cannot be loaded.

…ope (#540)
Workstream A of the pre-revamp quiz repair batch (epic #537):
A1 — 'adaptive' is a real difficulty. The route accepts it, the agent
picks the per-question mix (ADAPTIVE MODE prompt rules), the attempt row
records the request (CHECK extended by migration), and the response
reports requested_difficulty + resolved_difficulty (mode, ties break
harder) so the client can say what was actually chosen.
A2 — GET /api/quiz/config is the single source of truth for selector
values; the Pydantic model reads the same constants. Cap decision is
measured, not guessed: scripts/bench_quiz_question_cap.py shows 15- and
20-question schemas are rejected outright by gemini-2.5-flash-lite
("too many states for serving"), so 10 is a hard ceiling. QuizPanel now
builds its selects from the endpoint (static fallback mirrors it; the
dead "15 questions" option is gone).
A3 — every quiz-route 4xx/5xx returns {error: {code, message, detail?,
request_id}} with codes in services/quiz_errors.py::QuizErrorCode; the
legacy top-level detail key is kept so the current client still works.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@supabase

supabaseBot commented Aug 12, 2026

Copy link
Copy Markdown

This pull request has been ignored for the connected project ybgqdonkoqftwrmweuyv because there are no changes detected in supabase directory. You can change this behaviour in Project Integrations Settings ↗︎.


Preview Branches by Supabase.
Learn more about Supabase Branching ↗︎.

@coderabbitai

coderabbitaiBot commented Aug 12, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@AndresL230, you've reached your PR review limit, so we couldn't start this review.

Next review available in:19 minutes

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 12e52a35-4af8-4ba0-a17a-a7c1770ce8eb

📥 Commits

Reviewing files that changed from the base of the PR and between afc97df and 1bd3512.

📒 Files selected for processing (11)
  • backend/agents/quiz.py
  • backend/db/migrations/20260812204809_quiz_attempts_adaptive_difficulty.sql
  • backend/main.py
  • backend/routes/quiz.py
  • backend/scripts/bench_quiz_question_cap.py
  • backend/services/quiz_config.py
  • backend/services/quiz_errors.py
  • backend/tests/test_quiz_preflight_a.py
  • frontend/e2e/quiz.spec.ts
  • frontend/src/components/QuizPanel.test.tsx
  • frontend/src/components/QuizPanel.tsx
📝 Walkthrough

Walkthrough

The PR adds adaptive quiz difficulty, centralizes quiz configuration, updates question-count validation and persistence constraints, introduces structured quiz errors, and makes frontend selectors load backend-defined options with static fallbacks.

Changes

Adaptive quiz configuration

Layer / File(s)Summary
Centralized quiz configuration and persistence contracts
backend/services/quiz_config.py, backend/models/__init__.py, backend/db/migrations/..., backend/db/e2e_checks/quiz.py, frontend/src/lib/api.ts
Shared question bounds, difficulty values, question types, persistence constraints, and frontend response types now include adaptive configuration.
Adaptive generation and difficulty resolution
backend/agents/quiz.py, backend/routes/quiz.py, backend/scripts/bench_quiz_question_cap.py, backend/tests/test_quiz_preflight_a.py
Adaptive requests delegate per-question difficulty selection to the agent. Generation responses include requested and resolved difficulty. The benchmark tests question caps of 10, 15, and 20.
Quiz error envelopes and route handling
backend/services/quiz_errors.py, backend/main.py, backend/routes/quiz.py
Quiz errors use stable codes, machine-readable details, request IDs, and legacy detail fields. Non-quiz responses retain their existing format.
Configuration-driven quiz selectors
frontend/src/components/QuizPanel.tsx, frontend/src/components/QuizPanel.test.tsx
The panel loads selector options from the backend and uses static options when configuration loading fails. Adaptive difficulty is available in the selectors.

Estimated code review effort: 4 (Complex) | ~45 minutes

Possibly related issues

Possibly related PRs

Sequence Diagram(s)

sequenceDiagram
participant QuizPanel
participant QuizRoute
participant QuizAgent
participant Database
QuizPanel->>QuizRoute: Request adaptive quiz generation
QuizRoute->>Database: Read mastery and recent attempts
QuizRoute->>QuizAgent: Generate questions with adaptive instructions
QuizAgent-->>QuizRoute: Return concrete question difficulties
QuizRoute->>Database: Store quiz attempt
QuizRoute-->>QuizPanel: Return requested and resolved difficulty
Loading

Suggested reviewers:darkest-teddy

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 21.74% which is insufficient. The required threshold is 80.00%.Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check nameStatusExplanation
Title check✅ PassedThe title clearly summarizes the primary changes: adaptive difficulty, the quiz configuration endpoint, and stable error envelopes.
Description check✅ PassedThe description covers the implementation, related issues, testing, compatibility notes, benchmark results, and verification details.
Linked Issues check✅ PassedThe changes address #540 objectives for adaptive difficulty, centralized configuration, measured caps, coded quiz errors, persistence, and real-database testing.
Out of Scope Changes check✅ PassedThe migration, benchmark, tests, frontend updates, documentation, and error handling are all directly related to the linked issue objectives.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/540-quiz-preflight-a

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Aug 12, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-staging1bd3512Commit Preview URL

Branch Preview URL
Aug 12 2026, 09:41 PM

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
backend/db/e2e_checks/quiz.py (1)

35-44: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Exercise the adaptive persistence path.

Line 42 still sends "easy". This E2E flow does not verify that a successful adaptive request stores "adaptive" in quiz_attempts.difficulty. Send an adaptive request and, when generation succeeds, assert the created attempt row has difficulty == "adaptive" before submit.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/db/e2e_checks/quiz.py` around lines 35 - 44, Update the generate
request in the E2E flow to use difficulty "adaptive" instead of "easy". After
successful generation, query the created quiz_attempts row and assert its
difficulty is "adaptive" before submitting the quiz.
🧹 Nitpick comments (1)
frontend/src/components/QuizPanel.tsx (1)

129-137: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Reconcile selected values with loaded quiz configuration.

If the server removes "5" or "medium", the selectors render no selected option but start() still sends those stale values. This defeats the server-defined selector contract.

  • frontend/src/components/QuizPanel.tsx#L129-L137: after accepting a non-empty config payload, use functional state setters to retain a selected value only when it remains allowed; otherwise select the first allowed value.
  • frontend/src/components/QuizPanel.test.tsx#L45-L47: add a successful config mock that excludes "5" and "medium", then assert quiz generation uses the reconciled values.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@frontend/src/components/QuizPanel.tsx` around lines 129 - 137, The quiz
configuration load must reconcile selected count and difficulty values with the
server’s allowed options. In frontend/src/components/QuizPanel.tsx lines
129-137, after accepting a non-empty payload, use functional setters to preserve
each current selection only when allowed, otherwise choose the first allowed
value. In frontend/src/components/QuizPanel.test.tsx lines 45-47, add a
successful config mock excluding "5" and "medium", then assert quiz generation
uses the reconciled selections.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@backend/agents/quiz.py`:
- Around line 116-124: Update the fixed-difficulty rules immediately following
the adaptive-mode instructions in the quiz prompt so they apply only when the
user provides a concrete difficulty request. Prevent adaptive mode from
inheriting rules that shift from or honor a requested difficulty; preserve
adaptive’s mastery-based selection and medium baseline when no history exists.
In `@backend/db/migrations/20260812204809_quiz_attempts_adaptive_difficulty.sql`:
- Around line 9-14: Update the quiz_attempts_difficulty_check replacement in
this migration to add the constraint as NOT VALID, avoiding validation during
the migration transaction. Create a subsequent timestamped migration that
validates quiz_attempts_difficulty_check with VALIDATE CONSTRAINT.
In `@backend/services/quiz_errors.py`:
- Around line 44-51: Update the status-resolution logic using _STATUS_FALLBACK
so unlisted 4xx statuses, including 400, return QUIZ_VALIDATION_ERROR, while
unlisted 5xx statuses continue returning QUIZ_INTERNAL_ERROR. Preserve the
explicit mappings for listed statuses.
In `@backend/tests/test_quiz_preflight_a.py`:
- Around line 14-19: Update the tests in this module to remove the global
TestClient setup and route-internal patching, and use the shared Supabase and
Gemini fixtures provided by tests/conftest.py. Adjust affected test functions to
receive and use those fixtures while preserving their existing assertions and
behavior.
---
Outside diff comments:
In `@backend/db/e2e_checks/quiz.py`:
- Around line 35-44: Update the generate request in the E2E flow to use
difficulty "adaptive" instead of "easy". After successful generation, query the
created quiz_attempts row and assert its difficulty is "adaptive" before
submitting the quiz.
---
Nitpick comments:
In `@frontend/src/components/QuizPanel.tsx`:
- Around line 129-137: The quiz configuration load must reconcile selected count
and difficulty values with the server’s allowed options. In
frontend/src/components/QuizPanel.tsx lines 129-137, after accepting a non-empty
payload, use functional setters to preserve each current selection only when
allowed, otherwise choose the first allowed value. In
frontend/src/components/QuizPanel.test.tsx lines 45-47, add a successful config
mock excluding "5" and "medium", then assert quiz generation uses the reconciled
selections.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: ee118b9c-e231-4709-a0b2-ba45685d6e26

📥 Commits

Reviewing files that changed from the base of the PR and between 7681c48 and afc97df.

📒 Files selected for processing (13)
  • backend/agents/quiz.py
  • backend/db/e2e_checks/quiz.py
  • backend/db/migrations/20260812204809_quiz_attempts_adaptive_difficulty.sql
  • backend/main.py
  • backend/models/__init__.py
  • backend/routes/quiz.py
  • backend/scripts/bench_quiz_question_cap.py
  • backend/services/quiz_config.py
  • backend/services/quiz_errors.py
  • backend/tests/test_quiz_preflight_a.py
  • frontend/src/components/QuizPanel.test.tsx
  • frontend/src/components/QuizPanel.tsx
  • frontend/src/lib/api.ts

Comment threadbackend/agents/quiz.py Outdated
Comment on lines +116 to +124
"ADAPTIVE MODE (#540): when the user message says the quiz is in "
"adaptive mode, there is no user-requested difficulty — you choose "
"each question's difficulty yourself. Base the mix on mastery and "
"`recent_attempts.accuracy`: struggling/low-accuracy concepts get "
"easy-leaning questions, strong/high-accuracy ones get hard-leaning "
"questions; with no history at all, center the mix on medium. The "
"±1-step limits below do not apply in adaptive mode, but every "
"question still carries a concrete easy|medium|hard difficulty — "
"'adaptive' is never a per-question value.\n\n"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Scope the fixed-difficulty rules to non-adaptive requests.

Adaptive mode says there is no requested difficulty and defaults to medium without history. The following rules still instruct the agent to shift from, or honor, a requested difficulty. This gives adaptive generation an undefined baseline.

Limit the rules at lines 125-136 to concrete requests, or define an explicit adaptive baseline for each rule.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/agents/quiz.py` around lines 116 - 124, Update the fixed-difficulty
rules immediately following the adaptive-mode instructions in the quiz prompt so
they apply only when the user provides a concrete difficulty request. Prevent
adaptive mode from inheriting rules that shift from or honor a requested
difficulty; preserve adaptive’s mastery-based selection and medium baseline when
no history exists.

Comment on lines +9 to +14
ALTER TABLE quiz_attempts
DROP CONSTRAINT IF EXISTS quiz_attempts_difficulty_check;

ALTER TABLE quiz_attempts
ADD CONSTRAINT quiz_attempts_difficulty_check
CHECK (difficulty IN ('easy', 'medium', 'hard', 'adaptive'));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bashset -euo pipefail
migrator="$(fd -a '^migrate\.py$' backend/db | head -n1)"test -n "$migrator"
ast-grep outline "$migrator" --items all
rg -n -C 5 'BEGIN|COMMIT|transaction|execute|migrations'"$migrator"

Repository: SaplingLearn/Sapling

Length of output: 7968


🏁 Script executed:

#!/bin/bashset -euo pipefail
migration="$(fd -a '20260812204809_quiz_attempts_adaptive_difficulty\.sql$' backend/db/migrations | head -n1)"test -n "$migration"
cat -n "$migration"printf'\nConstraint migration patterns:\n'
rg -n -C 4 'ADD CONSTRAINT|DROP CONSTRAINT|NOT VALID|VALIDATE CONSTRAINT' backend/db/migrations

Repository: SaplingLearn/Sapling

Length of output: 19373


🏁 Script executed:

#!/bin/bashset -euo pipefail
python3 - <<'PY'from pathlib import Pathimport rerunner = Path("backend/db/migrate.py").read_text()migration = Path("backend/db/migrations/20260812204809_quiz_attempts_adaptive_difficulty.sql").read_text()apply = re.search( r"def apply_migration\(.*?^\s*def run\(", runner, re.MULTILINE | re.DOTALL,)assert apply, "apply_migration block not found"block = apply.group(0)checks = { "executes migration SQL": "cur.execute(path.read_text" in block, "records ledger before commit": block.find("INSERT INTO schema_migrations") < block.find("conn.commit()"), "commits once per migration": block.count("conn.commit()") == 1, "current constraint is not NOT VALID": "NOT VALID" not in migration.upper(),}for name, result in checks.items(): print(f"{name}: {result}")assert all(checks.values())PY

Repository: SaplingLearn/Sapling

Length of output: 297


🌐 Web query:

PostgreSQL documentation ALTER TABLE ADD CHECK NOT VALID lock level VALIDATE CONSTRAINT

💡 Result:

In PostgreSQL, the VALIDATE CONSTRAINT command acquires a SHARE UPDATE EXCLUSIVE lock on the table being altered [1][2]. This lock level is specifically designed to allow concurrent reads, updates, inserts, and deletes, as the validation process only needs to check pre-existing rows [1][2]. Because new rows inserted or updated after the constraint was initially added (using NOT VALID) are already being enforced by the constraint, they do not need to be scanned during the validation phase [1][2]. If the constraint being validated is a foreign key, an additional ROW SHARE lock is required on the referenced table [1][2]. When using ALTER TABLE ADD CONSTRAINT with the NOT VALID option, the command itself requires a SHARE ROW EXCLUSIVE lock (for check constraints) [3][4], which is less restrictive than the ACCESS EXCLUSIVE lock required for standard constraint addition without NOT VALID [1][2]. This allows the initial constraint definition to proceed without blocking concurrent data modifications [1][2].

Citations:


Avoid full-table blocking validation in this migration.

backend/db/migrate.py commits each migration after executing its SQL and ledger insert. The current ADD CONSTRAINT therefore holds an ACCESS EXCLUSIVE lock on quiz_attempts through the validation scan. Add the replacement constraint as NOT VALID, then validate it in a later timestamped migration with VALIDATE CONSTRAINT.

🧰 Tools
🪛 Squawk (2.61.0)

[warning] 13-14: By default new constraints require a table scan and block writes to the table while that scan occurs. Use NOT VALID with a later VALIDATE CONSTRAINT call.

(constraint-missing-not-valid)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/db/migrations/20260812204809_quiz_attempts_adaptive_difficulty.sql`
around lines 9 - 14, Update the quiz_attempts_difficulty_check replacement in
this migration to add the constraint as NOT VALID, avoiding validation during
the migration transaction. Create a subsequent timestamped migration that
validates quiz_attempts_difficulty_check with VALIDATE CONSTRAINT.

Source: Linters/SAST tools

Comment on lines +44 to +51
_STATUS_FALLBACK: dict[int, QuizErrorCode] = {
401: QuizErrorCode.QUIZ_NOT_AUTHORIZED,
403: QuizErrorCode.QUIZ_NOT_AUTHORIZED,
404: QuizErrorCode.QUIZ_ATTEMPT_NOT_FOUND,
409: QuizErrorCode.QUIZ_ATTEMPT_ALREADY_COMPLETED,
422: QuizErrorCode.QUIZ_VALIDATION_ERROR,
502: QuizErrorCode.QUIZ_GENERATION_FAILED,
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Map unlisted 4xx statuses to a client-error code.

A plain quiz-route HTTPException(status_code=400) resolves to QUIZ_INTERNAL_ERROR. This conflicts with the stable error-code contract because the response reports a client error as an internal failure.

Use QUIZ_VALIDATION_ERROR as the fallback for unlisted 4xx statuses. Keep QUIZ_INTERNAL_ERROR as the fallback for 5xx statuses.

Proposed fix
- resolved_code = code or _STATUS_FALLBACK.get(- status_code,- QuizErrorCode.QUIZ_INTERNAL_ERROR,- )+ resolved_code = code or _STATUS_FALLBACK.get(+ status_code,+ (+ QuizErrorCode.QUIZ_VALIDATION_ERROR+ if 400 <= status_code < 500+ else QuizErrorCode.QUIZ_INTERNAL_ERROR+ ),+ )

Also applies to: 87-90

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/services/quiz_errors.py` around lines 44 - 51, Update the
status-resolution logic using _STATUS_FALLBACK so unlisted 4xx statuses,
including 400, return QUIZ_VALIDATION_ERROR, while unlisted 5xx statuses
continue returning QUIZ_INTERNAL_ERROR. Preserve the explicit mappings for
listed statuses.

Comment on lines +14 to +19
from fastapi.testclient import TestClient

from main import app
from agents.quiz import Quiz, QuizQuestion

client = TestClient(app)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Use the shared backend fixtures.

This module creates its own global TestClient and patches route internals. Use the shared Supabase and Gemini fixtures from tests/conftest.py instead. This keeps backend test setup consistent and prevents fixture behavior from being bypassed.

As per coding guidelines, backend/tests/**/*.py must “use the shared Supabase and Gemini fixtures from tests/conftest.py.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/tests/test_quiz_preflight_a.py` around lines 14 - 19, Update the
tests in this module to remove the global TestClient setup and route-internal
patching, and use the shared Supabase and Gemini fixtures provided by
tests/conftest.py. Adjust affected test functions to receive and use those
fixtures while preserving their existing assertions and behavior.

Source: Coding guidelines

AndresL230and others added 2 commits August 12, 2026 17:31
… semantics, config-driven coverage
Review findings (xhigh, 15 defects):
- 422s on num_questions only get QUIZ_COUNT_OUT_OF_RANGE for actual
bounds violations; type errors stay QUIZ_VALIDATION_ERROR.
- _STATUS_FALLBACK narrowed to auth/validation; uncoded 404/405s get the
new generic QUIZ_HTTP_ERROR instead of impersonating domain states, and
a non-QuizErrorCode `code` attr can no longer crash the handler.
- The envelope-vs-legacy branch now lives once in quiz_errors.error_content;
main.py's three handlers all call it.
- The system prompt defines stored difficulty='adaptive' history rows
(judge by accuracy) so the quiz-history tool doesn't feed the stepping
rules an undefined token.
- The adaptive migration drops the old CHECK by introspection (name-robust)
and documents migrate-before-deploy ordering.
- bench_quiz_question_cap exits 1 when the baseline cap measured nothing.
- QUIZ_QUESTION_TYPES uses the schema token 'multiple_choice', not 'mcq'.
- _DIFFICULTY_RANK derived from CONCRETE_DIFFICULTIES.
- QuizPanel: humanizeError in both catches (the envelope's sentence now
actually reaches the student) and an "Adaptive · <resolved>" chip in the
active phase; vitest covers the config-driven select path both ways; a
promoted #540 journey pins config-mirroring selects + the adaptive wire
round trip in a real browser.
Skipped (documented): moving difficulty validation into a Pydantic
Literal — the 400 + QUIZ_DIFFICULTY_INVALID contract is published in #540
and changing it to a 422 buys no client anything today; revisit in #537.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…urney
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@AndresL230
AndresL230 merged commit 6081ec5 into mainAug 12, 2026
6 of 8 checks passed
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

quiz A (P0): accept 'adaptive' difficulty, single-source /api/quiz/config, stable error envelope

1 participant

@AndresL230
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' feat(quiz): adaptive difficulty, /api/quiz/config, stable error envelope (#540) by AndresL230 · Pull Request #547 · SaplingLearn/Sapling · GitHub
Skip to content

feat(quiz): adaptive difficulty, /api/quiz/config, stable error envelope (#540) - #547

Merged
AndresL230 merged 3 commits into
mainfrom
feat/540-quiz-preflight-a
Aug 12, 2026
Merged

feat(quiz): adaptive difficulty, /api/quiz/config, stable error envelope (#540)#547
AndresL230 merged 3 commits into
mainfrom
feat/540-quiz-preflight-a

Conversation

@AndresL230

@AndresL230AndresL230 commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator

Closes#540. Workstream A of the pre-revamp quiz repair batch (epic #537) — the P0 that ships standalone.

What

A1 — adaptive is a real difficulty. The route accepts it (REQUESTED_DIFFICULTIES), the agent picks the per-question mix under new ADAPTIVE MODE prompt rules (no ±1 clamp; every question still emits a concrete easy|medium|hard), and the attempt row stores the requested value — quiz_attempts_difficulty_check extended by migration 20260812204809. The response now carries requested_difficulty + resolved_difficulty (mode of per-question difficulties, ties break harder) so the client can tell the student what was actually chosen. Both fields are additive; the old client ignores them.

A2 — one source of truth for selector options.GET /api/quiz/config serves num_questions {min,max,options}, difficulties, question_types from services/quiz_config.py; GenerateQuizBody reads the same constants (pinned by a sync test). QuizPanel builds its selects from the endpoint with a static fallback mirroring it — the dead "15 questions" option is gone.

Cap decision (measured, not guessed):scripts/bench_quiz_question_cap.py, 2026-08-12, gemini-2.5-flash-lite:

  • cap 10 — serves fine: 4.6s/5.6s, ~260 in / ~1500 out tokens, 10/10 delivered
  • cap 15 — rejected before generation: HTTP 400 INVALID_ARGUMENT "schema produces a constraint that has too many states for serving"
  • cap 20 — same rejection

10 is a hard ceiling for a single structured call on the quiz model tier, not a preference. Raising it = model-tier change or batched generation = #537 revamp scope.

A3 — stable error envelope. Every quiz-route 4xx/5xx returns { error: { code, message, detail?, request_id } }; codes live in one enum (services/quiz_errors.py::QuizErrorCode); scoped strictly to /api/quiz/* via the main.py handlers, so no other route changes shape.

Wire-contract notes (for the current client)

  • Generate response: additive fields only (requested_difficulty, resolved_difficulty).
  • Error payloads on quiz routes: legacy top-level detail and request_id keys are kept alongside the new error object — frontend/src/lib/api.ts reads data?.detail and keeps working unchanged.
  • QuizPanel.tsx selector lists are now config-driven; defaults (5 / medium) unchanged, so the test(e2e): journey — quiz answer → mastery update (UI + DB) #393 journey is untouched.

Verification

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added adaptive quiz difficulty, selecting easy, medium, or hard per question based on performance history.
    • Added server-driven quiz configuration for question counts, difficulties, and question types.
    • Quiz generation now reports requested and resolved difficulty.
    • Added structured quiz error responses with stable error codes and details.
  • Bug Fixes

    • Improved validation for question counts, difficulty, concepts, and quiz submissions.
    • Added fallback quiz options when configuration cannot be loaded.

…ope (#540)
Workstream A of the pre-revamp quiz repair batch (epic #537):
A1 — 'adaptive' is a real difficulty. The route accepts it, the agent
picks the per-question mix (ADAPTIVE MODE prompt rules), the attempt row
records the request (CHECK extended by migration), and the response
reports requested_difficulty + resolved_difficulty (mode, ties break
harder) so the client can say what was actually chosen.
A2 — GET /api/quiz/config is the single source of truth for selector
values; the Pydantic model reads the same constants. Cap decision is
measured, not guessed: scripts/bench_quiz_question_cap.py shows 15- and
20-question schemas are rejected outright by gemini-2.5-flash-lite
("too many states for serving"), so 10 is a hard ceiling. QuizPanel now
builds its selects from the endpoint (static fallback mirrors it; the
dead "15 questions" option is gone).
A3 — every quiz-route 4xx/5xx returns {error: {code, message, detail?,
request_id}} with codes in services/quiz_errors.py::QuizErrorCode; the
legacy top-level detail key is kept so the current client still works.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@supabase

supabaseBot commented Aug 12, 2026

Copy link
Copy Markdown

This pull request has been ignored for the connected project ybgqdonkoqftwrmweuyv because there are no changes detected in supabase directory. You can change this behaviour in Project Integrations Settings ↗︎.


Preview Branches by Supabase.
Learn more about Supabase Branching ↗︎.

@coderabbitai

coderabbitaiBot commented Aug 12, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@AndresL230, you've reached your PR review limit, so we couldn't start this review.

Next review available in:19 minutes

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 12e52a35-4af8-4ba0-a17a-a7c1770ce8eb

📥 Commits

Reviewing files that changed from the base of the PR and between afc97df and 1bd3512.

📒 Files selected for processing (11)
  • backend/agents/quiz.py
  • backend/db/migrations/20260812204809_quiz_attempts_adaptive_difficulty.sql
  • backend/main.py
  • backend/routes/quiz.py
  • backend/scripts/bench_quiz_question_cap.py
  • backend/services/quiz_config.py
  • backend/services/quiz_errors.py
  • backend/tests/test_quiz_preflight_a.py
  • frontend/e2e/quiz.spec.ts
  • frontend/src/components/QuizPanel.test.tsx
  • frontend/src/components/QuizPanel.tsx
📝 Walkthrough

Walkthrough

The PR adds adaptive quiz difficulty, centralizes quiz configuration, updates question-count validation and persistence constraints, introduces structured quiz errors, and makes frontend selectors load backend-defined options with static fallbacks.

Changes

Adaptive quiz configuration

Layer / File(s)Summary
Centralized quiz configuration and persistence contracts
backend/services/quiz_config.py, backend/models/__init__.py, backend/db/migrations/..., backend/db/e2e_checks/quiz.py, frontend/src/lib/api.ts
Shared question bounds, difficulty values, question types, persistence constraints, and frontend response types now include adaptive configuration.
Adaptive generation and difficulty resolution
backend/agents/quiz.py, backend/routes/quiz.py, backend/scripts/bench_quiz_question_cap.py, backend/tests/test_quiz_preflight_a.py
Adaptive requests delegate per-question difficulty selection to the agent. Generation responses include requested and resolved difficulty. The benchmark tests question caps of 10, 15, and 20.
Quiz error envelopes and route handling
backend/services/quiz_errors.py, backend/main.py, backend/routes/quiz.py
Quiz errors use stable codes, machine-readable details, request IDs, and legacy detail fields. Non-quiz responses retain their existing format.
Configuration-driven quiz selectors
frontend/src/components/QuizPanel.tsx, frontend/src/components/QuizPanel.test.tsx
The panel loads selector options from the backend and uses static options when configuration loading fails. Adaptive difficulty is available in the selectors.

Estimated code review effort: 4 (Complex) | ~45 minutes

Possibly related issues

Possibly related PRs

Sequence Diagram(s)

sequenceDiagram
participant QuizPanel
participant QuizRoute
participant QuizAgent
participant Database
QuizPanel->>QuizRoute: Request adaptive quiz generation
QuizRoute->>Database: Read mastery and recent attempts
QuizRoute->>QuizAgent: Generate questions with adaptive instructions
QuizAgent-->>QuizRoute: Return concrete question difficulties
QuizRoute->>Database: Store quiz attempt
QuizRoute-->>QuizPanel: Return requested and resolved difficulty
Loading

Suggested reviewers:darkest-teddy

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 21.74% which is insufficient. The required threshold is 80.00%.Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check nameStatusExplanation
Title check✅ PassedThe title clearly summarizes the primary changes: adaptive difficulty, the quiz configuration endpoint, and stable error envelopes.
Description check✅ PassedThe description covers the implementation, related issues, testing, compatibility notes, benchmark results, and verification details.
Linked Issues check✅ PassedThe changes address #540 objectives for adaptive difficulty, centralized configuration, measured caps, coded quiz errors, persistence, and real-database testing.
Out of Scope Changes check✅ PassedThe migration, benchmark, tests, frontend updates, documentation, and error handling are all directly related to the linked issue objectives.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/540-quiz-preflight-a

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Aug 12, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-staging1bd3512Commit Preview URL

Branch Preview URL
Aug 12 2026, 09:41 PM

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
backend/db/e2e_checks/quiz.py (1)

35-44: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Exercise the adaptive persistence path.

Line 42 still sends "easy". This E2E flow does not verify that a successful adaptive request stores "adaptive" in quiz_attempts.difficulty. Send an adaptive request and, when generation succeeds, assert the created attempt row has difficulty == "adaptive" before submit.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/db/e2e_checks/quiz.py` around lines 35 - 44, Update the generate
request in the E2E flow to use difficulty "adaptive" instead of "easy". After
successful generation, query the created quiz_attempts row and assert its
difficulty is "adaptive" before submitting the quiz.
🧹 Nitpick comments (1)
frontend/src/components/QuizPanel.tsx (1)

129-137: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Reconcile selected values with loaded quiz configuration.

If the server removes "5" or "medium", the selectors render no selected option but start() still sends those stale values. This defeats the server-defined selector contract.

  • frontend/src/components/QuizPanel.tsx#L129-L137: after accepting a non-empty config payload, use functional state setters to retain a selected value only when it remains allowed; otherwise select the first allowed value.
  • frontend/src/components/QuizPanel.test.tsx#L45-L47: add a successful config mock that excludes "5" and "medium", then assert quiz generation uses the reconciled values.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@frontend/src/components/QuizPanel.tsx` around lines 129 - 137, The quiz
configuration load must reconcile selected count and difficulty values with the
server’s allowed options. In frontend/src/components/QuizPanel.tsx lines
129-137, after accepting a non-empty payload, use functional setters to preserve
each current selection only when allowed, otherwise choose the first allowed
value. In frontend/src/components/QuizPanel.test.tsx lines 45-47, add a
successful config mock excluding "5" and "medium", then assert quiz generation
uses the reconciled selections.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@backend/agents/quiz.py`:
- Around line 116-124: Update the fixed-difficulty rules immediately following
the adaptive-mode instructions in the quiz prompt so they apply only when the
user provides a concrete difficulty request. Prevent adaptive mode from
inheriting rules that shift from or honor a requested difficulty; preserve
adaptive’s mastery-based selection and medium baseline when no history exists.
In `@backend/db/migrations/20260812204809_quiz_attempts_adaptive_difficulty.sql`:
- Around line 9-14: Update the quiz_attempts_difficulty_check replacement in
this migration to add the constraint as NOT VALID, avoiding validation during
the migration transaction. Create a subsequent timestamped migration that
validates quiz_attempts_difficulty_check with VALIDATE CONSTRAINT.
In `@backend/services/quiz_errors.py`:
- Around line 44-51: Update the status-resolution logic using _STATUS_FALLBACK
so unlisted 4xx statuses, including 400, return QUIZ_VALIDATION_ERROR, while
unlisted 5xx statuses continue returning QUIZ_INTERNAL_ERROR. Preserve the
explicit mappings for listed statuses.
In `@backend/tests/test_quiz_preflight_a.py`:
- Around line 14-19: Update the tests in this module to remove the global
TestClient setup and route-internal patching, and use the shared Supabase and
Gemini fixtures provided by tests/conftest.py. Adjust affected test functions to
receive and use those fixtures while preserving their existing assertions and
behavior.
---
Outside diff comments:
In `@backend/db/e2e_checks/quiz.py`:
- Around line 35-44: Update the generate request in the E2E flow to use
difficulty "adaptive" instead of "easy". After successful generation, query the
created quiz_attempts row and assert its difficulty is "adaptive" before
submitting the quiz.
---
Nitpick comments:
In `@frontend/src/components/QuizPanel.tsx`:
- Around line 129-137: The quiz configuration load must reconcile selected count
and difficulty values with the server’s allowed options. In
frontend/src/components/QuizPanel.tsx lines 129-137, after accepting a non-empty
payload, use functional setters to preserve each current selection only when
allowed, otherwise choose the first allowed value. In
frontend/src/components/QuizPanel.test.tsx lines 45-47, add a successful config
mock excluding "5" and "medium", then assert quiz generation uses the reconciled
selections.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: ee118b9c-e231-4709-a0b2-ba45685d6e26

📥 Commits

Reviewing files that changed from the base of the PR and between 7681c48 and afc97df.

📒 Files selected for processing (13)
  • backend/agents/quiz.py
  • backend/db/e2e_checks/quiz.py
  • backend/db/migrations/20260812204809_quiz_attempts_adaptive_difficulty.sql
  • backend/main.py
  • backend/models/__init__.py
  • backend/routes/quiz.py
  • backend/scripts/bench_quiz_question_cap.py
  • backend/services/quiz_config.py
  • backend/services/quiz_errors.py
  • backend/tests/test_quiz_preflight_a.py
  • frontend/src/components/QuizPanel.test.tsx
  • frontend/src/components/QuizPanel.tsx
  • frontend/src/lib/api.ts

Comment threadbackend/agents/quiz.py Outdated
Comment on lines +116 to +124
"ADAPTIVE MODE (#540): when the user message says the quiz is in "
"adaptive mode, there is no user-requested difficulty — you choose "
"each question's difficulty yourself. Base the mix on mastery and "
"`recent_attempts.accuracy`: struggling/low-accuracy concepts get "
"easy-leaning questions, strong/high-accuracy ones get hard-leaning "
"questions; with no history at all, center the mix on medium. The "
"±1-step limits below do not apply in adaptive mode, but every "
"question still carries a concrete easy|medium|hard difficulty — "
"'adaptive' is never a per-question value.\n\n"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Scope the fixed-difficulty rules to non-adaptive requests.

Adaptive mode says there is no requested difficulty and defaults to medium without history. The following rules still instruct the agent to shift from, or honor, a requested difficulty. This gives adaptive generation an undefined baseline.

Limit the rules at lines 125-136 to concrete requests, or define an explicit adaptive baseline for each rule.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/agents/quiz.py` around lines 116 - 124, Update the fixed-difficulty
rules immediately following the adaptive-mode instructions in the quiz prompt so
they apply only when the user provides a concrete difficulty request. Prevent
adaptive mode from inheriting rules that shift from or honor a requested
difficulty; preserve adaptive’s mastery-based selection and medium baseline when
no history exists.

Comment on lines +9 to +14
ALTER TABLE quiz_attempts
DROP CONSTRAINT IF EXISTS quiz_attempts_difficulty_check;

ALTER TABLE quiz_attempts
ADD CONSTRAINT quiz_attempts_difficulty_check
CHECK (difficulty IN ('easy', 'medium', 'hard', 'adaptive'));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bashset -euo pipefail
migrator="$(fd -a '^migrate\.py$' backend/db | head -n1)"test -n "$migrator"
ast-grep outline "$migrator" --items all
rg -n -C 5 'BEGIN|COMMIT|transaction|execute|migrations'"$migrator"

Repository: SaplingLearn/Sapling

Length of output: 7968


🏁 Script executed:

#!/bin/bashset -euo pipefail
migration="$(fd -a '20260812204809_quiz_attempts_adaptive_difficulty\.sql$' backend/db/migrations | head -n1)"test -n "$migration"
cat -n "$migration"printf'\nConstraint migration patterns:\n'
rg -n -C 4 'ADD CONSTRAINT|DROP CONSTRAINT|NOT VALID|VALIDATE CONSTRAINT' backend/db/migrations

Repository: SaplingLearn/Sapling

Length of output: 19373


🏁 Script executed:

#!/bin/bashset -euo pipefail
python3 - <<'PY'from pathlib import Pathimport rerunner = Path("backend/db/migrate.py").read_text()migration = Path("backend/db/migrations/20260812204809_quiz_attempts_adaptive_difficulty.sql").read_text()apply = re.search( r"def apply_migration\(.*?^\s*def run\(", runner, re.MULTILINE | re.DOTALL,)assert apply, "apply_migration block not found"block = apply.group(0)checks = { "executes migration SQL": "cur.execute(path.read_text" in block, "records ledger before commit": block.find("INSERT INTO schema_migrations") < block.find("conn.commit()"), "commits once per migration": block.count("conn.commit()") == 1, "current constraint is not NOT VALID": "NOT VALID" not in migration.upper(),}for name, result in checks.items(): print(f"{name}: {result}")assert all(checks.values())PY

Repository: SaplingLearn/Sapling

Length of output: 297


🌐 Web query:

PostgreSQL documentation ALTER TABLE ADD CHECK NOT VALID lock level VALIDATE CONSTRAINT

💡 Result:

In PostgreSQL, the VALIDATE CONSTRAINT command acquires a SHARE UPDATE EXCLUSIVE lock on the table being altered [1][2]. This lock level is specifically designed to allow concurrent reads, updates, inserts, and deletes, as the validation process only needs to check pre-existing rows [1][2]. Because new rows inserted or updated after the constraint was initially added (using NOT VALID) are already being enforced by the constraint, they do not need to be scanned during the validation phase [1][2]. If the constraint being validated is a foreign key, an additional ROW SHARE lock is required on the referenced table [1][2]. When using ALTER TABLE ADD CONSTRAINT with the NOT VALID option, the command itself requires a SHARE ROW EXCLUSIVE lock (for check constraints) [3][4], which is less restrictive than the ACCESS EXCLUSIVE lock required for standard constraint addition without NOT VALID [1][2]. This allows the initial constraint definition to proceed without blocking concurrent data modifications [1][2].

Citations:


Avoid full-table blocking validation in this migration.

backend/db/migrate.py commits each migration after executing its SQL and ledger insert. The current ADD CONSTRAINT therefore holds an ACCESS EXCLUSIVE lock on quiz_attempts through the validation scan. Add the replacement constraint as NOT VALID, then validate it in a later timestamped migration with VALIDATE CONSTRAINT.

🧰 Tools
🪛 Squawk (2.61.0)

[warning] 13-14: By default new constraints require a table scan and block writes to the table while that scan occurs. Use NOT VALID with a later VALIDATE CONSTRAINT call.

(constraint-missing-not-valid)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/db/migrations/20260812204809_quiz_attempts_adaptive_difficulty.sql`
around lines 9 - 14, Update the quiz_attempts_difficulty_check replacement in
this migration to add the constraint as NOT VALID, avoiding validation during
the migration transaction. Create a subsequent timestamped migration that
validates quiz_attempts_difficulty_check with VALIDATE CONSTRAINT.

Source: Linters/SAST tools

Comment on lines +44 to +51
_STATUS_FALLBACK: dict[int, QuizErrorCode] = {
401: QuizErrorCode.QUIZ_NOT_AUTHORIZED,
403: QuizErrorCode.QUIZ_NOT_AUTHORIZED,
404: QuizErrorCode.QUIZ_ATTEMPT_NOT_FOUND,
409: QuizErrorCode.QUIZ_ATTEMPT_ALREADY_COMPLETED,
422: QuizErrorCode.QUIZ_VALIDATION_ERROR,
502: QuizErrorCode.QUIZ_GENERATION_FAILED,
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Map unlisted 4xx statuses to a client-error code.

A plain quiz-route HTTPException(status_code=400) resolves to QUIZ_INTERNAL_ERROR. This conflicts with the stable error-code contract because the response reports a client error as an internal failure.

Use QUIZ_VALIDATION_ERROR as the fallback for unlisted 4xx statuses. Keep QUIZ_INTERNAL_ERROR as the fallback for 5xx statuses.

Proposed fix
- resolved_code = code or _STATUS_FALLBACK.get(- status_code,- QuizErrorCode.QUIZ_INTERNAL_ERROR,- )+ resolved_code = code or _STATUS_FALLBACK.get(+ status_code,+ (+ QuizErrorCode.QUIZ_VALIDATION_ERROR+ if 400 <= status_code < 500+ else QuizErrorCode.QUIZ_INTERNAL_ERROR+ ),+ )

Also applies to: 87-90

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/services/quiz_errors.py` around lines 44 - 51, Update the
status-resolution logic using _STATUS_FALLBACK so unlisted 4xx statuses,
including 400, return QUIZ_VALIDATION_ERROR, while unlisted 5xx statuses
continue returning QUIZ_INTERNAL_ERROR. Preserve the explicit mappings for
listed statuses.

Comment on lines +14 to +19
from fastapi.testclient import TestClient

from main import app
from agents.quiz import Quiz, QuizQuestion

client = TestClient(app)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Use the shared backend fixtures.

This module creates its own global TestClient and patches route internals. Use the shared Supabase and Gemini fixtures from tests/conftest.py instead. This keeps backend test setup consistent and prevents fixture behavior from being bypassed.

As per coding guidelines, backend/tests/**/*.py must “use the shared Supabase and Gemini fixtures from tests/conftest.py.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/tests/test_quiz_preflight_a.py` around lines 14 - 19, Update the
tests in this module to remove the global TestClient setup and route-internal
patching, and use the shared Supabase and Gemini fixtures provided by
tests/conftest.py. Adjust affected test functions to receive and use those
fixtures while preserving their existing assertions and behavior.

Source: Coding guidelines

AndresL230and others added 2 commits August 12, 2026 17:31
… semantics, config-driven coverage
Review findings (xhigh, 15 defects):
- 422s on num_questions only get QUIZ_COUNT_OUT_OF_RANGE for actual
bounds violations; type errors stay QUIZ_VALIDATION_ERROR.
- _STATUS_FALLBACK narrowed to auth/validation; uncoded 404/405s get the
new generic QUIZ_HTTP_ERROR instead of impersonating domain states, and
a non-QuizErrorCode `code` attr can no longer crash the handler.
- The envelope-vs-legacy branch now lives once in quiz_errors.error_content;
main.py's three handlers all call it.
- The system prompt defines stored difficulty='adaptive' history rows
(judge by accuracy) so the quiz-history tool doesn't feed the stepping
rules an undefined token.
- The adaptive migration drops the old CHECK by introspection (name-robust)
and documents migrate-before-deploy ordering.
- bench_quiz_question_cap exits 1 when the baseline cap measured nothing.
- QUIZ_QUESTION_TYPES uses the schema token 'multiple_choice', not 'mcq'.
- _DIFFICULTY_RANK derived from CONCRETE_DIFFICULTIES.
- QuizPanel: humanizeError in both catches (the envelope's sentence now
actually reaches the student) and an "Adaptive · <resolved>" chip in the
active phase; vitest covers the config-driven select path both ways; a
promoted #540 journey pins config-mirroring selects + the adaptive wire
round trip in a real browser.
Skipped (documented): moving difficulty validation into a Pydantic
Literal — the 400 + QUIZ_DIFFICULTY_INVALID contract is published in #540
and changing it to a 422 buys no client anything today; revisit in #537.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…urney
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@AndresL230
AndresL230 merged commit 6081ec5 into mainAug 12, 2026
6 of 8 checks passed
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

quiz A (P0): accept 'adaptive' difficulty, single-source /api/quiz/config, stable error envelope

1 participant

@AndresL230
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat(quiz): adaptive difficulty, /api/quiz/config, stable error envelope (#540) by AndresL230 · Pull Request #547 · SaplingLearn/Sapling · GitHub
Skip to content

feat(quiz): adaptive difficulty, /api/quiz/config, stable error envelope (#540) - #547

Merged
AndresL230 merged 3 commits into
mainfrom
feat/540-quiz-preflight-a
Aug 12, 2026
Merged

feat(quiz): adaptive difficulty, /api/quiz/config, stable error envelope (#540)#547
AndresL230 merged 3 commits into
mainfrom
feat/540-quiz-preflight-a

Conversation

@AndresL230

@AndresL230AndresL230 commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator

Closes#540. Workstream A of the pre-revamp quiz repair batch (epic #537) — the P0 that ships standalone.

What

A1 — adaptive is a real difficulty. The route accepts it (REQUESTED_DIFFICULTIES), the agent picks the per-question mix under new ADAPTIVE MODE prompt rules (no ±1 clamp; every question still emits a concrete easy|medium|hard), and the attempt row stores the requested value — quiz_attempts_difficulty_check extended by migration 20260812204809. The response now carries requested_difficulty + resolved_difficulty (mode of per-question difficulties, ties break harder) so the client can tell the student what was actually chosen. Both fields are additive; the old client ignores them.

A2 — one source of truth for selector options.GET /api/quiz/config serves num_questions {min,max,options}, difficulties, question_types from services/quiz_config.py; GenerateQuizBody reads the same constants (pinned by a sync test). QuizPanel builds its selects from the endpoint with a static fallback mirroring it — the dead "15 questions" option is gone.

Cap decision (measured, not guessed):scripts/bench_quiz_question_cap.py, 2026-08-12, gemini-2.5-flash-lite:

  • cap 10 — serves fine: 4.6s/5.6s, ~260 in / ~1500 out tokens, 10/10 delivered
  • cap 15 — rejected before generation: HTTP 400 INVALID_ARGUMENT "schema produces a constraint that has too many states for serving"
  • cap 20 — same rejection

10 is a hard ceiling for a single structured call on the quiz model tier, not a preference. Raising it = model-tier change or batched generation = #537 revamp scope.

A3 — stable error envelope. Every quiz-route 4xx/5xx returns { error: { code, message, detail?, request_id } }; codes live in one enum (services/quiz_errors.py::QuizErrorCode); scoped strictly to /api/quiz/* via the main.py handlers, so no other route changes shape.

Wire-contract notes (for the current client)

  • Generate response: additive fields only (requested_difficulty, resolved_difficulty).
  • Error payloads on quiz routes: legacy top-level detail and request_id keys are kept alongside the new error object — frontend/src/lib/api.ts reads data?.detail and keeps working unchanged.
  • QuizPanel.tsx selector lists are now config-driven; defaults (5 / medium) unchanged, so the test(e2e): journey — quiz answer → mastery update (UI + DB) #393 journey is untouched.

Verification

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added adaptive quiz difficulty, selecting easy, medium, or hard per question based on performance history.
    • Added server-driven quiz configuration for question counts, difficulties, and question types.
    • Quiz generation now reports requested and resolved difficulty.
    • Added structured quiz error responses with stable error codes and details.
  • Bug Fixes

    • Improved validation for question counts, difficulty, concepts, and quiz submissions.
    • Added fallback quiz options when configuration cannot be loaded.

…ope (#540)
Workstream A of the pre-revamp quiz repair batch (epic #537):
A1 — 'adaptive' is a real difficulty. The route accepts it, the agent
picks the per-question mix (ADAPTIVE MODE prompt rules), the attempt row
records the request (CHECK extended by migration), and the response
reports requested_difficulty + resolved_difficulty (mode, ties break
harder) so the client can say what was actually chosen.
A2 — GET /api/quiz/config is the single source of truth for selector
values; the Pydantic model reads the same constants. Cap decision is
measured, not guessed: scripts/bench_quiz_question_cap.py shows 15- and
20-question schemas are rejected outright by gemini-2.5-flash-lite
("too many states for serving"), so 10 is a hard ceiling. QuizPanel now
builds its selects from the endpoint (static fallback mirrors it; the
dead "15 questions" option is gone).
A3 — every quiz-route 4xx/5xx returns {error: {code, message, detail?,
request_id}} with codes in services/quiz_errors.py::QuizErrorCode; the
legacy top-level detail key is kept so the current client still works.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@supabase

supabaseBot commented Aug 12, 2026

Copy link
Copy Markdown

This pull request has been ignored for the connected project ybgqdonkoqftwrmweuyv because there are no changes detected in supabase directory. You can change this behaviour in Project Integrations Settings ↗︎.


Preview Branches by Supabase.
Learn more about Supabase Branching ↗︎.

@coderabbitai

coderabbitaiBot commented Aug 12, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@AndresL230, you've reached your PR review limit, so we couldn't start this review.

Next review available in:19 minutes

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 12e52a35-4af8-4ba0-a17a-a7c1770ce8eb

📥 Commits

Reviewing files that changed from the base of the PR and between afc97df and 1bd3512.

📒 Files selected for processing (11)
  • backend/agents/quiz.py
  • backend/db/migrations/20260812204809_quiz_attempts_adaptive_difficulty.sql
  • backend/main.py
  • backend/routes/quiz.py
  • backend/scripts/bench_quiz_question_cap.py
  • backend/services/quiz_config.py
  • backend/services/quiz_errors.py
  • backend/tests/test_quiz_preflight_a.py
  • frontend/e2e/quiz.spec.ts
  • frontend/src/components/QuizPanel.test.tsx
  • frontend/src/components/QuizPanel.tsx
📝 Walkthrough

Walkthrough

The PR adds adaptive quiz difficulty, centralizes quiz configuration, updates question-count validation and persistence constraints, introduces structured quiz errors, and makes frontend selectors load backend-defined options with static fallbacks.

Changes

Adaptive quiz configuration

Layer / File(s)Summary
Centralized quiz configuration and persistence contracts
backend/services/quiz_config.py, backend/models/__init__.py, backend/db/migrations/..., backend/db/e2e_checks/quiz.py, frontend/src/lib/api.ts
Shared question bounds, difficulty values, question types, persistence constraints, and frontend response types now include adaptive configuration.
Adaptive generation and difficulty resolution
backend/agents/quiz.py, backend/routes/quiz.py, backend/scripts/bench_quiz_question_cap.py, backend/tests/test_quiz_preflight_a.py
Adaptive requests delegate per-question difficulty selection to the agent. Generation responses include requested and resolved difficulty. The benchmark tests question caps of 10, 15, and 20.
Quiz error envelopes and route handling
backend/services/quiz_errors.py, backend/main.py, backend/routes/quiz.py
Quiz errors use stable codes, machine-readable details, request IDs, and legacy detail fields. Non-quiz responses retain their existing format.
Configuration-driven quiz selectors
frontend/src/components/QuizPanel.tsx, frontend/src/components/QuizPanel.test.tsx
The panel loads selector options from the backend and uses static options when configuration loading fails. Adaptive difficulty is available in the selectors.

Estimated code review effort: 4 (Complex) | ~45 minutes

Possibly related issues

Possibly related PRs

Sequence Diagram(s)

sequenceDiagram
participant QuizPanel
participant QuizRoute
participant QuizAgent
participant Database
QuizPanel->>QuizRoute: Request adaptive quiz generation
QuizRoute->>Database: Read mastery and recent attempts
QuizRoute->>QuizAgent: Generate questions with adaptive instructions
QuizAgent-->>QuizRoute: Return concrete question difficulties
QuizRoute->>Database: Store quiz attempt
QuizRoute-->>QuizPanel: Return requested and resolved difficulty
Loading

Suggested reviewers:darkest-teddy

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 21.74% which is insufficient. The required threshold is 80.00%.Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check nameStatusExplanation
Title check✅ PassedThe title clearly summarizes the primary changes: adaptive difficulty, the quiz configuration endpoint, and stable error envelopes.
Description check✅ PassedThe description covers the implementation, related issues, testing, compatibility notes, benchmark results, and verification details.
Linked Issues check✅ PassedThe changes address #540 objectives for adaptive difficulty, centralized configuration, measured caps, coded quiz errors, persistence, and real-database testing.
Out of Scope Changes check✅ PassedThe migration, benchmark, tests, frontend updates, documentation, and error handling are all directly related to the linked issue objectives.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/540-quiz-preflight-a

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Aug 12, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-staging1bd3512Commit Preview URL

Branch Preview URL
Aug 12 2026, 09:41 PM

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
backend/db/e2e_checks/quiz.py (1)

35-44: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Exercise the adaptive persistence path.

Line 42 still sends "easy". This E2E flow does not verify that a successful adaptive request stores "adaptive" in quiz_attempts.difficulty. Send an adaptive request and, when generation succeeds, assert the created attempt row has difficulty == "adaptive" before submit.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/db/e2e_checks/quiz.py` around lines 35 - 44, Update the generate
request in the E2E flow to use difficulty "adaptive" instead of "easy". After
successful generation, query the created quiz_attempts row and assert its
difficulty is "adaptive" before submitting the quiz.
🧹 Nitpick comments (1)
frontend/src/components/QuizPanel.tsx (1)

129-137: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Reconcile selected values with loaded quiz configuration.

If the server removes "5" or "medium", the selectors render no selected option but start() still sends those stale values. This defeats the server-defined selector contract.

  • frontend/src/components/QuizPanel.tsx#L129-L137: after accepting a non-empty config payload, use functional state setters to retain a selected value only when it remains allowed; otherwise select the first allowed value.
  • frontend/src/components/QuizPanel.test.tsx#L45-L47: add a successful config mock that excludes "5" and "medium", then assert quiz generation uses the reconciled values.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@frontend/src/components/QuizPanel.tsx` around lines 129 - 137, The quiz
configuration load must reconcile selected count and difficulty values with the
server’s allowed options. In frontend/src/components/QuizPanel.tsx lines
129-137, after accepting a non-empty payload, use functional setters to preserve
each current selection only when allowed, otherwise choose the first allowed
value. In frontend/src/components/QuizPanel.test.tsx lines 45-47, add a
successful config mock excluding "5" and "medium", then assert quiz generation
uses the reconciled selections.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@backend/agents/quiz.py`:
- Around line 116-124: Update the fixed-difficulty rules immediately following
the adaptive-mode instructions in the quiz prompt so they apply only when the
user provides a concrete difficulty request. Prevent adaptive mode from
inheriting rules that shift from or honor a requested difficulty; preserve
adaptive’s mastery-based selection and medium baseline when no history exists.
In `@backend/db/migrations/20260812204809_quiz_attempts_adaptive_difficulty.sql`:
- Around line 9-14: Update the quiz_attempts_difficulty_check replacement in
this migration to add the constraint as NOT VALID, avoiding validation during
the migration transaction. Create a subsequent timestamped migration that
validates quiz_attempts_difficulty_check with VALIDATE CONSTRAINT.
In `@backend/services/quiz_errors.py`:
- Around line 44-51: Update the status-resolution logic using _STATUS_FALLBACK
so unlisted 4xx statuses, including 400, return QUIZ_VALIDATION_ERROR, while
unlisted 5xx statuses continue returning QUIZ_INTERNAL_ERROR. Preserve the
explicit mappings for listed statuses.
In `@backend/tests/test_quiz_preflight_a.py`:
- Around line 14-19: Update the tests in this module to remove the global
TestClient setup and route-internal patching, and use the shared Supabase and
Gemini fixtures provided by tests/conftest.py. Adjust affected test functions to
receive and use those fixtures while preserving their existing assertions and
behavior.
---
Outside diff comments:
In `@backend/db/e2e_checks/quiz.py`:
- Around line 35-44: Update the generate request in the E2E flow to use
difficulty "adaptive" instead of "easy". After successful generation, query the
created quiz_attempts row and assert its difficulty is "adaptive" before
submitting the quiz.
---
Nitpick comments:
In `@frontend/src/components/QuizPanel.tsx`:
- Around line 129-137: The quiz configuration load must reconcile selected count
and difficulty values with the server’s allowed options. In
frontend/src/components/QuizPanel.tsx lines 129-137, after accepting a non-empty
payload, use functional setters to preserve each current selection only when
allowed, otherwise choose the first allowed value. In
frontend/src/components/QuizPanel.test.tsx lines 45-47, add a successful config
mock excluding "5" and "medium", then assert quiz generation uses the reconciled
selections.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: ee118b9c-e231-4709-a0b2-ba45685d6e26

📥 Commits

Reviewing files that changed from the base of the PR and between 7681c48 and afc97df.

📒 Files selected for processing (13)
  • backend/agents/quiz.py
  • backend/db/e2e_checks/quiz.py
  • backend/db/migrations/20260812204809_quiz_attempts_adaptive_difficulty.sql
  • backend/main.py
  • backend/models/__init__.py
  • backend/routes/quiz.py
  • backend/scripts/bench_quiz_question_cap.py
  • backend/services/quiz_config.py
  • backend/services/quiz_errors.py
  • backend/tests/test_quiz_preflight_a.py
  • frontend/src/components/QuizPanel.test.tsx
  • frontend/src/components/QuizPanel.tsx
  • frontend/src/lib/api.ts

Comment threadbackend/agents/quiz.py Outdated
Comment on lines +116 to +124
"ADAPTIVE MODE (#540): when the user message says the quiz is in "
"adaptive mode, there is no user-requested difficulty — you choose "
"each question's difficulty yourself. Base the mix on mastery and "
"`recent_attempts.accuracy`: struggling/low-accuracy concepts get "
"easy-leaning questions, strong/high-accuracy ones get hard-leaning "
"questions; with no history at all, center the mix on medium. The "
"±1-step limits below do not apply in adaptive mode, but every "
"question still carries a concrete easy|medium|hard difficulty — "
"'adaptive' is never a per-question value.\n\n"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Scope the fixed-difficulty rules to non-adaptive requests.

Adaptive mode says there is no requested difficulty and defaults to medium without history. The following rules still instruct the agent to shift from, or honor, a requested difficulty. This gives adaptive generation an undefined baseline.

Limit the rules at lines 125-136 to concrete requests, or define an explicit adaptive baseline for each rule.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/agents/quiz.py` around lines 116 - 124, Update the fixed-difficulty
rules immediately following the adaptive-mode instructions in the quiz prompt so
they apply only when the user provides a concrete difficulty request. Prevent
adaptive mode from inheriting rules that shift from or honor a requested
difficulty; preserve adaptive’s mastery-based selection and medium baseline when
no history exists.

Comment on lines +9 to +14
ALTER TABLE quiz_attempts
DROP CONSTRAINT IF EXISTS quiz_attempts_difficulty_check;

ALTER TABLE quiz_attempts
ADD CONSTRAINT quiz_attempts_difficulty_check
CHECK (difficulty IN ('easy', 'medium', 'hard', 'adaptive'));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bashset -euo pipefail
migrator="$(fd -a '^migrate\.py$' backend/db | head -n1)"test -n "$migrator"
ast-grep outline "$migrator" --items all
rg -n -C 5 'BEGIN|COMMIT|transaction|execute|migrations'"$migrator"

Repository: SaplingLearn/Sapling

Length of output: 7968


🏁 Script executed:

#!/bin/bashset -euo pipefail
migration="$(fd -a '20260812204809_quiz_attempts_adaptive_difficulty\.sql$' backend/db/migrations | head -n1)"test -n "$migration"
cat -n "$migration"printf'\nConstraint migration patterns:\n'
rg -n -C 4 'ADD CONSTRAINT|DROP CONSTRAINT|NOT VALID|VALIDATE CONSTRAINT' backend/db/migrations

Repository: SaplingLearn/Sapling

Length of output: 19373


🏁 Script executed:

#!/bin/bashset -euo pipefail
python3 - <<'PY'from pathlib import Pathimport rerunner = Path("backend/db/migrate.py").read_text()migration = Path("backend/db/migrations/20260812204809_quiz_attempts_adaptive_difficulty.sql").read_text()apply = re.search( r"def apply_migration\(.*?^\s*def run\(", runner, re.MULTILINE | re.DOTALL,)assert apply, "apply_migration block not found"block = apply.group(0)checks = { "executes migration SQL": "cur.execute(path.read_text" in block, "records ledger before commit": block.find("INSERT INTO schema_migrations") < block.find("conn.commit()"), "commits once per migration": block.count("conn.commit()") == 1, "current constraint is not NOT VALID": "NOT VALID" not in migration.upper(),}for name, result in checks.items(): print(f"{name}: {result}")assert all(checks.values())PY

Repository: SaplingLearn/Sapling

Length of output: 297


🌐 Web query:

PostgreSQL documentation ALTER TABLE ADD CHECK NOT VALID lock level VALIDATE CONSTRAINT

💡 Result:

In PostgreSQL, the VALIDATE CONSTRAINT command acquires a SHARE UPDATE EXCLUSIVE lock on the table being altered [1][2]. This lock level is specifically designed to allow concurrent reads, updates, inserts, and deletes, as the validation process only needs to check pre-existing rows [1][2]. Because new rows inserted or updated after the constraint was initially added (using NOT VALID) are already being enforced by the constraint, they do not need to be scanned during the validation phase [1][2]. If the constraint being validated is a foreign key, an additional ROW SHARE lock is required on the referenced table [1][2]. When using ALTER TABLE ADD CONSTRAINT with the NOT VALID option, the command itself requires a SHARE ROW EXCLUSIVE lock (for check constraints) [3][4], which is less restrictive than the ACCESS EXCLUSIVE lock required for standard constraint addition without NOT VALID [1][2]. This allows the initial constraint definition to proceed without blocking concurrent data modifications [1][2].

Citations:


Avoid full-table blocking validation in this migration.

backend/db/migrate.py commits each migration after executing its SQL and ledger insert. The current ADD CONSTRAINT therefore holds an ACCESS EXCLUSIVE lock on quiz_attempts through the validation scan. Add the replacement constraint as NOT VALID, then validate it in a later timestamped migration with VALIDATE CONSTRAINT.

🧰 Tools
🪛 Squawk (2.61.0)

[warning] 13-14: By default new constraints require a table scan and block writes to the table while that scan occurs. Use NOT VALID with a later VALIDATE CONSTRAINT call.

(constraint-missing-not-valid)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/db/migrations/20260812204809_quiz_attempts_adaptive_difficulty.sql`
around lines 9 - 14, Update the quiz_attempts_difficulty_check replacement in
this migration to add the constraint as NOT VALID, avoiding validation during
the migration transaction. Create a subsequent timestamped migration that
validates quiz_attempts_difficulty_check with VALIDATE CONSTRAINT.

Source: Linters/SAST tools

Comment on lines +44 to +51
_STATUS_FALLBACK: dict[int, QuizErrorCode] = {
401: QuizErrorCode.QUIZ_NOT_AUTHORIZED,
403: QuizErrorCode.QUIZ_NOT_AUTHORIZED,
404: QuizErrorCode.QUIZ_ATTEMPT_NOT_FOUND,
409: QuizErrorCode.QUIZ_ATTEMPT_ALREADY_COMPLETED,
422: QuizErrorCode.QUIZ_VALIDATION_ERROR,
502: QuizErrorCode.QUIZ_GENERATION_FAILED,
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Map unlisted 4xx statuses to a client-error code.

A plain quiz-route HTTPException(status_code=400) resolves to QUIZ_INTERNAL_ERROR. This conflicts with the stable error-code contract because the response reports a client error as an internal failure.

Use QUIZ_VALIDATION_ERROR as the fallback for unlisted 4xx statuses. Keep QUIZ_INTERNAL_ERROR as the fallback for 5xx statuses.

Proposed fix
- resolved_code = code or _STATUS_FALLBACK.get(- status_code,- QuizErrorCode.QUIZ_INTERNAL_ERROR,- )+ resolved_code = code or _STATUS_FALLBACK.get(+ status_code,+ (+ QuizErrorCode.QUIZ_VALIDATION_ERROR+ if 400 <= status_code < 500+ else QuizErrorCode.QUIZ_INTERNAL_ERROR+ ),+ )

Also applies to: 87-90

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/services/quiz_errors.py` around lines 44 - 51, Update the
status-resolution logic using _STATUS_FALLBACK so unlisted 4xx statuses,
including 400, return QUIZ_VALIDATION_ERROR, while unlisted 5xx statuses
continue returning QUIZ_INTERNAL_ERROR. Preserve the explicit mappings for
listed statuses.

Comment on lines +14 to +19
from fastapi.testclient import TestClient

from main import app
from agents.quiz import Quiz, QuizQuestion

client = TestClient(app)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Use the shared backend fixtures.

This module creates its own global TestClient and patches route internals. Use the shared Supabase and Gemini fixtures from tests/conftest.py instead. This keeps backend test setup consistent and prevents fixture behavior from being bypassed.

As per coding guidelines, backend/tests/**/*.py must “use the shared Supabase and Gemini fixtures from tests/conftest.py.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/tests/test_quiz_preflight_a.py` around lines 14 - 19, Update the
tests in this module to remove the global TestClient setup and route-internal
patching, and use the shared Supabase and Gemini fixtures provided by
tests/conftest.py. Adjust affected test functions to receive and use those
fixtures while preserving their existing assertions and behavior.

Source: Coding guidelines

AndresL230and others added 2 commits August 12, 2026 17:31
… semantics, config-driven coverage
Review findings (xhigh, 15 defects):
- 422s on num_questions only get QUIZ_COUNT_OUT_OF_RANGE for actual
bounds violations; type errors stay QUIZ_VALIDATION_ERROR.
- _STATUS_FALLBACK narrowed to auth/validation; uncoded 404/405s get the
new generic QUIZ_HTTP_ERROR instead of impersonating domain states, and
a non-QuizErrorCode `code` attr can no longer crash the handler.
- The envelope-vs-legacy branch now lives once in quiz_errors.error_content;
main.py's three handlers all call it.
- The system prompt defines stored difficulty='adaptive' history rows
(judge by accuracy) so the quiz-history tool doesn't feed the stepping
rules an undefined token.
- The adaptive migration drops the old CHECK by introspection (name-robust)
and documents migrate-before-deploy ordering.
- bench_quiz_question_cap exits 1 when the baseline cap measured nothing.
- QUIZ_QUESTION_TYPES uses the schema token 'multiple_choice', not 'mcq'.
- _DIFFICULTY_RANK derived from CONCRETE_DIFFICULTIES.
- QuizPanel: humanizeError in both catches (the envelope's sentence now
actually reaches the student) and an "Adaptive · <resolved>" chip in the
active phase; vitest covers the config-driven select path both ways; a
promoted #540 journey pins config-mirroring selects + the adaptive wire
round trip in a real browser.
Skipped (documented): moving difficulty validation into a Pydantic
Literal — the 400 + QUIZ_DIFFICULTY_INVALID contract is published in #540
and changing it to a 422 buys no client anything today; revisit in #537.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…urney
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@AndresL230
AndresL230 merged commit 6081ec5 into mainAug 12, 2026
6 of 8 checks passed
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

quiz A (P0): accept 'adaptive' difficulty, single-source /api/quiz/config, stable error envelope

1 participant

@AndresL230
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat(quiz): adaptive difficulty, /api/quiz/config, stable error envelope (#540) by AndresL230 · Pull Request #547 · SaplingLearn/Sapling · GitHub
Skip to content

feat(quiz): adaptive difficulty, /api/quiz/config, stable error envelope (#540) - #547

Merged
AndresL230 merged 3 commits into
mainfrom
feat/540-quiz-preflight-a
Aug 12, 2026
Merged

feat(quiz): adaptive difficulty, /api/quiz/config, stable error envelope (#540)#547
AndresL230 merged 3 commits into
mainfrom
feat/540-quiz-preflight-a

Conversation

@AndresL230

@AndresL230AndresL230 commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator

Closes#540. Workstream A of the pre-revamp quiz repair batch (epic #537) — the P0 that ships standalone.

What

A1 — adaptive is a real difficulty. The route accepts it (REQUESTED_DIFFICULTIES), the agent picks the per-question mix under new ADAPTIVE MODE prompt rules (no ±1 clamp; every question still emits a concrete easy|medium|hard), and the attempt row stores the requested value — quiz_attempts_difficulty_check extended by migration 20260812204809. The response now carries requested_difficulty + resolved_difficulty (mode of per-question difficulties, ties break harder) so the client can tell the student what was actually chosen. Both fields are additive; the old client ignores them.

A2 — one source of truth for selector options.GET /api/quiz/config serves num_questions {min,max,options}, difficulties, question_types from services/quiz_config.py; GenerateQuizBody reads the same constants (pinned by a sync test). QuizPanel builds its selects from the endpoint with a static fallback mirroring it — the dead "15 questions" option is gone.

Cap decision (measured, not guessed):scripts/bench_quiz_question_cap.py, 2026-08-12, gemini-2.5-flash-lite:

  • cap 10 — serves fine: 4.6s/5.6s, ~260 in / ~1500 out tokens, 10/10 delivered
  • cap 15 — rejected before generation: HTTP 400 INVALID_ARGUMENT "schema produces a constraint that has too many states for serving"
  • cap 20 — same rejection

10 is a hard ceiling for a single structured call on the quiz model tier, not a preference. Raising it = model-tier change or batched generation = #537 revamp scope.

A3 — stable error envelope. Every quiz-route 4xx/5xx returns { error: { code, message, detail?, request_id } }; codes live in one enum (services/quiz_errors.py::QuizErrorCode); scoped strictly to /api/quiz/* via the main.py handlers, so no other route changes shape.

Wire-contract notes (for the current client)

  • Generate response: additive fields only (requested_difficulty, resolved_difficulty).
  • Error payloads on quiz routes: legacy top-level detail and request_id keys are kept alongside the new error object — frontend/src/lib/api.ts reads data?.detail and keeps working unchanged.
  • QuizPanel.tsx selector lists are now config-driven; defaults (5 / medium) unchanged, so the test(e2e): journey — quiz answer → mastery update (UI + DB) #393 journey is untouched.

Verification

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added adaptive quiz difficulty, selecting easy, medium, or hard per question based on performance history.
    • Added server-driven quiz configuration for question counts, difficulties, and question types.
    • Quiz generation now reports requested and resolved difficulty.
    • Added structured quiz error responses with stable error codes and details.
  • Bug Fixes

    • Improved validation for question counts, difficulty, concepts, and quiz submissions.
    • Added fallback quiz options when configuration cannot be loaded.

…ope (#540)
Workstream A of the pre-revamp quiz repair batch (epic #537):
A1 — 'adaptive' is a real difficulty. The route accepts it, the agent
picks the per-question mix (ADAPTIVE MODE prompt rules), the attempt row
records the request (CHECK extended by migration), and the response
reports requested_difficulty + resolved_difficulty (mode, ties break
harder) so the client can say what was actually chosen.
A2 — GET /api/quiz/config is the single source of truth for selector
values; the Pydantic model reads the same constants. Cap decision is
measured, not guessed: scripts/bench_quiz_question_cap.py shows 15- and
20-question schemas are rejected outright by gemini-2.5-flash-lite
("too many states for serving"), so 10 is a hard ceiling. QuizPanel now
builds its selects from the endpoint (static fallback mirrors it; the
dead "15 questions" option is gone).
A3 — every quiz-route 4xx/5xx returns {error: {code, message, detail?,
request_id}} with codes in services/quiz_errors.py::QuizErrorCode; the
legacy top-level detail key is kept so the current client still works.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@supabase

supabaseBot commented Aug 12, 2026

Copy link
Copy Markdown

This pull request has been ignored for the connected project ybgqdonkoqftwrmweuyv because there are no changes detected in supabase directory. You can change this behaviour in Project Integrations Settings ↗︎.


Preview Branches by Supabase.
Learn more about Supabase Branching ↗︎.

@coderabbitai

coderabbitaiBot commented Aug 12, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@AndresL230, you've reached your PR review limit, so we couldn't start this review.

Next review available in:19 minutes

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 12e52a35-4af8-4ba0-a17a-a7c1770ce8eb

📥 Commits

Reviewing files that changed from the base of the PR and between afc97df and 1bd3512.

📒 Files selected for processing (11)
  • backend/agents/quiz.py
  • backend/db/migrations/20260812204809_quiz_attempts_adaptive_difficulty.sql
  • backend/main.py
  • backend/routes/quiz.py
  • backend/scripts/bench_quiz_question_cap.py
  • backend/services/quiz_config.py
  • backend/services/quiz_errors.py
  • backend/tests/test_quiz_preflight_a.py
  • frontend/e2e/quiz.spec.ts
  • frontend/src/components/QuizPanel.test.tsx
  • frontend/src/components/QuizPanel.tsx
📝 Walkthrough

Walkthrough

The PR adds adaptive quiz difficulty, centralizes quiz configuration, updates question-count validation and persistence constraints, introduces structured quiz errors, and makes frontend selectors load backend-defined options with static fallbacks.

Changes

Adaptive quiz configuration

Layer / File(s)Summary
Centralized quiz configuration and persistence contracts
backend/services/quiz_config.py, backend/models/__init__.py, backend/db/migrations/..., backend/db/e2e_checks/quiz.py, frontend/src/lib/api.ts
Shared question bounds, difficulty values, question types, persistence constraints, and frontend response types now include adaptive configuration.
Adaptive generation and difficulty resolution
backend/agents/quiz.py, backend/routes/quiz.py, backend/scripts/bench_quiz_question_cap.py, backend/tests/test_quiz_preflight_a.py
Adaptive requests delegate per-question difficulty selection to the agent. Generation responses include requested and resolved difficulty. The benchmark tests question caps of 10, 15, and 20.
Quiz error envelopes and route handling
backend/services/quiz_errors.py, backend/main.py, backend/routes/quiz.py
Quiz errors use stable codes, machine-readable details, request IDs, and legacy detail fields. Non-quiz responses retain their existing format.
Configuration-driven quiz selectors
frontend/src/components/QuizPanel.tsx, frontend/src/components/QuizPanel.test.tsx
The panel loads selector options from the backend and uses static options when configuration loading fails. Adaptive difficulty is available in the selectors.

Estimated code review effort: 4 (Complex) | ~45 minutes

Possibly related issues

Possibly related PRs

Sequence Diagram(s)

sequenceDiagram
participant QuizPanel
participant QuizRoute
participant QuizAgent
participant Database
QuizPanel->>QuizRoute: Request adaptive quiz generation
QuizRoute->>Database: Read mastery and recent attempts
QuizRoute->>QuizAgent: Generate questions with adaptive instructions
QuizAgent-->>QuizRoute: Return concrete question difficulties
QuizRoute->>Database: Store quiz attempt
QuizRoute-->>QuizPanel: Return requested and resolved difficulty
Loading

Suggested reviewers:darkest-teddy

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 21.74% which is insufficient. The required threshold is 80.00%.Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check nameStatusExplanation
Title check✅ PassedThe title clearly summarizes the primary changes: adaptive difficulty, the quiz configuration endpoint, and stable error envelopes.
Description check✅ PassedThe description covers the implementation, related issues, testing, compatibility notes, benchmark results, and verification details.
Linked Issues check✅ PassedThe changes address #540 objectives for adaptive difficulty, centralized configuration, measured caps, coded quiz errors, persistence, and real-database testing.
Out of Scope Changes check✅ PassedThe migration, benchmark, tests, frontend updates, documentation, and error handling are all directly related to the linked issue objectives.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/540-quiz-preflight-a

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Aug 12, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-staging1bd3512Commit Preview URL

Branch Preview URL
Aug 12 2026, 09:41 PM

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
backend/db/e2e_checks/quiz.py (1)

35-44: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Exercise the adaptive persistence path.

Line 42 still sends "easy". This E2E flow does not verify that a successful adaptive request stores "adaptive" in quiz_attempts.difficulty. Send an adaptive request and, when generation succeeds, assert the created attempt row has difficulty == "adaptive" before submit.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/db/e2e_checks/quiz.py` around lines 35 - 44, Update the generate
request in the E2E flow to use difficulty "adaptive" instead of "easy". After
successful generation, query the created quiz_attempts row and assert its
difficulty is "adaptive" before submitting the quiz.
🧹 Nitpick comments (1)
frontend/src/components/QuizPanel.tsx (1)

129-137: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Reconcile selected values with loaded quiz configuration.

If the server removes "5" or "medium", the selectors render no selected option but start() still sends those stale values. This defeats the server-defined selector contract.

  • frontend/src/components/QuizPanel.tsx#L129-L137: after accepting a non-empty config payload, use functional state setters to retain a selected value only when it remains allowed; otherwise select the first allowed value.
  • frontend/src/components/QuizPanel.test.tsx#L45-L47: add a successful config mock that excludes "5" and "medium", then assert quiz generation uses the reconciled values.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@frontend/src/components/QuizPanel.tsx` around lines 129 - 137, The quiz
configuration load must reconcile selected count and difficulty values with the
server’s allowed options. In frontend/src/components/QuizPanel.tsx lines
129-137, after accepting a non-empty payload, use functional setters to preserve
each current selection only when allowed, otherwise choose the first allowed
value. In frontend/src/components/QuizPanel.test.tsx lines 45-47, add a
successful config mock excluding "5" and "medium", then assert quiz generation
uses the reconciled selections.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@backend/agents/quiz.py`:
- Around line 116-124: Update the fixed-difficulty rules immediately following
the adaptive-mode instructions in the quiz prompt so they apply only when the
user provides a concrete difficulty request. Prevent adaptive mode from
inheriting rules that shift from or honor a requested difficulty; preserve
adaptive’s mastery-based selection and medium baseline when no history exists.
In `@backend/db/migrations/20260812204809_quiz_attempts_adaptive_difficulty.sql`:
- Around line 9-14: Update the quiz_attempts_difficulty_check replacement in
this migration to add the constraint as NOT VALID, avoiding validation during
the migration transaction. Create a subsequent timestamped migration that
validates quiz_attempts_difficulty_check with VALIDATE CONSTRAINT.
In `@backend/services/quiz_errors.py`:
- Around line 44-51: Update the status-resolution logic using _STATUS_FALLBACK
so unlisted 4xx statuses, including 400, return QUIZ_VALIDATION_ERROR, while
unlisted 5xx statuses continue returning QUIZ_INTERNAL_ERROR. Preserve the
explicit mappings for listed statuses.
In `@backend/tests/test_quiz_preflight_a.py`:
- Around line 14-19: Update the tests in this module to remove the global
TestClient setup and route-internal patching, and use the shared Supabase and
Gemini fixtures provided by tests/conftest.py. Adjust affected test functions to
receive and use those fixtures while preserving their existing assertions and
behavior.
---
Outside diff comments:
In `@backend/db/e2e_checks/quiz.py`:
- Around line 35-44: Update the generate request in the E2E flow to use
difficulty "adaptive" instead of "easy". After successful generation, query the
created quiz_attempts row and assert its difficulty is "adaptive" before
submitting the quiz.
---
Nitpick comments:
In `@frontend/src/components/QuizPanel.tsx`:
- Around line 129-137: The quiz configuration load must reconcile selected count
and difficulty values with the server’s allowed options. In
frontend/src/components/QuizPanel.tsx lines 129-137, after accepting a non-empty
payload, use functional setters to preserve each current selection only when
allowed, otherwise choose the first allowed value. In
frontend/src/components/QuizPanel.test.tsx lines 45-47, add a successful config
mock excluding "5" and "medium", then assert quiz generation uses the reconciled
selections.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: ee118b9c-e231-4709-a0b2-ba45685d6e26

📥 Commits

Reviewing files that changed from the base of the PR and between 7681c48 and afc97df.

📒 Files selected for processing (13)
  • backend/agents/quiz.py
  • backend/db/e2e_checks/quiz.py
  • backend/db/migrations/20260812204809_quiz_attempts_adaptive_difficulty.sql
  • backend/main.py
  • backend/models/__init__.py
  • backend/routes/quiz.py
  • backend/scripts/bench_quiz_question_cap.py
  • backend/services/quiz_config.py
  • backend/services/quiz_errors.py
  • backend/tests/test_quiz_preflight_a.py
  • frontend/src/components/QuizPanel.test.tsx
  • frontend/src/components/QuizPanel.tsx
  • frontend/src/lib/api.ts

Comment threadbackend/agents/quiz.py Outdated
Comment on lines +116 to +124
"ADAPTIVE MODE (#540): when the user message says the quiz is in "
"adaptive mode, there is no user-requested difficulty — you choose "
"each question's difficulty yourself. Base the mix on mastery and "
"`recent_attempts.accuracy`: struggling/low-accuracy concepts get "
"easy-leaning questions, strong/high-accuracy ones get hard-leaning "
"questions; with no history at all, center the mix on medium. The "
"±1-step limits below do not apply in adaptive mode, but every "
"question still carries a concrete easy|medium|hard difficulty — "
"'adaptive' is never a per-question value.\n\n"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Scope the fixed-difficulty rules to non-adaptive requests.

Adaptive mode says there is no requested difficulty and defaults to medium without history. The following rules still instruct the agent to shift from, or honor, a requested difficulty. This gives adaptive generation an undefined baseline.

Limit the rules at lines 125-136 to concrete requests, or define an explicit adaptive baseline for each rule.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/agents/quiz.py` around lines 116 - 124, Update the fixed-difficulty
rules immediately following the adaptive-mode instructions in the quiz prompt so
they apply only when the user provides a concrete difficulty request. Prevent
adaptive mode from inheriting rules that shift from or honor a requested
difficulty; preserve adaptive’s mastery-based selection and medium baseline when
no history exists.

Comment on lines +9 to +14
ALTER TABLE quiz_attempts
DROP CONSTRAINT IF EXISTS quiz_attempts_difficulty_check;

ALTER TABLE quiz_attempts
ADD CONSTRAINT quiz_attempts_difficulty_check
CHECK (difficulty IN ('easy', 'medium', 'hard', 'adaptive'));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bashset -euo pipefail
migrator="$(fd -a '^migrate\.py$' backend/db | head -n1)"test -n "$migrator"
ast-grep outline "$migrator" --items all
rg -n -C 5 'BEGIN|COMMIT|transaction|execute|migrations'"$migrator"

Repository: SaplingLearn/Sapling

Length of output: 7968


🏁 Script executed:

#!/bin/bashset -euo pipefail
migration="$(fd -a '20260812204809_quiz_attempts_adaptive_difficulty\.sql$' backend/db/migrations | head -n1)"test -n "$migration"
cat -n "$migration"printf'\nConstraint migration patterns:\n'
rg -n -C 4 'ADD CONSTRAINT|DROP CONSTRAINT|NOT VALID|VALIDATE CONSTRAINT' backend/db/migrations

Repository: SaplingLearn/Sapling

Length of output: 19373


🏁 Script executed:

#!/bin/bashset -euo pipefail
python3 - <<'PY'from pathlib import Pathimport rerunner = Path("backend/db/migrate.py").read_text()migration = Path("backend/db/migrations/20260812204809_quiz_attempts_adaptive_difficulty.sql").read_text()apply = re.search( r"def apply_migration\(.*?^\s*def run\(", runner, re.MULTILINE | re.DOTALL,)assert apply, "apply_migration block not found"block = apply.group(0)checks = { "executes migration SQL": "cur.execute(path.read_text" in block, "records ledger before commit": block.find("INSERT INTO schema_migrations") < block.find("conn.commit()"), "commits once per migration": block.count("conn.commit()") == 1, "current constraint is not NOT VALID": "NOT VALID" not in migration.upper(),}for name, result in checks.items(): print(f"{name}: {result}")assert all(checks.values())PY

Repository: SaplingLearn/Sapling

Length of output: 297


🌐 Web query:

PostgreSQL documentation ALTER TABLE ADD CHECK NOT VALID lock level VALIDATE CONSTRAINT

💡 Result:

In PostgreSQL, the VALIDATE CONSTRAINT command acquires a SHARE UPDATE EXCLUSIVE lock on the table being altered [1][2]. This lock level is specifically designed to allow concurrent reads, updates, inserts, and deletes, as the validation process only needs to check pre-existing rows [1][2]. Because new rows inserted or updated after the constraint was initially added (using NOT VALID) are already being enforced by the constraint, they do not need to be scanned during the validation phase [1][2]. If the constraint being validated is a foreign key, an additional ROW SHARE lock is required on the referenced table [1][2]. When using ALTER TABLE ADD CONSTRAINT with the NOT VALID option, the command itself requires a SHARE ROW EXCLUSIVE lock (for check constraints) [3][4], which is less restrictive than the ACCESS EXCLUSIVE lock required for standard constraint addition without NOT VALID [1][2]. This allows the initial constraint definition to proceed without blocking concurrent data modifications [1][2].

Citations:


Avoid full-table blocking validation in this migration.

backend/db/migrate.py commits each migration after executing its SQL and ledger insert. The current ADD CONSTRAINT therefore holds an ACCESS EXCLUSIVE lock on quiz_attempts through the validation scan. Add the replacement constraint as NOT VALID, then validate it in a later timestamped migration with VALIDATE CONSTRAINT.

🧰 Tools
🪛 Squawk (2.61.0)

[warning] 13-14: By default new constraints require a table scan and block writes to the table while that scan occurs. Use NOT VALID with a later VALIDATE CONSTRAINT call.

(constraint-missing-not-valid)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/db/migrations/20260812204809_quiz_attempts_adaptive_difficulty.sql`
around lines 9 - 14, Update the quiz_attempts_difficulty_check replacement in
this migration to add the constraint as NOT VALID, avoiding validation during
the migration transaction. Create a subsequent timestamped migration that
validates quiz_attempts_difficulty_check with VALIDATE CONSTRAINT.

Source: Linters/SAST tools

Comment on lines +44 to +51
_STATUS_FALLBACK: dict[int, QuizErrorCode] = {
401: QuizErrorCode.QUIZ_NOT_AUTHORIZED,
403: QuizErrorCode.QUIZ_NOT_AUTHORIZED,
404: QuizErrorCode.QUIZ_ATTEMPT_NOT_FOUND,
409: QuizErrorCode.QUIZ_ATTEMPT_ALREADY_COMPLETED,
422: QuizErrorCode.QUIZ_VALIDATION_ERROR,
502: QuizErrorCode.QUIZ_GENERATION_FAILED,
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Map unlisted 4xx statuses to a client-error code.

A plain quiz-route HTTPException(status_code=400) resolves to QUIZ_INTERNAL_ERROR. This conflicts with the stable error-code contract because the response reports a client error as an internal failure.

Use QUIZ_VALIDATION_ERROR as the fallback for unlisted 4xx statuses. Keep QUIZ_INTERNAL_ERROR as the fallback for 5xx statuses.

Proposed fix
- resolved_code = code or _STATUS_FALLBACK.get(- status_code,- QuizErrorCode.QUIZ_INTERNAL_ERROR,- )+ resolved_code = code or _STATUS_FALLBACK.get(+ status_code,+ (+ QuizErrorCode.QUIZ_VALIDATION_ERROR+ if 400 <= status_code < 500+ else QuizErrorCode.QUIZ_INTERNAL_ERROR+ ),+ )

Also applies to: 87-90

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/services/quiz_errors.py` around lines 44 - 51, Update the
status-resolution logic using _STATUS_FALLBACK so unlisted 4xx statuses,
including 400, return QUIZ_VALIDATION_ERROR, while unlisted 5xx statuses
continue returning QUIZ_INTERNAL_ERROR. Preserve the explicit mappings for
listed statuses.

Comment on lines +14 to +19
from fastapi.testclient import TestClient

from main import app
from agents.quiz import Quiz, QuizQuestion

client = TestClient(app)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Use the shared backend fixtures.

This module creates its own global TestClient and patches route internals. Use the shared Supabase and Gemini fixtures from tests/conftest.py instead. This keeps backend test setup consistent and prevents fixture behavior from being bypassed.

As per coding guidelines, backend/tests/**/*.py must “use the shared Supabase and Gemini fixtures from tests/conftest.py.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/tests/test_quiz_preflight_a.py` around lines 14 - 19, Update the
tests in this module to remove the global TestClient setup and route-internal
patching, and use the shared Supabase and Gemini fixtures provided by
tests/conftest.py. Adjust affected test functions to receive and use those
fixtures while preserving their existing assertions and behavior.

Source: Coding guidelines

AndresL230and others added 2 commits August 12, 2026 17:31
… semantics, config-driven coverage
Review findings (xhigh, 15 defects):
- 422s on num_questions only get QUIZ_COUNT_OUT_OF_RANGE for actual
bounds violations; type errors stay QUIZ_VALIDATION_ERROR.
- _STATUS_FALLBACK narrowed to auth/validation; uncoded 404/405s get the
new generic QUIZ_HTTP_ERROR instead of impersonating domain states, and
a non-QuizErrorCode `code` attr can no longer crash the handler.
- The envelope-vs-legacy branch now lives once in quiz_errors.error_content;
main.py's three handlers all call it.
- The system prompt defines stored difficulty='adaptive' history rows
(judge by accuracy) so the quiz-history tool doesn't feed the stepping
rules an undefined token.
- The adaptive migration drops the old CHECK by introspection (name-robust)
and documents migrate-before-deploy ordering.
- bench_quiz_question_cap exits 1 when the baseline cap measured nothing.
- QUIZ_QUESTION_TYPES uses the schema token 'multiple_choice', not 'mcq'.
- _DIFFICULTY_RANK derived from CONCRETE_DIFFICULTIES.
- QuizPanel: humanizeError in both catches (the envelope's sentence now
actually reaches the student) and an "Adaptive · <resolved>" chip in the
active phase; vitest covers the config-driven select path both ways; a
promoted #540 journey pins config-mirroring selects + the adaptive wire
round trip in a real browser.
Skipped (documented): moving difficulty validation into a Pydantic
Literal — the 400 + QUIZ_DIFFICULTY_INVALID contract is published in #540
and changing it to a 422 buys no client anything today; revisit in #537.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…urney
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@AndresL230
AndresL230 merged commit 6081ec5 into mainAug 12, 2026
6 of 8 checks passed
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

quiz A (P0): accept 'adaptive' difficulty, single-source /api/quiz/config, stable error envelope

1 participant

@AndresL230
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })(); feat(quiz): adaptive difficulty, /api/quiz/config, stable error envelope (#540) by AndresL230 · Pull Request #547 · SaplingLearn/Sapling · GitHub
Skip to content

feat(quiz): adaptive difficulty, /api/quiz/config, stable error envelope (#540) - #547

Merged
AndresL230 merged 3 commits into
mainfrom
feat/540-quiz-preflight-a
Aug 12, 2026
Merged

feat(quiz): adaptive difficulty, /api/quiz/config, stable error envelope (#540)#547
AndresL230 merged 3 commits into
mainfrom
feat/540-quiz-preflight-a

Conversation

@AndresL230

@AndresL230AndresL230 commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator

Closes#540. Workstream A of the pre-revamp quiz repair batch (epic #537) — the P0 that ships standalone.

What

A1 — adaptive is a real difficulty. The route accepts it (REQUESTED_DIFFICULTIES), the agent picks the per-question mix under new ADAPTIVE MODE prompt rules (no ±1 clamp; every question still emits a concrete easy|medium|hard), and the attempt row stores the requested value — quiz_attempts_difficulty_check extended by migration 20260812204809. The response now carries requested_difficulty + resolved_difficulty (mode of per-question difficulties, ties break harder) so the client can tell the student what was actually chosen. Both fields are additive; the old client ignores them.

A2 — one source of truth for selector options.GET /api/quiz/config serves num_questions {min,max,options}, difficulties, question_types from services/quiz_config.py; GenerateQuizBody reads the same constants (pinned by a sync test). QuizPanel builds its selects from the endpoint with a static fallback mirroring it — the dead "15 questions" option is gone.

Cap decision (measured, not guessed):scripts/bench_quiz_question_cap.py, 2026-08-12, gemini-2.5-flash-lite:

  • cap 10 — serves fine: 4.6s/5.6s, ~260 in / ~1500 out tokens, 10/10 delivered
  • cap 15 — rejected before generation: HTTP 400 INVALID_ARGUMENT "schema produces a constraint that has too many states for serving"
  • cap 20 — same rejection

10 is a hard ceiling for a single structured call on the quiz model tier, not a preference. Raising it = model-tier change or batched generation = #537 revamp scope.

A3 — stable error envelope. Every quiz-route 4xx/5xx returns { error: { code, message, detail?, request_id } }; codes live in one enum (services/quiz_errors.py::QuizErrorCode); scoped strictly to /api/quiz/* via the main.py handlers, so no other route changes shape.

Wire-contract notes (for the current client)

  • Generate response: additive fields only (requested_difficulty, resolved_difficulty).
  • Error payloads on quiz routes: legacy top-level detail and request_id keys are kept alongside the new error object — frontend/src/lib/api.ts reads data?.detail and keeps working unchanged.
  • QuizPanel.tsx selector lists are now config-driven; defaults (5 / medium) unchanged, so the test(e2e): journey — quiz answer → mastery update (UI + DB) #393 journey is untouched.

Verification

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added adaptive quiz difficulty, selecting easy, medium, or hard per question based on performance history.
    • Added server-driven quiz configuration for question counts, difficulties, and question types.
    • Quiz generation now reports requested and resolved difficulty.
    • Added structured quiz error responses with stable error codes and details.
  • Bug Fixes

    • Improved validation for question counts, difficulty, concepts, and quiz submissions.
    • Added fallback quiz options when configuration cannot be loaded.

…ope (#540)
Workstream A of the pre-revamp quiz repair batch (epic #537):
A1 — 'adaptive' is a real difficulty. The route accepts it, the agent
picks the per-question mix (ADAPTIVE MODE prompt rules), the attempt row
records the request (CHECK extended by migration), and the response
reports requested_difficulty + resolved_difficulty (mode, ties break
harder) so the client can say what was actually chosen.
A2 — GET /api/quiz/config is the single source of truth for selector
values; the Pydantic model reads the same constants. Cap decision is
measured, not guessed: scripts/bench_quiz_question_cap.py shows 15- and
20-question schemas are rejected outright by gemini-2.5-flash-lite
("too many states for serving"), so 10 is a hard ceiling. QuizPanel now
builds its selects from the endpoint (static fallback mirrors it; the
dead "15 questions" option is gone).
A3 — every quiz-route 4xx/5xx returns {error: {code, message, detail?,
request_id}} with codes in services/quiz_errors.py::QuizErrorCode; the
legacy top-level detail key is kept so the current client still works.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@supabase

supabaseBot commented Aug 12, 2026

Copy link
Copy Markdown

This pull request has been ignored for the connected project ybgqdonkoqftwrmweuyv because there are no changes detected in supabase directory. You can change this behaviour in Project Integrations Settings ↗︎.


Preview Branches by Supabase.
Learn more about Supabase Branching ↗︎.

@coderabbitai

coderabbitaiBot commented Aug 12, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@AndresL230, you've reached your PR review limit, so we couldn't start this review.

Next review available in:19 minutes

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 12e52a35-4af8-4ba0-a17a-a7c1770ce8eb

📥 Commits

Reviewing files that changed from the base of the PR and between afc97df and 1bd3512.

📒 Files selected for processing (11)
  • backend/agents/quiz.py
  • backend/db/migrations/20260812204809_quiz_attempts_adaptive_difficulty.sql
  • backend/main.py
  • backend/routes/quiz.py
  • backend/scripts/bench_quiz_question_cap.py
  • backend/services/quiz_config.py
  • backend/services/quiz_errors.py
  • backend/tests/test_quiz_preflight_a.py
  • frontend/e2e/quiz.spec.ts
  • frontend/src/components/QuizPanel.test.tsx
  • frontend/src/components/QuizPanel.tsx
📝 Walkthrough

Walkthrough

The PR adds adaptive quiz difficulty, centralizes quiz configuration, updates question-count validation and persistence constraints, introduces structured quiz errors, and makes frontend selectors load backend-defined options with static fallbacks.

Changes

Adaptive quiz configuration

Layer / File(s)Summary
Centralized quiz configuration and persistence contracts
backend/services/quiz_config.py, backend/models/__init__.py, backend/db/migrations/..., backend/db/e2e_checks/quiz.py, frontend/src/lib/api.ts
Shared question bounds, difficulty values, question types, persistence constraints, and frontend response types now include adaptive configuration.
Adaptive generation and difficulty resolution
backend/agents/quiz.py, backend/routes/quiz.py, backend/scripts/bench_quiz_question_cap.py, backend/tests/test_quiz_preflight_a.py
Adaptive requests delegate per-question difficulty selection to the agent. Generation responses include requested and resolved difficulty. The benchmark tests question caps of 10, 15, and 20.
Quiz error envelopes and route handling
backend/services/quiz_errors.py, backend/main.py, backend/routes/quiz.py
Quiz errors use stable codes, machine-readable details, request IDs, and legacy detail fields. Non-quiz responses retain their existing format.
Configuration-driven quiz selectors
frontend/src/components/QuizPanel.tsx, frontend/src/components/QuizPanel.test.tsx
The panel loads selector options from the backend and uses static options when configuration loading fails. Adaptive difficulty is available in the selectors.

Estimated code review effort: 4 (Complex) | ~45 minutes

Possibly related issues

Possibly related PRs

Sequence Diagram(s)

sequenceDiagram
participant QuizPanel
participant QuizRoute
participant QuizAgent
participant Database
QuizPanel->>QuizRoute: Request adaptive quiz generation
QuizRoute->>Database: Read mastery and recent attempts
QuizRoute->>QuizAgent: Generate questions with adaptive instructions
QuizAgent-->>QuizRoute: Return concrete question difficulties
QuizRoute->>Database: Store quiz attempt
QuizRoute-->>QuizPanel: Return requested and resolved difficulty
Loading

Suggested reviewers:darkest-teddy

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 21.74% which is insufficient. The required threshold is 80.00%.Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check nameStatusExplanation
Title check✅ PassedThe title clearly summarizes the primary changes: adaptive difficulty, the quiz configuration endpoint, and stable error envelopes.
Description check✅ PassedThe description covers the implementation, related issues, testing, compatibility notes, benchmark results, and verification details.
Linked Issues check✅ PassedThe changes address #540 objectives for adaptive difficulty, centralized configuration, measured caps, coded quiz errors, persistence, and real-database testing.
Out of Scope Changes check✅ PassedThe migration, benchmark, tests, frontend updates, documentation, and error handling are all directly related to the linked issue objectives.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/540-quiz-preflight-a

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Aug 12, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-staging1bd3512Commit Preview URL

Branch Preview URL
Aug 12 2026, 09:41 PM

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
backend/db/e2e_checks/quiz.py (1)

35-44: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Exercise the adaptive persistence path.

Line 42 still sends "easy". This E2E flow does not verify that a successful adaptive request stores "adaptive" in quiz_attempts.difficulty. Send an adaptive request and, when generation succeeds, assert the created attempt row has difficulty == "adaptive" before submit.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/db/e2e_checks/quiz.py` around lines 35 - 44, Update the generate
request in the E2E flow to use difficulty "adaptive" instead of "easy". After
successful generation, query the created quiz_attempts row and assert its
difficulty is "adaptive" before submitting the quiz.
🧹 Nitpick comments (1)
frontend/src/components/QuizPanel.tsx (1)

129-137: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Reconcile selected values with loaded quiz configuration.

If the server removes "5" or "medium", the selectors render no selected option but start() still sends those stale values. This defeats the server-defined selector contract.

  • frontend/src/components/QuizPanel.tsx#L129-L137: after accepting a non-empty config payload, use functional state setters to retain a selected value only when it remains allowed; otherwise select the first allowed value.
  • frontend/src/components/QuizPanel.test.tsx#L45-L47: add a successful config mock that excludes "5" and "medium", then assert quiz generation uses the reconciled values.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@frontend/src/components/QuizPanel.tsx` around lines 129 - 137, The quiz
configuration load must reconcile selected count and difficulty values with the
server’s allowed options. In frontend/src/components/QuizPanel.tsx lines
129-137, after accepting a non-empty payload, use functional setters to preserve
each current selection only when allowed, otherwise choose the first allowed
value. In frontend/src/components/QuizPanel.test.tsx lines 45-47, add a
successful config mock excluding "5" and "medium", then assert quiz generation
uses the reconciled selections.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@backend/agents/quiz.py`:
- Around line 116-124: Update the fixed-difficulty rules immediately following
the adaptive-mode instructions in the quiz prompt so they apply only when the
user provides a concrete difficulty request. Prevent adaptive mode from
inheriting rules that shift from or honor a requested difficulty; preserve
adaptive’s mastery-based selection and medium baseline when no history exists.
In `@backend/db/migrations/20260812204809_quiz_attempts_adaptive_difficulty.sql`:
- Around line 9-14: Update the quiz_attempts_difficulty_check replacement in
this migration to add the constraint as NOT VALID, avoiding validation during
the migration transaction. Create a subsequent timestamped migration that
validates quiz_attempts_difficulty_check with VALIDATE CONSTRAINT.
In `@backend/services/quiz_errors.py`:
- Around line 44-51: Update the status-resolution logic using _STATUS_FALLBACK
so unlisted 4xx statuses, including 400, return QUIZ_VALIDATION_ERROR, while
unlisted 5xx statuses continue returning QUIZ_INTERNAL_ERROR. Preserve the
explicit mappings for listed statuses.
In `@backend/tests/test_quiz_preflight_a.py`:
- Around line 14-19: Update the tests in this module to remove the global
TestClient setup and route-internal patching, and use the shared Supabase and
Gemini fixtures provided by tests/conftest.py. Adjust affected test functions to
receive and use those fixtures while preserving their existing assertions and
behavior.
---
Outside diff comments:
In `@backend/db/e2e_checks/quiz.py`:
- Around line 35-44: Update the generate request in the E2E flow to use
difficulty "adaptive" instead of "easy". After successful generation, query the
created quiz_attempts row and assert its difficulty is "adaptive" before
submitting the quiz.
---
Nitpick comments:
In `@frontend/src/components/QuizPanel.tsx`:
- Around line 129-137: The quiz configuration load must reconcile selected count
and difficulty values with the server’s allowed options. In
frontend/src/components/QuizPanel.tsx lines 129-137, after accepting a non-empty
payload, use functional setters to preserve each current selection only when
allowed, otherwise choose the first allowed value. In
frontend/src/components/QuizPanel.test.tsx lines 45-47, add a successful config
mock excluding "5" and "medium", then assert quiz generation uses the reconciled
selections.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: ee118b9c-e231-4709-a0b2-ba45685d6e26

📥 Commits

Reviewing files that changed from the base of the PR and between 7681c48 and afc97df.

📒 Files selected for processing (13)
  • backend/agents/quiz.py
  • backend/db/e2e_checks/quiz.py
  • backend/db/migrations/20260812204809_quiz_attempts_adaptive_difficulty.sql
  • backend/main.py
  • backend/models/__init__.py
  • backend/routes/quiz.py
  • backend/scripts/bench_quiz_question_cap.py
  • backend/services/quiz_config.py
  • backend/services/quiz_errors.py
  • backend/tests/test_quiz_preflight_a.py
  • frontend/src/components/QuizPanel.test.tsx
  • frontend/src/components/QuizPanel.tsx
  • frontend/src/lib/api.ts

Comment threadbackend/agents/quiz.py Outdated
Comment on lines +116 to +124
"ADAPTIVE MODE (#540): when the user message says the quiz is in "
"adaptive mode, there is no user-requested difficulty — you choose "
"each question's difficulty yourself. Base the mix on mastery and "
"`recent_attempts.accuracy`: struggling/low-accuracy concepts get "
"easy-leaning questions, strong/high-accuracy ones get hard-leaning "
"questions; with no history at all, center the mix on medium. The "
"±1-step limits below do not apply in adaptive mode, but every "
"question still carries a concrete easy|medium|hard difficulty — "
"'adaptive' is never a per-question value.\n\n"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Scope the fixed-difficulty rules to non-adaptive requests.

Adaptive mode says there is no requested difficulty and defaults to medium without history. The following rules still instruct the agent to shift from, or honor, a requested difficulty. This gives adaptive generation an undefined baseline.

Limit the rules at lines 125-136 to concrete requests, or define an explicit adaptive baseline for each rule.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/agents/quiz.py` around lines 116 - 124, Update the fixed-difficulty
rules immediately following the adaptive-mode instructions in the quiz prompt so
they apply only when the user provides a concrete difficulty request. Prevent
adaptive mode from inheriting rules that shift from or honor a requested
difficulty; preserve adaptive’s mastery-based selection and medium baseline when
no history exists.

Comment on lines +9 to +14
ALTER TABLE quiz_attempts
DROP CONSTRAINT IF EXISTS quiz_attempts_difficulty_check;

ALTER TABLE quiz_attempts
ADD CONSTRAINT quiz_attempts_difficulty_check
CHECK (difficulty IN ('easy', 'medium', 'hard', 'adaptive'));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bashset -euo pipefail
migrator="$(fd -a '^migrate\.py$' backend/db | head -n1)"test -n "$migrator"
ast-grep outline "$migrator" --items all
rg -n -C 5 'BEGIN|COMMIT|transaction|execute|migrations'"$migrator"

Repository: SaplingLearn/Sapling

Length of output: 7968


🏁 Script executed:

#!/bin/bashset -euo pipefail
migration="$(fd -a '20260812204809_quiz_attempts_adaptive_difficulty\.sql$' backend/db/migrations | head -n1)"test -n "$migration"
cat -n "$migration"printf'\nConstraint migration patterns:\n'
rg -n -C 4 'ADD CONSTRAINT|DROP CONSTRAINT|NOT VALID|VALIDATE CONSTRAINT' backend/db/migrations

Repository: SaplingLearn/Sapling

Length of output: 19373


🏁 Script executed:

#!/bin/bashset -euo pipefail
python3 - <<'PY'from pathlib import Pathimport rerunner = Path("backend/db/migrate.py").read_text()migration = Path("backend/db/migrations/20260812204809_quiz_attempts_adaptive_difficulty.sql").read_text()apply = re.search( r"def apply_migration\(.*?^\s*def run\(", runner, re.MULTILINE | re.DOTALL,)assert apply, "apply_migration block not found"block = apply.group(0)checks = { "executes migration SQL": "cur.execute(path.read_text" in block, "records ledger before commit": block.find("INSERT INTO schema_migrations") < block.find("conn.commit()"), "commits once per migration": block.count("conn.commit()") == 1, "current constraint is not NOT VALID": "NOT VALID" not in migration.upper(),}for name, result in checks.items(): print(f"{name}: {result}")assert all(checks.values())PY

Repository: SaplingLearn/Sapling

Length of output: 297


🌐 Web query:

PostgreSQL documentation ALTER TABLE ADD CHECK NOT VALID lock level VALIDATE CONSTRAINT

💡 Result:

In PostgreSQL, the VALIDATE CONSTRAINT command acquires a SHARE UPDATE EXCLUSIVE lock on the table being altered [1][2]. This lock level is specifically designed to allow concurrent reads, updates, inserts, and deletes, as the validation process only needs to check pre-existing rows [1][2]. Because new rows inserted or updated after the constraint was initially added (using NOT VALID) are already being enforced by the constraint, they do not need to be scanned during the validation phase [1][2]. If the constraint being validated is a foreign key, an additional ROW SHARE lock is required on the referenced table [1][2]. When using ALTER TABLE ADD CONSTRAINT with the NOT VALID option, the command itself requires a SHARE ROW EXCLUSIVE lock (for check constraints) [3][4], which is less restrictive than the ACCESS EXCLUSIVE lock required for standard constraint addition without NOT VALID [1][2]. This allows the initial constraint definition to proceed without blocking concurrent data modifications [1][2].

Citations:


Avoid full-table blocking validation in this migration.

backend/db/migrate.py commits each migration after executing its SQL and ledger insert. The current ADD CONSTRAINT therefore holds an ACCESS EXCLUSIVE lock on quiz_attempts through the validation scan. Add the replacement constraint as NOT VALID, then validate it in a later timestamped migration with VALIDATE CONSTRAINT.

🧰 Tools
🪛 Squawk (2.61.0)

[warning] 13-14: By default new constraints require a table scan and block writes to the table while that scan occurs. Use NOT VALID with a later VALIDATE CONSTRAINT call.

(constraint-missing-not-valid)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/db/migrations/20260812204809_quiz_attempts_adaptive_difficulty.sql`
around lines 9 - 14, Update the quiz_attempts_difficulty_check replacement in
this migration to add the constraint as NOT VALID, avoiding validation during
the migration transaction. Create a subsequent timestamped migration that
validates quiz_attempts_difficulty_check with VALIDATE CONSTRAINT.

Source: Linters/SAST tools

Comment on lines +44 to +51
_STATUS_FALLBACK: dict[int, QuizErrorCode] = {
401: QuizErrorCode.QUIZ_NOT_AUTHORIZED,
403: QuizErrorCode.QUIZ_NOT_AUTHORIZED,
404: QuizErrorCode.QUIZ_ATTEMPT_NOT_FOUND,
409: QuizErrorCode.QUIZ_ATTEMPT_ALREADY_COMPLETED,
422: QuizErrorCode.QUIZ_VALIDATION_ERROR,
502: QuizErrorCode.QUIZ_GENERATION_FAILED,
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Map unlisted 4xx statuses to a client-error code.

A plain quiz-route HTTPException(status_code=400) resolves to QUIZ_INTERNAL_ERROR. This conflicts with the stable error-code contract because the response reports a client error as an internal failure.

Use QUIZ_VALIDATION_ERROR as the fallback for unlisted 4xx statuses. Keep QUIZ_INTERNAL_ERROR as the fallback for 5xx statuses.

Proposed fix
- resolved_code = code or _STATUS_FALLBACK.get(- status_code,- QuizErrorCode.QUIZ_INTERNAL_ERROR,- )+ resolved_code = code or _STATUS_FALLBACK.get(+ status_code,+ (+ QuizErrorCode.QUIZ_VALIDATION_ERROR+ if 400 <= status_code < 500+ else QuizErrorCode.QUIZ_INTERNAL_ERROR+ ),+ )

Also applies to: 87-90

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/services/quiz_errors.py` around lines 44 - 51, Update the
status-resolution logic using _STATUS_FALLBACK so unlisted 4xx statuses,
including 400, return QUIZ_VALIDATION_ERROR, while unlisted 5xx statuses
continue returning QUIZ_INTERNAL_ERROR. Preserve the explicit mappings for
listed statuses.

Comment on lines +14 to +19
from fastapi.testclient import TestClient

from main import app
from agents.quiz import Quiz, QuizQuestion

client = TestClient(app)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Use the shared backend fixtures.

This module creates its own global TestClient and patches route internals. Use the shared Supabase and Gemini fixtures from tests/conftest.py instead. This keeps backend test setup consistent and prevents fixture behavior from being bypassed.

As per coding guidelines, backend/tests/**/*.py must “use the shared Supabase and Gemini fixtures from tests/conftest.py.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/tests/test_quiz_preflight_a.py` around lines 14 - 19, Update the
tests in this module to remove the global TestClient setup and route-internal
patching, and use the shared Supabase and Gemini fixtures provided by
tests/conftest.py. Adjust affected test functions to receive and use those
fixtures while preserving their existing assertions and behavior.

Source: Coding guidelines

AndresL230and others added 2 commits August 12, 2026 17:31
… semantics, config-driven coverage
Review findings (xhigh, 15 defects):
- 422s on num_questions only get QUIZ_COUNT_OUT_OF_RANGE for actual
bounds violations; type errors stay QUIZ_VALIDATION_ERROR.
- _STATUS_FALLBACK narrowed to auth/validation; uncoded 404/405s get the
new generic QUIZ_HTTP_ERROR instead of impersonating domain states, and
a non-QuizErrorCode `code` attr can no longer crash the handler.
- The envelope-vs-legacy branch now lives once in quiz_errors.error_content;
main.py's three handlers all call it.
- The system prompt defines stored difficulty='adaptive' history rows
(judge by accuracy) so the quiz-history tool doesn't feed the stepping
rules an undefined token.
- The adaptive migration drops the old CHECK by introspection (name-robust)
and documents migrate-before-deploy ordering.
- bench_quiz_question_cap exits 1 when the baseline cap measured nothing.
- QUIZ_QUESTION_TYPES uses the schema token 'multiple_choice', not 'mcq'.
- _DIFFICULTY_RANK derived from CONCRETE_DIFFICULTIES.
- QuizPanel: humanizeError in both catches (the envelope's sentence now
actually reaches the student) and an "Adaptive · <resolved>" chip in the
active phase; vitest covers the config-driven select path both ways; a
promoted #540 journey pins config-mirroring selects + the adaptive wire
round trip in a real browser.
Skipped (documented): moving difficulty validation into a Pydantic
Literal — the 400 + QUIZ_DIFFICULTY_INVALID contract is published in #540
and changing it to a 422 buys no client anything today; revisit in #537.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…urney
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@AndresL230
AndresL230 merged commit 6081ec5 into mainAug 12, 2026
6 of 8 checks passed
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

quiz A (P0): accept 'adaptive' difficulty, single-source /api/quiz/config, stable error envelope

1 participant

@AndresL230