Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 45 additions & 11 deletions backend/agents/quiz.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -24,6 +24,7 @@
read_concepts_for_user_tool,
read_misconceptions_for_course_tool,
)
from agents.tools.quiz_history import read_recent_quiz_attempts_tool


# Difficulty + question type are Literals so Gemini's enum constraint
Expand DownExpand Up@@ -69,31 +70,63 @@ class Quiz(BaseModel):
_SYSTEM_PROMPT = (
"You generate adaptive multiple-choice quizzes for a student. Each "
"question must target a specific concept the student has weak "
"mastery on, OR address a class-level misconception you've seen.\n\n"
"mastery on, OR address a class-level misconception you've seen, "
"OR revive a concept the student hasn't reviewed in a while.\n\n"
"Workflow:\n"
"1. Call `read_concepts_for_user` to see the student's mastery per "
" concept for this course (returned sorted by mastery ASC — "
" weakest first).\n"
" weakest first). Each concept also carries `last_reviewed_at`, "
" which you use for spaced repetition (see rules below).\n"
"2. Call `read_misconceptions_for_course` to see anonymized class "
" misconceptions. Use these to phrase distractors and to write "
" a question that probes the misconception.\n"
"3. Compose `Quiz.questions` so the WEAKEST concepts get the most "
" questions, AND each item's `concept` field exactly matches a "
" concept_name returned by tool 1.\n\n"
"3. Call `read_recent_quiz_attempts(concept_node_id)` for the "
" target concept_node_id given in the user message. The "
" `summary` is a digest of past mistakes the student has made "
" on this concept — mine it for distractor inspiration. The "
" `recent_attempts` list (newest first) drives adaptive "
" difficulty (see rules below).\n"
"4. Compose `Quiz.questions` so the WEAKEST and STALEST concepts "
" get the most questions, AND each item's `concept` field "
" exactly matches a concept_name returned by tool 1.\n\n"
"Concept-selection rules (combine all three signals):\n"
"- Bias question count toward the lowest-mastery concepts (the "
" weakest first in the tool 1 return).\n"
"- SPACED REPETITION: also surface concepts whose "
" `last_reviewed_at` is older than ~7 days, even if their "
" mastery is mid-tier — they're due for review and decay over "
" time. Concepts with `last_reviewed_at = null` are unreviewed; "
" treat them as stale.\n"
"- Don't drop high-mastery, recently-reviewed concepts entirely; "
" include 1 question on a strong-and-fresh concept to keep the "
" quiz from feeling punishing.\n\n"
"Adaptive-difficulty rules (use `recent_attempts.accuracy`):\n"
"- If the most recent 2-3 attempts on this concept averaged < "
" 0.5 accuracy, drop the difficulty mix one step from what the "
" user asked (hard -> medium, medium -> easy, easy stays easy). "
" The student is struggling; keep them on track.\n"
"- If the most recent 3 attempts all scored >= 0.8, you may "
" include 1-2 questions one step harder than the requested "
" difficulty to push them.\n"
"- If `recent_attempts` is empty (first attempt), honor the "
" user-requested difficulty exactly.\n"
"- Never override the user-requested difficulty by more than one "
" step in either direction. Stay close to what they asked for.\n\n"
"Per-question rules (multiple-choice only — the type field is "
"constrained to 'multiple_choice'):\n"
"- 4 options, exactly one correct. The text in `correct_answer` "
" MUST appear verbatim in `options` — character-for-character. "
" Questions that violate this are dropped at the route layer.\n"
"- Distractors should reflect plausible misconceptions, not random "
" noise. Use the read_misconceptions_for_course return value.\n"
" noise. Combine signals from `read_misconceptions_for_course` "
" (class-wide) and `read_recent_quiz_attempts.summary` "
" (this student's prior errors) when writing them.\n"
"- explanation: 1-3 sentences explaining WHY the correct answer "
" is correct — used in the post-quiz review screen.\n"
"- difficulty: align with the student's mastery on the concept; "
" weakest concepts get easy/medium, strongest get hard.\n\n"
"Honor the requested num_questions and difficulty distribution "
"in the user message. Don't invent concepts the student doesn't "
"have."
"- difficulty: align with the student's mastery on the concept "
" AND the adaptive-difficulty rules above.\n\n"
"Honor the requested num_questions. Don't invent concepts the "
"student doesn't have."
)
_PROMPT_HASH = hashlib.sha256(_SYSTEM_PROMPT.encode("utf-8")).hexdigest()[:12]

Expand All@@ -107,5 +140,6 @@ class Quiz(BaseModel):
tools=[
read_concepts_for_user_tool,
read_misconceptions_for_course_tool,
read_recent_quiz_attempts_tool,
],
)
234 changes: 234 additions & 0 deletions backend/agents/tools/quiz_history.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,234 @@
"""Quiz-history read tool for the quiz agent.

Surfaces what the student previously got wrong on a concept and how
their last few attempts scored. The agent uses this for two things:

1. Targeting — write distractors that mirror the student's prior
mistakes (the LLM-generated `summary` from `quiz_context`
captures patterns rolled up across past attempts).
2. Adaptive difficulty — read the last few `quiz_attempts` rows and
step difficulty down when the student has been struggling, up
when they've been crushing it.

The pure async function is callable from routes/tests; the *_tool
wrapper registers on a Pydantic AI Agent.
"""

from __future__ import annotations

import asyncio
import logging
from typing import Any

from pydantic import BaseModel, Field
from pydantic_ai import RunContext

from agents.deps import SaplingDeps
from db.connection import table

logger = logging.getLogger(__name__)


# How many past attempts the agent gets to see. 5 is enough to spot a
# trend without flooding the prompt with state. Older attempts are
# already rolled into `summary` by the post-quiz context update job.
_RECENT_ATTEMPTS_LIMIT = 5


class RecentQuizAttempt(BaseModel):
"""One past attempt's headline numbers."""

score: int = Field(ge=0)
total: int = Field(ge=0)
difficulty: str
completed_at: str | None = None
accuracy: float = Field(ge=0.0, le=1.0)


class QuizHistory(BaseModel):
"""The agent's view of a student's history on one concept."""

# LLM-generated digest of past quiz mistakes/patterns for this
# (user, concept). Populated by the background context-update job
# in routes/quiz.py:submit_quiz. May be None on a first attempt.
summary: str | None = None
# Most recent attempts, newest first. Empty on first attempt.
recent_attempts: list[RecentQuizAttempt] = Field(default_factory=list)


def _coerce_summary(ctx: Any) -> str | None:
"""quiz_context.context_json is free-form (whatever the post-submit
LLM produced). Different prompt versions have stored either a flat
string or a small dict. Coerce to a single string the agent can
reason over, or None if there's nothing useful."""
if not ctx:
return None
if isinstance(ctx, str):
text = ctx.strip()
return text or None
if isinstance(ctx, dict):
# Common shapes: {"summary": "..."}, {"notes": "..."},
# {"misconceptions": [...], "weak_areas": [...]}.
for key in ("summary", "notes", "context", "digest"):
v = ctx.get(key)
if isinstance(v, str) and v.strip():
return v.strip()
# Fall back to flattening list-of-strings entries so the agent
# at least sees the misconceptions/weak_areas the prior job
# extracted, even when no top-level summary string exists.
parts: list[str] = []
for key in ("misconceptions", "weak_areas", "common_errors"):
for item in ctx.get(key) or []:
if isinstance(item, str) and item.strip():
parts.append(f"- {item.strip()}")
return "\n".join(parts) or None
return None


async def read_recent_quiz_attempts(
user_id: str,
concept_node_id: str,
) -> QuizHistory:
"""Return the agent's view of a student's history on one concept.

Reads two sources:

- `quiz_context` (one row per (user, concept)): the rolling
LLM-generated digest of what the student has been getting
wrong. This is the same blob legacy `routes/quiz.py` used to
stuff into the prompt template.
- `quiz_attempts` (one row per attempt, filtered to completed
attempts): the last N completed attempts, newest first, with
accuracy precomputed so the agent doesn't have to.

Wraps the sync Supabase reads in `asyncio.to_thread` so we don't
block the event loop. Failures degrade silently — the agent can
still generate a quiz without history (just less adaptive).
"""

def _fetch_summary() -> Any:
try:
rows = table("quiz_context").select(
"context_json",
filters={
"user_id": f"eq.{user_id}",
"concept_node_id": f"eq.{concept_node_id}",
},
limit=1,
)
return rows[0]["context_json"] if rows else None
except Exception:
logger.exception(
"read_recent_quiz_attempts: quiz_context fetch failed "
"user=%s concept=%s",
user_id,
concept_node_id,
)
return None

def _fetch_attempts() -> list[dict[str, Any]]:
try:
return (
table("quiz_attempts").select(
"score,total,difficulty,completed_at",
filters={
"user_id": f"eq.{user_id}",
"concept_node_id": f"eq.{concept_node_id}",
# Only count completed attempts. PostgREST `not.is.null`
# filters out rows where completed_at is NULL, which is
# how `routes/quiz.py:generate_quiz` marks an in-flight
# attempt before submission.
"completed_at": "not.is.null",
},
order="completed_at.desc",
limit=_RECENT_ATTEMPTS_LIMIT,
)
or []
)
except Exception:
logger.exception(
"read_recent_quiz_attempts: quiz_attempts fetch failed "
"user=%s concept=%s",
user_id,
concept_node_id,
)
return []

summary_raw, attempt_rows = await asyncio.gather(
asyncio.to_thread(_fetch_summary),
asyncio.to_thread(_fetch_attempts),
)

attempts: list[RecentQuizAttempt] = []
for r in attempt_rows:
raw_score = r.get("score")
raw_total = r.get("total")
if raw_score is None or raw_total is None:
# `submit_quiz` writes score+total atomically, so a row with
# `completed_at IS NOT NULL` but a null score/total is
# corruption (or an out-of-band edit). Drop it rather than
# coercing to 0/0 — feeding the LLM a bogus 0% accuracy
# could trigger a spurious adaptive downshift.
logger.warning(
"read_recent_quiz_attempts: dropping row with null "
"score/total (score=%r, total=%r) user=%s concept=%s",
raw_score,
raw_total,
user_id,
concept_node_id,
)
continue
try:
score = int(raw_score)
total = int(raw_total)
except (TypeError, ValueError):
continue
if total <= 0:
# Skip rows that look incomplete — accuracy is undefined and
# the agent shouldn't have to guess.
continue
if score < 0 or score > total:
# Corrupt row (score outside [0, total]). Drop entirely
# rather than passing impossible numbers to the LLM —
# `score=7, total=5` would prompt the agent to wonder
# whether to trust the data at all.
logger.warning(
"read_recent_quiz_attempts: dropping corrupt row "
"(score=%d outside [0, total=%d]) user=%s concept=%s",
score,
total,
user_id,
concept_node_id,
)
continue
accuracy = score / total
attempts.append(
RecentQuizAttempt(
score=score,
total=total,
difficulty=str(r.get("difficulty") or ""),
completed_at=r.get("completed_at"),
accuracy=round(accuracy, 4),
)
)

return QuizHistory(
summary=_coerce_summary(summary_raw),
recent_attempts=attempts,
)


async def read_recent_quiz_attempts_tool(
ctx: RunContext[SaplingDeps],
concept_node_id: str,
) -> QuizHistory:
"""Returns this student's history on one concept: a `summary`
string digesting their prior mistakes (mine for distractor
inspiration) and `recent_attempts` — the last 5 completed quiz
attempts on this concept, newest first, with `accuracy` precomputed
so you can apply the adaptive-difficulty rule directly. Empty
history on first attempt. Pass the `concept_node_id` from the
user message; user identity is taken from context.
"""
# user_id comes from ctx.deps so a tool call can't cross users.
return await read_recent_quiz_attempts(ctx.deps.user_id, concept_node_id)
10 changes: 7 additions & 3 deletions backend/routes/quiz.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -166,11 +166,15 @@ async def _quiz_via_agent(
supabase=None,
request_id=request_id,
)
# Keep this message routing-only; the workflow + adaptive rules
# live in the system prompt. We just hand the agent the inputs it
# needs and trust the prompt to drive tool calls.
user_message = (
f"Generate {num_questions} {difficulty} questions for the student. "
f"The target concept is '{concept_name}' (concept_node_id={concept_node_id}). "
f"Call read_concepts_for_user to find the student's weakest concepts in this course "
f"and bias the question mix toward those."
f"The target concept is '{concept_name}' "
f"(concept_node_id={concept_node_id}). Follow the workflow in your "
f"system prompt; pass concept_node_id='{concept_node_id}' to "
f"read_recent_quiz_attempts."
)
if use_shared_context:
user_message += (
Expand Down
Loading
Loading
, '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" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 45 additions & 11 deletions backend/agents/quiz.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -24,6 +24,7 @@
read_concepts_for_user_tool,
read_misconceptions_for_course_tool,
)
from agents.tools.quiz_history import read_recent_quiz_attempts_tool


# Difficulty + question type are Literals so Gemini's enum constraint
Expand DownExpand Up@@ -69,31 +70,63 @@ class Quiz(BaseModel):
_SYSTEM_PROMPT = (
"You generate adaptive multiple-choice quizzes for a student. Each "
"question must target a specific concept the student has weak "
"mastery on, OR address a class-level misconception you've seen.\n\n"
"mastery on, OR address a class-level misconception you've seen, "
"OR revive a concept the student hasn't reviewed in a while.\n\n"
"Workflow:\n"
"1. Call `read_concepts_for_user` to see the student's mastery per "
" concept for this course (returned sorted by mastery ASC — "
" weakest first).\n"
" weakest first). Each concept also carries `last_reviewed_at`, "
" which you use for spaced repetition (see rules below).\n"
"2. Call `read_misconceptions_for_course` to see anonymized class "
" misconceptions. Use these to phrase distractors and to write "
" a question that probes the misconception.\n"
"3. Compose `Quiz.questions` so the WEAKEST concepts get the most "
" questions, AND each item's `concept` field exactly matches a "
" concept_name returned by tool 1.\n\n"
"3. Call `read_recent_quiz_attempts(concept_node_id)` for the "
" target concept_node_id given in the user message. The "
" `summary` is a digest of past mistakes the student has made "
" on this concept — mine it for distractor inspiration. The "
" `recent_attempts` list (newest first) drives adaptive "
" difficulty (see rules below).\n"
"4. Compose `Quiz.questions` so the WEAKEST and STALEST concepts "
" get the most questions, AND each item's `concept` field "
" exactly matches a concept_name returned by tool 1.\n\n"
"Concept-selection rules (combine all three signals):\n"
"- Bias question count toward the lowest-mastery concepts (the "
" weakest first in the tool 1 return).\n"
"- SPACED REPETITION: also surface concepts whose "
" `last_reviewed_at` is older than ~7 days, even if their "
" mastery is mid-tier — they're due for review and decay over "
" time. Concepts with `last_reviewed_at = null` are unreviewed; "
" treat them as stale.\n"
"- Don't drop high-mastery, recently-reviewed concepts entirely; "
" include 1 question on a strong-and-fresh concept to keep the "
" quiz from feeling punishing.\n\n"
"Adaptive-difficulty rules (use `recent_attempts.accuracy`):\n"
"- If the most recent 2-3 attempts on this concept averaged < "
" 0.5 accuracy, drop the difficulty mix one step from what the "
" user asked (hard -> medium, medium -> easy, easy stays easy). "
" The student is struggling; keep them on track.\n"
"- If the most recent 3 attempts all scored >= 0.8, you may "
" include 1-2 questions one step harder than the requested "
" difficulty to push them.\n"
"- If `recent_attempts` is empty (first attempt), honor the "
" user-requested difficulty exactly.\n"
"- Never override the user-requested difficulty by more than one "
" step in either direction. Stay close to what they asked for.\n\n"
"Per-question rules (multiple-choice only — the type field is "
"constrained to 'multiple_choice'):\n"
"- 4 options, exactly one correct. The text in `correct_answer` "
" MUST appear verbatim in `options` — character-for-character. "
" Questions that violate this are dropped at the route layer.\n"
"- Distractors should reflect plausible misconceptions, not random "
" noise. Use the read_misconceptions_for_course return value.\n"
" noise. Combine signals from `read_misconceptions_for_course` "
" (class-wide) and `read_recent_quiz_attempts.summary` "
" (this student's prior errors) when writing them.\n"
"- explanation: 1-3 sentences explaining WHY the correct answer "
" is correct — used in the post-quiz review screen.\n"
"- difficulty: align with the student's mastery on the concept; "
" weakest concepts get easy/medium, strongest get hard.\n\n"
"Honor the requested num_questions and difficulty distribution "
"in the user message. Don't invent concepts the student doesn't "
"have."
"- difficulty: align with the student's mastery on the concept "
" AND the adaptive-difficulty rules above.\n\n"
"Honor the requested num_questions. Don't invent concepts the "
"student doesn't have."
)
_PROMPT_HASH = hashlib.sha256(_SYSTEM_PROMPT.encode("utf-8")).hexdigest()[:12]

Expand All@@ -107,5 +140,6 @@ class Quiz(BaseModel):
tools=[
read_concepts_for_user_tool,
read_misconceptions_for_course_tool,
read_recent_quiz_attempts_tool,
],
)
234 changes: 234 additions & 0 deletions backend/agents/tools/quiz_history.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,234 @@
"""Quiz-history read tool for the quiz agent.

Surfaces what the student previously got wrong on a concept and how
their last few attempts scored. The agent uses this for two things:

1. Targeting — write distractors that mirror the student's prior
mistakes (the LLM-generated `summary` from `quiz_context`
captures patterns rolled up across past attempts).
2. Adaptive difficulty — read the last few `quiz_attempts` rows and
step difficulty down when the student has been struggling, up
when they've been crushing it.

The pure async function is callable from routes/tests; the *_tool
wrapper registers on a Pydantic AI Agent.
"""

from __future__ import annotations

import asyncio
import logging
from typing import Any

from pydantic import BaseModel, Field
from pydantic_ai import RunContext

from agents.deps import SaplingDeps
from db.connection import table

logger = logging.getLogger(__name__)


# How many past attempts the agent gets to see. 5 is enough to spot a
# trend without flooding the prompt with state. Older attempts are
# already rolled into `summary` by the post-quiz context update job.
_RECENT_ATTEMPTS_LIMIT = 5


class RecentQuizAttempt(BaseModel):
"""One past attempt's headline numbers."""

score: int = Field(ge=0)
total: int = Field(ge=0)
difficulty: str
completed_at: str | None = None
accuracy: float = Field(ge=0.0, le=1.0)


class QuizHistory(BaseModel):
"""The agent's view of a student's history on one concept."""

# LLM-generated digest of past quiz mistakes/patterns for this
# (user, concept). Populated by the background context-update job
# in routes/quiz.py:submit_quiz. May be None on a first attempt.
summary: str | None = None
# Most recent attempts, newest first. Empty on first attempt.
recent_attempts: list[RecentQuizAttempt] = Field(default_factory=list)


def _coerce_summary(ctx: Any) -> str | None:
"""quiz_context.context_json is free-form (whatever the post-submit
LLM produced). Different prompt versions have stored either a flat
string or a small dict. Coerce to a single string the agent can
reason over, or None if there's nothing useful."""
if not ctx:
return None
if isinstance(ctx, str):
text = ctx.strip()
return text or None
if isinstance(ctx, dict):
# Common shapes: {"summary": "..."}, {"notes": "..."},
# {"misconceptions": [...], "weak_areas": [...]}.
for key in ("summary", "notes", "context", "digest"):
v = ctx.get(key)
if isinstance(v, str) and v.strip():
return v.strip()
# Fall back to flattening list-of-strings entries so the agent
# at least sees the misconceptions/weak_areas the prior job
# extracted, even when no top-level summary string exists.
parts: list[str] = []
for key in ("misconceptions", "weak_areas", "common_errors"):
for item in ctx.get(key) or []:
if isinstance(item, str) and item.strip():
parts.append(f"- {item.strip()}")
return "\n".join(parts) or None
return None


async def read_recent_quiz_attempts(
user_id: str,
concept_node_id: str,
) -> QuizHistory:
"""Return the agent's view of a student's history on one concept.

Reads two sources:

- `quiz_context` (one row per (user, concept)): the rolling
LLM-generated digest of what the student has been getting
wrong. This is the same blob legacy `routes/quiz.py` used to
stuff into the prompt template.
- `quiz_attempts` (one row per attempt, filtered to completed
attempts): the last N completed attempts, newest first, with
accuracy precomputed so the agent doesn't have to.

Wraps the sync Supabase reads in `asyncio.to_thread` so we don't
block the event loop. Failures degrade silently — the agent can
still generate a quiz without history (just less adaptive).
"""

def _fetch_summary() -> Any:
try:
rows = table("quiz_context").select(
"context_json",
filters={
"user_id": f"eq.{user_id}",
"concept_node_id": f"eq.{concept_node_id}",
},
limit=1,
)
return rows[0]["context_json"] if rows else None
except Exception:
logger.exception(
"read_recent_quiz_attempts: quiz_context fetch failed "
"user=%s concept=%s",
user_id,
concept_node_id,
)
return None

def _fetch_attempts() -> list[dict[str, Any]]:
try:
return (
table("quiz_attempts").select(
"score,total,difficulty,completed_at",
filters={
"user_id": f"eq.{user_id}",
"concept_node_id": f"eq.{concept_node_id}",
# Only count completed attempts. PostgREST `not.is.null`
# filters out rows where completed_at is NULL, which is
# how `routes/quiz.py:generate_quiz` marks an in-flight
# attempt before submission.
"completed_at": "not.is.null",
},
order="completed_at.desc",
limit=_RECENT_ATTEMPTS_LIMIT,
)
or []
)
except Exception:
logger.exception(
"read_recent_quiz_attempts: quiz_attempts fetch failed "
"user=%s concept=%s",
user_id,
concept_node_id,
)
return []

summary_raw, attempt_rows = await asyncio.gather(
asyncio.to_thread(_fetch_summary),
asyncio.to_thread(_fetch_attempts),
)

attempts: list[RecentQuizAttempt] = []
for r in attempt_rows:
raw_score = r.get("score")
raw_total = r.get("total")
if raw_score is None or raw_total is None:
# `submit_quiz` writes score+total atomically, so a row with
# `completed_at IS NOT NULL` but a null score/total is
# corruption (or an out-of-band edit). Drop it rather than
# coercing to 0/0 — feeding the LLM a bogus 0% accuracy
# could trigger a spurious adaptive downshift.
logger.warning(
"read_recent_quiz_attempts: dropping row with null "
"score/total (score=%r, total=%r) user=%s concept=%s",
raw_score,
raw_total,
user_id,
concept_node_id,
)
continue
try:
score = int(raw_score)
total = int(raw_total)
except (TypeError, ValueError):
continue
if total <= 0:
# Skip rows that look incomplete — accuracy is undefined and
# the agent shouldn't have to guess.
continue
if score < 0 or score > total:
# Corrupt row (score outside [0, total]). Drop entirely
# rather than passing impossible numbers to the LLM —
# `score=7, total=5` would prompt the agent to wonder
# whether to trust the data at all.
logger.warning(
"read_recent_quiz_attempts: dropping corrupt row "
"(score=%d outside [0, total=%d]) user=%s concept=%s",
score,
total,
user_id,
concept_node_id,
)
continue
accuracy = score / total
attempts.append(
RecentQuizAttempt(
score=score,
total=total,
difficulty=str(r.get("difficulty") or ""),
completed_at=r.get("completed_at"),
accuracy=round(accuracy, 4),
)
)

return QuizHistory(
summary=_coerce_summary(summary_raw),
recent_attempts=attempts,
)


async def read_recent_quiz_attempts_tool(
ctx: RunContext[SaplingDeps],
concept_node_id: str,
) -> QuizHistory:
"""Returns this student's history on one concept: a `summary`
string digesting their prior mistakes (mine for distractor
inspiration) and `recent_attempts` — the last 5 completed quiz
attempts on this concept, newest first, with `accuracy` precomputed
so you can apply the adaptive-difficulty rule directly. Empty
history on first attempt. Pass the `concept_node_id` from the
user message; user identity is taken from context.
"""
# user_id comes from ctx.deps so a tool call can't cross users.
return await read_recent_quiz_attempts(ctx.deps.user_id, concept_node_id)
10 changes: 7 additions & 3 deletions backend/routes/quiz.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -166,11 +166,15 @@ async def _quiz_via_agent(
supabase=None,
request_id=request_id,
)
# Keep this message routing-only; the workflow + adaptive rules
# live in the system prompt. We just hand the agent the inputs it
# needs and trust the prompt to drive tool calls.
user_message = (
f"Generate {num_questions} {difficulty} questions for the student. "
f"The target concept is '{concept_name}' (concept_node_id={concept_node_id}). "
f"Call read_concepts_for_user to find the student's weakest concepts in this course "
f"and bias the question mix toward those."
f"The target concept is '{concept_name}' "
f"(concept_node_id={concept_node_id}). Follow the workflow in your "
f"system prompt; pass concept_node_id='{concept_node_id}' to "
f"read_recent_quiz_attempts."
)
if use_shared_context:
user_message += (
Expand Down
Loading
Loading
, '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('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 45 additions & 11 deletions backend/agents/quiz.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -24,6 +24,7 @@
read_concepts_for_user_tool,
read_misconceptions_for_course_tool,
)
from agents.tools.quiz_history import read_recent_quiz_attempts_tool


# Difficulty + question type are Literals so Gemini's enum constraint
Expand DownExpand Up@@ -69,31 +70,63 @@ class Quiz(BaseModel):
_SYSTEM_PROMPT = (
"You generate adaptive multiple-choice quizzes for a student. Each "
"question must target a specific concept the student has weak "
"mastery on, OR address a class-level misconception you've seen.\n\n"
"mastery on, OR address a class-level misconception you've seen, "
"OR revive a concept the student hasn't reviewed in a while.\n\n"
"Workflow:\n"
"1. Call `read_concepts_for_user` to see the student's mastery per "
" concept for this course (returned sorted by mastery ASC — "
" weakest first).\n"
" weakest first). Each concept also carries `last_reviewed_at`, "
" which you use for spaced repetition (see rules below).\n"
"2. Call `read_misconceptions_for_course` to see anonymized class "
" misconceptions. Use these to phrase distractors and to write "
" a question that probes the misconception.\n"
"3. Compose `Quiz.questions` so the WEAKEST concepts get the most "
" questions, AND each item's `concept` field exactly matches a "
" concept_name returned by tool 1.\n\n"
"3. Call `read_recent_quiz_attempts(concept_node_id)` for the "
" target concept_node_id given in the user message. The "
" `summary` is a digest of past mistakes the student has made "
" on this concept — mine it for distractor inspiration. The "
" `recent_attempts` list (newest first) drives adaptive "
" difficulty (see rules below).\n"
"4. Compose `Quiz.questions` so the WEAKEST and STALEST concepts "
" get the most questions, AND each item's `concept` field "
" exactly matches a concept_name returned by tool 1.\n\n"
"Concept-selection rules (combine all three signals):\n"
"- Bias question count toward the lowest-mastery concepts (the "
" weakest first in the tool 1 return).\n"
"- SPACED REPETITION: also surface concepts whose "
" `last_reviewed_at` is older than ~7 days, even if their "
" mastery is mid-tier — they're due for review and decay over "
" time. Concepts with `last_reviewed_at = null` are unreviewed; "
" treat them as stale.\n"
"- Don't drop high-mastery, recently-reviewed concepts entirely; "
" include 1 question on a strong-and-fresh concept to keep the "
" quiz from feeling punishing.\n\n"
"Adaptive-difficulty rules (use `recent_attempts.accuracy`):\n"
"- If the most recent 2-3 attempts on this concept averaged < "
" 0.5 accuracy, drop the difficulty mix one step from what the "
" user asked (hard -> medium, medium -> easy, easy stays easy). "
" The student is struggling; keep them on track.\n"
"- If the most recent 3 attempts all scored >= 0.8, you may "
" include 1-2 questions one step harder than the requested "
" difficulty to push them.\n"
"- If `recent_attempts` is empty (first attempt), honor the "
" user-requested difficulty exactly.\n"
"- Never override the user-requested difficulty by more than one "
" step in either direction. Stay close to what they asked for.\n\n"
"Per-question rules (multiple-choice only — the type field is "
"constrained to 'multiple_choice'):\n"
"- 4 options, exactly one correct. The text in `correct_answer` "
" MUST appear verbatim in `options` — character-for-character. "
" Questions that violate this are dropped at the route layer.\n"
"- Distractors should reflect plausible misconceptions, not random "
" noise. Use the read_misconceptions_for_course return value.\n"
" noise. Combine signals from `read_misconceptions_for_course` "
" (class-wide) and `read_recent_quiz_attempts.summary` "
" (this student's prior errors) when writing them.\n"
"- explanation: 1-3 sentences explaining WHY the correct answer "
" is correct — used in the post-quiz review screen.\n"
"- difficulty: align with the student's mastery on the concept; "
" weakest concepts get easy/medium, strongest get hard.\n\n"
"Honor the requested num_questions and difficulty distribution "
"in the user message. Don't invent concepts the student doesn't "
"have."
"- difficulty: align with the student's mastery on the concept "
" AND the adaptive-difficulty rules above.\n\n"
"Honor the requested num_questions. Don't invent concepts the "
"student doesn't have."
)
_PROMPT_HASH = hashlib.sha256(_SYSTEM_PROMPT.encode("utf-8")).hexdigest()[:12]

Expand All@@ -107,5 +140,6 @@ class Quiz(BaseModel):
tools=[
read_concepts_for_user_tool,
read_misconceptions_for_course_tool,
read_recent_quiz_attempts_tool,
],
)
234 changes: 234 additions & 0 deletions backend/agents/tools/quiz_history.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,234 @@
"""Quiz-history read tool for the quiz agent.

Surfaces what the student previously got wrong on a concept and how
their last few attempts scored. The agent uses this for two things:

1. Targeting — write distractors that mirror the student's prior
mistakes (the LLM-generated `summary` from `quiz_context`
captures patterns rolled up across past attempts).
2. Adaptive difficulty — read the last few `quiz_attempts` rows and
step difficulty down when the student has been struggling, up
when they've been crushing it.

The pure async function is callable from routes/tests; the *_tool
wrapper registers on a Pydantic AI Agent.
"""

from __future__ import annotations

import asyncio
import logging
from typing import Any

from pydantic import BaseModel, Field
from pydantic_ai import RunContext

from agents.deps import SaplingDeps
from db.connection import table

logger = logging.getLogger(__name__)


# How many past attempts the agent gets to see. 5 is enough to spot a
# trend without flooding the prompt with state. Older attempts are
# already rolled into `summary` by the post-quiz context update job.
_RECENT_ATTEMPTS_LIMIT = 5


class RecentQuizAttempt(BaseModel):
"""One past attempt's headline numbers."""

score: int = Field(ge=0)
total: int = Field(ge=0)
difficulty: str
completed_at: str | None = None
accuracy: float = Field(ge=0.0, le=1.0)


class QuizHistory(BaseModel):
"""The agent's view of a student's history on one concept."""

# LLM-generated digest of past quiz mistakes/patterns for this
# (user, concept). Populated by the background context-update job
# in routes/quiz.py:submit_quiz. May be None on a first attempt.
summary: str | None = None
# Most recent attempts, newest first. Empty on first attempt.
recent_attempts: list[RecentQuizAttempt] = Field(default_factory=list)


def _coerce_summary(ctx: Any) -> str | None:
"""quiz_context.context_json is free-form (whatever the post-submit
LLM produced). Different prompt versions have stored either a flat
string or a small dict. Coerce to a single string the agent can
reason over, or None if there's nothing useful."""
if not ctx:
return None
if isinstance(ctx, str):
text = ctx.strip()
return text or None
if isinstance(ctx, dict):
# Common shapes: {"summary": "..."}, {"notes": "..."},
# {"misconceptions": [...], "weak_areas": [...]}.
for key in ("summary", "notes", "context", "digest"):
v = ctx.get(key)
if isinstance(v, str) and v.strip():
return v.strip()
# Fall back to flattening list-of-strings entries so the agent
# at least sees the misconceptions/weak_areas the prior job
# extracted, even when no top-level summary string exists.
parts: list[str] = []
for key in ("misconceptions", "weak_areas", "common_errors"):
for item in ctx.get(key) or []:
if isinstance(item, str) and item.strip():
parts.append(f"- {item.strip()}")
return "\n".join(parts) or None
return None


async def read_recent_quiz_attempts(
user_id: str,
concept_node_id: str,
) -> QuizHistory:
"""Return the agent's view of a student's history on one concept.

Reads two sources:

- `quiz_context` (one row per (user, concept)): the rolling
LLM-generated digest of what the student has been getting
wrong. This is the same blob legacy `routes/quiz.py` used to
stuff into the prompt template.
- `quiz_attempts` (one row per attempt, filtered to completed
attempts): the last N completed attempts, newest first, with
accuracy precomputed so the agent doesn't have to.

Wraps the sync Supabase reads in `asyncio.to_thread` so we don't
block the event loop. Failures degrade silently — the agent can
still generate a quiz without history (just less adaptive).
"""

def _fetch_summary() -> Any:
try:
rows = table("quiz_context").select(
"context_json",
filters={
"user_id": f"eq.{user_id}",
"concept_node_id": f"eq.{concept_node_id}",
},
limit=1,
)
return rows[0]["context_json"] if rows else None
except Exception:
logger.exception(
"read_recent_quiz_attempts: quiz_context fetch failed "
"user=%s concept=%s",
user_id,
concept_node_id,
)
return None

def _fetch_attempts() -> list[dict[str, Any]]:
try:
return (
table("quiz_attempts").select(
"score,total,difficulty,completed_at",
filters={
"user_id": f"eq.{user_id}",
"concept_node_id": f"eq.{concept_node_id}",
# Only count completed attempts. PostgREST `not.is.null`
# filters out rows where completed_at is NULL, which is
# how `routes/quiz.py:generate_quiz` marks an in-flight
# attempt before submission.
"completed_at": "not.is.null",
},
order="completed_at.desc",
limit=_RECENT_ATTEMPTS_LIMIT,
)
or []
)
except Exception:
logger.exception(
"read_recent_quiz_attempts: quiz_attempts fetch failed "
"user=%s concept=%s",
user_id,
concept_node_id,
)
return []

summary_raw, attempt_rows = await asyncio.gather(
asyncio.to_thread(_fetch_summary),
asyncio.to_thread(_fetch_attempts),
)

attempts: list[RecentQuizAttempt] = []
for r in attempt_rows:
raw_score = r.get("score")
raw_total = r.get("total")
if raw_score is None or raw_total is None:
# `submit_quiz` writes score+total atomically, so a row with
# `completed_at IS NOT NULL` but a null score/total is
# corruption (or an out-of-band edit). Drop it rather than
# coercing to 0/0 — feeding the LLM a bogus 0% accuracy
# could trigger a spurious adaptive downshift.
logger.warning(
"read_recent_quiz_attempts: dropping row with null "
"score/total (score=%r, total=%r) user=%s concept=%s",
raw_score,
raw_total,
user_id,
concept_node_id,
)
continue
try:
score = int(raw_score)
total = int(raw_total)
except (TypeError, ValueError):
continue
if total <= 0:
# Skip rows that look incomplete — accuracy is undefined and
# the agent shouldn't have to guess.
continue
if score < 0 or score > total:
# Corrupt row (score outside [0, total]). Drop entirely
# rather than passing impossible numbers to the LLM —
# `score=7, total=5` would prompt the agent to wonder
# whether to trust the data at all.
logger.warning(
"read_recent_quiz_attempts: dropping corrupt row "
"(score=%d outside [0, total=%d]) user=%s concept=%s",
score,
total,
user_id,
concept_node_id,
)
continue
accuracy = score / total
attempts.append(
RecentQuizAttempt(
score=score,
total=total,
difficulty=str(r.get("difficulty") or ""),
completed_at=r.get("completed_at"),
accuracy=round(accuracy, 4),
)
)

return QuizHistory(
summary=_coerce_summary(summary_raw),
recent_attempts=attempts,
)


async def read_recent_quiz_attempts_tool(
ctx: RunContext[SaplingDeps],
concept_node_id: str,
) -> QuizHistory:
"""Returns this student's history on one concept: a `summary`
string digesting their prior mistakes (mine for distractor
inspiration) and `recent_attempts` — the last 5 completed quiz
attempts on this concept, newest first, with `accuracy` precomputed
so you can apply the adaptive-difficulty rule directly. Empty
history on first attempt. Pass the `concept_node_id` from the
user message; user identity is taken from context.
"""
# user_id comes from ctx.deps so a tool call can't cross users.
return await read_recent_quiz_attempts(ctx.deps.user_id, concept_node_id)
10 changes: 7 additions & 3 deletions backend/routes/quiz.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -166,11 +166,15 @@ async def _quiz_via_agent(
supabase=None,
request_id=request_id,
)
# Keep this message routing-only; the workflow + adaptive rules
# live in the system prompt. We just hand the agent the inputs it
# needs and trust the prompt to drive tool calls.
user_message = (
f"Generate {num_questions} {difficulty} questions for the student. "
f"The target concept is '{concept_name}' (concept_node_id={concept_node_id}). "
f"Call read_concepts_for_user to find the student's weakest concepts in this course "
f"and bias the question mix toward those."
f"The target concept is '{concept_name}' "
f"(concept_node_id={concept_node_id}). Follow the workflow in your "
f"system prompt; pass concept_node_id='{concept_node_id}' to "
f"read_recent_quiz_attempts."
)
if use_shared_context:
user_message += (
Expand Down
Loading
Loading
, '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('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 45 additions & 11 deletions backend/agents/quiz.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -24,6 +24,7 @@
read_concepts_for_user_tool,
read_misconceptions_for_course_tool,
)
from agents.tools.quiz_history import read_recent_quiz_attempts_tool


# Difficulty + question type are Literals so Gemini's enum constraint
Expand DownExpand Up@@ -69,31 +70,63 @@ class Quiz(BaseModel):
_SYSTEM_PROMPT = (
"You generate adaptive multiple-choice quizzes for a student. Each "
"question must target a specific concept the student has weak "
"mastery on, OR address a class-level misconception you've seen.\n\n"
"mastery on, OR address a class-level misconception you've seen, "
"OR revive a concept the student hasn't reviewed in a while.\n\n"
"Workflow:\n"
"1. Call `read_concepts_for_user` to see the student's mastery per "
" concept for this course (returned sorted by mastery ASC — "
" weakest first).\n"
" weakest first). Each concept also carries `last_reviewed_at`, "
" which you use for spaced repetition (see rules below).\n"
"2. Call `read_misconceptions_for_course` to see anonymized class "
" misconceptions. Use these to phrase distractors and to write "
" a question that probes the misconception.\n"
"3. Compose `Quiz.questions` so the WEAKEST concepts get the most "
" questions, AND each item's `concept` field exactly matches a "
" concept_name returned by tool 1.\n\n"
"3. Call `read_recent_quiz_attempts(concept_node_id)` for the "
" target concept_node_id given in the user message. The "
" `summary` is a digest of past mistakes the student has made "
" on this concept — mine it for distractor inspiration. The "
" `recent_attempts` list (newest first) drives adaptive "
" difficulty (see rules below).\n"
"4. Compose `Quiz.questions` so the WEAKEST and STALEST concepts "
" get the most questions, AND each item's `concept` field "
" exactly matches a concept_name returned by tool 1.\n\n"
"Concept-selection rules (combine all three signals):\n"
"- Bias question count toward the lowest-mastery concepts (the "
" weakest first in the tool 1 return).\n"
"- SPACED REPETITION: also surface concepts whose "
" `last_reviewed_at` is older than ~7 days, even if their "
" mastery is mid-tier — they're due for review and decay over "
" time. Concepts with `last_reviewed_at = null` are unreviewed; "
" treat them as stale.\n"
"- Don't drop high-mastery, recently-reviewed concepts entirely; "
" include 1 question on a strong-and-fresh concept to keep the "
" quiz from feeling punishing.\n\n"
"Adaptive-difficulty rules (use `recent_attempts.accuracy`):\n"
"- If the most recent 2-3 attempts on this concept averaged < "
" 0.5 accuracy, drop the difficulty mix one step from what the "
" user asked (hard -> medium, medium -> easy, easy stays easy). "
" The student is struggling; keep them on track.\n"
"- If the most recent 3 attempts all scored >= 0.8, you may "
" include 1-2 questions one step harder than the requested "
" difficulty to push them.\n"
"- If `recent_attempts` is empty (first attempt), honor the "
" user-requested difficulty exactly.\n"
"- Never override the user-requested difficulty by more than one "
" step in either direction. Stay close to what they asked for.\n\n"
"Per-question rules (multiple-choice only — the type field is "
"constrained to 'multiple_choice'):\n"
"- 4 options, exactly one correct. The text in `correct_answer` "
" MUST appear verbatim in `options` — character-for-character. "
" Questions that violate this are dropped at the route layer.\n"
"- Distractors should reflect plausible misconceptions, not random "
" noise. Use the read_misconceptions_for_course return value.\n"
" noise. Combine signals from `read_misconceptions_for_course` "
" (class-wide) and `read_recent_quiz_attempts.summary` "
" (this student's prior errors) when writing them.\n"
"- explanation: 1-3 sentences explaining WHY the correct answer "
" is correct — used in the post-quiz review screen.\n"
"- difficulty: align with the student's mastery on the concept; "
" weakest concepts get easy/medium, strongest get hard.\n\n"
"Honor the requested num_questions and difficulty distribution "
"in the user message. Don't invent concepts the student doesn't "
"have."
"- difficulty: align with the student's mastery on the concept "
" AND the adaptive-difficulty rules above.\n\n"
"Honor the requested num_questions. Don't invent concepts the "
"student doesn't have."
)
_PROMPT_HASH = hashlib.sha256(_SYSTEM_PROMPT.encode("utf-8")).hexdigest()[:12]

Expand All@@ -107,5 +140,6 @@ class Quiz(BaseModel):
tools=[
read_concepts_for_user_tool,
read_misconceptions_for_course_tool,
read_recent_quiz_attempts_tool,
],
)
234 changes: 234 additions & 0 deletions backend/agents/tools/quiz_history.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,234 @@
"""Quiz-history read tool for the quiz agent.

Surfaces what the student previously got wrong on a concept and how
their last few attempts scored. The agent uses this for two things:

1. Targeting — write distractors that mirror the student's prior
mistakes (the LLM-generated `summary` from `quiz_context`
captures patterns rolled up across past attempts).
2. Adaptive difficulty — read the last few `quiz_attempts` rows and
step difficulty down when the student has been struggling, up
when they've been crushing it.

The pure async function is callable from routes/tests; the *_tool
wrapper registers on a Pydantic AI Agent.
"""

from __future__ import annotations

import asyncio
import logging
from typing import Any

from pydantic import BaseModel, Field
from pydantic_ai import RunContext

from agents.deps import SaplingDeps
from db.connection import table

logger = logging.getLogger(__name__)


# How many past attempts the agent gets to see. 5 is enough to spot a
# trend without flooding the prompt with state. Older attempts are
# already rolled into `summary` by the post-quiz context update job.
_RECENT_ATTEMPTS_LIMIT = 5


class RecentQuizAttempt(BaseModel):
"""One past attempt's headline numbers."""

score: int = Field(ge=0)
total: int = Field(ge=0)
difficulty: str
completed_at: str | None = None
accuracy: float = Field(ge=0.0, le=1.0)


class QuizHistory(BaseModel):
"""The agent's view of a student's history on one concept."""

# LLM-generated digest of past quiz mistakes/patterns for this
# (user, concept). Populated by the background context-update job
# in routes/quiz.py:submit_quiz. May be None on a first attempt.
summary: str | None = None
# Most recent attempts, newest first. Empty on first attempt.
recent_attempts: list[RecentQuizAttempt] = Field(default_factory=list)


def _coerce_summary(ctx: Any) -> str | None:
"""quiz_context.context_json is free-form (whatever the post-submit
LLM produced). Different prompt versions have stored either a flat
string or a small dict. Coerce to a single string the agent can
reason over, or None if there's nothing useful."""
if not ctx:
return None
if isinstance(ctx, str):
text = ctx.strip()
return text or None
if isinstance(ctx, dict):
# Common shapes: {"summary": "..."}, {"notes": "..."},
# {"misconceptions": [...], "weak_areas": [...]}.
for key in ("summary", "notes", "context", "digest"):
v = ctx.get(key)
if isinstance(v, str) and v.strip():
return v.strip()
# Fall back to flattening list-of-strings entries so the agent
# at least sees the misconceptions/weak_areas the prior job
# extracted, even when no top-level summary string exists.
parts: list[str] = []
for key in ("misconceptions", "weak_areas", "common_errors"):
for item in ctx.get(key) or []:
if isinstance(item, str) and item.strip():
parts.append(f"- {item.strip()}")
return "\n".join(parts) or None
return None


async def read_recent_quiz_attempts(
user_id: str,
concept_node_id: str,
) -> QuizHistory:
"""Return the agent's view of a student's history on one concept.

Reads two sources:

- `quiz_context` (one row per (user, concept)): the rolling
LLM-generated digest of what the student has been getting
wrong. This is the same blob legacy `routes/quiz.py` used to
stuff into the prompt template.
- `quiz_attempts` (one row per attempt, filtered to completed
attempts): the last N completed attempts, newest first, with
accuracy precomputed so the agent doesn't have to.

Wraps the sync Supabase reads in `asyncio.to_thread` so we don't
block the event loop. Failures degrade silently — the agent can
still generate a quiz without history (just less adaptive).
"""

def _fetch_summary() -> Any:
try:
rows = table("quiz_context").select(
"context_json",
filters={
"user_id": f"eq.{user_id}",
"concept_node_id": f"eq.{concept_node_id}",
},
limit=1,
)
return rows[0]["context_json"] if rows else None
except Exception:
logger.exception(
"read_recent_quiz_attempts: quiz_context fetch failed "
"user=%s concept=%s",
user_id,
concept_node_id,
)
return None

def _fetch_attempts() -> list[dict[str, Any]]:
try:
return (
table("quiz_attempts").select(
"score,total,difficulty,completed_at",
filters={
"user_id": f"eq.{user_id}",
"concept_node_id": f"eq.{concept_node_id}",
# Only count completed attempts. PostgREST `not.is.null`
# filters out rows where completed_at is NULL, which is
# how `routes/quiz.py:generate_quiz` marks an in-flight
# attempt before submission.
"completed_at": "not.is.null",
},
order="completed_at.desc",
limit=_RECENT_ATTEMPTS_LIMIT,
)
or []
)
except Exception:
logger.exception(
"read_recent_quiz_attempts: quiz_attempts fetch failed "
"user=%s concept=%s",
user_id,
concept_node_id,
)
return []

summary_raw, attempt_rows = await asyncio.gather(
asyncio.to_thread(_fetch_summary),
asyncio.to_thread(_fetch_attempts),
)

attempts: list[RecentQuizAttempt] = []
for r in attempt_rows:
raw_score = r.get("score")
raw_total = r.get("total")
if raw_score is None or raw_total is None:
# `submit_quiz` writes score+total atomically, so a row with
# `completed_at IS NOT NULL` but a null score/total is
# corruption (or an out-of-band edit). Drop it rather than
# coercing to 0/0 — feeding the LLM a bogus 0% accuracy
# could trigger a spurious adaptive downshift.
logger.warning(
"read_recent_quiz_attempts: dropping row with null "
"score/total (score=%r, total=%r) user=%s concept=%s",
raw_score,
raw_total,
user_id,
concept_node_id,
)
continue
try:
score = int(raw_score)
total = int(raw_total)
except (TypeError, ValueError):
continue
if total <= 0:
# Skip rows that look incomplete — accuracy is undefined and
# the agent shouldn't have to guess.
continue
if score < 0 or score > total:
# Corrupt row (score outside [0, total]). Drop entirely
# rather than passing impossible numbers to the LLM —
# `score=7, total=5` would prompt the agent to wonder
# whether to trust the data at all.
logger.warning(
"read_recent_quiz_attempts: dropping corrupt row "
"(score=%d outside [0, total=%d]) user=%s concept=%s",
score,
total,
user_id,
concept_node_id,
)
continue
accuracy = score / total
attempts.append(
RecentQuizAttempt(
score=score,
total=total,
difficulty=str(r.get("difficulty") or ""),
completed_at=r.get("completed_at"),
accuracy=round(accuracy, 4),
)
)

return QuizHistory(
summary=_coerce_summary(summary_raw),
recent_attempts=attempts,
)


async def read_recent_quiz_attempts_tool(
ctx: RunContext[SaplingDeps],
concept_node_id: str,
) -> QuizHistory:
"""Returns this student's history on one concept: a `summary`
string digesting their prior mistakes (mine for distractor
inspiration) and `recent_attempts` — the last 5 completed quiz
attempts on this concept, newest first, with `accuracy` precomputed
so you can apply the adaptive-difficulty rule directly. Empty
history on first attempt. Pass the `concept_node_id` from the
user message; user identity is taken from context.
"""
# user_id comes from ctx.deps so a tool call can't cross users.
return await read_recent_quiz_attempts(ctx.deps.user_id, concept_node_id)
10 changes: 7 additions & 3 deletions backend/routes/quiz.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -166,11 +166,15 @@ async def _quiz_via_agent(
supabase=None,
request_id=request_id,
)
# Keep this message routing-only; the workflow + adaptive rules
# live in the system prompt. We just hand the agent the inputs it
# needs and trust the prompt to drive tool calls.
user_message = (
f"Generate {num_questions} {difficulty} questions for the student. "
f"The target concept is '{concept_name}' (concept_node_id={concept_node_id}). "
f"Call read_concepts_for_user to find the student's weakest concepts in this course "
f"and bias the question mix toward those."
f"The target concept is '{concept_name}' "
f"(concept_node_id={concept_node_id}). Follow the workflow in your "
f"system prompt; pass concept_node_id='{concept_node_id}' to "
f"read_recent_quiz_attempts."
)
if use_shared_context:
user_message += (
Expand Down
Loading
Loading
, '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" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 45 additions & 11 deletions backend/agents/quiz.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -24,6 +24,7 @@
read_concepts_for_user_tool,
read_misconceptions_for_course_tool,
)
from agents.tools.quiz_history import read_recent_quiz_attempts_tool


# Difficulty + question type are Literals so Gemini's enum constraint
Expand DownExpand Up@@ -69,31 +70,63 @@ class Quiz(BaseModel):
_SYSTEM_PROMPT = (
"You generate adaptive multiple-choice quizzes for a student. Each "
"question must target a specific concept the student has weak "
"mastery on, OR address a class-level misconception you've seen.\n\n"
"mastery on, OR address a class-level misconception you've seen, "
"OR revive a concept the student hasn't reviewed in a while.\n\n"
"Workflow:\n"
"1. Call `read_concepts_for_user` to see the student's mastery per "
" concept for this course (returned sorted by mastery ASC — "
" weakest first).\n"
" weakest first). Each concept also carries `last_reviewed_at`, "
" which you use for spaced repetition (see rules below).\n"
"2. Call `read_misconceptions_for_course` to see anonymized class "
" misconceptions. Use these to phrase distractors and to write "
" a question that probes the misconception.\n"
"3. Compose `Quiz.questions` so the WEAKEST concepts get the most "
" questions, AND each item's `concept` field exactly matches a "
" concept_name returned by tool 1.\n\n"
"3. Call `read_recent_quiz_attempts(concept_node_id)` for the "
" target concept_node_id given in the user message. The "
" `summary` is a digest of past mistakes the student has made "
" on this concept — mine it for distractor inspiration. The "
" `recent_attempts` list (newest first) drives adaptive "
" difficulty (see rules below).\n"
"4. Compose `Quiz.questions` so the WEAKEST and STALEST concepts "
" get the most questions, AND each item's `concept` field "
" exactly matches a concept_name returned by tool 1.\n\n"
"Concept-selection rules (combine all three signals):\n"
"- Bias question count toward the lowest-mastery concepts (the "
" weakest first in the tool 1 return).\n"
"- SPACED REPETITION: also surface concepts whose "
" `last_reviewed_at` is older than ~7 days, even if their "
" mastery is mid-tier — they're due for review and decay over "
" time. Concepts with `last_reviewed_at = null` are unreviewed; "
" treat them as stale.\n"
"- Don't drop high-mastery, recently-reviewed concepts entirely; "
" include 1 question on a strong-and-fresh concept to keep the "
" quiz from feeling punishing.\n\n"
"Adaptive-difficulty rules (use `recent_attempts.accuracy`):\n"
"- If the most recent 2-3 attempts on this concept averaged < "
" 0.5 accuracy, drop the difficulty mix one step from what the "
" user asked (hard -> medium, medium -> easy, easy stays easy). "
" The student is struggling; keep them on track.\n"
"- If the most recent 3 attempts all scored >= 0.8, you may "
" include 1-2 questions one step harder than the requested "
" difficulty to push them.\n"
"- If `recent_attempts` is empty (first attempt), honor the "
" user-requested difficulty exactly.\n"
"- Never override the user-requested difficulty by more than one "
" step in either direction. Stay close to what they asked for.\n\n"
"Per-question rules (multiple-choice only — the type field is "
"constrained to 'multiple_choice'):\n"
"- 4 options, exactly one correct. The text in `correct_answer` "
" MUST appear verbatim in `options` — character-for-character. "
" Questions that violate this are dropped at the route layer.\n"
"- Distractors should reflect plausible misconceptions, not random "
" noise. Use the read_misconceptions_for_course return value.\n"
" noise. Combine signals from `read_misconceptions_for_course` "
" (class-wide) and `read_recent_quiz_attempts.summary` "
" (this student's prior errors) when writing them.\n"
"- explanation: 1-3 sentences explaining WHY the correct answer "
" is correct — used in the post-quiz review screen.\n"
"- difficulty: align with the student's mastery on the concept; "
" weakest concepts get easy/medium, strongest get hard.\n\n"
"Honor the requested num_questions and difficulty distribution "
"in the user message. Don't invent concepts the student doesn't "
"have."
"- difficulty: align with the student's mastery on the concept "
" AND the adaptive-difficulty rules above.\n\n"
"Honor the requested num_questions. Don't invent concepts the "
"student doesn't have."
)
_PROMPT_HASH = hashlib.sha256(_SYSTEM_PROMPT.encode("utf-8")).hexdigest()[:12]

Expand All@@ -107,5 +140,6 @@ class Quiz(BaseModel):
tools=[
read_concepts_for_user_tool,
read_misconceptions_for_course_tool,
read_recent_quiz_attempts_tool,
],
)
234 changes: 234 additions & 0 deletions backend/agents/tools/quiz_history.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,234 @@
"""Quiz-history read tool for the quiz agent.

Surfaces what the student previously got wrong on a concept and how
their last few attempts scored. The agent uses this for two things:

1. Targeting — write distractors that mirror the student's prior
mistakes (the LLM-generated `summary` from `quiz_context`
captures patterns rolled up across past attempts).
2. Adaptive difficulty — read the last few `quiz_attempts` rows and
step difficulty down when the student has been struggling, up
when they've been crushing it.

The pure async function is callable from routes/tests; the *_tool
wrapper registers on a Pydantic AI Agent.
"""

from __future__ import annotations

import asyncio
import logging
from typing import Any

from pydantic import BaseModel, Field
from pydantic_ai import RunContext

from agents.deps import SaplingDeps
from db.connection import table

logger = logging.getLogger(__name__)


# How many past attempts the agent gets to see. 5 is enough to spot a
# trend without flooding the prompt with state. Older attempts are
# already rolled into `summary` by the post-quiz context update job.
_RECENT_ATTEMPTS_LIMIT = 5


class RecentQuizAttempt(BaseModel):
"""One past attempt's headline numbers."""

score: int = Field(ge=0)
total: int = Field(ge=0)
difficulty: str
completed_at: str | None = None
accuracy: float = Field(ge=0.0, le=1.0)


class QuizHistory(BaseModel):
"""The agent's view of a student's history on one concept."""

# LLM-generated digest of past quiz mistakes/patterns for this
# (user, concept). Populated by the background context-update job
# in routes/quiz.py:submit_quiz. May be None on a first attempt.
summary: str | None = None
# Most recent attempts, newest first. Empty on first attempt.
recent_attempts: list[RecentQuizAttempt] = Field(default_factory=list)


def _coerce_summary(ctx: Any) -> str | None:
"""quiz_context.context_json is free-form (whatever the post-submit
LLM produced). Different prompt versions have stored either a flat
string or a small dict. Coerce to a single string the agent can
reason over, or None if there's nothing useful."""
if not ctx:
return None
if isinstance(ctx, str):
text = ctx.strip()
return text or None
if isinstance(ctx, dict):
# Common shapes: {"summary": "..."}, {"notes": "..."},
# {"misconceptions": [...], "weak_areas": [...]}.
for key in ("summary", "notes", "context", "digest"):
v = ctx.get(key)
if isinstance(v, str) and v.strip():
return v.strip()
# Fall back to flattening list-of-strings entries so the agent
# at least sees the misconceptions/weak_areas the prior job
# extracted, even when no top-level summary string exists.
parts: list[str] = []
for key in ("misconceptions", "weak_areas", "common_errors"):
for item in ctx.get(key) or []:
if isinstance(item, str) and item.strip():
parts.append(f"- {item.strip()}")
return "\n".join(parts) or None
return None


async def read_recent_quiz_attempts(
user_id: str,
concept_node_id: str,
) -> QuizHistory:
"""Return the agent's view of a student's history on one concept.

Reads two sources:

- `quiz_context` (one row per (user, concept)): the rolling
LLM-generated digest of what the student has been getting
wrong. This is the same blob legacy `routes/quiz.py` used to
stuff into the prompt template.
- `quiz_attempts` (one row per attempt, filtered to completed
attempts): the last N completed attempts, newest first, with
accuracy precomputed so the agent doesn't have to.

Wraps the sync Supabase reads in `asyncio.to_thread` so we don't
block the event loop. Failures degrade silently — the agent can
still generate a quiz without history (just less adaptive).
"""

def _fetch_summary() -> Any:
try:
rows = table("quiz_context").select(
"context_json",
filters={
"user_id": f"eq.{user_id}",
"concept_node_id": f"eq.{concept_node_id}",
},
limit=1,
)
return rows[0]["context_json"] if rows else None
except Exception:
logger.exception(
"read_recent_quiz_attempts: quiz_context fetch failed "
"user=%s concept=%s",
user_id,
concept_node_id,
)
return None

def _fetch_attempts() -> list[dict[str, Any]]:
try:
return (
table("quiz_attempts").select(
"score,total,difficulty,completed_at",
filters={
"user_id": f"eq.{user_id}",
"concept_node_id": f"eq.{concept_node_id}",
# Only count completed attempts. PostgREST `not.is.null`
# filters out rows where completed_at is NULL, which is
# how `routes/quiz.py:generate_quiz` marks an in-flight
# attempt before submission.
"completed_at": "not.is.null",
},
order="completed_at.desc",
limit=_RECENT_ATTEMPTS_LIMIT,
)
or []
)
except Exception:
logger.exception(
"read_recent_quiz_attempts: quiz_attempts fetch failed "
"user=%s concept=%s",
user_id,
concept_node_id,
)
return []

summary_raw, attempt_rows = await asyncio.gather(
asyncio.to_thread(_fetch_summary),
asyncio.to_thread(_fetch_attempts),
)

attempts: list[RecentQuizAttempt] = []
for r in attempt_rows:
raw_score = r.get("score")
raw_total = r.get("total")
if raw_score is None or raw_total is None:
# `submit_quiz` writes score+total atomically, so a row with
# `completed_at IS NOT NULL` but a null score/total is
# corruption (or an out-of-band edit). Drop it rather than
# coercing to 0/0 — feeding the LLM a bogus 0% accuracy
# could trigger a spurious adaptive downshift.
logger.warning(
"read_recent_quiz_attempts: dropping row with null "
"score/total (score=%r, total=%r) user=%s concept=%s",
raw_score,
raw_total,
user_id,
concept_node_id,
)
continue
try:
score = int(raw_score)
total = int(raw_total)
except (TypeError, ValueError):
continue
if total <= 0:
# Skip rows that look incomplete — accuracy is undefined and
# the agent shouldn't have to guess.
continue
if score < 0 or score > total:
# Corrupt row (score outside [0, total]). Drop entirely
# rather than passing impossible numbers to the LLM —
# `score=7, total=5` would prompt the agent to wonder
# whether to trust the data at all.
logger.warning(
"read_recent_quiz_attempts: dropping corrupt row "
"(score=%d outside [0, total=%d]) user=%s concept=%s",
score,
total,
user_id,
concept_node_id,
)
continue
accuracy = score / total
attempts.append(
RecentQuizAttempt(
score=score,
total=total,
difficulty=str(r.get("difficulty") or ""),
completed_at=r.get("completed_at"),
accuracy=round(accuracy, 4),
)
)

return QuizHistory(
summary=_coerce_summary(summary_raw),
recent_attempts=attempts,
)


async def read_recent_quiz_attempts_tool(
ctx: RunContext[SaplingDeps],
concept_node_id: str,
) -> QuizHistory:
"""Returns this student's history on one concept: a `summary`
string digesting their prior mistakes (mine for distractor
inspiration) and `recent_attempts` — the last 5 completed quiz
attempts on this concept, newest first, with `accuracy` precomputed
so you can apply the adaptive-difficulty rule directly. Empty
history on first attempt. Pass the `concept_node_id` from the
user message; user identity is taken from context.
"""
# user_id comes from ctx.deps so a tool call can't cross users.
return await read_recent_quiz_attempts(ctx.deps.user_id, concept_node_id)
10 changes: 7 additions & 3 deletions backend/routes/quiz.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -166,11 +166,15 @@ async def _quiz_via_agent(
supabase=None,
request_id=request_id,
)
# Keep this message routing-only; the workflow + adaptive rules
# live in the system prompt. We just hand the agent the inputs it
# needs and trust the prompt to drive tool calls.
user_message = (
f"Generate {num_questions} {difficulty} questions for the student. "
f"The target concept is '{concept_name}' (concept_node_id={concept_node_id}). "
f"Call read_concepts_for_user to find the student's weakest concepts in this course "
f"and bias the question mix toward those."
f"The target concept is '{concept_name}' "
f"(concept_node_id={concept_node_id}). Follow the workflow in your "
f"system prompt; pass concept_node_id='{concept_node_id}' to "
f"read_recent_quiz_attempts."
)
if use_shared_context:
user_message += (
Expand Down
Loading
Loading
, '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('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 45 additions & 11 deletions backend/agents/quiz.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -24,6 +24,7 @@
read_concepts_for_user_tool,
read_misconceptions_for_course_tool,
)
from agents.tools.quiz_history import read_recent_quiz_attempts_tool


# Difficulty + question type are Literals so Gemini's enum constraint
Expand DownExpand Up@@ -69,31 +70,63 @@ class Quiz(BaseModel):
_SYSTEM_PROMPT = (
"You generate adaptive multiple-choice quizzes for a student. Each "
"question must target a specific concept the student has weak "
"mastery on, OR address a class-level misconception you've seen.\n\n"
"mastery on, OR address a class-level misconception you've seen, "
"OR revive a concept the student hasn't reviewed in a while.\n\n"
"Workflow:\n"
"1. Call `read_concepts_for_user` to see the student's mastery per "
" concept for this course (returned sorted by mastery ASC — "
" weakest first).\n"
" weakest first). Each concept also carries `last_reviewed_at`, "
" which you use for spaced repetition (see rules below).\n"
"2. Call `read_misconceptions_for_course` to see anonymized class "
" misconceptions. Use these to phrase distractors and to write "
" a question that probes the misconception.\n"
"3. Compose `Quiz.questions` so the WEAKEST concepts get the most "
" questions, AND each item's `concept` field exactly matches a "
" concept_name returned by tool 1.\n\n"
"3. Call `read_recent_quiz_attempts(concept_node_id)` for the "
" target concept_node_id given in the user message. The "
" `summary` is a digest of past mistakes the student has made "
" on this concept — mine it for distractor inspiration. The "
" `recent_attempts` list (newest first) drives adaptive "
" difficulty (see rules below).\n"
"4. Compose `Quiz.questions` so the WEAKEST and STALEST concepts "
" get the most questions, AND each item's `concept` field "
" exactly matches a concept_name returned by tool 1.\n\n"
"Concept-selection rules (combine all three signals):\n"
"- Bias question count toward the lowest-mastery concepts (the "
" weakest first in the tool 1 return).\n"
"- SPACED REPETITION: also surface concepts whose "
" `last_reviewed_at` is older than ~7 days, even if their "
" mastery is mid-tier — they're due for review and decay over "
" time. Concepts with `last_reviewed_at = null` are unreviewed; "
" treat them as stale.\n"
"- Don't drop high-mastery, recently-reviewed concepts entirely; "
" include 1 question on a strong-and-fresh concept to keep the "
" quiz from feeling punishing.\n\n"
"Adaptive-difficulty rules (use `recent_attempts.accuracy`):\n"
"- If the most recent 2-3 attempts on this concept averaged < "
" 0.5 accuracy, drop the difficulty mix one step from what the "
" user asked (hard -> medium, medium -> easy, easy stays easy). "
" The student is struggling; keep them on track.\n"
"- If the most recent 3 attempts all scored >= 0.8, you may "
" include 1-2 questions one step harder than the requested "
" difficulty to push them.\n"
"- If `recent_attempts` is empty (first attempt), honor the "
" user-requested difficulty exactly.\n"
"- Never override the user-requested difficulty by more than one "
" step in either direction. Stay close to what they asked for.\n\n"
"Per-question rules (multiple-choice only — the type field is "
"constrained to 'multiple_choice'):\n"
"- 4 options, exactly one correct. The text in `correct_answer` "
" MUST appear verbatim in `options` — character-for-character. "
" Questions that violate this are dropped at the route layer.\n"
"- Distractors should reflect plausible misconceptions, not random "
" noise. Use the read_misconceptions_for_course return value.\n"
" noise. Combine signals from `read_misconceptions_for_course` "
" (class-wide) and `read_recent_quiz_attempts.summary` "
" (this student's prior errors) when writing them.\n"
"- explanation: 1-3 sentences explaining WHY the correct answer "
" is correct — used in the post-quiz review screen.\n"
"- difficulty: align with the student's mastery on the concept; "
" weakest concepts get easy/medium, strongest get hard.\n\n"
"Honor the requested num_questions and difficulty distribution "
"in the user message. Don't invent concepts the student doesn't "
"have."
"- difficulty: align with the student's mastery on the concept "
" AND the adaptive-difficulty rules above.\n\n"
"Honor the requested num_questions. Don't invent concepts the "
"student doesn't have."
)
_PROMPT_HASH = hashlib.sha256(_SYSTEM_PROMPT.encode("utf-8")).hexdigest()[:12]

Expand All@@ -107,5 +140,6 @@ class Quiz(BaseModel):
tools=[
read_concepts_for_user_tool,
read_misconceptions_for_course_tool,
read_recent_quiz_attempts_tool,
],
)
234 changes: 234 additions & 0 deletions backend/agents/tools/quiz_history.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,234 @@
"""Quiz-history read tool for the quiz agent.

Surfaces what the student previously got wrong on a concept and how
their last few attempts scored. The agent uses this for two things:

1. Targeting — write distractors that mirror the student's prior
mistakes (the LLM-generated `summary` from `quiz_context`
captures patterns rolled up across past attempts).
2. Adaptive difficulty — read the last few `quiz_attempts` rows and
step difficulty down when the student has been struggling, up
when they've been crushing it.

The pure async function is callable from routes/tests; the *_tool
wrapper registers on a Pydantic AI Agent.
"""

from __future__ import annotations

import asyncio
import logging
from typing import Any

from pydantic import BaseModel, Field
from pydantic_ai import RunContext

from agents.deps import SaplingDeps
from db.connection import table

logger = logging.getLogger(__name__)


# How many past attempts the agent gets to see. 5 is enough to spot a
# trend without flooding the prompt with state. Older attempts are
# already rolled into `summary` by the post-quiz context update job.
_RECENT_ATTEMPTS_LIMIT = 5


class RecentQuizAttempt(BaseModel):
"""One past attempt's headline numbers."""

score: int = Field(ge=0)
total: int = Field(ge=0)
difficulty: str
completed_at: str | None = None
accuracy: float = Field(ge=0.0, le=1.0)


class QuizHistory(BaseModel):
"""The agent's view of a student's history on one concept."""

# LLM-generated digest of past quiz mistakes/patterns for this
# (user, concept). Populated by the background context-update job
# in routes/quiz.py:submit_quiz. May be None on a first attempt.
summary: str | None = None
# Most recent attempts, newest first. Empty on first attempt.
recent_attempts: list[RecentQuizAttempt] = Field(default_factory=list)


def _coerce_summary(ctx: Any) -> str | None:
"""quiz_context.context_json is free-form (whatever the post-submit
LLM produced). Different prompt versions have stored either a flat
string or a small dict. Coerce to a single string the agent can
reason over, or None if there's nothing useful."""
if not ctx:
return None
if isinstance(ctx, str):
text = ctx.strip()
return text or None
if isinstance(ctx, dict):
# Common shapes: {"summary": "..."}, {"notes": "..."},
# {"misconceptions": [...], "weak_areas": [...]}.
for key in ("summary", "notes", "context", "digest"):
v = ctx.get(key)
if isinstance(v, str) and v.strip():
return v.strip()
# Fall back to flattening list-of-strings entries so the agent
# at least sees the misconceptions/weak_areas the prior job
# extracted, even when no top-level summary string exists.
parts: list[str] = []
for key in ("misconceptions", "weak_areas", "common_errors"):
for item in ctx.get(key) or []:
if isinstance(item, str) and item.strip():
parts.append(f"- {item.strip()}")
return "\n".join(parts) or None
return None


async def read_recent_quiz_attempts(
user_id: str,
concept_node_id: str,
) -> QuizHistory:
"""Return the agent's view of a student's history on one concept.

Reads two sources:

- `quiz_context` (one row per (user, concept)): the rolling
LLM-generated digest of what the student has been getting
wrong. This is the same blob legacy `routes/quiz.py` used to
stuff into the prompt template.
- `quiz_attempts` (one row per attempt, filtered to completed
attempts): the last N completed attempts, newest first, with
accuracy precomputed so the agent doesn't have to.

Wraps the sync Supabase reads in `asyncio.to_thread` so we don't
block the event loop. Failures degrade silently — the agent can
still generate a quiz without history (just less adaptive).
"""

def _fetch_summary() -> Any:
try:
rows = table("quiz_context").select(
"context_json",
filters={
"user_id": f"eq.{user_id}",
"concept_node_id": f"eq.{concept_node_id}",
},
limit=1,
)
return rows[0]["context_json"] if rows else None
except Exception:
logger.exception(
"read_recent_quiz_attempts: quiz_context fetch failed "
"user=%s concept=%s",
user_id,
concept_node_id,
)
return None

def _fetch_attempts() -> list[dict[str, Any]]:
try:
return (
table("quiz_attempts").select(
"score,total,difficulty,completed_at",
filters={
"user_id": f"eq.{user_id}",
"concept_node_id": f"eq.{concept_node_id}",
# Only count completed attempts. PostgREST `not.is.null`
# filters out rows where completed_at is NULL, which is
# how `routes/quiz.py:generate_quiz` marks an in-flight
# attempt before submission.
"completed_at": "not.is.null",
},
order="completed_at.desc",
limit=_RECENT_ATTEMPTS_LIMIT,
)
or []
)
except Exception:
logger.exception(
"read_recent_quiz_attempts: quiz_attempts fetch failed "
"user=%s concept=%s",
user_id,
concept_node_id,
)
return []

summary_raw, attempt_rows = await asyncio.gather(
asyncio.to_thread(_fetch_summary),
asyncio.to_thread(_fetch_attempts),
)

attempts: list[RecentQuizAttempt] = []
for r in attempt_rows:
raw_score = r.get("score")
raw_total = r.get("total")
if raw_score is None or raw_total is None:
# `submit_quiz` writes score+total atomically, so a row with
# `completed_at IS NOT NULL` but a null score/total is
# corruption (or an out-of-band edit). Drop it rather than
# coercing to 0/0 — feeding the LLM a bogus 0% accuracy
# could trigger a spurious adaptive downshift.
logger.warning(
"read_recent_quiz_attempts: dropping row with null "
"score/total (score=%r, total=%r) user=%s concept=%s",
raw_score,
raw_total,
user_id,
concept_node_id,
)
continue
try:
score = int(raw_score)
total = int(raw_total)
except (TypeError, ValueError):
continue
if total <= 0:
# Skip rows that look incomplete — accuracy is undefined and
# the agent shouldn't have to guess.
continue
if score < 0 or score > total:
# Corrupt row (score outside [0, total]). Drop entirely
# rather than passing impossible numbers to the LLM —
# `score=7, total=5` would prompt the agent to wonder
# whether to trust the data at all.
logger.warning(
"read_recent_quiz_attempts: dropping corrupt row "
"(score=%d outside [0, total=%d]) user=%s concept=%s",
score,
total,
user_id,
concept_node_id,
)
continue
accuracy = score / total
attempts.append(
RecentQuizAttempt(
score=score,
total=total,
difficulty=str(r.get("difficulty") or ""),
completed_at=r.get("completed_at"),
accuracy=round(accuracy, 4),
)
)

return QuizHistory(
summary=_coerce_summary(summary_raw),
recent_attempts=attempts,
)


async def read_recent_quiz_attempts_tool(
ctx: RunContext[SaplingDeps],
concept_node_id: str,
) -> QuizHistory:
"""Returns this student's history on one concept: a `summary`
string digesting their prior mistakes (mine for distractor
inspiration) and `recent_attempts` — the last 5 completed quiz
attempts on this concept, newest first, with `accuracy` precomputed
so you can apply the adaptive-difficulty rule directly. Empty
history on first attempt. Pass the `concept_node_id` from the
user message; user identity is taken from context.
"""
# user_id comes from ctx.deps so a tool call can't cross users.
return await read_recent_quiz_attempts(ctx.deps.user_id, concept_node_id)
10 changes: 7 additions & 3 deletions backend/routes/quiz.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -166,11 +166,15 @@ async def _quiz_via_agent(
supabase=None,
request_id=request_id,
)
# Keep this message routing-only; the workflow + adaptive rules
# live in the system prompt. We just hand the agent the inputs it
# needs and trust the prompt to drive tool calls.
user_message = (
f"Generate {num_questions} {difficulty} questions for the student. "
f"The target concept is '{concept_name}' (concept_node_id={concept_node_id}). "
f"Call read_concepts_for_user to find the student's weakest concepts in this course "
f"and bias the question mix toward those."
f"The target concept is '{concept_name}' "
f"(concept_node_id={concept_node_id}). Follow the workflow in your "
f"system prompt; pass concept_node_id='{concept_node_id}' to "
f"read_recent_quiz_attempts."
)
if use_shared_context:
user_message += (
Expand Down
Loading
Loading
, '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('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 45 additions & 11 deletions backend/agents/quiz.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -24,6 +24,7 @@
read_concepts_for_user_tool,
read_misconceptions_for_course_tool,
)
from agents.tools.quiz_history import read_recent_quiz_attempts_tool


# Difficulty + question type are Literals so Gemini's enum constraint
Expand DownExpand Up@@ -69,31 +70,63 @@ class Quiz(BaseModel):
_SYSTEM_PROMPT = (
"You generate adaptive multiple-choice quizzes for a student. Each "
"question must target a specific concept the student has weak "
"mastery on, OR address a class-level misconception you've seen.\n\n"
"mastery on, OR address a class-level misconception you've seen, "
"OR revive a concept the student hasn't reviewed in a while.\n\n"
"Workflow:\n"
"1. Call `read_concepts_for_user` to see the student's mastery per "
" concept for this course (returned sorted by mastery ASC — "
" weakest first).\n"
" weakest first). Each concept also carries `last_reviewed_at`, "
" which you use for spaced repetition (see rules below).\n"
"2. Call `read_misconceptions_for_course` to see anonymized class "
" misconceptions. Use these to phrase distractors and to write "
" a question that probes the misconception.\n"
"3. Compose `Quiz.questions` so the WEAKEST concepts get the most "
" questions, AND each item's `concept` field exactly matches a "
" concept_name returned by tool 1.\n\n"
"3. Call `read_recent_quiz_attempts(concept_node_id)` for the "
" target concept_node_id given in the user message. The "
" `summary` is a digest of past mistakes the student has made "
" on this concept — mine it for distractor inspiration. The "
" `recent_attempts` list (newest first) drives adaptive "
" difficulty (see rules below).\n"
"4. Compose `Quiz.questions` so the WEAKEST and STALEST concepts "
" get the most questions, AND each item's `concept` field "
" exactly matches a concept_name returned by tool 1.\n\n"
"Concept-selection rules (combine all three signals):\n"
"- Bias question count toward the lowest-mastery concepts (the "
" weakest first in the tool 1 return).\n"
"- SPACED REPETITION: also surface concepts whose "
" `last_reviewed_at` is older than ~7 days, even if their "
" mastery is mid-tier — they're due for review and decay over "
" time. Concepts with `last_reviewed_at = null` are unreviewed; "
" treat them as stale.\n"
"- Don't drop high-mastery, recently-reviewed concepts entirely; "
" include 1 question on a strong-and-fresh concept to keep the "
" quiz from feeling punishing.\n\n"
"Adaptive-difficulty rules (use `recent_attempts.accuracy`):\n"
"- If the most recent 2-3 attempts on this concept averaged < "
" 0.5 accuracy, drop the difficulty mix one step from what the "
" user asked (hard -> medium, medium -> easy, easy stays easy). "
" The student is struggling; keep them on track.\n"
"- If the most recent 3 attempts all scored >= 0.8, you may "
" include 1-2 questions one step harder than the requested "
" difficulty to push them.\n"
"- If `recent_attempts` is empty (first attempt), honor the "
" user-requested difficulty exactly.\n"
"- Never override the user-requested difficulty by more than one "
" step in either direction. Stay close to what they asked for.\n\n"
"Per-question rules (multiple-choice only — the type field is "
"constrained to 'multiple_choice'):\n"
"- 4 options, exactly one correct. The text in `correct_answer` "
" MUST appear verbatim in `options` — character-for-character. "
" Questions that violate this are dropped at the route layer.\n"
"- Distractors should reflect plausible misconceptions, not random "
" noise. Use the read_misconceptions_for_course return value.\n"
" noise. Combine signals from `read_misconceptions_for_course` "
" (class-wide) and `read_recent_quiz_attempts.summary` "
" (this student's prior errors) when writing them.\n"
"- explanation: 1-3 sentences explaining WHY the correct answer "
" is correct — used in the post-quiz review screen.\n"
"- difficulty: align with the student's mastery on the concept; "
" weakest concepts get easy/medium, strongest get hard.\n\n"
"Honor the requested num_questions and difficulty distribution "
"in the user message. Don't invent concepts the student doesn't "
"have."
"- difficulty: align with the student's mastery on the concept "
" AND the adaptive-difficulty rules above.\n\n"
"Honor the requested num_questions. Don't invent concepts the "
"student doesn't have."
)
_PROMPT_HASH = hashlib.sha256(_SYSTEM_PROMPT.encode("utf-8")).hexdigest()[:12]

Expand All@@ -107,5 +140,6 @@ class Quiz(BaseModel):
tools=[
read_concepts_for_user_tool,
read_misconceptions_for_course_tool,
read_recent_quiz_attempts_tool,
],
)
234 changes: 234 additions & 0 deletions backend/agents/tools/quiz_history.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,234 @@
"""Quiz-history read tool for the quiz agent.

Surfaces what the student previously got wrong on a concept and how
their last few attempts scored. The agent uses this for two things:

1. Targeting — write distractors that mirror the student's prior
mistakes (the LLM-generated `summary` from `quiz_context`
captures patterns rolled up across past attempts).
2. Adaptive difficulty — read the last few `quiz_attempts` rows and
step difficulty down when the student has been struggling, up
when they've been crushing it.

The pure async function is callable from routes/tests; the *_tool
wrapper registers on a Pydantic AI Agent.
"""

from __future__ import annotations

import asyncio
import logging
from typing import Any

from pydantic import BaseModel, Field
from pydantic_ai import RunContext

from agents.deps import SaplingDeps
from db.connection import table

logger = logging.getLogger(__name__)


# How many past attempts the agent gets to see. 5 is enough to spot a
# trend without flooding the prompt with state. Older attempts are
# already rolled into `summary` by the post-quiz context update job.
_RECENT_ATTEMPTS_LIMIT = 5


class RecentQuizAttempt(BaseModel):
"""One past attempt's headline numbers."""

score: int = Field(ge=0)
total: int = Field(ge=0)
difficulty: str
completed_at: str | None = None
accuracy: float = Field(ge=0.0, le=1.0)


class QuizHistory(BaseModel):
"""The agent's view of a student's history on one concept."""

# LLM-generated digest of past quiz mistakes/patterns for this
# (user, concept). Populated by the background context-update job
# in routes/quiz.py:submit_quiz. May be None on a first attempt.
summary: str | None = None
# Most recent attempts, newest first. Empty on first attempt.
recent_attempts: list[RecentQuizAttempt] = Field(default_factory=list)


def _coerce_summary(ctx: Any) -> str | None:
"""quiz_context.context_json is free-form (whatever the post-submit
LLM produced). Different prompt versions have stored either a flat
string or a small dict. Coerce to a single string the agent can
reason over, or None if there's nothing useful."""
if not ctx:
return None
if isinstance(ctx, str):
text = ctx.strip()
return text or None
if isinstance(ctx, dict):
# Common shapes: {"summary": "..."}, {"notes": "..."},
# {"misconceptions": [...], "weak_areas": [...]}.
for key in ("summary", "notes", "context", "digest"):
v = ctx.get(key)
if isinstance(v, str) and v.strip():
return v.strip()
# Fall back to flattening list-of-strings entries so the agent
# at least sees the misconceptions/weak_areas the prior job
# extracted, even when no top-level summary string exists.
parts: list[str] = []
for key in ("misconceptions", "weak_areas", "common_errors"):
for item in ctx.get(key) or []:
if isinstance(item, str) and item.strip():
parts.append(f"- {item.strip()}")
return "\n".join(parts) or None
return None


async def read_recent_quiz_attempts(
user_id: str,
concept_node_id: str,
) -> QuizHistory:
"""Return the agent's view of a student's history on one concept.

Reads two sources:

- `quiz_context` (one row per (user, concept)): the rolling
LLM-generated digest of what the student has been getting
wrong. This is the same blob legacy `routes/quiz.py` used to
stuff into the prompt template.
- `quiz_attempts` (one row per attempt, filtered to completed
attempts): the last N completed attempts, newest first, with
accuracy precomputed so the agent doesn't have to.

Wraps the sync Supabase reads in `asyncio.to_thread` so we don't
block the event loop. Failures degrade silently — the agent can
still generate a quiz without history (just less adaptive).
"""

def _fetch_summary() -> Any:
try:
rows = table("quiz_context").select(
"context_json",
filters={
"user_id": f"eq.{user_id}",
"concept_node_id": f"eq.{concept_node_id}",
},
limit=1,
)
return rows[0]["context_json"] if rows else None
except Exception:
logger.exception(
"read_recent_quiz_attempts: quiz_context fetch failed "
"user=%s concept=%s",
user_id,
concept_node_id,
)
return None

def _fetch_attempts() -> list[dict[str, Any]]:
try:
return (
table("quiz_attempts").select(
"score,total,difficulty,completed_at",
filters={
"user_id": f"eq.{user_id}",
"concept_node_id": f"eq.{concept_node_id}",
# Only count completed attempts. PostgREST `not.is.null`
# filters out rows where completed_at is NULL, which is
# how `routes/quiz.py:generate_quiz` marks an in-flight
# attempt before submission.
"completed_at": "not.is.null",
},
order="completed_at.desc",
limit=_RECENT_ATTEMPTS_LIMIT,
)
or []
)
except Exception:
logger.exception(
"read_recent_quiz_attempts: quiz_attempts fetch failed "
"user=%s concept=%s",
user_id,
concept_node_id,
)
return []

summary_raw, attempt_rows = await asyncio.gather(
asyncio.to_thread(_fetch_summary),
asyncio.to_thread(_fetch_attempts),
)

attempts: list[RecentQuizAttempt] = []
for r in attempt_rows:
raw_score = r.get("score")
raw_total = r.get("total")
if raw_score is None or raw_total is None:
# `submit_quiz` writes score+total atomically, so a row with
# `completed_at IS NOT NULL` but a null score/total is
# corruption (or an out-of-band edit). Drop it rather than
# coercing to 0/0 — feeding the LLM a bogus 0% accuracy
# could trigger a spurious adaptive downshift.
logger.warning(
"read_recent_quiz_attempts: dropping row with null "
"score/total (score=%r, total=%r) user=%s concept=%s",
raw_score,
raw_total,
user_id,
concept_node_id,
)
continue
try:
score = int(raw_score)
total = int(raw_total)
except (TypeError, ValueError):
continue
if total <= 0:
# Skip rows that look incomplete — accuracy is undefined and
# the agent shouldn't have to guess.
continue
if score < 0 or score > total:
# Corrupt row (score outside [0, total]). Drop entirely
# rather than passing impossible numbers to the LLM —
# `score=7, total=5` would prompt the agent to wonder
# whether to trust the data at all.
logger.warning(
"read_recent_quiz_attempts: dropping corrupt row "
"(score=%d outside [0, total=%d]) user=%s concept=%s",
score,
total,
user_id,
concept_node_id,
)
continue
accuracy = score / total
attempts.append(
RecentQuizAttempt(
score=score,
total=total,
difficulty=str(r.get("difficulty") or ""),
completed_at=r.get("completed_at"),
accuracy=round(accuracy, 4),
)
)

return QuizHistory(
summary=_coerce_summary(summary_raw),
recent_attempts=attempts,
)


async def read_recent_quiz_attempts_tool(
ctx: RunContext[SaplingDeps],
concept_node_id: str,
) -> QuizHistory:
"""Returns this student's history on one concept: a `summary`
string digesting their prior mistakes (mine for distractor
inspiration) and `recent_attempts` — the last 5 completed quiz
attempts on this concept, newest first, with `accuracy` precomputed
so you can apply the adaptive-difficulty rule directly. Empty
history on first attempt. Pass the `concept_node_id` from the
user message; user identity is taken from context.
"""
# user_id comes from ctx.deps so a tool call can't cross users.
return await read_recent_quiz_attempts(ctx.deps.user_id, concept_node_id)
10 changes: 7 additions & 3 deletions backend/routes/quiz.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -166,11 +166,15 @@ async def _quiz_via_agent(
supabase=None,
request_id=request_id,
)
# Keep this message routing-only; the workflow + adaptive rules
# live in the system prompt. We just hand the agent the inputs it
# needs and trust the prompt to drive tool calls.
user_message = (
f"Generate {num_questions} {difficulty} questions for the student. "
f"The target concept is '{concept_name}' (concept_node_id={concept_node_id}). "
f"Call read_concepts_for_user to find the student's weakest concepts in this course "
f"and bias the question mix toward those."
f"The target concept is '{concept_name}' "
f"(concept_node_id={concept_node_id}). Follow the workflow in your "
f"system prompt; pass concept_node_id='{concept_node_id}' to "
f"read_recent_quiz_attempts."
)
if use_shared_context:
user_message += (
Expand Down
Loading
Loading
, '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); } })(); })();
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 45 additions & 11 deletions backend/agents/quiz.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -24,6 +24,7 @@
read_concepts_for_user_tool,
read_misconceptions_for_course_tool,
)
from agents.tools.quiz_history import read_recent_quiz_attempts_tool


# Difficulty + question type are Literals so Gemini's enum constraint
Expand DownExpand Up@@ -69,31 +70,63 @@ class Quiz(BaseModel):
_SYSTEM_PROMPT = (
"You generate adaptive multiple-choice quizzes for a student. Each "
"question must target a specific concept the student has weak "
"mastery on, OR address a class-level misconception you've seen.\n\n"
"mastery on, OR address a class-level misconception you've seen, "
"OR revive a concept the student hasn't reviewed in a while.\n\n"
"Workflow:\n"
"1. Call `read_concepts_for_user` to see the student's mastery per "
" concept for this course (returned sorted by mastery ASC — "
" weakest first).\n"
" weakest first). Each concept also carries `last_reviewed_at`, "
" which you use for spaced repetition (see rules below).\n"
"2. Call `read_misconceptions_for_course` to see anonymized class "
" misconceptions. Use these to phrase distractors and to write "
" a question that probes the misconception.\n"
"3. Compose `Quiz.questions` so the WEAKEST concepts get the most "
" questions, AND each item's `concept` field exactly matches a "
" concept_name returned by tool 1.\n\n"
"3. Call `read_recent_quiz_attempts(concept_node_id)` for the "
" target concept_node_id given in the user message. The "
" `summary` is a digest of past mistakes the student has made "
" on this concept — mine it for distractor inspiration. The "
" `recent_attempts` list (newest first) drives adaptive "
" difficulty (see rules below).\n"
"4. Compose `Quiz.questions` so the WEAKEST and STALEST concepts "
" get the most questions, AND each item's `concept` field "
" exactly matches a concept_name returned by tool 1.\n\n"
"Concept-selection rules (combine all three signals):\n"
"- Bias question count toward the lowest-mastery concepts (the "
" weakest first in the tool 1 return).\n"
"- SPACED REPETITION: also surface concepts whose "
" `last_reviewed_at` is older than ~7 days, even if their "
" mastery is mid-tier — they're due for review and decay over "
" time. Concepts with `last_reviewed_at = null` are unreviewed; "
" treat them as stale.\n"
"- Don't drop high-mastery, recently-reviewed concepts entirely; "
" include 1 question on a strong-and-fresh concept to keep the "
" quiz from feeling punishing.\n\n"
"Adaptive-difficulty rules (use `recent_attempts.accuracy`):\n"
"- If the most recent 2-3 attempts on this concept averaged < "
" 0.5 accuracy, drop the difficulty mix one step from what the "
" user asked (hard -> medium, medium -> easy, easy stays easy). "
" The student is struggling; keep them on track.\n"
"- If the most recent 3 attempts all scored >= 0.8, you may "
" include 1-2 questions one step harder than the requested "
" difficulty to push them.\n"
"- If `recent_attempts` is empty (first attempt), honor the "
" user-requested difficulty exactly.\n"
"- Never override the user-requested difficulty by more than one "
" step in either direction. Stay close to what they asked for.\n\n"
"Per-question rules (multiple-choice only — the type field is "
"constrained to 'multiple_choice'):\n"
"- 4 options, exactly one correct. The text in `correct_answer` "
" MUST appear verbatim in `options` — character-for-character. "
" Questions that violate this are dropped at the route layer.\n"
"- Distractors should reflect plausible misconceptions, not random "
" noise. Use the read_misconceptions_for_course return value.\n"
" noise. Combine signals from `read_misconceptions_for_course` "
" (class-wide) and `read_recent_quiz_attempts.summary` "
" (this student's prior errors) when writing them.\n"
"- explanation: 1-3 sentences explaining WHY the correct answer "
" is correct — used in the post-quiz review screen.\n"
"- difficulty: align with the student's mastery on the concept; "
" weakest concepts get easy/medium, strongest get hard.\n\n"
"Honor the requested num_questions and difficulty distribution "
"in the user message. Don't invent concepts the student doesn't "
"have."
"- difficulty: align with the student's mastery on the concept "
" AND the adaptive-difficulty rules above.\n\n"
"Honor the requested num_questions. Don't invent concepts the "
"student doesn't have."
)
_PROMPT_HASH = hashlib.sha256(_SYSTEM_PROMPT.encode("utf-8")).hexdigest()[:12]

Expand All@@ -107,5 +140,6 @@ class Quiz(BaseModel):
tools=[
read_concepts_for_user_tool,
read_misconceptions_for_course_tool,
read_recent_quiz_attempts_tool,
],
)
234 changes: 234 additions & 0 deletions backend/agents/tools/quiz_history.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,234 @@
"""Quiz-history read tool for the quiz agent.

Surfaces what the student previously got wrong on a concept and how
their last few attempts scored. The agent uses this for two things:

1. Targeting — write distractors that mirror the student's prior
mistakes (the LLM-generated `summary` from `quiz_context`
captures patterns rolled up across past attempts).
2. Adaptive difficulty — read the last few `quiz_attempts` rows and
step difficulty down when the student has been struggling, up
when they've been crushing it.

The pure async function is callable from routes/tests; the *_tool
wrapper registers on a Pydantic AI Agent.
"""

from __future__ import annotations

import asyncio
import logging
from typing import Any

from pydantic import BaseModel, Field
from pydantic_ai import RunContext

from agents.deps import SaplingDeps
from db.connection import table

logger = logging.getLogger(__name__)


# How many past attempts the agent gets to see. 5 is enough to spot a
# trend without flooding the prompt with state. Older attempts are
# already rolled into `summary` by the post-quiz context update job.
_RECENT_ATTEMPTS_LIMIT = 5


class RecentQuizAttempt(BaseModel):
"""One past attempt's headline numbers."""

score: int = Field(ge=0)
total: int = Field(ge=0)
difficulty: str
completed_at: str | None = None
accuracy: float = Field(ge=0.0, le=1.0)


class QuizHistory(BaseModel):
"""The agent's view of a student's history on one concept."""

# LLM-generated digest of past quiz mistakes/patterns for this
# (user, concept). Populated by the background context-update job
# in routes/quiz.py:submit_quiz. May be None on a first attempt.
summary: str | None = None
# Most recent attempts, newest first. Empty on first attempt.
recent_attempts: list[RecentQuizAttempt] = Field(default_factory=list)


def _coerce_summary(ctx: Any) -> str | None:
"""quiz_context.context_json is free-form (whatever the post-submit
LLM produced). Different prompt versions have stored either a flat
string or a small dict. Coerce to a single string the agent can
reason over, or None if there's nothing useful."""
if not ctx:
return None
if isinstance(ctx, str):
text = ctx.strip()
return text or None
if isinstance(ctx, dict):
# Common shapes: {"summary": "..."}, {"notes": "..."},
# {"misconceptions": [...], "weak_areas": [...]}.
for key in ("summary", "notes", "context", "digest"):
v = ctx.get(key)
if isinstance(v, str) and v.strip():
return v.strip()
# Fall back to flattening list-of-strings entries so the agent
# at least sees the misconceptions/weak_areas the prior job
# extracted, even when no top-level summary string exists.
parts: list[str] = []
for key in ("misconceptions", "weak_areas", "common_errors"):
for item in ctx.get(key) or []:
if isinstance(item, str) and item.strip():
parts.append(f"- {item.strip()}")
return "\n".join(parts) or None
return None


async def read_recent_quiz_attempts(
user_id: str,
concept_node_id: str,
) -> QuizHistory:
"""Return the agent's view of a student's history on one concept.

Reads two sources:

- `quiz_context` (one row per (user, concept)): the rolling
LLM-generated digest of what the student has been getting
wrong. This is the same blob legacy `routes/quiz.py` used to
stuff into the prompt template.
- `quiz_attempts` (one row per attempt, filtered to completed
attempts): the last N completed attempts, newest first, with
accuracy precomputed so the agent doesn't have to.

Wraps the sync Supabase reads in `asyncio.to_thread` so we don't
block the event loop. Failures degrade silently — the agent can
still generate a quiz without history (just less adaptive).
"""

def _fetch_summary() -> Any:
try:
rows = table("quiz_context").select(
"context_json",
filters={
"user_id": f"eq.{user_id}",
"concept_node_id": f"eq.{concept_node_id}",
},
limit=1,
)
return rows[0]["context_json"] if rows else None
except Exception:
logger.exception(
"read_recent_quiz_attempts: quiz_context fetch failed "
"user=%s concept=%s",
user_id,
concept_node_id,
)
return None

def _fetch_attempts() -> list[dict[str, Any]]:
try:
return (
table("quiz_attempts").select(
"score,total,difficulty,completed_at",
filters={
"user_id": f"eq.{user_id}",
"concept_node_id": f"eq.{concept_node_id}",
# Only count completed attempts. PostgREST `not.is.null`
# filters out rows where completed_at is NULL, which is
# how `routes/quiz.py:generate_quiz` marks an in-flight
# attempt before submission.
"completed_at": "not.is.null",
},
order="completed_at.desc",
limit=_RECENT_ATTEMPTS_LIMIT,
)
or []
)
except Exception:
logger.exception(
"read_recent_quiz_attempts: quiz_attempts fetch failed "
"user=%s concept=%s",
user_id,
concept_node_id,
)
return []

summary_raw, attempt_rows = await asyncio.gather(
asyncio.to_thread(_fetch_summary),
asyncio.to_thread(_fetch_attempts),
)

attempts: list[RecentQuizAttempt] = []
for r in attempt_rows:
raw_score = r.get("score")
raw_total = r.get("total")
if raw_score is None or raw_total is None:
# `submit_quiz` writes score+total atomically, so a row with
# `completed_at IS NOT NULL` but a null score/total is
# corruption (or an out-of-band edit). Drop it rather than
# coercing to 0/0 — feeding the LLM a bogus 0% accuracy
# could trigger a spurious adaptive downshift.
logger.warning(
"read_recent_quiz_attempts: dropping row with null "
"score/total (score=%r, total=%r) user=%s concept=%s",
raw_score,
raw_total,
user_id,
concept_node_id,
)
continue
try:
score = int(raw_score)
total = int(raw_total)
except (TypeError, ValueError):
continue
if total <= 0:
# Skip rows that look incomplete — accuracy is undefined and
# the agent shouldn't have to guess.
continue
if score < 0 or score > total:
# Corrupt row (score outside [0, total]). Drop entirely
# rather than passing impossible numbers to the LLM —
# `score=7, total=5` would prompt the agent to wonder
# whether to trust the data at all.
logger.warning(
"read_recent_quiz_attempts: dropping corrupt row "
"(score=%d outside [0, total=%d]) user=%s concept=%s",
score,
total,
user_id,
concept_node_id,
)
continue
accuracy = score / total
attempts.append(
RecentQuizAttempt(
score=score,
total=total,
difficulty=str(r.get("difficulty") or ""),
completed_at=r.get("completed_at"),
accuracy=round(accuracy, 4),
)
)

return QuizHistory(
summary=_coerce_summary(summary_raw),
recent_attempts=attempts,
)


async def read_recent_quiz_attempts_tool(
ctx: RunContext[SaplingDeps],
concept_node_id: str,
) -> QuizHistory:
"""Returns this student's history on one concept: a `summary`
string digesting their prior mistakes (mine for distractor
inspiration) and `recent_attempts` — the last 5 completed quiz
attempts on this concept, newest first, with `accuracy` precomputed
so you can apply the adaptive-difficulty rule directly. Empty
history on first attempt. Pass the `concept_node_id` from the
user message; user identity is taken from context.
"""
# user_id comes from ctx.deps so a tool call can't cross users.
return await read_recent_quiz_attempts(ctx.deps.user_id, concept_node_id)
10 changes: 7 additions & 3 deletions backend/routes/quiz.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -166,11 +166,15 @@ async def _quiz_via_agent(
supabase=None,
request_id=request_id,
)
# Keep this message routing-only; the workflow + adaptive rules
# live in the system prompt. We just hand the agent the inputs it
# needs and trust the prompt to drive tool calls.
user_message = (
f"Generate {num_questions} {difficulty} questions for the student. "
f"The target concept is '{concept_name}' (concept_node_id={concept_node_id}). "
f"Call read_concepts_for_user to find the student's weakest concepts in this course "
f"and bias the question mix toward those."
f"The target concept is '{concept_name}' "
f"(concept_node_id={concept_node_id}). Follow the workflow in your "
f"system prompt; pass concept_node_id='{concept_node_id}' to "
f"read_recent_quiz_attempts."
)
if use_shared_context:
user_message += (
Expand Down
Loading
Loading