Skip to content

feat(quiz): adaptive iteration — spaced repetition + history + difficulty - #77

Merged
Jose-Gael-Cruz-Lopez merged 5 commits into
mainfrom
feat/quiz-spaced-repetition
May 5, 2026
Merged

feat(quiz): adaptive iteration — spaced repetition + history + difficulty#77
Jose-Gael-Cruz-Lopez merged 5 commits into
mainfrom
feat/quiz-spaced-repetition

Conversation

@Jose-Gael-Cruz-Lopez

@Jose-Gael-Cruz-LopezJose-Gael-Cruz-Lopez commented May 5, 2026

Copy link
Copy Markdown
Member

Summary

Closes the three "doesn't do (yet)" gaps from ADR 0013 with one new tool and a prompt update on quiz_agent. No wire-format change, no fallback-contract change, no new agent.

  • Spaced repetition — prompt now weights graph_nodes.last_studied_at. Stale (~7d+) and unreviewed (null) concepts surface even when their mastery is mid-tier.
  • Adaptive difficulty — agent reads recent attempt accuracy and modulates the difficulty mix. Bounded to one step in either direction so it can't override the user-requested difficulty by more than that.
  • Quiz-attempt history on the agent path — new read_recent_quiz_attempts(concept_node_id) tool exposes the per-(user, concept) digest from quiz_context plus the last 5 completed quiz_attempts rows (newest first, accuracy precomputed). The legacy fallback already read this; the agent path now does too.

Prompt hash bumps from 17ab80b30316358613666dbc.

Files

  • backend/agents/tools/quiz_history.py — new pure-async function + _tool wrapper. completed_at IS NOT NULL filter excludes the in-flight row that generate_quiz writes pre-submission. DB failures degrade silently to empty history.
  • backend/agents/quiz.py — registers the new tool; system prompt adds explicit spaced-repetition + adaptive-difficulty rule blocks.
  • backend/routes/quiz.py_quiz_via_agent user message nudges the agent to call the new tool with the target concept_node_id.
  • docs/decisions/0014-adaptive-quiz-iteration.md — captures the decision, deliberate non-goals (no decay-formula spaced rep, no cross-concept history yet, no tool consolidation), and a single-revert rollback path.
  • backend/tests/test_quiz_history_tool.py (+9 tests) — pins shape coercion (_coerce_summary handles legacy string, {summary: ...}, and {misconceptions, weak_areas} shapes), accuracy math + clamping, the filter wiring (completed_at=not.is.null, order=completed_at.desc, limit=5), and the silent-degrade-on-DB-error contract.
  • backend/tests/test_quiz_agent_imports.py — extended to assert the new tool is registered.

Why this isn't refactor #4

ADR 0005 carved out "adaptive quiz history" as a future iteration on the same agent, not a separate refactor. This ships under that carve-out: no new agent, no new route, no wire-format change, no fallback-contract change.

Test plan

  • pytest tests/test_quiz_history_tool.py tests/test_quiz_agent_imports.py tests/test_quiz_routes.py tests/test_graph_read_tools.py -q → 51 passed
  • Verified _PROMPT_HASH round-trips and is logged on every quiz run via the existing Logfire instrumentation
  • Live-mode eval check (SAPLING_EVAL_MODE=live pytest tests/evals/quiz_generation.py -q) — the prompt-driven adaptive behaviors are LLM-decided; unit tests can only pin the tool's I/O contract. Recommended before merge.
  • Manual: trigger a quiz on a concept with prior attempts and confirm the agent's distractors mirror the per-student summary in quiz_context.

Rollback

Single-revert clean: revert this commit and the tool import + registration disappear, the prompt reverts to 17ab80b30316, and the route's user message reverts to refactor-#2 wording. The new quiz_history.py file is pure-leaf (no other module imports it), so it's harmless after rollback.

🤖 Generated with Claude Code

Summary by CodeRabbit

Release Notes

  • New Features

    • Quizzes now adapt difficulty based on your recent quiz performance and accuracy scores.
    • System identifies both your weakest concepts and those you haven't practiced recently.
    • Question difficulty automatically adjusts within reasonable bounds based on recent attempts.
    • Question distractors now incorporate both common misconceptions and your personal prior mistakes.
  • Documentation

    • Added architecture decision documenting adaptive quiz iteration behavior.

…ifficulty
Closes the three "doesn't do (yet)" gaps from ADR 0013 with one new
tool and a prompt update. No wire-format change, no fallback-contract
change.
- agents/tools/quiz_history.py — read_recent_quiz_attempts surfaces
the per-(user, concept) digest from quiz_context plus the last 5
completed attempts (newest first, accuracy precomputed). DB
failures degrade silently to empty history.
- agents/quiz.py — registers the new tool, expands prompt with
spaced-repetition rules (weight last_reviewed_at on graph_nodes)
and adaptive-difficulty rules (modulate by recent_attempts.accuracy
with a one-step bound). Prompt hash bumps to 358613666dbc.
- routes/quiz.py — _quiz_via_agent user message nudges the agent to
call the new tool with the target concept_node_id.
- docs/decisions/0014-adaptive-quiz-iteration.md — captures the
decision, what we deliberately didn't do, and the rollback path.
- Tests: new test_quiz_history_tool.py pins shape coercion, the
completed_at IS NOT NULL filter, and silent-degrade contract;
test_quiz_agent_imports.py asserts the new tool is registered.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitaiBot commented May 5, 2026

Copy link
Copy Markdown

Warning

Rate limit exceeded

@Jose-Gael-Cruz-Lopez has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 34 minutes and 35 seconds before requesting another review.

To keep reviews running without waiting, you can enable usage-based add-on for your organization. This allows additional reviews beyond the hourly cap. Account admins can enable it under billing.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 40d6e941-69bf-4bec-9878-1012e8e6aacf

📥 Commits

Reviewing files that changed from the base of the PR and between 1a47fec and e2112a8.

📒 Files selected for processing (5)
  • backend/agents/tools/quiz_history.py
  • backend/routes/quiz.py
  • backend/tests/evals/quiz_generation.py
  • backend/tests/test_quiz_history_tool.py
  • docs/decisions/0014-adaptive-quiz-iteration.md
📝 Walkthrough

Walkthrough

A new tool read_recent_quiz_attempts retrieves a student's recent quiz performance for a target concept, and the quiz agent system prompt is extended to call this tool and adapt question difficulty based on recent attempt accuracy. Supporting changes include agent tool registration, route workflow updates to nudge the agent toward calling the tool, comprehensive tests, and an architectural decision record.

Changes

Adaptive Quiz Difficulty via Recent Attempts

Layer / File(s)Summary
Data Models & Tool Implementation
backend/agents/tools/quiz_history.py
New models RecentQuizAttempt and QuizHistory encapsulate attempt history with accuracy. Core function read_recent_quiz_attempts fetches quiz context summary and recent completed attempts from Supabase via asyncio.to_thread, coerces context JSON (supporting multiple shapes), parses attempt scores, validates and clamps accuracy to [0.0, 1.0], and degrades gracefully on DB errors. Tool wrapper read_recent_quiz_attempts_tool bridges the async function to Pydantic AI context.
Agent Prompt & Tool Registration
backend/agents/quiz.py
Import and register read_recent_quiz_attempts_tool in the quiz agent. Extend _SYSTEM_PROMPT to instruct calling the tool with the target concept and to incorporate recent_attempts.accuracy (with empty-list fallback) into adaptive-difficulty rules and distractor guidance alongside existing misconception data.
Route Workflow
backend/routes/quiz.py
Update _quiz_via_agent's user message to instruct the agent to identify both weakest and "stalest" concepts via read_concepts_for_user, and to call read_recent_quiz_attempts(concept_node_id) to adapt difficulty based on recent scoring.
Tests & Documentation
backend/tests/test_quiz_agent_imports.py, backend/tests/test_quiz_history_tool.py, docs/decisions/0014-adaptive-quiz-iteration.md
Verify tool registration in agent. Comprehensive unit tests cover summary coercion edge cases (None, dicts with summary/notes keys, list flattening), async read_recent_quiz_attempts behavior (empty history, summary extraction, accuracy math with invalid-row skipping, DB error degradation, query filter/order/limit wiring). ADR documents feature intent, implementation, non-goals, and rollback plan.

Sequence Diagram

sequenceDiagram
participant Router as Quiz Route
participant Agent as Quiz Agent<br/>(Gemini)
participant HistoryTool as History Tool
participant DB as Supabase<br/>(quiz_context,<br/>quiz_attempts)
Router->>Agent: user_message: find weakest,<br/>stalest concepts & call<br/>read_recent_quiz_attempts
Agent->>Agent: Call read_concepts_for_user<br/>(concept selection)
loop For each selected concept
Agent->>HistoryTool: read_recent_quiz_attempts<br/>(concept_node_id)
HistoryTool->>DB: Fetch quiz_context<br/>summary (async)
HistoryTool->>DB: Fetch quiz_attempts<br/>(completed_at ≠ null,<br/>limit 5, order recent)
DB-->>HistoryTool: context + attempts
HistoryTool->>HistoryTool: Coerce summary<br/>(handle legacy shapes)
HistoryTool->>HistoryTool: Parse & validate<br/>score/total, compute<br/>accuracy ∈ [0,1]
HistoryTool-->>Agent: QuizHistory{summary,<br/>recent_attempts}
end
Agent->>Agent: Generate questions<br/>with difficulty adapted<br/>by recent_attempts.accuracy
Agent-->>Router: Quiz with adaptive<br/>difficulty
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Poem

🐰 A quiz that learns, oh what delight!
Past attempts now guide the next fight,
Spaced out stalest, recent accuracy strong,
The agent whispers: "Here's where you belong!"
Adaptive hops toward mastery's light. 🌟

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 22.22% 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 main changes: adaptive iteration incorporating spaced repetition, history tracking, and adaptive difficulty adjustments to the quiz feature.
Description check✅ PassedThe description comprehensively addresses all required template sections: clear summary, detailed changes, related issues (Closes), testing status, and notes for reviewers. All critical information is present and well-organized.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/quiz-spaced-repetition

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 and usage tips.

@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented May 5, 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
frontende2112a8Commit Preview URL

Branch Preview URL
May 05 2026, 02:40 AM

Jose-Gael-Cruz-Lopezand others added 4 commits May 4, 2026 22:22
…nits
Closes the four issues flagged on PR #77's self-review.
- tests/evals/quiz_generation.py — adds 2 cases (count: 8 -> 10) and
2 evaluators that pin the new prompt rules structurally:
- AdaptiveDifficultyEvaluator: requested vs. produced difficulty
rank stays within ±1 step. Permits the prompt's allowed adaptive
shift, flags overshoots.
- SpacedRepetitionConceptEvaluator: when metadata names a
`stale_concept`, at least one question must target it.
Smoke-tested against synthetic Quiz outputs: bounds in → 1.0,
overshoots → 0.0.
- agents/tools/quiz_history.py — drop rows with score outside
[0, total] entirely (with a logger.warning) instead of clamping
accuracy and passing impossible numbers (score=7, total=5) to the
LLM. Matching test_corrupt_rows_are_dropped replaces the old
clamp-asserting test.
- agents/tools/quiz_history.py — read_recent_quiz_attempts_tool
docstring is now LLM-facing ("returns this student's history…")
instead of engineering-facing. Pydantic AI surfaces this as the
tool's description to the model.
- routes/quiz.py — _quiz_via_agent user message trimmed to
routing-only; the workflow + adaptive rules already live in the
system prompt and don't need to be restated per request.
- docs/decisions/0014-adaptive-quiz-iteration.md — Date corrected
(2026-05-03 → 2026-05-04). Eval-coverage section updated to
describe the two new structural sentinels.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes the two issues from PR #77's second review.
- AdaptiveDifficultyEvaluator now scores per-question (fraction
compliant) instead of average rank. The prompt rule is per-question
("Never override the user-requested difficulty by more than one
step"); the previous avg-based check let a 2-step outlier slip
through if the rest of the mix balanced it out — e.g. requested
hard with [easy, hard, hard, hard] used to score 1.0 (avg=1.5,
within 1 of target=2) and now correctly scores 0.75. Switched to
subscript _DIFF_RANK[q.difficulty] (Literal-constrained, fallback
was unreachable).
- The two new ADR-0014 cases now carry NOTE comments explaining that
recency/staleness state is baked into the user message for replay
determinism, while production sources it via read_recent_quiz_attempts.
The cases pin the prompt's rule application; live-mode evals are
the right place to catch tool-wiring regressions.
Smoke-tested: hard + [easy, hard, hard, hard] -> 0.75; hard + all
medium -> 1.0 (allowed shift); hard + all easy -> 0.0 (overshoot).
46 quiz unit tests still pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The section comment above adaptive_downshift_struggling_student
described the old average-based scoring; commit 6493988 rewrote the
evaluator to per-question fraction but missed this comment. Bring the
prose in line with the code so future readers don't trust an outdated
description.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
submit_quiz writes score+total atomically (routes/quiz.py:426), so any
row with completed_at IS NOT NULL but a null score or null total is
corruption. The previous coercion (`r.get('score') or 0`) silently
turned that into a 0/5 = 0% accuracy reading, which the LLM could
trust and use to trigger a spurious adaptive downshift on a perfectly
healthy concept.
Drop those rows alongside the existing score-out-of-bounds drop
branch (with the same logger.warning treatment) and tighten the test
to cover null score, null total, and total=0 in one sweep.
Surfaced by an independent code-reviewer pass on PR #77.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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.

1 participant

@Jose-Gael-Cruz-Lopez
, '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 iteration — spaced repetition + history + difficulty by Jose-Gael-Cruz-Lopez · Pull Request #77 · SaplingLearn/Sapling · GitHub
Skip to content

feat(quiz): adaptive iteration — spaced repetition + history + difficulty - #77

Merged
Jose-Gael-Cruz-Lopez merged 5 commits into
mainfrom
feat/quiz-spaced-repetition
May 5, 2026
Merged

feat(quiz): adaptive iteration — spaced repetition + history + difficulty#77
Jose-Gael-Cruz-Lopez merged 5 commits into
mainfrom
feat/quiz-spaced-repetition

Conversation

@Jose-Gael-Cruz-Lopez

@Jose-Gael-Cruz-LopezJose-Gael-Cruz-Lopez commented May 5, 2026

Copy link
Copy Markdown
Member

Summary

Closes the three "doesn't do (yet)" gaps from ADR 0013 with one new tool and a prompt update on quiz_agent. No wire-format change, no fallback-contract change, no new agent.

  • Spaced repetition — prompt now weights graph_nodes.last_studied_at. Stale (~7d+) and unreviewed (null) concepts surface even when their mastery is mid-tier.
  • Adaptive difficulty — agent reads recent attempt accuracy and modulates the difficulty mix. Bounded to one step in either direction so it can't override the user-requested difficulty by more than that.
  • Quiz-attempt history on the agent path — new read_recent_quiz_attempts(concept_node_id) tool exposes the per-(user, concept) digest from quiz_context plus the last 5 completed quiz_attempts rows (newest first, accuracy precomputed). The legacy fallback already read this; the agent path now does too.

Prompt hash bumps from 17ab80b30316358613666dbc.

Files

  • backend/agents/tools/quiz_history.py — new pure-async function + _tool wrapper. completed_at IS NOT NULL filter excludes the in-flight row that generate_quiz writes pre-submission. DB failures degrade silently to empty history.
  • backend/agents/quiz.py — registers the new tool; system prompt adds explicit spaced-repetition + adaptive-difficulty rule blocks.
  • backend/routes/quiz.py_quiz_via_agent user message nudges the agent to call the new tool with the target concept_node_id.
  • docs/decisions/0014-adaptive-quiz-iteration.md — captures the decision, deliberate non-goals (no decay-formula spaced rep, no cross-concept history yet, no tool consolidation), and a single-revert rollback path.
  • backend/tests/test_quiz_history_tool.py (+9 tests) — pins shape coercion (_coerce_summary handles legacy string, {summary: ...}, and {misconceptions, weak_areas} shapes), accuracy math + clamping, the filter wiring (completed_at=not.is.null, order=completed_at.desc, limit=5), and the silent-degrade-on-DB-error contract.
  • backend/tests/test_quiz_agent_imports.py — extended to assert the new tool is registered.

Why this isn't refactor #4

ADR 0005 carved out "adaptive quiz history" as a future iteration on the same agent, not a separate refactor. This ships under that carve-out: no new agent, no new route, no wire-format change, no fallback-contract change.

Test plan

  • pytest tests/test_quiz_history_tool.py tests/test_quiz_agent_imports.py tests/test_quiz_routes.py tests/test_graph_read_tools.py -q → 51 passed
  • Verified _PROMPT_HASH round-trips and is logged on every quiz run via the existing Logfire instrumentation
  • Live-mode eval check (SAPLING_EVAL_MODE=live pytest tests/evals/quiz_generation.py -q) — the prompt-driven adaptive behaviors are LLM-decided; unit tests can only pin the tool's I/O contract. Recommended before merge.
  • Manual: trigger a quiz on a concept with prior attempts and confirm the agent's distractors mirror the per-student summary in quiz_context.

Rollback

Single-revert clean: revert this commit and the tool import + registration disappear, the prompt reverts to 17ab80b30316, and the route's user message reverts to refactor-#2 wording. The new quiz_history.py file is pure-leaf (no other module imports it), so it's harmless after rollback.

🤖 Generated with Claude Code

Summary by CodeRabbit

Release Notes

  • New Features

    • Quizzes now adapt difficulty based on your recent quiz performance and accuracy scores.
    • System identifies both your weakest concepts and those you haven't practiced recently.
    • Question difficulty automatically adjusts within reasonable bounds based on recent attempts.
    • Question distractors now incorporate both common misconceptions and your personal prior mistakes.
  • Documentation

    • Added architecture decision documenting adaptive quiz iteration behavior.

…ifficulty
Closes the three "doesn't do (yet)" gaps from ADR 0013 with one new
tool and a prompt update. No wire-format change, no fallback-contract
change.
- agents/tools/quiz_history.py — read_recent_quiz_attempts surfaces
the per-(user, concept) digest from quiz_context plus the last 5
completed attempts (newest first, accuracy precomputed). DB
failures degrade silently to empty history.
- agents/quiz.py — registers the new tool, expands prompt with
spaced-repetition rules (weight last_reviewed_at on graph_nodes)
and adaptive-difficulty rules (modulate by recent_attempts.accuracy
with a one-step bound). Prompt hash bumps to 358613666dbc.
- routes/quiz.py — _quiz_via_agent user message nudges the agent to
call the new tool with the target concept_node_id.
- docs/decisions/0014-adaptive-quiz-iteration.md — captures the
decision, what we deliberately didn't do, and the rollback path.
- Tests: new test_quiz_history_tool.py pins shape coercion, the
completed_at IS NOT NULL filter, and silent-degrade contract;
test_quiz_agent_imports.py asserts the new tool is registered.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitaiBot commented May 5, 2026

Copy link
Copy Markdown

Warning

Rate limit exceeded

@Jose-Gael-Cruz-Lopez has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 34 minutes and 35 seconds before requesting another review.

To keep reviews running without waiting, you can enable usage-based add-on for your organization. This allows additional reviews beyond the hourly cap. Account admins can enable it under billing.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 40d6e941-69bf-4bec-9878-1012e8e6aacf

📥 Commits

Reviewing files that changed from the base of the PR and between 1a47fec and e2112a8.

📒 Files selected for processing (5)
  • backend/agents/tools/quiz_history.py
  • backend/routes/quiz.py
  • backend/tests/evals/quiz_generation.py
  • backend/tests/test_quiz_history_tool.py
  • docs/decisions/0014-adaptive-quiz-iteration.md
📝 Walkthrough

Walkthrough

A new tool read_recent_quiz_attempts retrieves a student's recent quiz performance for a target concept, and the quiz agent system prompt is extended to call this tool and adapt question difficulty based on recent attempt accuracy. Supporting changes include agent tool registration, route workflow updates to nudge the agent toward calling the tool, comprehensive tests, and an architectural decision record.

Changes

Adaptive Quiz Difficulty via Recent Attempts

Layer / File(s)Summary
Data Models & Tool Implementation
backend/agents/tools/quiz_history.py
New models RecentQuizAttempt and QuizHistory encapsulate attempt history with accuracy. Core function read_recent_quiz_attempts fetches quiz context summary and recent completed attempts from Supabase via asyncio.to_thread, coerces context JSON (supporting multiple shapes), parses attempt scores, validates and clamps accuracy to [0.0, 1.0], and degrades gracefully on DB errors. Tool wrapper read_recent_quiz_attempts_tool bridges the async function to Pydantic AI context.
Agent Prompt & Tool Registration
backend/agents/quiz.py
Import and register read_recent_quiz_attempts_tool in the quiz agent. Extend _SYSTEM_PROMPT to instruct calling the tool with the target concept and to incorporate recent_attempts.accuracy (with empty-list fallback) into adaptive-difficulty rules and distractor guidance alongside existing misconception data.
Route Workflow
backend/routes/quiz.py
Update _quiz_via_agent's user message to instruct the agent to identify both weakest and "stalest" concepts via read_concepts_for_user, and to call read_recent_quiz_attempts(concept_node_id) to adapt difficulty based on recent scoring.
Tests & Documentation
backend/tests/test_quiz_agent_imports.py, backend/tests/test_quiz_history_tool.py, docs/decisions/0014-adaptive-quiz-iteration.md
Verify tool registration in agent. Comprehensive unit tests cover summary coercion edge cases (None, dicts with summary/notes keys, list flattening), async read_recent_quiz_attempts behavior (empty history, summary extraction, accuracy math with invalid-row skipping, DB error degradation, query filter/order/limit wiring). ADR documents feature intent, implementation, non-goals, and rollback plan.

Sequence Diagram

sequenceDiagram
participant Router as Quiz Route
participant Agent as Quiz Agent<br/>(Gemini)
participant HistoryTool as History Tool
participant DB as Supabase<br/>(quiz_context,<br/>quiz_attempts)
Router->>Agent: user_message: find weakest,<br/>stalest concepts & call<br/>read_recent_quiz_attempts
Agent->>Agent: Call read_concepts_for_user<br/>(concept selection)
loop For each selected concept
Agent->>HistoryTool: read_recent_quiz_attempts<br/>(concept_node_id)
HistoryTool->>DB: Fetch quiz_context<br/>summary (async)
HistoryTool->>DB: Fetch quiz_attempts<br/>(completed_at ≠ null,<br/>limit 5, order recent)
DB-->>HistoryTool: context + attempts
HistoryTool->>HistoryTool: Coerce summary<br/>(handle legacy shapes)
HistoryTool->>HistoryTool: Parse & validate<br/>score/total, compute<br/>accuracy ∈ [0,1]
HistoryTool-->>Agent: QuizHistory{summary,<br/>recent_attempts}
end
Agent->>Agent: Generate questions<br/>with difficulty adapted<br/>by recent_attempts.accuracy
Agent-->>Router: Quiz with adaptive<br/>difficulty
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Poem

🐰 A quiz that learns, oh what delight!
Past attempts now guide the next fight,
Spaced out stalest, recent accuracy strong,
The agent whispers: "Here's where you belong!"
Adaptive hops toward mastery's light. 🌟

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 22.22% 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 main changes: adaptive iteration incorporating spaced repetition, history tracking, and adaptive difficulty adjustments to the quiz feature.
Description check✅ PassedThe description comprehensively addresses all required template sections: clear summary, detailed changes, related issues (Closes), testing status, and notes for reviewers. All critical information is present and well-organized.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/quiz-spaced-repetition

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 and usage tips.

@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented May 5, 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
frontende2112a8Commit Preview URL

Branch Preview URL
May 05 2026, 02:40 AM

Jose-Gael-Cruz-Lopezand others added 4 commits May 4, 2026 22:22
…nits
Closes the four issues flagged on PR #77's self-review.
- tests/evals/quiz_generation.py — adds 2 cases (count: 8 -> 10) and
2 evaluators that pin the new prompt rules structurally:
- AdaptiveDifficultyEvaluator: requested vs. produced difficulty
rank stays within ±1 step. Permits the prompt's allowed adaptive
shift, flags overshoots.
- SpacedRepetitionConceptEvaluator: when metadata names a
`stale_concept`, at least one question must target it.
Smoke-tested against synthetic Quiz outputs: bounds in → 1.0,
overshoots → 0.0.
- agents/tools/quiz_history.py — drop rows with score outside
[0, total] entirely (with a logger.warning) instead of clamping
accuracy and passing impossible numbers (score=7, total=5) to the
LLM. Matching test_corrupt_rows_are_dropped replaces the old
clamp-asserting test.
- agents/tools/quiz_history.py — read_recent_quiz_attempts_tool
docstring is now LLM-facing ("returns this student's history…")
instead of engineering-facing. Pydantic AI surfaces this as the
tool's description to the model.
- routes/quiz.py — _quiz_via_agent user message trimmed to
routing-only; the workflow + adaptive rules already live in the
system prompt and don't need to be restated per request.
- docs/decisions/0014-adaptive-quiz-iteration.md — Date corrected
(2026-05-03 → 2026-05-04). Eval-coverage section updated to
describe the two new structural sentinels.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes the two issues from PR #77's second review.
- AdaptiveDifficultyEvaluator now scores per-question (fraction
compliant) instead of average rank. The prompt rule is per-question
("Never override the user-requested difficulty by more than one
step"); the previous avg-based check let a 2-step outlier slip
through if the rest of the mix balanced it out — e.g. requested
hard with [easy, hard, hard, hard] used to score 1.0 (avg=1.5,
within 1 of target=2) and now correctly scores 0.75. Switched to
subscript _DIFF_RANK[q.difficulty] (Literal-constrained, fallback
was unreachable).
- The two new ADR-0014 cases now carry NOTE comments explaining that
recency/staleness state is baked into the user message for replay
determinism, while production sources it via read_recent_quiz_attempts.
The cases pin the prompt's rule application; live-mode evals are
the right place to catch tool-wiring regressions.
Smoke-tested: hard + [easy, hard, hard, hard] -> 0.75; hard + all
medium -> 1.0 (allowed shift); hard + all easy -> 0.0 (overshoot).
46 quiz unit tests still pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The section comment above adaptive_downshift_struggling_student
described the old average-based scoring; commit 6493988 rewrote the
evaluator to per-question fraction but missed this comment. Bring the
prose in line with the code so future readers don't trust an outdated
description.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
submit_quiz writes score+total atomically (routes/quiz.py:426), so any
row with completed_at IS NOT NULL but a null score or null total is
corruption. The previous coercion (`r.get('score') or 0`) silently
turned that into a 0/5 = 0% accuracy reading, which the LLM could
trust and use to trigger a spurious adaptive downshift on a perfectly
healthy concept.
Drop those rows alongside the existing score-out-of-bounds drop
branch (with the same logger.warning treatment) and tighten the test
to cover null score, null total, and total=0 in one sweep.
Surfaced by an independent code-reviewer pass on PR #77.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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.

1 participant

@Jose-Gael-Cruz-Lopez
, '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 iteration — spaced repetition + history + difficulty by Jose-Gael-Cruz-Lopez · Pull Request #77 · SaplingLearn/Sapling · GitHub
Skip to content

feat(quiz): adaptive iteration — spaced repetition + history + difficulty - #77

Merged
Jose-Gael-Cruz-Lopez merged 5 commits into
mainfrom
feat/quiz-spaced-repetition
May 5, 2026
Merged

feat(quiz): adaptive iteration — spaced repetition + history + difficulty#77
Jose-Gael-Cruz-Lopez merged 5 commits into
mainfrom
feat/quiz-spaced-repetition

Conversation

@Jose-Gael-Cruz-Lopez

@Jose-Gael-Cruz-LopezJose-Gael-Cruz-Lopez commented May 5, 2026

Copy link
Copy Markdown
Member

Summary

Closes the three "doesn't do (yet)" gaps from ADR 0013 with one new tool and a prompt update on quiz_agent. No wire-format change, no fallback-contract change, no new agent.

  • Spaced repetition — prompt now weights graph_nodes.last_studied_at. Stale (~7d+) and unreviewed (null) concepts surface even when their mastery is mid-tier.
  • Adaptive difficulty — agent reads recent attempt accuracy and modulates the difficulty mix. Bounded to one step in either direction so it can't override the user-requested difficulty by more than that.
  • Quiz-attempt history on the agent path — new read_recent_quiz_attempts(concept_node_id) tool exposes the per-(user, concept) digest from quiz_context plus the last 5 completed quiz_attempts rows (newest first, accuracy precomputed). The legacy fallback already read this; the agent path now does too.

Prompt hash bumps from 17ab80b30316358613666dbc.

Files

  • backend/agents/tools/quiz_history.py — new pure-async function + _tool wrapper. completed_at IS NOT NULL filter excludes the in-flight row that generate_quiz writes pre-submission. DB failures degrade silently to empty history.
  • backend/agents/quiz.py — registers the new tool; system prompt adds explicit spaced-repetition + adaptive-difficulty rule blocks.
  • backend/routes/quiz.py_quiz_via_agent user message nudges the agent to call the new tool with the target concept_node_id.
  • docs/decisions/0014-adaptive-quiz-iteration.md — captures the decision, deliberate non-goals (no decay-formula spaced rep, no cross-concept history yet, no tool consolidation), and a single-revert rollback path.
  • backend/tests/test_quiz_history_tool.py (+9 tests) — pins shape coercion (_coerce_summary handles legacy string, {summary: ...}, and {misconceptions, weak_areas} shapes), accuracy math + clamping, the filter wiring (completed_at=not.is.null, order=completed_at.desc, limit=5), and the silent-degrade-on-DB-error contract.
  • backend/tests/test_quiz_agent_imports.py — extended to assert the new tool is registered.

Why this isn't refactor #4

ADR 0005 carved out "adaptive quiz history" as a future iteration on the same agent, not a separate refactor. This ships under that carve-out: no new agent, no new route, no wire-format change, no fallback-contract change.

Test plan

  • pytest tests/test_quiz_history_tool.py tests/test_quiz_agent_imports.py tests/test_quiz_routes.py tests/test_graph_read_tools.py -q → 51 passed
  • Verified _PROMPT_HASH round-trips and is logged on every quiz run via the existing Logfire instrumentation
  • Live-mode eval check (SAPLING_EVAL_MODE=live pytest tests/evals/quiz_generation.py -q) — the prompt-driven adaptive behaviors are LLM-decided; unit tests can only pin the tool's I/O contract. Recommended before merge.
  • Manual: trigger a quiz on a concept with prior attempts and confirm the agent's distractors mirror the per-student summary in quiz_context.

Rollback

Single-revert clean: revert this commit and the tool import + registration disappear, the prompt reverts to 17ab80b30316, and the route's user message reverts to refactor-#2 wording. The new quiz_history.py file is pure-leaf (no other module imports it), so it's harmless after rollback.

🤖 Generated with Claude Code

Summary by CodeRabbit

Release Notes

  • New Features

    • Quizzes now adapt difficulty based on your recent quiz performance and accuracy scores.
    • System identifies both your weakest concepts and those you haven't practiced recently.
    • Question difficulty automatically adjusts within reasonable bounds based on recent attempts.
    • Question distractors now incorporate both common misconceptions and your personal prior mistakes.
  • Documentation

    • Added architecture decision documenting adaptive quiz iteration behavior.

…ifficulty
Closes the three "doesn't do (yet)" gaps from ADR 0013 with one new
tool and a prompt update. No wire-format change, no fallback-contract
change.
- agents/tools/quiz_history.py — read_recent_quiz_attempts surfaces
the per-(user, concept) digest from quiz_context plus the last 5
completed attempts (newest first, accuracy precomputed). DB
failures degrade silently to empty history.
- agents/quiz.py — registers the new tool, expands prompt with
spaced-repetition rules (weight last_reviewed_at on graph_nodes)
and adaptive-difficulty rules (modulate by recent_attempts.accuracy
with a one-step bound). Prompt hash bumps to 358613666dbc.
- routes/quiz.py — _quiz_via_agent user message nudges the agent to
call the new tool with the target concept_node_id.
- docs/decisions/0014-adaptive-quiz-iteration.md — captures the
decision, what we deliberately didn't do, and the rollback path.
- Tests: new test_quiz_history_tool.py pins shape coercion, the
completed_at IS NOT NULL filter, and silent-degrade contract;
test_quiz_agent_imports.py asserts the new tool is registered.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitaiBot commented May 5, 2026

Copy link
Copy Markdown

Warning

Rate limit exceeded

@Jose-Gael-Cruz-Lopez has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 34 minutes and 35 seconds before requesting another review.

To keep reviews running without waiting, you can enable usage-based add-on for your organization. This allows additional reviews beyond the hourly cap. Account admins can enable it under billing.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 40d6e941-69bf-4bec-9878-1012e8e6aacf

📥 Commits

Reviewing files that changed from the base of the PR and between 1a47fec and e2112a8.

📒 Files selected for processing (5)
  • backend/agents/tools/quiz_history.py
  • backend/routes/quiz.py
  • backend/tests/evals/quiz_generation.py
  • backend/tests/test_quiz_history_tool.py
  • docs/decisions/0014-adaptive-quiz-iteration.md
📝 Walkthrough

Walkthrough

A new tool read_recent_quiz_attempts retrieves a student's recent quiz performance for a target concept, and the quiz agent system prompt is extended to call this tool and adapt question difficulty based on recent attempt accuracy. Supporting changes include agent tool registration, route workflow updates to nudge the agent toward calling the tool, comprehensive tests, and an architectural decision record.

Changes

Adaptive Quiz Difficulty via Recent Attempts

Layer / File(s)Summary
Data Models & Tool Implementation
backend/agents/tools/quiz_history.py
New models RecentQuizAttempt and QuizHistory encapsulate attempt history with accuracy. Core function read_recent_quiz_attempts fetches quiz context summary and recent completed attempts from Supabase via asyncio.to_thread, coerces context JSON (supporting multiple shapes), parses attempt scores, validates and clamps accuracy to [0.0, 1.0], and degrades gracefully on DB errors. Tool wrapper read_recent_quiz_attempts_tool bridges the async function to Pydantic AI context.
Agent Prompt & Tool Registration
backend/agents/quiz.py
Import and register read_recent_quiz_attempts_tool in the quiz agent. Extend _SYSTEM_PROMPT to instruct calling the tool with the target concept and to incorporate recent_attempts.accuracy (with empty-list fallback) into adaptive-difficulty rules and distractor guidance alongside existing misconception data.
Route Workflow
backend/routes/quiz.py
Update _quiz_via_agent's user message to instruct the agent to identify both weakest and "stalest" concepts via read_concepts_for_user, and to call read_recent_quiz_attempts(concept_node_id) to adapt difficulty based on recent scoring.
Tests & Documentation
backend/tests/test_quiz_agent_imports.py, backend/tests/test_quiz_history_tool.py, docs/decisions/0014-adaptive-quiz-iteration.md
Verify tool registration in agent. Comprehensive unit tests cover summary coercion edge cases (None, dicts with summary/notes keys, list flattening), async read_recent_quiz_attempts behavior (empty history, summary extraction, accuracy math with invalid-row skipping, DB error degradation, query filter/order/limit wiring). ADR documents feature intent, implementation, non-goals, and rollback plan.

Sequence Diagram

sequenceDiagram
participant Router as Quiz Route
participant Agent as Quiz Agent<br/>(Gemini)
participant HistoryTool as History Tool
participant DB as Supabase<br/>(quiz_context,<br/>quiz_attempts)
Router->>Agent: user_message: find weakest,<br/>stalest concepts & call<br/>read_recent_quiz_attempts
Agent->>Agent: Call read_concepts_for_user<br/>(concept selection)
loop For each selected concept
Agent->>HistoryTool: read_recent_quiz_attempts<br/>(concept_node_id)
HistoryTool->>DB: Fetch quiz_context<br/>summary (async)
HistoryTool->>DB: Fetch quiz_attempts<br/>(completed_at ≠ null,<br/>limit 5, order recent)
DB-->>HistoryTool: context + attempts
HistoryTool->>HistoryTool: Coerce summary<br/>(handle legacy shapes)
HistoryTool->>HistoryTool: Parse & validate<br/>score/total, compute<br/>accuracy ∈ [0,1]
HistoryTool-->>Agent: QuizHistory{summary,<br/>recent_attempts}
end
Agent->>Agent: Generate questions<br/>with difficulty adapted<br/>by recent_attempts.accuracy
Agent-->>Router: Quiz with adaptive<br/>difficulty
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Poem

🐰 A quiz that learns, oh what delight!
Past attempts now guide the next fight,
Spaced out stalest, recent accuracy strong,
The agent whispers: "Here's where you belong!"
Adaptive hops toward mastery's light. 🌟

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 22.22% 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 main changes: adaptive iteration incorporating spaced repetition, history tracking, and adaptive difficulty adjustments to the quiz feature.
Description check✅ PassedThe description comprehensively addresses all required template sections: clear summary, detailed changes, related issues (Closes), testing status, and notes for reviewers. All critical information is present and well-organized.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/quiz-spaced-repetition

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 and usage tips.

@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented May 5, 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
frontende2112a8Commit Preview URL

Branch Preview URL
May 05 2026, 02:40 AM

Jose-Gael-Cruz-Lopezand others added 4 commits May 4, 2026 22:22
…nits
Closes the four issues flagged on PR #77's self-review.
- tests/evals/quiz_generation.py — adds 2 cases (count: 8 -> 10) and
2 evaluators that pin the new prompt rules structurally:
- AdaptiveDifficultyEvaluator: requested vs. produced difficulty
rank stays within ±1 step. Permits the prompt's allowed adaptive
shift, flags overshoots.
- SpacedRepetitionConceptEvaluator: when metadata names a
`stale_concept`, at least one question must target it.
Smoke-tested against synthetic Quiz outputs: bounds in → 1.0,
overshoots → 0.0.
- agents/tools/quiz_history.py — drop rows with score outside
[0, total] entirely (with a logger.warning) instead of clamping
accuracy and passing impossible numbers (score=7, total=5) to the
LLM. Matching test_corrupt_rows_are_dropped replaces the old
clamp-asserting test.
- agents/tools/quiz_history.py — read_recent_quiz_attempts_tool
docstring is now LLM-facing ("returns this student's history…")
instead of engineering-facing. Pydantic AI surfaces this as the
tool's description to the model.
- routes/quiz.py — _quiz_via_agent user message trimmed to
routing-only; the workflow + adaptive rules already live in the
system prompt and don't need to be restated per request.
- docs/decisions/0014-adaptive-quiz-iteration.md — Date corrected
(2026-05-03 → 2026-05-04). Eval-coverage section updated to
describe the two new structural sentinels.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes the two issues from PR #77's second review.
- AdaptiveDifficultyEvaluator now scores per-question (fraction
compliant) instead of average rank. The prompt rule is per-question
("Never override the user-requested difficulty by more than one
step"); the previous avg-based check let a 2-step outlier slip
through if the rest of the mix balanced it out — e.g. requested
hard with [easy, hard, hard, hard] used to score 1.0 (avg=1.5,
within 1 of target=2) and now correctly scores 0.75. Switched to
subscript _DIFF_RANK[q.difficulty] (Literal-constrained, fallback
was unreachable).
- The two new ADR-0014 cases now carry NOTE comments explaining that
recency/staleness state is baked into the user message for replay
determinism, while production sources it via read_recent_quiz_attempts.
The cases pin the prompt's rule application; live-mode evals are
the right place to catch tool-wiring regressions.
Smoke-tested: hard + [easy, hard, hard, hard] -> 0.75; hard + all
medium -> 1.0 (allowed shift); hard + all easy -> 0.0 (overshoot).
46 quiz unit tests still pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The section comment above adaptive_downshift_struggling_student
described the old average-based scoring; commit 6493988 rewrote the
evaluator to per-question fraction but missed this comment. Bring the
prose in line with the code so future readers don't trust an outdated
description.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
submit_quiz writes score+total atomically (routes/quiz.py:426), so any
row with completed_at IS NOT NULL but a null score or null total is
corruption. The previous coercion (`r.get('score') or 0`) silently
turned that into a 0/5 = 0% accuracy reading, which the LLM could
trust and use to trigger a spurious adaptive downshift on a perfectly
healthy concept.
Drop those rows alongside the existing score-out-of-bounds drop
branch (with the same logger.warning treatment) and tighten the test
to cover null score, null total, and total=0 in one sweep.
Surfaced by an independent code-reviewer pass on PR #77.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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.

1 participant

@Jose-Gael-Cruz-Lopez
, '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 iteration — spaced repetition + history + difficulty by Jose-Gael-Cruz-Lopez · Pull Request #77 · SaplingLearn/Sapling · GitHub
Skip to content

feat(quiz): adaptive iteration — spaced repetition + history + difficulty - #77

Merged
Jose-Gael-Cruz-Lopez merged 5 commits into
mainfrom
feat/quiz-spaced-repetition
May 5, 2026
Merged

feat(quiz): adaptive iteration — spaced repetition + history + difficulty#77
Jose-Gael-Cruz-Lopez merged 5 commits into
mainfrom
feat/quiz-spaced-repetition

Conversation

@Jose-Gael-Cruz-Lopez

@Jose-Gael-Cruz-LopezJose-Gael-Cruz-Lopez commented May 5, 2026

Copy link
Copy Markdown
Member

Summary

Closes the three "doesn't do (yet)" gaps from ADR 0013 with one new tool and a prompt update on quiz_agent. No wire-format change, no fallback-contract change, no new agent.

  • Spaced repetition — prompt now weights graph_nodes.last_studied_at. Stale (~7d+) and unreviewed (null) concepts surface even when their mastery is mid-tier.
  • Adaptive difficulty — agent reads recent attempt accuracy and modulates the difficulty mix. Bounded to one step in either direction so it can't override the user-requested difficulty by more than that.
  • Quiz-attempt history on the agent path — new read_recent_quiz_attempts(concept_node_id) tool exposes the per-(user, concept) digest from quiz_context plus the last 5 completed quiz_attempts rows (newest first, accuracy precomputed). The legacy fallback already read this; the agent path now does too.

Prompt hash bumps from 17ab80b30316358613666dbc.

Files

  • backend/agents/tools/quiz_history.py — new pure-async function + _tool wrapper. completed_at IS NOT NULL filter excludes the in-flight row that generate_quiz writes pre-submission. DB failures degrade silently to empty history.
  • backend/agents/quiz.py — registers the new tool; system prompt adds explicit spaced-repetition + adaptive-difficulty rule blocks.
  • backend/routes/quiz.py_quiz_via_agent user message nudges the agent to call the new tool with the target concept_node_id.
  • docs/decisions/0014-adaptive-quiz-iteration.md — captures the decision, deliberate non-goals (no decay-formula spaced rep, no cross-concept history yet, no tool consolidation), and a single-revert rollback path.
  • backend/tests/test_quiz_history_tool.py (+9 tests) — pins shape coercion (_coerce_summary handles legacy string, {summary: ...}, and {misconceptions, weak_areas} shapes), accuracy math + clamping, the filter wiring (completed_at=not.is.null, order=completed_at.desc, limit=5), and the silent-degrade-on-DB-error contract.
  • backend/tests/test_quiz_agent_imports.py — extended to assert the new tool is registered.

Why this isn't refactor #4

ADR 0005 carved out "adaptive quiz history" as a future iteration on the same agent, not a separate refactor. This ships under that carve-out: no new agent, no new route, no wire-format change, no fallback-contract change.

Test plan

  • pytest tests/test_quiz_history_tool.py tests/test_quiz_agent_imports.py tests/test_quiz_routes.py tests/test_graph_read_tools.py -q → 51 passed
  • Verified _PROMPT_HASH round-trips and is logged on every quiz run via the existing Logfire instrumentation
  • Live-mode eval check (SAPLING_EVAL_MODE=live pytest tests/evals/quiz_generation.py -q) — the prompt-driven adaptive behaviors are LLM-decided; unit tests can only pin the tool's I/O contract. Recommended before merge.
  • Manual: trigger a quiz on a concept with prior attempts and confirm the agent's distractors mirror the per-student summary in quiz_context.

Rollback

Single-revert clean: revert this commit and the tool import + registration disappear, the prompt reverts to 17ab80b30316, and the route's user message reverts to refactor-#2 wording. The new quiz_history.py file is pure-leaf (no other module imports it), so it's harmless after rollback.

🤖 Generated with Claude Code

Summary by CodeRabbit

Release Notes

  • New Features

    • Quizzes now adapt difficulty based on your recent quiz performance and accuracy scores.
    • System identifies both your weakest concepts and those you haven't practiced recently.
    • Question difficulty automatically adjusts within reasonable bounds based on recent attempts.
    • Question distractors now incorporate both common misconceptions and your personal prior mistakes.
  • Documentation

    • Added architecture decision documenting adaptive quiz iteration behavior.

…ifficulty
Closes the three "doesn't do (yet)" gaps from ADR 0013 with one new
tool and a prompt update. No wire-format change, no fallback-contract
change.
- agents/tools/quiz_history.py — read_recent_quiz_attempts surfaces
the per-(user, concept) digest from quiz_context plus the last 5
completed attempts (newest first, accuracy precomputed). DB
failures degrade silently to empty history.
- agents/quiz.py — registers the new tool, expands prompt with
spaced-repetition rules (weight last_reviewed_at on graph_nodes)
and adaptive-difficulty rules (modulate by recent_attempts.accuracy
with a one-step bound). Prompt hash bumps to 358613666dbc.
- routes/quiz.py — _quiz_via_agent user message nudges the agent to
call the new tool with the target concept_node_id.
- docs/decisions/0014-adaptive-quiz-iteration.md — captures the
decision, what we deliberately didn't do, and the rollback path.
- Tests: new test_quiz_history_tool.py pins shape coercion, the
completed_at IS NOT NULL filter, and silent-degrade contract;
test_quiz_agent_imports.py asserts the new tool is registered.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitaiBot commented May 5, 2026

Copy link
Copy Markdown

Warning

Rate limit exceeded

@Jose-Gael-Cruz-Lopez has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 34 minutes and 35 seconds before requesting another review.

To keep reviews running without waiting, you can enable usage-based add-on for your organization. This allows additional reviews beyond the hourly cap. Account admins can enable it under billing.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 40d6e941-69bf-4bec-9878-1012e8e6aacf

📥 Commits

Reviewing files that changed from the base of the PR and between 1a47fec and e2112a8.

📒 Files selected for processing (5)
  • backend/agents/tools/quiz_history.py
  • backend/routes/quiz.py
  • backend/tests/evals/quiz_generation.py
  • backend/tests/test_quiz_history_tool.py
  • docs/decisions/0014-adaptive-quiz-iteration.md
📝 Walkthrough

Walkthrough

A new tool read_recent_quiz_attempts retrieves a student's recent quiz performance for a target concept, and the quiz agent system prompt is extended to call this tool and adapt question difficulty based on recent attempt accuracy. Supporting changes include agent tool registration, route workflow updates to nudge the agent toward calling the tool, comprehensive tests, and an architectural decision record.

Changes

Adaptive Quiz Difficulty via Recent Attempts

Layer / File(s)Summary
Data Models & Tool Implementation
backend/agents/tools/quiz_history.py
New models RecentQuizAttempt and QuizHistory encapsulate attempt history with accuracy. Core function read_recent_quiz_attempts fetches quiz context summary and recent completed attempts from Supabase via asyncio.to_thread, coerces context JSON (supporting multiple shapes), parses attempt scores, validates and clamps accuracy to [0.0, 1.0], and degrades gracefully on DB errors. Tool wrapper read_recent_quiz_attempts_tool bridges the async function to Pydantic AI context.
Agent Prompt & Tool Registration
backend/agents/quiz.py
Import and register read_recent_quiz_attempts_tool in the quiz agent. Extend _SYSTEM_PROMPT to instruct calling the tool with the target concept and to incorporate recent_attempts.accuracy (with empty-list fallback) into adaptive-difficulty rules and distractor guidance alongside existing misconception data.
Route Workflow
backend/routes/quiz.py
Update _quiz_via_agent's user message to instruct the agent to identify both weakest and "stalest" concepts via read_concepts_for_user, and to call read_recent_quiz_attempts(concept_node_id) to adapt difficulty based on recent scoring.
Tests & Documentation
backend/tests/test_quiz_agent_imports.py, backend/tests/test_quiz_history_tool.py, docs/decisions/0014-adaptive-quiz-iteration.md
Verify tool registration in agent. Comprehensive unit tests cover summary coercion edge cases (None, dicts with summary/notes keys, list flattening), async read_recent_quiz_attempts behavior (empty history, summary extraction, accuracy math with invalid-row skipping, DB error degradation, query filter/order/limit wiring). ADR documents feature intent, implementation, non-goals, and rollback plan.

Sequence Diagram

sequenceDiagram
participant Router as Quiz Route
participant Agent as Quiz Agent<br/>(Gemini)
participant HistoryTool as History Tool
participant DB as Supabase<br/>(quiz_context,<br/>quiz_attempts)
Router->>Agent: user_message: find weakest,<br/>stalest concepts & call<br/>read_recent_quiz_attempts
Agent->>Agent: Call read_concepts_for_user<br/>(concept selection)
loop For each selected concept
Agent->>HistoryTool: read_recent_quiz_attempts<br/>(concept_node_id)
HistoryTool->>DB: Fetch quiz_context<br/>summary (async)
HistoryTool->>DB: Fetch quiz_attempts<br/>(completed_at ≠ null,<br/>limit 5, order recent)
DB-->>HistoryTool: context + attempts
HistoryTool->>HistoryTool: Coerce summary<br/>(handle legacy shapes)
HistoryTool->>HistoryTool: Parse & validate<br/>score/total, compute<br/>accuracy ∈ [0,1]
HistoryTool-->>Agent: QuizHistory{summary,<br/>recent_attempts}
end
Agent->>Agent: Generate questions<br/>with difficulty adapted<br/>by recent_attempts.accuracy
Agent-->>Router: Quiz with adaptive<br/>difficulty
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Poem

🐰 A quiz that learns, oh what delight!
Past attempts now guide the next fight,
Spaced out stalest, recent accuracy strong,
The agent whispers: "Here's where you belong!"
Adaptive hops toward mastery's light. 🌟

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 22.22% 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 main changes: adaptive iteration incorporating spaced repetition, history tracking, and adaptive difficulty adjustments to the quiz feature.
Description check✅ PassedThe description comprehensively addresses all required template sections: clear summary, detailed changes, related issues (Closes), testing status, and notes for reviewers. All critical information is present and well-organized.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/quiz-spaced-repetition

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 and usage tips.

@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented May 5, 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
frontende2112a8Commit Preview URL

Branch Preview URL
May 05 2026, 02:40 AM

Jose-Gael-Cruz-Lopezand others added 4 commits May 4, 2026 22:22
…nits
Closes the four issues flagged on PR #77's self-review.
- tests/evals/quiz_generation.py — adds 2 cases (count: 8 -> 10) and
2 evaluators that pin the new prompt rules structurally:
- AdaptiveDifficultyEvaluator: requested vs. produced difficulty
rank stays within ±1 step. Permits the prompt's allowed adaptive
shift, flags overshoots.
- SpacedRepetitionConceptEvaluator: when metadata names a
`stale_concept`, at least one question must target it.
Smoke-tested against synthetic Quiz outputs: bounds in → 1.0,
overshoots → 0.0.
- agents/tools/quiz_history.py — drop rows with score outside
[0, total] entirely (with a logger.warning) instead of clamping
accuracy and passing impossible numbers (score=7, total=5) to the
LLM. Matching test_corrupt_rows_are_dropped replaces the old
clamp-asserting test.
- agents/tools/quiz_history.py — read_recent_quiz_attempts_tool
docstring is now LLM-facing ("returns this student's history…")
instead of engineering-facing. Pydantic AI surfaces this as the
tool's description to the model.
- routes/quiz.py — _quiz_via_agent user message trimmed to
routing-only; the workflow + adaptive rules already live in the
system prompt and don't need to be restated per request.
- docs/decisions/0014-adaptive-quiz-iteration.md — Date corrected
(2026-05-03 → 2026-05-04). Eval-coverage section updated to
describe the two new structural sentinels.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes the two issues from PR #77's second review.
- AdaptiveDifficultyEvaluator now scores per-question (fraction
compliant) instead of average rank. The prompt rule is per-question
("Never override the user-requested difficulty by more than one
step"); the previous avg-based check let a 2-step outlier slip
through if the rest of the mix balanced it out — e.g. requested
hard with [easy, hard, hard, hard] used to score 1.0 (avg=1.5,
within 1 of target=2) and now correctly scores 0.75. Switched to
subscript _DIFF_RANK[q.difficulty] (Literal-constrained, fallback
was unreachable).
- The two new ADR-0014 cases now carry NOTE comments explaining that
recency/staleness state is baked into the user message for replay
determinism, while production sources it via read_recent_quiz_attempts.
The cases pin the prompt's rule application; live-mode evals are
the right place to catch tool-wiring regressions.
Smoke-tested: hard + [easy, hard, hard, hard] -> 0.75; hard + all
medium -> 1.0 (allowed shift); hard + all easy -> 0.0 (overshoot).
46 quiz unit tests still pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The section comment above adaptive_downshift_struggling_student
described the old average-based scoring; commit 6493988 rewrote the
evaluator to per-question fraction but missed this comment. Bring the
prose in line with the code so future readers don't trust an outdated
description.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
submit_quiz writes score+total atomically (routes/quiz.py:426), so any
row with completed_at IS NOT NULL but a null score or null total is
corruption. The previous coercion (`r.get('score') or 0`) silently
turned that into a 0/5 = 0% accuracy reading, which the LLM could
trust and use to trigger a spurious adaptive downshift on a perfectly
healthy concept.
Drop those rows alongside the existing score-out-of-bounds drop
branch (with the same logger.warning treatment) and tighten the test
to cover null score, null total, and total=0 in one sweep.
Surfaced by an independent code-reviewer pass on PR #77.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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.

1 participant

@Jose-Gael-Cruz-Lopez
, '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 iteration — spaced repetition + history + difficulty by Jose-Gael-Cruz-Lopez · Pull Request #77 · SaplingLearn/Sapling · GitHub
Skip to content

feat(quiz): adaptive iteration — spaced repetition + history + difficulty - #77

Merged
Jose-Gael-Cruz-Lopez merged 5 commits into
mainfrom
feat/quiz-spaced-repetition
May 5, 2026
Merged

feat(quiz): adaptive iteration — spaced repetition + history + difficulty#77
Jose-Gael-Cruz-Lopez merged 5 commits into
mainfrom
feat/quiz-spaced-repetition

Conversation

@Jose-Gael-Cruz-Lopez

@Jose-Gael-Cruz-LopezJose-Gael-Cruz-Lopez commented May 5, 2026

Copy link
Copy Markdown
Member

Summary

Closes the three "doesn't do (yet)" gaps from ADR 0013 with one new tool and a prompt update on quiz_agent. No wire-format change, no fallback-contract change, no new agent.

  • Spaced repetition — prompt now weights graph_nodes.last_studied_at. Stale (~7d+) and unreviewed (null) concepts surface even when their mastery is mid-tier.
  • Adaptive difficulty — agent reads recent attempt accuracy and modulates the difficulty mix. Bounded to one step in either direction so it can't override the user-requested difficulty by more than that.
  • Quiz-attempt history on the agent path — new read_recent_quiz_attempts(concept_node_id) tool exposes the per-(user, concept) digest from quiz_context plus the last 5 completed quiz_attempts rows (newest first, accuracy precomputed). The legacy fallback already read this; the agent path now does too.

Prompt hash bumps from 17ab80b30316358613666dbc.

Files

  • backend/agents/tools/quiz_history.py — new pure-async function + _tool wrapper. completed_at IS NOT NULL filter excludes the in-flight row that generate_quiz writes pre-submission. DB failures degrade silently to empty history.
  • backend/agents/quiz.py — registers the new tool; system prompt adds explicit spaced-repetition + adaptive-difficulty rule blocks.
  • backend/routes/quiz.py_quiz_via_agent user message nudges the agent to call the new tool with the target concept_node_id.
  • docs/decisions/0014-adaptive-quiz-iteration.md — captures the decision, deliberate non-goals (no decay-formula spaced rep, no cross-concept history yet, no tool consolidation), and a single-revert rollback path.
  • backend/tests/test_quiz_history_tool.py (+9 tests) — pins shape coercion (_coerce_summary handles legacy string, {summary: ...}, and {misconceptions, weak_areas} shapes), accuracy math + clamping, the filter wiring (completed_at=not.is.null, order=completed_at.desc, limit=5), and the silent-degrade-on-DB-error contract.
  • backend/tests/test_quiz_agent_imports.py — extended to assert the new tool is registered.

Why this isn't refactor #4

ADR 0005 carved out "adaptive quiz history" as a future iteration on the same agent, not a separate refactor. This ships under that carve-out: no new agent, no new route, no wire-format change, no fallback-contract change.

Test plan

  • pytest tests/test_quiz_history_tool.py tests/test_quiz_agent_imports.py tests/test_quiz_routes.py tests/test_graph_read_tools.py -q → 51 passed
  • Verified _PROMPT_HASH round-trips and is logged on every quiz run via the existing Logfire instrumentation
  • Live-mode eval check (SAPLING_EVAL_MODE=live pytest tests/evals/quiz_generation.py -q) — the prompt-driven adaptive behaviors are LLM-decided; unit tests can only pin the tool's I/O contract. Recommended before merge.
  • Manual: trigger a quiz on a concept with prior attempts and confirm the agent's distractors mirror the per-student summary in quiz_context.

Rollback

Single-revert clean: revert this commit and the tool import + registration disappear, the prompt reverts to 17ab80b30316, and the route's user message reverts to refactor-#2 wording. The new quiz_history.py file is pure-leaf (no other module imports it), so it's harmless after rollback.

🤖 Generated with Claude Code

Summary by CodeRabbit

Release Notes

  • New Features

    • Quizzes now adapt difficulty based on your recent quiz performance and accuracy scores.
    • System identifies both your weakest concepts and those you haven't practiced recently.
    • Question difficulty automatically adjusts within reasonable bounds based on recent attempts.
    • Question distractors now incorporate both common misconceptions and your personal prior mistakes.
  • Documentation

    • Added architecture decision documenting adaptive quiz iteration behavior.

…ifficulty
Closes the three "doesn't do (yet)" gaps from ADR 0013 with one new
tool and a prompt update. No wire-format change, no fallback-contract
change.
- agents/tools/quiz_history.py — read_recent_quiz_attempts surfaces
the per-(user, concept) digest from quiz_context plus the last 5
completed attempts (newest first, accuracy precomputed). DB
failures degrade silently to empty history.
- agents/quiz.py — registers the new tool, expands prompt with
spaced-repetition rules (weight last_reviewed_at on graph_nodes)
and adaptive-difficulty rules (modulate by recent_attempts.accuracy
with a one-step bound). Prompt hash bumps to 358613666dbc.
- routes/quiz.py — _quiz_via_agent user message nudges the agent to
call the new tool with the target concept_node_id.
- docs/decisions/0014-adaptive-quiz-iteration.md — captures the
decision, what we deliberately didn't do, and the rollback path.
- Tests: new test_quiz_history_tool.py pins shape coercion, the
completed_at IS NOT NULL filter, and silent-degrade contract;
test_quiz_agent_imports.py asserts the new tool is registered.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitaiBot commented May 5, 2026

Copy link
Copy Markdown

Warning

Rate limit exceeded

@Jose-Gael-Cruz-Lopez has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 34 minutes and 35 seconds before requesting another review.

To keep reviews running without waiting, you can enable usage-based add-on for your organization. This allows additional reviews beyond the hourly cap. Account admins can enable it under billing.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 40d6e941-69bf-4bec-9878-1012e8e6aacf

📥 Commits

Reviewing files that changed from the base of the PR and between 1a47fec and e2112a8.

📒 Files selected for processing (5)
  • backend/agents/tools/quiz_history.py
  • backend/routes/quiz.py
  • backend/tests/evals/quiz_generation.py
  • backend/tests/test_quiz_history_tool.py
  • docs/decisions/0014-adaptive-quiz-iteration.md
📝 Walkthrough

Walkthrough

A new tool read_recent_quiz_attempts retrieves a student's recent quiz performance for a target concept, and the quiz agent system prompt is extended to call this tool and adapt question difficulty based on recent attempt accuracy. Supporting changes include agent tool registration, route workflow updates to nudge the agent toward calling the tool, comprehensive tests, and an architectural decision record.

Changes

Adaptive Quiz Difficulty via Recent Attempts

Layer / File(s)Summary
Data Models & Tool Implementation
backend/agents/tools/quiz_history.py
New models RecentQuizAttempt and QuizHistory encapsulate attempt history with accuracy. Core function read_recent_quiz_attempts fetches quiz context summary and recent completed attempts from Supabase via asyncio.to_thread, coerces context JSON (supporting multiple shapes), parses attempt scores, validates and clamps accuracy to [0.0, 1.0], and degrades gracefully on DB errors. Tool wrapper read_recent_quiz_attempts_tool bridges the async function to Pydantic AI context.
Agent Prompt & Tool Registration
backend/agents/quiz.py
Import and register read_recent_quiz_attempts_tool in the quiz agent. Extend _SYSTEM_PROMPT to instruct calling the tool with the target concept and to incorporate recent_attempts.accuracy (with empty-list fallback) into adaptive-difficulty rules and distractor guidance alongside existing misconception data.
Route Workflow
backend/routes/quiz.py
Update _quiz_via_agent's user message to instruct the agent to identify both weakest and "stalest" concepts via read_concepts_for_user, and to call read_recent_quiz_attempts(concept_node_id) to adapt difficulty based on recent scoring.
Tests & Documentation
backend/tests/test_quiz_agent_imports.py, backend/tests/test_quiz_history_tool.py, docs/decisions/0014-adaptive-quiz-iteration.md
Verify tool registration in agent. Comprehensive unit tests cover summary coercion edge cases (None, dicts with summary/notes keys, list flattening), async read_recent_quiz_attempts behavior (empty history, summary extraction, accuracy math with invalid-row skipping, DB error degradation, query filter/order/limit wiring). ADR documents feature intent, implementation, non-goals, and rollback plan.

Sequence Diagram

sequenceDiagram
participant Router as Quiz Route
participant Agent as Quiz Agent<br/>(Gemini)
participant HistoryTool as History Tool
participant DB as Supabase<br/>(quiz_context,<br/>quiz_attempts)
Router->>Agent: user_message: find weakest,<br/>stalest concepts & call<br/>read_recent_quiz_attempts
Agent->>Agent: Call read_concepts_for_user<br/>(concept selection)
loop For each selected concept
Agent->>HistoryTool: read_recent_quiz_attempts<br/>(concept_node_id)
HistoryTool->>DB: Fetch quiz_context<br/>summary (async)
HistoryTool->>DB: Fetch quiz_attempts<br/>(completed_at ≠ null,<br/>limit 5, order recent)
DB-->>HistoryTool: context + attempts
HistoryTool->>HistoryTool: Coerce summary<br/>(handle legacy shapes)
HistoryTool->>HistoryTool: Parse & validate<br/>score/total, compute<br/>accuracy ∈ [0,1]
HistoryTool-->>Agent: QuizHistory{summary,<br/>recent_attempts}
end
Agent->>Agent: Generate questions<br/>with difficulty adapted<br/>by recent_attempts.accuracy
Agent-->>Router: Quiz with adaptive<br/>difficulty
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Poem

🐰 A quiz that learns, oh what delight!
Past attempts now guide the next fight,
Spaced out stalest, recent accuracy strong,
The agent whispers: "Here's where you belong!"
Adaptive hops toward mastery's light. 🌟

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 22.22% 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 main changes: adaptive iteration incorporating spaced repetition, history tracking, and adaptive difficulty adjustments to the quiz feature.
Description check✅ PassedThe description comprehensively addresses all required template sections: clear summary, detailed changes, related issues (Closes), testing status, and notes for reviewers. All critical information is present and well-organized.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/quiz-spaced-repetition

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 and usage tips.

@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented May 5, 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
frontende2112a8Commit Preview URL

Branch Preview URL
May 05 2026, 02:40 AM

Jose-Gael-Cruz-Lopezand others added 4 commits May 4, 2026 22:22
…nits
Closes the four issues flagged on PR #77's self-review.
- tests/evals/quiz_generation.py — adds 2 cases (count: 8 -> 10) and
2 evaluators that pin the new prompt rules structurally:
- AdaptiveDifficultyEvaluator: requested vs. produced difficulty
rank stays within ±1 step. Permits the prompt's allowed adaptive
shift, flags overshoots.
- SpacedRepetitionConceptEvaluator: when metadata names a
`stale_concept`, at least one question must target it.
Smoke-tested against synthetic Quiz outputs: bounds in → 1.0,
overshoots → 0.0.
- agents/tools/quiz_history.py — drop rows with score outside
[0, total] entirely (with a logger.warning) instead of clamping
accuracy and passing impossible numbers (score=7, total=5) to the
LLM. Matching test_corrupt_rows_are_dropped replaces the old
clamp-asserting test.
- agents/tools/quiz_history.py — read_recent_quiz_attempts_tool
docstring is now LLM-facing ("returns this student's history…")
instead of engineering-facing. Pydantic AI surfaces this as the
tool's description to the model.
- routes/quiz.py — _quiz_via_agent user message trimmed to
routing-only; the workflow + adaptive rules already live in the
system prompt and don't need to be restated per request.
- docs/decisions/0014-adaptive-quiz-iteration.md — Date corrected
(2026-05-03 → 2026-05-04). Eval-coverage section updated to
describe the two new structural sentinels.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes the two issues from PR #77's second review.
- AdaptiveDifficultyEvaluator now scores per-question (fraction
compliant) instead of average rank. The prompt rule is per-question
("Never override the user-requested difficulty by more than one
step"); the previous avg-based check let a 2-step outlier slip
through if the rest of the mix balanced it out — e.g. requested
hard with [easy, hard, hard, hard] used to score 1.0 (avg=1.5,
within 1 of target=2) and now correctly scores 0.75. Switched to
subscript _DIFF_RANK[q.difficulty] (Literal-constrained, fallback
was unreachable).
- The two new ADR-0014 cases now carry NOTE comments explaining that
recency/staleness state is baked into the user message for replay
determinism, while production sources it via read_recent_quiz_attempts.
The cases pin the prompt's rule application; live-mode evals are
the right place to catch tool-wiring regressions.
Smoke-tested: hard + [easy, hard, hard, hard] -> 0.75; hard + all
medium -> 1.0 (allowed shift); hard + all easy -> 0.0 (overshoot).
46 quiz unit tests still pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The section comment above adaptive_downshift_struggling_student
described the old average-based scoring; commit 6493988 rewrote the
evaluator to per-question fraction but missed this comment. Bring the
prose in line with the code so future readers don't trust an outdated
description.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
submit_quiz writes score+total atomically (routes/quiz.py:426), so any
row with completed_at IS NOT NULL but a null score or null total is
corruption. The previous coercion (`r.get('score') or 0`) silently
turned that into a 0/5 = 0% accuracy reading, which the LLM could
trust and use to trigger a spurious adaptive downshift on a perfectly
healthy concept.
Drop those rows alongside the existing score-out-of-bounds drop
branch (with the same logger.warning treatment) and tighten the test
to cover null score, null total, and total=0 in one sweep.
Surfaced by an independent code-reviewer pass on PR #77.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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.

1 participant

@Jose-Gael-Cruz-Lopez
, '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 iteration — spaced repetition + history + difficulty by Jose-Gael-Cruz-Lopez · Pull Request #77 · SaplingLearn/Sapling · GitHub
Skip to content

feat(quiz): adaptive iteration — spaced repetition + history + difficulty - #77

Merged
Jose-Gael-Cruz-Lopez merged 5 commits into
mainfrom
feat/quiz-spaced-repetition
May 5, 2026
Merged

feat(quiz): adaptive iteration — spaced repetition + history + difficulty#77
Jose-Gael-Cruz-Lopez merged 5 commits into
mainfrom
feat/quiz-spaced-repetition

Conversation

@Jose-Gael-Cruz-Lopez

@Jose-Gael-Cruz-LopezJose-Gael-Cruz-Lopez commented May 5, 2026

Copy link
Copy Markdown
Member

Summary

Closes the three "doesn't do (yet)" gaps from ADR 0013 with one new tool and a prompt update on quiz_agent. No wire-format change, no fallback-contract change, no new agent.

  • Spaced repetition — prompt now weights graph_nodes.last_studied_at. Stale (~7d+) and unreviewed (null) concepts surface even when their mastery is mid-tier.
  • Adaptive difficulty — agent reads recent attempt accuracy and modulates the difficulty mix. Bounded to one step in either direction so it can't override the user-requested difficulty by more than that.
  • Quiz-attempt history on the agent path — new read_recent_quiz_attempts(concept_node_id) tool exposes the per-(user, concept) digest from quiz_context plus the last 5 completed quiz_attempts rows (newest first, accuracy precomputed). The legacy fallback already read this; the agent path now does too.

Prompt hash bumps from 17ab80b30316358613666dbc.

Files

  • backend/agents/tools/quiz_history.py — new pure-async function + _tool wrapper. completed_at IS NOT NULL filter excludes the in-flight row that generate_quiz writes pre-submission. DB failures degrade silently to empty history.
  • backend/agents/quiz.py — registers the new tool; system prompt adds explicit spaced-repetition + adaptive-difficulty rule blocks.
  • backend/routes/quiz.py_quiz_via_agent user message nudges the agent to call the new tool with the target concept_node_id.
  • docs/decisions/0014-adaptive-quiz-iteration.md — captures the decision, deliberate non-goals (no decay-formula spaced rep, no cross-concept history yet, no tool consolidation), and a single-revert rollback path.
  • backend/tests/test_quiz_history_tool.py (+9 tests) — pins shape coercion (_coerce_summary handles legacy string, {summary: ...}, and {misconceptions, weak_areas} shapes), accuracy math + clamping, the filter wiring (completed_at=not.is.null, order=completed_at.desc, limit=5), and the silent-degrade-on-DB-error contract.
  • backend/tests/test_quiz_agent_imports.py — extended to assert the new tool is registered.

Why this isn't refactor #4

ADR 0005 carved out "adaptive quiz history" as a future iteration on the same agent, not a separate refactor. This ships under that carve-out: no new agent, no new route, no wire-format change, no fallback-contract change.

Test plan

  • pytest tests/test_quiz_history_tool.py tests/test_quiz_agent_imports.py tests/test_quiz_routes.py tests/test_graph_read_tools.py -q → 51 passed
  • Verified _PROMPT_HASH round-trips and is logged on every quiz run via the existing Logfire instrumentation
  • Live-mode eval check (SAPLING_EVAL_MODE=live pytest tests/evals/quiz_generation.py -q) — the prompt-driven adaptive behaviors are LLM-decided; unit tests can only pin the tool's I/O contract. Recommended before merge.
  • Manual: trigger a quiz on a concept with prior attempts and confirm the agent's distractors mirror the per-student summary in quiz_context.

Rollback

Single-revert clean: revert this commit and the tool import + registration disappear, the prompt reverts to 17ab80b30316, and the route's user message reverts to refactor-#2 wording. The new quiz_history.py file is pure-leaf (no other module imports it), so it's harmless after rollback.

🤖 Generated with Claude Code

Summary by CodeRabbit

Release Notes

  • New Features

    • Quizzes now adapt difficulty based on your recent quiz performance and accuracy scores.
    • System identifies both your weakest concepts and those you haven't practiced recently.
    • Question difficulty automatically adjusts within reasonable bounds based on recent attempts.
    • Question distractors now incorporate both common misconceptions and your personal prior mistakes.
  • Documentation

    • Added architecture decision documenting adaptive quiz iteration behavior.

…ifficulty
Closes the three "doesn't do (yet)" gaps from ADR 0013 with one new
tool and a prompt update. No wire-format change, no fallback-contract
change.
- agents/tools/quiz_history.py — read_recent_quiz_attempts surfaces
the per-(user, concept) digest from quiz_context plus the last 5
completed attempts (newest first, accuracy precomputed). DB
failures degrade silently to empty history.
- agents/quiz.py — registers the new tool, expands prompt with
spaced-repetition rules (weight last_reviewed_at on graph_nodes)
and adaptive-difficulty rules (modulate by recent_attempts.accuracy
with a one-step bound). Prompt hash bumps to 358613666dbc.
- routes/quiz.py — _quiz_via_agent user message nudges the agent to
call the new tool with the target concept_node_id.
- docs/decisions/0014-adaptive-quiz-iteration.md — captures the
decision, what we deliberately didn't do, and the rollback path.
- Tests: new test_quiz_history_tool.py pins shape coercion, the
completed_at IS NOT NULL filter, and silent-degrade contract;
test_quiz_agent_imports.py asserts the new tool is registered.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitaiBot commented May 5, 2026

Copy link
Copy Markdown

Warning

Rate limit exceeded

@Jose-Gael-Cruz-Lopez has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 34 minutes and 35 seconds before requesting another review.

To keep reviews running without waiting, you can enable usage-based add-on for your organization. This allows additional reviews beyond the hourly cap. Account admins can enable it under billing.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 40d6e941-69bf-4bec-9878-1012e8e6aacf

📥 Commits

Reviewing files that changed from the base of the PR and between 1a47fec and e2112a8.

📒 Files selected for processing (5)
  • backend/agents/tools/quiz_history.py
  • backend/routes/quiz.py
  • backend/tests/evals/quiz_generation.py
  • backend/tests/test_quiz_history_tool.py
  • docs/decisions/0014-adaptive-quiz-iteration.md
📝 Walkthrough

Walkthrough

A new tool read_recent_quiz_attempts retrieves a student's recent quiz performance for a target concept, and the quiz agent system prompt is extended to call this tool and adapt question difficulty based on recent attempt accuracy. Supporting changes include agent tool registration, route workflow updates to nudge the agent toward calling the tool, comprehensive tests, and an architectural decision record.

Changes

Adaptive Quiz Difficulty via Recent Attempts

Layer / File(s)Summary
Data Models & Tool Implementation
backend/agents/tools/quiz_history.py
New models RecentQuizAttempt and QuizHistory encapsulate attempt history with accuracy. Core function read_recent_quiz_attempts fetches quiz context summary and recent completed attempts from Supabase via asyncio.to_thread, coerces context JSON (supporting multiple shapes), parses attempt scores, validates and clamps accuracy to [0.0, 1.0], and degrades gracefully on DB errors. Tool wrapper read_recent_quiz_attempts_tool bridges the async function to Pydantic AI context.
Agent Prompt & Tool Registration
backend/agents/quiz.py
Import and register read_recent_quiz_attempts_tool in the quiz agent. Extend _SYSTEM_PROMPT to instruct calling the tool with the target concept and to incorporate recent_attempts.accuracy (with empty-list fallback) into adaptive-difficulty rules and distractor guidance alongside existing misconception data.
Route Workflow
backend/routes/quiz.py
Update _quiz_via_agent's user message to instruct the agent to identify both weakest and "stalest" concepts via read_concepts_for_user, and to call read_recent_quiz_attempts(concept_node_id) to adapt difficulty based on recent scoring.
Tests & Documentation
backend/tests/test_quiz_agent_imports.py, backend/tests/test_quiz_history_tool.py, docs/decisions/0014-adaptive-quiz-iteration.md
Verify tool registration in agent. Comprehensive unit tests cover summary coercion edge cases (None, dicts with summary/notes keys, list flattening), async read_recent_quiz_attempts behavior (empty history, summary extraction, accuracy math with invalid-row skipping, DB error degradation, query filter/order/limit wiring). ADR documents feature intent, implementation, non-goals, and rollback plan.

Sequence Diagram

sequenceDiagram
participant Router as Quiz Route
participant Agent as Quiz Agent<br/>(Gemini)
participant HistoryTool as History Tool
participant DB as Supabase<br/>(quiz_context,<br/>quiz_attempts)
Router->>Agent: user_message: find weakest,<br/>stalest concepts & call<br/>read_recent_quiz_attempts
Agent->>Agent: Call read_concepts_for_user<br/>(concept selection)
loop For each selected concept
Agent->>HistoryTool: read_recent_quiz_attempts<br/>(concept_node_id)
HistoryTool->>DB: Fetch quiz_context<br/>summary (async)
HistoryTool->>DB: Fetch quiz_attempts<br/>(completed_at ≠ null,<br/>limit 5, order recent)
DB-->>HistoryTool: context + attempts
HistoryTool->>HistoryTool: Coerce summary<br/>(handle legacy shapes)
HistoryTool->>HistoryTool: Parse & validate<br/>score/total, compute<br/>accuracy ∈ [0,1]
HistoryTool-->>Agent: QuizHistory{summary,<br/>recent_attempts}
end
Agent->>Agent: Generate questions<br/>with difficulty adapted<br/>by recent_attempts.accuracy
Agent-->>Router: Quiz with adaptive<br/>difficulty
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Poem

🐰 A quiz that learns, oh what delight!
Past attempts now guide the next fight,
Spaced out stalest, recent accuracy strong,
The agent whispers: "Here's where you belong!"
Adaptive hops toward mastery's light. 🌟

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 22.22% 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 main changes: adaptive iteration incorporating spaced repetition, history tracking, and adaptive difficulty adjustments to the quiz feature.
Description check✅ PassedThe description comprehensively addresses all required template sections: clear summary, detailed changes, related issues (Closes), testing status, and notes for reviewers. All critical information is present and well-organized.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/quiz-spaced-repetition

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 and usage tips.

@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented May 5, 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
frontende2112a8Commit Preview URL

Branch Preview URL
May 05 2026, 02:40 AM

Jose-Gael-Cruz-Lopezand others added 4 commits May 4, 2026 22:22
…nits
Closes the four issues flagged on PR #77's self-review.
- tests/evals/quiz_generation.py — adds 2 cases (count: 8 -> 10) and
2 evaluators that pin the new prompt rules structurally:
- AdaptiveDifficultyEvaluator: requested vs. produced difficulty
rank stays within ±1 step. Permits the prompt's allowed adaptive
shift, flags overshoots.
- SpacedRepetitionConceptEvaluator: when metadata names a
`stale_concept`, at least one question must target it.
Smoke-tested against synthetic Quiz outputs: bounds in → 1.0,
overshoots → 0.0.
- agents/tools/quiz_history.py — drop rows with score outside
[0, total] entirely (with a logger.warning) instead of clamping
accuracy and passing impossible numbers (score=7, total=5) to the
LLM. Matching test_corrupt_rows_are_dropped replaces the old
clamp-asserting test.
- agents/tools/quiz_history.py — read_recent_quiz_attempts_tool
docstring is now LLM-facing ("returns this student's history…")
instead of engineering-facing. Pydantic AI surfaces this as the
tool's description to the model.
- routes/quiz.py — _quiz_via_agent user message trimmed to
routing-only; the workflow + adaptive rules already live in the
system prompt and don't need to be restated per request.
- docs/decisions/0014-adaptive-quiz-iteration.md — Date corrected
(2026-05-03 → 2026-05-04). Eval-coverage section updated to
describe the two new structural sentinels.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes the two issues from PR #77's second review.
- AdaptiveDifficultyEvaluator now scores per-question (fraction
compliant) instead of average rank. The prompt rule is per-question
("Never override the user-requested difficulty by more than one
step"); the previous avg-based check let a 2-step outlier slip
through if the rest of the mix balanced it out — e.g. requested
hard with [easy, hard, hard, hard] used to score 1.0 (avg=1.5,
within 1 of target=2) and now correctly scores 0.75. Switched to
subscript _DIFF_RANK[q.difficulty] (Literal-constrained, fallback
was unreachable).
- The two new ADR-0014 cases now carry NOTE comments explaining that
recency/staleness state is baked into the user message for replay
determinism, while production sources it via read_recent_quiz_attempts.
The cases pin the prompt's rule application; live-mode evals are
the right place to catch tool-wiring regressions.
Smoke-tested: hard + [easy, hard, hard, hard] -> 0.75; hard + all
medium -> 1.0 (allowed shift); hard + all easy -> 0.0 (overshoot).
46 quiz unit tests still pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The section comment above adaptive_downshift_struggling_student
described the old average-based scoring; commit 6493988 rewrote the
evaluator to per-question fraction but missed this comment. Bring the
prose in line with the code so future readers don't trust an outdated
description.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
submit_quiz writes score+total atomically (routes/quiz.py:426), so any
row with completed_at IS NOT NULL but a null score or null total is
corruption. The previous coercion (`r.get('score') or 0`) silently
turned that into a 0/5 = 0% accuracy reading, which the LLM could
trust and use to trigger a spurious adaptive downshift on a perfectly
healthy concept.
Drop those rows alongside the existing score-out-of-bounds drop
branch (with the same logger.warning treatment) and tighten the test
to cover null score, null total, and total=0 in one sweep.
Surfaced by an independent code-reviewer pass on PR #77.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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.

1 participant

@Jose-Gael-Cruz-Lopez
, '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 iteration — spaced repetition + history + difficulty by Jose-Gael-Cruz-Lopez · Pull Request #77 · SaplingLearn/Sapling · GitHub
Skip to content

feat(quiz): adaptive iteration — spaced repetition + history + difficulty - #77

Merged
Jose-Gael-Cruz-Lopez merged 5 commits into
mainfrom
feat/quiz-spaced-repetition
May 5, 2026
Merged

feat(quiz): adaptive iteration — spaced repetition + history + difficulty#77
Jose-Gael-Cruz-Lopez merged 5 commits into
mainfrom
feat/quiz-spaced-repetition

Conversation

@Jose-Gael-Cruz-Lopez

@Jose-Gael-Cruz-LopezJose-Gael-Cruz-Lopez commented May 5, 2026

Copy link
Copy Markdown
Member

Summary

Closes the three "doesn't do (yet)" gaps from ADR 0013 with one new tool and a prompt update on quiz_agent. No wire-format change, no fallback-contract change, no new agent.

  • Spaced repetition — prompt now weights graph_nodes.last_studied_at. Stale (~7d+) and unreviewed (null) concepts surface even when their mastery is mid-tier.
  • Adaptive difficulty — agent reads recent attempt accuracy and modulates the difficulty mix. Bounded to one step in either direction so it can't override the user-requested difficulty by more than that.
  • Quiz-attempt history on the agent path — new read_recent_quiz_attempts(concept_node_id) tool exposes the per-(user, concept) digest from quiz_context plus the last 5 completed quiz_attempts rows (newest first, accuracy precomputed). The legacy fallback already read this; the agent path now does too.

Prompt hash bumps from 17ab80b30316358613666dbc.

Files

  • backend/agents/tools/quiz_history.py — new pure-async function + _tool wrapper. completed_at IS NOT NULL filter excludes the in-flight row that generate_quiz writes pre-submission. DB failures degrade silently to empty history.
  • backend/agents/quiz.py — registers the new tool; system prompt adds explicit spaced-repetition + adaptive-difficulty rule blocks.
  • backend/routes/quiz.py_quiz_via_agent user message nudges the agent to call the new tool with the target concept_node_id.
  • docs/decisions/0014-adaptive-quiz-iteration.md — captures the decision, deliberate non-goals (no decay-formula spaced rep, no cross-concept history yet, no tool consolidation), and a single-revert rollback path.
  • backend/tests/test_quiz_history_tool.py (+9 tests) — pins shape coercion (_coerce_summary handles legacy string, {summary: ...}, and {misconceptions, weak_areas} shapes), accuracy math + clamping, the filter wiring (completed_at=not.is.null, order=completed_at.desc, limit=5), and the silent-degrade-on-DB-error contract.
  • backend/tests/test_quiz_agent_imports.py — extended to assert the new tool is registered.

Why this isn't refactor #4

ADR 0005 carved out "adaptive quiz history" as a future iteration on the same agent, not a separate refactor. This ships under that carve-out: no new agent, no new route, no wire-format change, no fallback-contract change.

Test plan

  • pytest tests/test_quiz_history_tool.py tests/test_quiz_agent_imports.py tests/test_quiz_routes.py tests/test_graph_read_tools.py -q → 51 passed
  • Verified _PROMPT_HASH round-trips and is logged on every quiz run via the existing Logfire instrumentation
  • Live-mode eval check (SAPLING_EVAL_MODE=live pytest tests/evals/quiz_generation.py -q) — the prompt-driven adaptive behaviors are LLM-decided; unit tests can only pin the tool's I/O contract. Recommended before merge.
  • Manual: trigger a quiz on a concept with prior attempts and confirm the agent's distractors mirror the per-student summary in quiz_context.

Rollback

Single-revert clean: revert this commit and the tool import + registration disappear, the prompt reverts to 17ab80b30316, and the route's user message reverts to refactor-#2 wording. The new quiz_history.py file is pure-leaf (no other module imports it), so it's harmless after rollback.

🤖 Generated with Claude Code

Summary by CodeRabbit

Release Notes

  • New Features

    • Quizzes now adapt difficulty based on your recent quiz performance and accuracy scores.
    • System identifies both your weakest concepts and those you haven't practiced recently.
    • Question difficulty automatically adjusts within reasonable bounds based on recent attempts.
    • Question distractors now incorporate both common misconceptions and your personal prior mistakes.
  • Documentation

    • Added architecture decision documenting adaptive quiz iteration behavior.

…ifficulty
Closes the three "doesn't do (yet)" gaps from ADR 0013 with one new
tool and a prompt update. No wire-format change, no fallback-contract
change.
- agents/tools/quiz_history.py — read_recent_quiz_attempts surfaces
the per-(user, concept) digest from quiz_context plus the last 5
completed attempts (newest first, accuracy precomputed). DB
failures degrade silently to empty history.
- agents/quiz.py — registers the new tool, expands prompt with
spaced-repetition rules (weight last_reviewed_at on graph_nodes)
and adaptive-difficulty rules (modulate by recent_attempts.accuracy
with a one-step bound). Prompt hash bumps to 358613666dbc.
- routes/quiz.py — _quiz_via_agent user message nudges the agent to
call the new tool with the target concept_node_id.
- docs/decisions/0014-adaptive-quiz-iteration.md — captures the
decision, what we deliberately didn't do, and the rollback path.
- Tests: new test_quiz_history_tool.py pins shape coercion, the
completed_at IS NOT NULL filter, and silent-degrade contract;
test_quiz_agent_imports.py asserts the new tool is registered.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitaiBot commented May 5, 2026

Copy link
Copy Markdown

Warning

Rate limit exceeded

@Jose-Gael-Cruz-Lopez has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 34 minutes and 35 seconds before requesting another review.

To keep reviews running without waiting, you can enable usage-based add-on for your organization. This allows additional reviews beyond the hourly cap. Account admins can enable it under billing.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 40d6e941-69bf-4bec-9878-1012e8e6aacf

📥 Commits

Reviewing files that changed from the base of the PR and between 1a47fec and e2112a8.

📒 Files selected for processing (5)
  • backend/agents/tools/quiz_history.py
  • backend/routes/quiz.py
  • backend/tests/evals/quiz_generation.py
  • backend/tests/test_quiz_history_tool.py
  • docs/decisions/0014-adaptive-quiz-iteration.md
📝 Walkthrough

Walkthrough

A new tool read_recent_quiz_attempts retrieves a student's recent quiz performance for a target concept, and the quiz agent system prompt is extended to call this tool and adapt question difficulty based on recent attempt accuracy. Supporting changes include agent tool registration, route workflow updates to nudge the agent toward calling the tool, comprehensive tests, and an architectural decision record.

Changes

Adaptive Quiz Difficulty via Recent Attempts

Layer / File(s)Summary
Data Models & Tool Implementation
backend/agents/tools/quiz_history.py
New models RecentQuizAttempt and QuizHistory encapsulate attempt history with accuracy. Core function read_recent_quiz_attempts fetches quiz context summary and recent completed attempts from Supabase via asyncio.to_thread, coerces context JSON (supporting multiple shapes), parses attempt scores, validates and clamps accuracy to [0.0, 1.0], and degrades gracefully on DB errors. Tool wrapper read_recent_quiz_attempts_tool bridges the async function to Pydantic AI context.
Agent Prompt & Tool Registration
backend/agents/quiz.py
Import and register read_recent_quiz_attempts_tool in the quiz agent. Extend _SYSTEM_PROMPT to instruct calling the tool with the target concept and to incorporate recent_attempts.accuracy (with empty-list fallback) into adaptive-difficulty rules and distractor guidance alongside existing misconception data.
Route Workflow
backend/routes/quiz.py
Update _quiz_via_agent's user message to instruct the agent to identify both weakest and "stalest" concepts via read_concepts_for_user, and to call read_recent_quiz_attempts(concept_node_id) to adapt difficulty based on recent scoring.
Tests & Documentation
backend/tests/test_quiz_agent_imports.py, backend/tests/test_quiz_history_tool.py, docs/decisions/0014-adaptive-quiz-iteration.md
Verify tool registration in agent. Comprehensive unit tests cover summary coercion edge cases (None, dicts with summary/notes keys, list flattening), async read_recent_quiz_attempts behavior (empty history, summary extraction, accuracy math with invalid-row skipping, DB error degradation, query filter/order/limit wiring). ADR documents feature intent, implementation, non-goals, and rollback plan.

Sequence Diagram

sequenceDiagram
participant Router as Quiz Route
participant Agent as Quiz Agent<br/>(Gemini)
participant HistoryTool as History Tool
participant DB as Supabase<br/>(quiz_context,<br/>quiz_attempts)
Router->>Agent: user_message: find weakest,<br/>stalest concepts & call<br/>read_recent_quiz_attempts
Agent->>Agent: Call read_concepts_for_user<br/>(concept selection)
loop For each selected concept
Agent->>HistoryTool: read_recent_quiz_attempts<br/>(concept_node_id)
HistoryTool->>DB: Fetch quiz_context<br/>summary (async)
HistoryTool->>DB: Fetch quiz_attempts<br/>(completed_at ≠ null,<br/>limit 5, order recent)
DB-->>HistoryTool: context + attempts
HistoryTool->>HistoryTool: Coerce summary<br/>(handle legacy shapes)
HistoryTool->>HistoryTool: Parse & validate<br/>score/total, compute<br/>accuracy ∈ [0,1]
HistoryTool-->>Agent: QuizHistory{summary,<br/>recent_attempts}
end
Agent->>Agent: Generate questions<br/>with difficulty adapted<br/>by recent_attempts.accuracy
Agent-->>Router: Quiz with adaptive<br/>difficulty
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Poem

🐰 A quiz that learns, oh what delight!
Past attempts now guide the next fight,
Spaced out stalest, recent accuracy strong,
The agent whispers: "Here's where you belong!"
Adaptive hops toward mastery's light. 🌟

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 22.22% 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 main changes: adaptive iteration incorporating spaced repetition, history tracking, and adaptive difficulty adjustments to the quiz feature.
Description check✅ PassedThe description comprehensively addresses all required template sections: clear summary, detailed changes, related issues (Closes), testing status, and notes for reviewers. All critical information is present and well-organized.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/quiz-spaced-repetition

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 and usage tips.

@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented May 5, 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
frontende2112a8Commit Preview URL

Branch Preview URL
May 05 2026, 02:40 AM

Jose-Gael-Cruz-Lopezand others added 4 commits May 4, 2026 22:22
…nits
Closes the four issues flagged on PR #77's self-review.
- tests/evals/quiz_generation.py — adds 2 cases (count: 8 -> 10) and
2 evaluators that pin the new prompt rules structurally:
- AdaptiveDifficultyEvaluator: requested vs. produced difficulty
rank stays within ±1 step. Permits the prompt's allowed adaptive
shift, flags overshoots.
- SpacedRepetitionConceptEvaluator: when metadata names a
`stale_concept`, at least one question must target it.
Smoke-tested against synthetic Quiz outputs: bounds in → 1.0,
overshoots → 0.0.
- agents/tools/quiz_history.py — drop rows with score outside
[0, total] entirely (with a logger.warning) instead of clamping
accuracy and passing impossible numbers (score=7, total=5) to the
LLM. Matching test_corrupt_rows_are_dropped replaces the old
clamp-asserting test.
- agents/tools/quiz_history.py — read_recent_quiz_attempts_tool
docstring is now LLM-facing ("returns this student's history…")
instead of engineering-facing. Pydantic AI surfaces this as the
tool's description to the model.
- routes/quiz.py — _quiz_via_agent user message trimmed to
routing-only; the workflow + adaptive rules already live in the
system prompt and don't need to be restated per request.
- docs/decisions/0014-adaptive-quiz-iteration.md — Date corrected
(2026-05-03 → 2026-05-04). Eval-coverage section updated to
describe the two new structural sentinels.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes the two issues from PR #77's second review.
- AdaptiveDifficultyEvaluator now scores per-question (fraction
compliant) instead of average rank. The prompt rule is per-question
("Never override the user-requested difficulty by more than one
step"); the previous avg-based check let a 2-step outlier slip
through if the rest of the mix balanced it out — e.g. requested
hard with [easy, hard, hard, hard] used to score 1.0 (avg=1.5,
within 1 of target=2) and now correctly scores 0.75. Switched to
subscript _DIFF_RANK[q.difficulty] (Literal-constrained, fallback
was unreachable).
- The two new ADR-0014 cases now carry NOTE comments explaining that
recency/staleness state is baked into the user message for replay
determinism, while production sources it via read_recent_quiz_attempts.
The cases pin the prompt's rule application; live-mode evals are
the right place to catch tool-wiring regressions.
Smoke-tested: hard + [easy, hard, hard, hard] -> 0.75; hard + all
medium -> 1.0 (allowed shift); hard + all easy -> 0.0 (overshoot).
46 quiz unit tests still pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The section comment above adaptive_downshift_struggling_student
described the old average-based scoring; commit 6493988 rewrote the
evaluator to per-question fraction but missed this comment. Bring the
prose in line with the code so future readers don't trust an outdated
description.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
submit_quiz writes score+total atomically (routes/quiz.py:426), so any
row with completed_at IS NOT NULL but a null score or null total is
corruption. The previous coercion (`r.get('score') or 0`) silently
turned that into a 0/5 = 0% accuracy reading, which the LLM could
trust and use to trigger a spurious adaptive downshift on a perfectly
healthy concept.
Drop those rows alongside the existing score-out-of-bounds drop
branch (with the same logger.warning treatment) and tighten the test
to cover null score, null total, and total=0 in one sweep.
Surfaced by an independent code-reviewer pass on PR #77.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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.

1 participant

@Jose-Gael-Cruz-Lopez
, '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 iteration — spaced repetition + history + difficulty by Jose-Gael-Cruz-Lopez · Pull Request #77 · SaplingLearn/Sapling · GitHub
Skip to content

feat(quiz): adaptive iteration — spaced repetition + history + difficulty - #77

Merged
Jose-Gael-Cruz-Lopez merged 5 commits into
mainfrom
feat/quiz-spaced-repetition
May 5, 2026
Merged

feat(quiz): adaptive iteration — spaced repetition + history + difficulty#77
Jose-Gael-Cruz-Lopez merged 5 commits into
mainfrom
feat/quiz-spaced-repetition

Conversation

@Jose-Gael-Cruz-Lopez

@Jose-Gael-Cruz-LopezJose-Gael-Cruz-Lopez commented May 5, 2026

Copy link
Copy Markdown
Member

Summary

Closes the three "doesn't do (yet)" gaps from ADR 0013 with one new tool and a prompt update on quiz_agent. No wire-format change, no fallback-contract change, no new agent.

  • Spaced repetition — prompt now weights graph_nodes.last_studied_at. Stale (~7d+) and unreviewed (null) concepts surface even when their mastery is mid-tier.
  • Adaptive difficulty — agent reads recent attempt accuracy and modulates the difficulty mix. Bounded to one step in either direction so it can't override the user-requested difficulty by more than that.
  • Quiz-attempt history on the agent path — new read_recent_quiz_attempts(concept_node_id) tool exposes the per-(user, concept) digest from quiz_context plus the last 5 completed quiz_attempts rows (newest first, accuracy precomputed). The legacy fallback already read this; the agent path now does too.

Prompt hash bumps from 17ab80b30316358613666dbc.

Files

  • backend/agents/tools/quiz_history.py — new pure-async function + _tool wrapper. completed_at IS NOT NULL filter excludes the in-flight row that generate_quiz writes pre-submission. DB failures degrade silently to empty history.
  • backend/agents/quiz.py — registers the new tool; system prompt adds explicit spaced-repetition + adaptive-difficulty rule blocks.
  • backend/routes/quiz.py_quiz_via_agent user message nudges the agent to call the new tool with the target concept_node_id.
  • docs/decisions/0014-adaptive-quiz-iteration.md — captures the decision, deliberate non-goals (no decay-formula spaced rep, no cross-concept history yet, no tool consolidation), and a single-revert rollback path.
  • backend/tests/test_quiz_history_tool.py (+9 tests) — pins shape coercion (_coerce_summary handles legacy string, {summary: ...}, and {misconceptions, weak_areas} shapes), accuracy math + clamping, the filter wiring (completed_at=not.is.null, order=completed_at.desc, limit=5), and the silent-degrade-on-DB-error contract.
  • backend/tests/test_quiz_agent_imports.py — extended to assert the new tool is registered.

Why this isn't refactor #4

ADR 0005 carved out "adaptive quiz history" as a future iteration on the same agent, not a separate refactor. This ships under that carve-out: no new agent, no new route, no wire-format change, no fallback-contract change.

Test plan

  • pytest tests/test_quiz_history_tool.py tests/test_quiz_agent_imports.py tests/test_quiz_routes.py tests/test_graph_read_tools.py -q → 51 passed
  • Verified _PROMPT_HASH round-trips and is logged on every quiz run via the existing Logfire instrumentation
  • Live-mode eval check (SAPLING_EVAL_MODE=live pytest tests/evals/quiz_generation.py -q) — the prompt-driven adaptive behaviors are LLM-decided; unit tests can only pin the tool's I/O contract. Recommended before merge.
  • Manual: trigger a quiz on a concept with prior attempts and confirm the agent's distractors mirror the per-student summary in quiz_context.

Rollback

Single-revert clean: revert this commit and the tool import + registration disappear, the prompt reverts to 17ab80b30316, and the route's user message reverts to refactor-#2 wording. The new quiz_history.py file is pure-leaf (no other module imports it), so it's harmless after rollback.

🤖 Generated with Claude Code

Summary by CodeRabbit

Release Notes

  • New Features

    • Quizzes now adapt difficulty based on your recent quiz performance and accuracy scores.
    • System identifies both your weakest concepts and those you haven't practiced recently.
    • Question difficulty automatically adjusts within reasonable bounds based on recent attempts.
    • Question distractors now incorporate both common misconceptions and your personal prior mistakes.
  • Documentation

    • Added architecture decision documenting adaptive quiz iteration behavior.

…ifficulty
Closes the three "doesn't do (yet)" gaps from ADR 0013 with one new
tool and a prompt update. No wire-format change, no fallback-contract
change.
- agents/tools/quiz_history.py — read_recent_quiz_attempts surfaces
the per-(user, concept) digest from quiz_context plus the last 5
completed attempts (newest first, accuracy precomputed). DB
failures degrade silently to empty history.
- agents/quiz.py — registers the new tool, expands prompt with
spaced-repetition rules (weight last_reviewed_at on graph_nodes)
and adaptive-difficulty rules (modulate by recent_attempts.accuracy
with a one-step bound). Prompt hash bumps to 358613666dbc.
- routes/quiz.py — _quiz_via_agent user message nudges the agent to
call the new tool with the target concept_node_id.
- docs/decisions/0014-adaptive-quiz-iteration.md — captures the
decision, what we deliberately didn't do, and the rollback path.
- Tests: new test_quiz_history_tool.py pins shape coercion, the
completed_at IS NOT NULL filter, and silent-degrade contract;
test_quiz_agent_imports.py asserts the new tool is registered.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitaiBot commented May 5, 2026

Copy link
Copy Markdown

Warning

Rate limit exceeded

@Jose-Gael-Cruz-Lopez has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 34 minutes and 35 seconds before requesting another review.

To keep reviews running without waiting, you can enable usage-based add-on for your organization. This allows additional reviews beyond the hourly cap. Account admins can enable it under billing.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 40d6e941-69bf-4bec-9878-1012e8e6aacf

📥 Commits

Reviewing files that changed from the base of the PR and between 1a47fec and e2112a8.

📒 Files selected for processing (5)
  • backend/agents/tools/quiz_history.py
  • backend/routes/quiz.py
  • backend/tests/evals/quiz_generation.py
  • backend/tests/test_quiz_history_tool.py
  • docs/decisions/0014-adaptive-quiz-iteration.md
📝 Walkthrough

Walkthrough

A new tool read_recent_quiz_attempts retrieves a student's recent quiz performance for a target concept, and the quiz agent system prompt is extended to call this tool and adapt question difficulty based on recent attempt accuracy. Supporting changes include agent tool registration, route workflow updates to nudge the agent toward calling the tool, comprehensive tests, and an architectural decision record.

Changes

Adaptive Quiz Difficulty via Recent Attempts

Layer / File(s)Summary
Data Models & Tool Implementation
backend/agents/tools/quiz_history.py
New models RecentQuizAttempt and QuizHistory encapsulate attempt history with accuracy. Core function read_recent_quiz_attempts fetches quiz context summary and recent completed attempts from Supabase via asyncio.to_thread, coerces context JSON (supporting multiple shapes), parses attempt scores, validates and clamps accuracy to [0.0, 1.0], and degrades gracefully on DB errors. Tool wrapper read_recent_quiz_attempts_tool bridges the async function to Pydantic AI context.
Agent Prompt & Tool Registration
backend/agents/quiz.py
Import and register read_recent_quiz_attempts_tool in the quiz agent. Extend _SYSTEM_PROMPT to instruct calling the tool with the target concept and to incorporate recent_attempts.accuracy (with empty-list fallback) into adaptive-difficulty rules and distractor guidance alongside existing misconception data.
Route Workflow
backend/routes/quiz.py
Update _quiz_via_agent's user message to instruct the agent to identify both weakest and "stalest" concepts via read_concepts_for_user, and to call read_recent_quiz_attempts(concept_node_id) to adapt difficulty based on recent scoring.
Tests & Documentation
backend/tests/test_quiz_agent_imports.py, backend/tests/test_quiz_history_tool.py, docs/decisions/0014-adaptive-quiz-iteration.md
Verify tool registration in agent. Comprehensive unit tests cover summary coercion edge cases (None, dicts with summary/notes keys, list flattening), async read_recent_quiz_attempts behavior (empty history, summary extraction, accuracy math with invalid-row skipping, DB error degradation, query filter/order/limit wiring). ADR documents feature intent, implementation, non-goals, and rollback plan.

Sequence Diagram

sequenceDiagram
participant Router as Quiz Route
participant Agent as Quiz Agent<br/>(Gemini)
participant HistoryTool as History Tool
participant DB as Supabase<br/>(quiz_context,<br/>quiz_attempts)
Router->>Agent: user_message: find weakest,<br/>stalest concepts & call<br/>read_recent_quiz_attempts
Agent->>Agent: Call read_concepts_for_user<br/>(concept selection)
loop For each selected concept
Agent->>HistoryTool: read_recent_quiz_attempts<br/>(concept_node_id)
HistoryTool->>DB: Fetch quiz_context<br/>summary (async)
HistoryTool->>DB: Fetch quiz_attempts<br/>(completed_at ≠ null,<br/>limit 5, order recent)
DB-->>HistoryTool: context + attempts
HistoryTool->>HistoryTool: Coerce summary<br/>(handle legacy shapes)
HistoryTool->>HistoryTool: Parse & validate<br/>score/total, compute<br/>accuracy ∈ [0,1]
HistoryTool-->>Agent: QuizHistory{summary,<br/>recent_attempts}
end
Agent->>Agent: Generate questions<br/>with difficulty adapted<br/>by recent_attempts.accuracy
Agent-->>Router: Quiz with adaptive<br/>difficulty
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Poem

🐰 A quiz that learns, oh what delight!
Past attempts now guide the next fight,
Spaced out stalest, recent accuracy strong,
The agent whispers: "Here's where you belong!"
Adaptive hops toward mastery's light. 🌟

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 22.22% 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 main changes: adaptive iteration incorporating spaced repetition, history tracking, and adaptive difficulty adjustments to the quiz feature.
Description check✅ PassedThe description comprehensively addresses all required template sections: clear summary, detailed changes, related issues (Closes), testing status, and notes for reviewers. All critical information is present and well-organized.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/quiz-spaced-repetition

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 and usage tips.

@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented May 5, 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
frontende2112a8Commit Preview URL

Branch Preview URL
May 05 2026, 02:40 AM

Jose-Gael-Cruz-Lopezand others added 4 commits May 4, 2026 22:22
…nits
Closes the four issues flagged on PR #77's self-review.
- tests/evals/quiz_generation.py — adds 2 cases (count: 8 -> 10) and
2 evaluators that pin the new prompt rules structurally:
- AdaptiveDifficultyEvaluator: requested vs. produced difficulty
rank stays within ±1 step. Permits the prompt's allowed adaptive
shift, flags overshoots.
- SpacedRepetitionConceptEvaluator: when metadata names a
`stale_concept`, at least one question must target it.
Smoke-tested against synthetic Quiz outputs: bounds in → 1.0,
overshoots → 0.0.
- agents/tools/quiz_history.py — drop rows with score outside
[0, total] entirely (with a logger.warning) instead of clamping
accuracy and passing impossible numbers (score=7, total=5) to the
LLM. Matching test_corrupt_rows_are_dropped replaces the old
clamp-asserting test.
- agents/tools/quiz_history.py — read_recent_quiz_attempts_tool
docstring is now LLM-facing ("returns this student's history…")
instead of engineering-facing. Pydantic AI surfaces this as the
tool's description to the model.
- routes/quiz.py — _quiz_via_agent user message trimmed to
routing-only; the workflow + adaptive rules already live in the
system prompt and don't need to be restated per request.
- docs/decisions/0014-adaptive-quiz-iteration.md — Date corrected
(2026-05-03 → 2026-05-04). Eval-coverage section updated to
describe the two new structural sentinels.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes the two issues from PR #77's second review.
- AdaptiveDifficultyEvaluator now scores per-question (fraction
compliant) instead of average rank. The prompt rule is per-question
("Never override the user-requested difficulty by more than one
step"); the previous avg-based check let a 2-step outlier slip
through if the rest of the mix balanced it out — e.g. requested
hard with [easy, hard, hard, hard] used to score 1.0 (avg=1.5,
within 1 of target=2) and now correctly scores 0.75. Switched to
subscript _DIFF_RANK[q.difficulty] (Literal-constrained, fallback
was unreachable).
- The two new ADR-0014 cases now carry NOTE comments explaining that
recency/staleness state is baked into the user message for replay
determinism, while production sources it via read_recent_quiz_attempts.
The cases pin the prompt's rule application; live-mode evals are
the right place to catch tool-wiring regressions.
Smoke-tested: hard + [easy, hard, hard, hard] -> 0.75; hard + all
medium -> 1.0 (allowed shift); hard + all easy -> 0.0 (overshoot).
46 quiz unit tests still pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The section comment above adaptive_downshift_struggling_student
described the old average-based scoring; commit 6493988 rewrote the
evaluator to per-question fraction but missed this comment. Bring the
prose in line with the code so future readers don't trust an outdated
description.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
submit_quiz writes score+total atomically (routes/quiz.py:426), so any
row with completed_at IS NOT NULL but a null score or null total is
corruption. The previous coercion (`r.get('score') or 0`) silently
turned that into a 0/5 = 0% accuracy reading, which the LLM could
trust and use to trigger a spurious adaptive downshift on a perfectly
healthy concept.
Drop those rows alongside the existing score-out-of-bounds drop
branch (with the same logger.warning treatment) and tighten the test
to cover null score, null total, and total=0 in one sweep.
Surfaced by an independent code-reviewer pass on PR #77.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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.

1 participant

@Jose-Gael-Cruz-Lopez