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
30 changes: 30 additions & 0 deletions backend/db/migrations/20260812214402_quiz_responses.sql
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
-- #541 C2: per-question quiz responses. This is the table that makes item
-- statistics, timing analysis, and misconception mining possible — one row
-- per answered question, written by POST /api/quiz/attempts/{id}/answer.
--
-- Everything here is a plaintext analytics scalar (same rationale as
-- quiz_attempts.score/total in #521): indexes and booleans carry no student
-- free text. The question/option TEXT lives encrypted in
-- quiz_attempts.questions_json; this table references it only by position.
--
-- The UNIQUE is the C1 idempotency contract: one response per
-- (attempt, question); re-answering returns the first recorded response
-- (no revision — the #537 revamp decides if that changes).

CREATE TABLE quiz_responses (
id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
attempt_id TEXT NOT NULL REFERENCES quiz_attempts(id) ON DELETE CASCADE,
question_index INTEGER NOT NULL CHECK (question_index >= 0),
selected_index INTEGER NOT NULL CHECK (selected_index >= 0),
is_correct BOOLEAN NOT NULL,
time_ms INTEGER CHECK (time_ms >= 0),
confidence REAL CHECK (confidence >= 0.0 AND confidence <= 1.0),
answered_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT quiz_responses_attempt_question_key UNIQUE (attempt_id, question_index)
);

-- No separate attempt_id index: Postgres backs the UNIQUE above with a btree
-- whose LEADING column is attempt_id, which already serves both access
-- patterns (submit's per-attempt scan and the answer endpoint's
-- (attempt_id, question_index) lookup). A standalone index would just add a
-- second write to the per-answer hot path.
25 changes: 25 additions & 0 deletions backend/models/__init__.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -60,6 +60,31 @@ class GenerateQuizBody(BaseModel):
# through to whatever SAPLING_MODEL_QUIZ resolves to (default
# gemini-2.5-flash-lite per ADR 0008).
model_pref: Optional[Literal["fast", "smart"]] = None
# DEPRECATED (#541 C3, removal tracked in #546): when true (the default
# the current QuizPanel needs), the response's per-option dicts carry
# `correct` booleans — the full answer key, client-side. Removing the
# key is a hard requirement of the #537 revamp: the new client grades
# through POST /api/quiz/attempts/{id}/answer instead. Every keyed
# response is logged so we can see when usage reaches zero.
include_answer_key: bool = True


class AnswerQuestionBody(BaseModel):
"""One answer for POST /api/quiz/attempts/{attempt_id}/answer (#541 C1).

Indexes are 0-based positions into the attempt's stored questions and
the question's options. The wire questions carry 1-based `id`s (what
/submit keys on), so `question_index = id - 1` — two addressing schemes
for one question. `question_id` is the guard against confusing them:
send the id you displayed and the route rejects a mismatch instead of
silently grading the neighbouring question (which the idempotency rule
would then lock in). The response echoes both either way."""

question_index: int = Field(ge=0)
selected_index: int = Field(ge=0)
question_id: Optional[int] = None
time_ms: Optional[int] = Field(default=None, ge=0)
confidence: Optional[float] = Field(default=None, ge=0.0, le=1.0)


class AnswerItem(BaseModel):
Expand Down
195 changes: 190 additions & 5 deletions backend/routes/quiz.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -18,7 +18,7 @@
from agents.quiz_context import quiz_context_agent
from agents.usage import record_agent_usage
from db.connection import table
from models import GenerateQuizBody, SubmitQuizBody
from models import AnswerQuestionBody, GenerateQuizBody, SubmitQuizBody
from routes.learn import _get_catalog_chunk
from services import events_service
from services.auth_guard import require_self
Expand DownExpand Up@@ -78,6 +78,21 @@ def _load_prompt(name: str) -> str:
_DIFFICULTY_RANK = {d: i for i, d in enumerate(CONCRETE_DIFFICULTIES)}


def _strip_answer_key(wire_questions: list[dict]) -> list[dict]:
"""Deep-enough copy of the wire questions without per-option `correct`
booleans (#541 C3). Storage always keeps the key — grading is
server-side; only the RESPONSE is stripped."""
stripped = []
for q in wire_questions:
q2 = dict(q)
q2["options"] = [
{k: v for k, v in o.items() if k != "correct"}
for o in q.get("options", [])
]
stripped.append(q2)
return stripped


def _resolved_difficulty(wire_questions: list[dict]) -> str:
"""The overall difficulty generation actually produced (#540 A1).

Expand DownExpand Up@@ -441,19 +456,153 @@ async def generate_quiz(body: GenerateQuizBody, request: Request):
"difficulty": body.difficulty,
},
)
# #541 C3: the answer key (per-option `correct` booleans) ships to the
# client only behind the deprecated include_answer_key flag — default
# true for the current QuizPanel, removed with #546 once the #537
# client grades via /attempts/{id}/answer. Log every keyed response so
# zero-usage is observable before the default flips.
if body.include_answer_key:
logger.info(
"quiz: generate served the client-side answer key "
"(include_answer_key=true, deprecated — #546) quiz_id=%s", quiz_id,
)
response_questions = questions
else:
response_questions = _strip_answer_key(questions)

# #540 A1: echo what generation actually chose. requested_difficulty
# is what the student asked for (may be 'adaptive');
# resolved_difficulty is the overall mix the agent produced (always
# concrete) — so the client can say "we picked hard for you" instead
# of repeating the request back.
return {
"quiz_id": quiz_id,
"questions": questions,
"questions": response_questions,
"requested_difficulty": body.difficulty,
"resolved_difficulty": _resolved_difficulty(questions),
}


@router.post("/attempts/{attempt_id}/answer")
def answer_question(attempt_id: str, body: AnswerQuestionBody, request: Request):
"""#541 C1: grade one question server-side and record the response.

Idempotent on (attempt_id, question_index): re-answering returns the
FIRST recorded response (`recorded: false` marks the replay) rather
than overwriting — no revision, decided for the #537 revamp flow.
"""
attempt_rows = table("quiz_attempts").select(
"*", filters={"id": f"eq.{attempt_id}"}
)
if not attempt_rows:
raise QuizAPIError(
status_code=404,
code=QuizErrorCode.QUIZ_ATTEMPT_NOT_FOUND,
message="We couldn't find that quiz.",
)
attempt = attempt_rows[0]
require_self(attempt["user_id"], request)

if attempt.get("completed_at"):
raise QuizAPIError(
status_code=409,
code=QuizErrorCode.QUIZ_ATTEMPT_ALREADY_COMPLETED,
message="This quiz has already been submitted.",
)

questions = decrypt_json_column(attempt["questions_json"]) or []
if body.question_index >= len(questions):
raise QuizAPIError(
status_code=400,
code=QuizErrorCode.QUIZ_QUESTION_INVALID,
message="That question isn't part of this quiz.",
)
question = questions[body.question_index]
options = question.get("options", [])
if body.selected_index >= len(options):
raise QuizAPIError(
status_code=400,
code=QuizErrorCode.QUIZ_QUESTION_INVALID,
message="That answer choice isn't part of this question.",
)
# Wire ids are 1-based, question_index is 0-based. When the client sends
# both, they must agree — otherwise passing the displayed id as the index
# silently grades the NEXT question and idempotency locks that in.
if body.question_id is not None and body.question_id != question.get("id"):
raise QuizAPIError(
status_code=400,
code=QuizErrorCode.QUIZ_QUESTION_INVALID,
message="That answer doesn't match the question it was sent for.",
)

# The correct option is a property of the question, not of the answer —
# resolve it once. -1 means a malformed item with no correct option,
# which must never grade correct (same rule as submit's #129 fix).
correct_index = next(
(i for i, o in enumerate(options) if o.get("correct")), -1
)

def _is_correct(selected_index: int) -> bool:
return correct_index >= 0 and correct_index == selected_index

recorded = True
response_row = None
existing = table("quiz_responses").select(
"*",
filters={
"attempt_id": f"eq.{attempt_id}",
"question_index": f"eq.{body.question_index}",
},
)
if existing:
recorded = False
response_row = existing[0]
else:
row = {
"attempt_id": attempt_id,
"question_index": body.question_index,
"selected_index": body.selected_index,
"is_correct": _is_correct(body.selected_index),
"time_ms": body.time_ms,
"confidence": body.confidence,
}
try:
table("quiz_responses").insert(row)
response_row = row
except Exception:
# Lost a race with a concurrent answer for the same index — the
# UNIQUE arbitrates; return whatever won.
recorded = False
raced = table("quiz_responses").select(
"*",
filters={
"attempt_id": f"eq.{attempt_id}",
"question_index": f"eq.{body.question_index}",
},
)
if not raced:
raise
response_row = raced[0]

next_index = body.question_index + 1
next_question = (
_strip_answer_key([questions[next_index]])[0]
if next_index < len(questions)
else None
)
return {
# Echo both addressing schemes so a client that mixed them up sees
# it immediately rather than discovering it at submit time.
"question_index": body.question_index,
"question_id": question.get("id"),
"is_correct": _is_correct(response_row["selected_index"]),
"correct_index": correct_index,
"explanation": question.get("explanation", ""),
"next_question": next_question,
"recorded": recorded,
}


@router.post("/submit")
def submit_quiz(body: SubmitQuizBody, background_tasks: BackgroundTasks, request: Request):
attempt_rows = table("quiz_attempts").select("*", filters={"id": f"eq.{body.quiz_id}"})
Expand DownExpand Up@@ -503,12 +652,45 @@ def submit_quiz(body: SubmitQuizBody, background_tasks: BackgroundTasks, request

concept_node_id = attempt["concept_node_id"]

# #541 C4: responses recorded through /attempts/{id}/answer are the
# source of truth — a payload answer for the same question is ignored
# (the recorded response was graded at answer time; letting the final
# POST override it would reopen the client-side-grading hole C exists
# to close). Questions never answered through C1 fall back to the
# submitted payload, so the current all-at-the-end client keeps working.
recorded_rows = table("quiz_responses").select(
"question_index,selected_index",
filters={"attempt_id": f"eq.{body.quiz_id}"},
) or []
recorded_by_index = {r["question_index"]: r for r in recorded_rows}

answer_map = {str(a.question_id): a.selected_label for a in body.answers}
results = []
# The reconciled answer set — what was ACTUALLY graded, which is what
# answers_json must persist. Storing the raw payload instead left a
# recorded-only submit with a full score beside an empty answer list,
# and a contradicted payload answer stored despite losing to the
# recorded response.
graded_answers: list[dict] = []
score = 0
for q in questions:
for q_index, q in enumerate(questions):
qid = str(q["id"])
selected = answer_map.get(qid, "")
recorded = recorded_by_index.get(q_index)
if recorded is not None:
sel_idx = recorded.get("selected_index")
options = q.get("options", [])
selected = (
options[sel_idx]["label"]
if isinstance(sel_idx, int) and 0 <= sel_idx < len(options)
else ""
)
else:
selected = answer_map.get(qid, "")
if selected:
graded_answers.append({
"question_id": q["id"],
"selected_label": selected,
})
correct_opt = next((o for o in q["options"] if o.get("correct")), None)
correct_label = correct_opt["label"] if correct_opt else ""
# #129: a malformed item with NO correct option must never grade as
Expand DownExpand Up@@ -580,7 +762,10 @@ def submit_quiz(body: SubmitQuizBody, background_tasks: BackgroundTasks, request
{
"score": score,
"total": total,
"answers_json": encrypt_json([a.model_dump() for a in body.answers]),
# The reconciled set (recorded responses winning over payload),
# not the raw request — the attempt's stored answers must agree
# with the score computed from them.
"answers_json": encrypt_json(graded_answers),
# completed_at was already stamped by the atomic claim above.
},
filters={"id": f"eq.{body.quiz_id}"},
Expand Down
3 changes: 3 additions & 0 deletions backend/services/quiz_errors.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -34,6 +34,9 @@ class QuizErrorCode(str, Enum):
QUIZ_CONCEPT_NOT_FOUND = "QUIZ_CONCEPT_NOT_FOUND"
QUIZ_ATTEMPT_NOT_FOUND = "QUIZ_ATTEMPT_NOT_FOUND"
QUIZ_ATTEMPT_ALREADY_COMPLETED = "QUIZ_ATTEMPT_ALREADY_COMPLETED"
# #541 C1: the answer endpoint got an index that doesn't exist on this
# attempt (question_index past the quiz, selected_index past the options).
QUIZ_QUESTION_INVALID = "QUIZ_QUESTION_INVALID"
QUIZ_NOT_AUTHORIZED = "QUIZ_NOT_AUTHORIZED"
QUIZ_GENERATION_FAILED = "QUIZ_GENERATION_FAILED"
QUIZ_INTERNAL_ERROR = "QUIZ_INTERNAL_ERROR"
Expand Down
74 changes: 74 additions & 0 deletions backend/tests/integration/test_quiz_responses_db.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
"""#541 C2 real-DB half: quiz_responses storage against local Supabase.

Proves the migration's shape actually holds in Postgres — the UNIQUE
arbitrates duplicate answers, the FK cascades with the attempt — the
exact class of constraint behavior MagicMock suites cannot see (#529's
lesson). #397 seam: writes through the app layer, raw reads via psycopg.
"""
import uuid

import pytest

pytestmark = pytest.mark.integration

USER = "rich-user-active"


def _make_attempt(db_conn) -> str:
from db.connection import table

node = db_conn.execute(
"SELECT id FROM graph_nodes WHERE user_id = %s ORDER BY id LIMIT 1",
(USER,),
).fetchone()
assert node is not None
attempt_id = str(uuid.uuid4())
table("quiz_attempts").insert({
"id": attempt_id,
"user_id": USER,
"concept_node_id": node["id"],
"difficulty": "adaptive", # also exercises the #540 CHECK widening
"questions_json": [],
})
return attempt_id


def test_unique_arbitrates_duplicate_answers(db_conn):
from db.connection import table

attempt_id = _make_attempt(db_conn)
table("quiz_responses").insert({
"attempt_id": attempt_id, "question_index": 0,
"selected_index": 1, "is_correct": True, "time_ms": 1200,
})
with pytest.raises(Exception):
table("quiz_responses").insert({
"attempt_id": attempt_id, "question_index": 0,
"selected_index": 0, "is_correct": False,
})

rows = db_conn.execute(
"SELECT selected_index, is_correct, time_ms FROM quiz_responses "
"WHERE attempt_id = %s",
(attempt_id,),
).fetchall()
assert len(rows) == 1
assert rows[0]["selected_index"] == 1 # the first write won
assert rows[0]["is_correct"] is True
assert rows[0]["time_ms"] == 1200


def test_responses_cascade_with_their_attempt(db_conn):
from db.connection import table

attempt_id = _make_attempt(db_conn)
table("quiz_responses").insert({
"attempt_id": attempt_id, "question_index": 0,
"selected_index": 0, "is_correct": False,
})
db_conn.execute("DELETE FROM quiz_attempts WHERE id = %s", (attempt_id,))
left = db_conn.execute(
"SELECT count(*) AS n FROM quiz_responses WHERE attempt_id = %s",
(attempt_id,),
).fetchone()
assert left["n"] == 0
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n 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;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} 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
30 changes: 30 additions & 0 deletions backend/db/migrations/20260812214402_quiz_responses.sql
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
-- #541 C2: per-question quiz responses. This is the table that makes item
-- statistics, timing analysis, and misconception mining possible — one row
-- per answered question, written by POST /api/quiz/attempts/{id}/answer.
--
-- Everything here is a plaintext analytics scalar (same rationale as
-- quiz_attempts.score/total in #521): indexes and booleans carry no student
-- free text. The question/option TEXT lives encrypted in
-- quiz_attempts.questions_json; this table references it only by position.
--
-- The UNIQUE is the C1 idempotency contract: one response per
-- (attempt, question); re-answering returns the first recorded response
-- (no revision — the #537 revamp decides if that changes).

CREATE TABLE quiz_responses (
id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
attempt_id TEXT NOT NULL REFERENCES quiz_attempts(id) ON DELETE CASCADE,
question_index INTEGER NOT NULL CHECK (question_index >= 0),
selected_index INTEGER NOT NULL CHECK (selected_index >= 0),
is_correct BOOLEAN NOT NULL,
time_ms INTEGER CHECK (time_ms >= 0),
confidence REAL CHECK (confidence >= 0.0 AND confidence <= 1.0),
answered_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT quiz_responses_attempt_question_key UNIQUE (attempt_id, question_index)
);

-- No separate attempt_id index: Postgres backs the UNIQUE above with a btree
-- whose LEADING column is attempt_id, which already serves both access
-- patterns (submit's per-attempt scan and the answer endpoint's
-- (attempt_id, question_index) lookup). A standalone index would just add a
-- second write to the per-answer hot path.
25 changes: 25 additions & 0 deletions backend/models/__init__.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -60,6 +60,31 @@ class GenerateQuizBody(BaseModel):
# through to whatever SAPLING_MODEL_QUIZ resolves to (default
# gemini-2.5-flash-lite per ADR 0008).
model_pref: Optional[Literal["fast", "smart"]] = None
# DEPRECATED (#541 C3, removal tracked in #546): when true (the default
# the current QuizPanel needs), the response's per-option dicts carry
# `correct` booleans — the full answer key, client-side. Removing the
# key is a hard requirement of the #537 revamp: the new client grades
# through POST /api/quiz/attempts/{id}/answer instead. Every keyed
# response is logged so we can see when usage reaches zero.
include_answer_key: bool = True


class AnswerQuestionBody(BaseModel):
"""One answer for POST /api/quiz/attempts/{attempt_id}/answer (#541 C1).

Indexes are 0-based positions into the attempt's stored questions and
the question's options. The wire questions carry 1-based `id`s (what
/submit keys on), so `question_index = id - 1` — two addressing schemes
for one question. `question_id` is the guard against confusing them:
send the id you displayed and the route rejects a mismatch instead of
silently grading the neighbouring question (which the idempotency rule
would then lock in). The response echoes both either way."""

question_index: int = Field(ge=0)
selected_index: int = Field(ge=0)
question_id: Optional[int] = None
time_ms: Optional[int] = Field(default=None, ge=0)
confidence: Optional[float] = Field(default=None, ge=0.0, le=1.0)


class AnswerItem(BaseModel):
Expand Down
195 changes: 190 additions & 5 deletions backend/routes/quiz.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -18,7 +18,7 @@
from agents.quiz_context import quiz_context_agent
from agents.usage import record_agent_usage
from db.connection import table
from models import GenerateQuizBody, SubmitQuizBody
from models import AnswerQuestionBody, GenerateQuizBody, SubmitQuizBody
from routes.learn import _get_catalog_chunk
from services import events_service
from services.auth_guard import require_self
Expand DownExpand Up@@ -78,6 +78,21 @@ def _load_prompt(name: str) -> str:
_DIFFICULTY_RANK = {d: i for i, d in enumerate(CONCRETE_DIFFICULTIES)}


def _strip_answer_key(wire_questions: list[dict]) -> list[dict]:
"""Deep-enough copy of the wire questions without per-option `correct`
booleans (#541 C3). Storage always keeps the key — grading is
server-side; only the RESPONSE is stripped."""
stripped = []
for q in wire_questions:
q2 = dict(q)
q2["options"] = [
{k: v for k, v in o.items() if k != "correct"}
for o in q.get("options", [])
]
stripped.append(q2)
return stripped


def _resolved_difficulty(wire_questions: list[dict]) -> str:
"""The overall difficulty generation actually produced (#540 A1).

Expand DownExpand Up@@ -441,19 +456,153 @@ async def generate_quiz(body: GenerateQuizBody, request: Request):
"difficulty": body.difficulty,
},
)
# #541 C3: the answer key (per-option `correct` booleans) ships to the
# client only behind the deprecated include_answer_key flag — default
# true for the current QuizPanel, removed with #546 once the #537
# client grades via /attempts/{id}/answer. Log every keyed response so
# zero-usage is observable before the default flips.
if body.include_answer_key:
logger.info(
"quiz: generate served the client-side answer key "
"(include_answer_key=true, deprecated — #546) quiz_id=%s", quiz_id,
)
response_questions = questions
else:
response_questions = _strip_answer_key(questions)

# #540 A1: echo what generation actually chose. requested_difficulty
# is what the student asked for (may be 'adaptive');
# resolved_difficulty is the overall mix the agent produced (always
# concrete) — so the client can say "we picked hard for you" instead
# of repeating the request back.
return {
"quiz_id": quiz_id,
"questions": questions,
"questions": response_questions,
"requested_difficulty": body.difficulty,
"resolved_difficulty": _resolved_difficulty(questions),
}


@router.post("/attempts/{attempt_id}/answer")
def answer_question(attempt_id: str, body: AnswerQuestionBody, request: Request):
"""#541 C1: grade one question server-side and record the response.

Idempotent on (attempt_id, question_index): re-answering returns the
FIRST recorded response (`recorded: false` marks the replay) rather
than overwriting — no revision, decided for the #537 revamp flow.
"""
attempt_rows = table("quiz_attempts").select(
"*", filters={"id": f"eq.{attempt_id}"}
)
if not attempt_rows:
raise QuizAPIError(
status_code=404,
code=QuizErrorCode.QUIZ_ATTEMPT_NOT_FOUND,
message="We couldn't find that quiz.",
)
attempt = attempt_rows[0]
require_self(attempt["user_id"], request)

if attempt.get("completed_at"):
raise QuizAPIError(
status_code=409,
code=QuizErrorCode.QUIZ_ATTEMPT_ALREADY_COMPLETED,
message="This quiz has already been submitted.",
)

questions = decrypt_json_column(attempt["questions_json"]) or []
if body.question_index >= len(questions):
raise QuizAPIError(
status_code=400,
code=QuizErrorCode.QUIZ_QUESTION_INVALID,
message="That question isn't part of this quiz.",
)
question = questions[body.question_index]
options = question.get("options", [])
if body.selected_index >= len(options):
raise QuizAPIError(
status_code=400,
code=QuizErrorCode.QUIZ_QUESTION_INVALID,
message="That answer choice isn't part of this question.",
)
# Wire ids are 1-based, question_index is 0-based. When the client sends
# both, they must agree — otherwise passing the displayed id as the index
# silently grades the NEXT question and idempotency locks that in.
if body.question_id is not None and body.question_id != question.get("id"):
raise QuizAPIError(
status_code=400,
code=QuizErrorCode.QUIZ_QUESTION_INVALID,
message="That answer doesn't match the question it was sent for.",
)

# The correct option is a property of the question, not of the answer —
# resolve it once. -1 means a malformed item with no correct option,
# which must never grade correct (same rule as submit's #129 fix).
correct_index = next(
(i for i, o in enumerate(options) if o.get("correct")), -1
)

def _is_correct(selected_index: int) -> bool:
return correct_index >= 0 and correct_index == selected_index

recorded = True
response_row = None
existing = table("quiz_responses").select(
"*",
filters={
"attempt_id": f"eq.{attempt_id}",
"question_index": f"eq.{body.question_index}",
},
)
if existing:
recorded = False
response_row = existing[0]
else:
row = {
"attempt_id": attempt_id,
"question_index": body.question_index,
"selected_index": body.selected_index,
"is_correct": _is_correct(body.selected_index),
"time_ms": body.time_ms,
"confidence": body.confidence,
}
try:
table("quiz_responses").insert(row)
response_row = row
except Exception:
# Lost a race with a concurrent answer for the same index — the
# UNIQUE arbitrates; return whatever won.
recorded = False
raced = table("quiz_responses").select(
"*",
filters={
"attempt_id": f"eq.{attempt_id}",
"question_index": f"eq.{body.question_index}",
},
)
if not raced:
raise
response_row = raced[0]

next_index = body.question_index + 1
next_question = (
_strip_answer_key([questions[next_index]])[0]
if next_index < len(questions)
else None
)
return {
# Echo both addressing schemes so a client that mixed them up sees
# it immediately rather than discovering it at submit time.
"question_index": body.question_index,
"question_id": question.get("id"),
"is_correct": _is_correct(response_row["selected_index"]),
"correct_index": correct_index,
"explanation": question.get("explanation", ""),
"next_question": next_question,
"recorded": recorded,
}


@router.post("/submit")
def submit_quiz(body: SubmitQuizBody, background_tasks: BackgroundTasks, request: Request):
attempt_rows = table("quiz_attempts").select("*", filters={"id": f"eq.{body.quiz_id}"})
Expand DownExpand Up@@ -503,12 +652,45 @@ def submit_quiz(body: SubmitQuizBody, background_tasks: BackgroundTasks, request

concept_node_id = attempt["concept_node_id"]

# #541 C4: responses recorded through /attempts/{id}/answer are the
# source of truth — a payload answer for the same question is ignored
# (the recorded response was graded at answer time; letting the final
# POST override it would reopen the client-side-grading hole C exists
# to close). Questions never answered through C1 fall back to the
# submitted payload, so the current all-at-the-end client keeps working.
recorded_rows = table("quiz_responses").select(
"question_index,selected_index",
filters={"attempt_id": f"eq.{body.quiz_id}"},
) or []
recorded_by_index = {r["question_index"]: r for r in recorded_rows}

answer_map = {str(a.question_id): a.selected_label for a in body.answers}
results = []
# The reconciled answer set — what was ACTUALLY graded, which is what
# answers_json must persist. Storing the raw payload instead left a
# recorded-only submit with a full score beside an empty answer list,
# and a contradicted payload answer stored despite losing to the
# recorded response.
graded_answers: list[dict] = []
score = 0
for q in questions:
for q_index, q in enumerate(questions):
qid = str(q["id"])
selected = answer_map.get(qid, "")
recorded = recorded_by_index.get(q_index)
if recorded is not None:
sel_idx = recorded.get("selected_index")
options = q.get("options", [])
selected = (
options[sel_idx]["label"]
if isinstance(sel_idx, int) and 0 <= sel_idx < len(options)
else ""
)
else:
selected = answer_map.get(qid, "")
if selected:
graded_answers.append({
"question_id": q["id"],
"selected_label": selected,
})
correct_opt = next((o for o in q["options"] if o.get("correct")), None)
correct_label = correct_opt["label"] if correct_opt else ""
# #129: a malformed item with NO correct option must never grade as
Expand DownExpand Up@@ -580,7 +762,10 @@ def submit_quiz(body: SubmitQuizBody, background_tasks: BackgroundTasks, request
{
"score": score,
"total": total,
"answers_json": encrypt_json([a.model_dump() for a in body.answers]),
# The reconciled set (recorded responses winning over payload),
# not the raw request — the attempt's stored answers must agree
# with the score computed from them.
"answers_json": encrypt_json(graded_answers),
# completed_at was already stamped by the atomic claim above.
},
filters={"id": f"eq.{body.quiz_id}"},
Expand Down
3 changes: 3 additions & 0 deletions backend/services/quiz_errors.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -34,6 +34,9 @@ class QuizErrorCode(str, Enum):
QUIZ_CONCEPT_NOT_FOUND = "QUIZ_CONCEPT_NOT_FOUND"
QUIZ_ATTEMPT_NOT_FOUND = "QUIZ_ATTEMPT_NOT_FOUND"
QUIZ_ATTEMPT_ALREADY_COMPLETED = "QUIZ_ATTEMPT_ALREADY_COMPLETED"
# #541 C1: the answer endpoint got an index that doesn't exist on this
# attempt (question_index past the quiz, selected_index past the options).
QUIZ_QUESTION_INVALID = "QUIZ_QUESTION_INVALID"
QUIZ_NOT_AUTHORIZED = "QUIZ_NOT_AUTHORIZED"
QUIZ_GENERATION_FAILED = "QUIZ_GENERATION_FAILED"
QUIZ_INTERNAL_ERROR = "QUIZ_INTERNAL_ERROR"
Expand Down
74 changes: 74 additions & 0 deletions backend/tests/integration/test_quiz_responses_db.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
"""#541 C2 real-DB half: quiz_responses storage against local Supabase.

Proves the migration's shape actually holds in Postgres — the UNIQUE
arbitrates duplicate answers, the FK cascades with the attempt — the
exact class of constraint behavior MagicMock suites cannot see (#529's
lesson). #397 seam: writes through the app layer, raw reads via psycopg.
"""
import uuid

import pytest

pytestmark = pytest.mark.integration

USER = "rich-user-active"


def _make_attempt(db_conn) -> str:
from db.connection import table

node = db_conn.execute(
"SELECT id FROM graph_nodes WHERE user_id = %s ORDER BY id LIMIT 1",
(USER,),
).fetchone()
assert node is not None
attempt_id = str(uuid.uuid4())
table("quiz_attempts").insert({
"id": attempt_id,
"user_id": USER,
"concept_node_id": node["id"],
"difficulty": "adaptive", # also exercises the #540 CHECK widening
"questions_json": [],
})
return attempt_id


def test_unique_arbitrates_duplicate_answers(db_conn):
from db.connection import table

attempt_id = _make_attempt(db_conn)
table("quiz_responses").insert({
"attempt_id": attempt_id, "question_index": 0,
"selected_index": 1, "is_correct": True, "time_ms": 1200,
})
with pytest.raises(Exception):
table("quiz_responses").insert({
"attempt_id": attempt_id, "question_index": 0,
"selected_index": 0, "is_correct": False,
})

rows = db_conn.execute(
"SELECT selected_index, is_correct, time_ms FROM quiz_responses "
"WHERE attempt_id = %s",
(attempt_id,),
).fetchall()
assert len(rows) == 1
assert rows[0]["selected_index"] == 1 # the first write won
assert rows[0]["is_correct"] is True
assert rows[0]["time_ms"] == 1200


def test_responses_cascade_with_their_attempt(db_conn):
from db.connection import table

attempt_id = _make_attempt(db_conn)
table("quiz_responses").insert({
"attempt_id": attempt_id, "question_index": 0,
"selected_index": 0, "is_correct": False,
})
db_conn.execute("DELETE FROM quiz_attempts WHERE id = %s", (attempt_id,))
left = db_conn.execute(
"SELECT count(*) AS n FROM quiz_responses WHERE attempt_id = %s",
(attempt_id,),
).fetchone()
assert left["n"] == 0
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } 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
30 changes: 30 additions & 0 deletions backend/db/migrations/20260812214402_quiz_responses.sql
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
-- #541 C2: per-question quiz responses. This is the table that makes item
-- statistics, timing analysis, and misconception mining possible — one row
-- per answered question, written by POST /api/quiz/attempts/{id}/answer.
--
-- Everything here is a plaintext analytics scalar (same rationale as
-- quiz_attempts.score/total in #521): indexes and booleans carry no student
-- free text. The question/option TEXT lives encrypted in
-- quiz_attempts.questions_json; this table references it only by position.
--
-- The UNIQUE is the C1 idempotency contract: one response per
-- (attempt, question); re-answering returns the first recorded response
-- (no revision — the #537 revamp decides if that changes).

CREATE TABLE quiz_responses (
id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
attempt_id TEXT NOT NULL REFERENCES quiz_attempts(id) ON DELETE CASCADE,
question_index INTEGER NOT NULL CHECK (question_index >= 0),
selected_index INTEGER NOT NULL CHECK (selected_index >= 0),
is_correct BOOLEAN NOT NULL,
time_ms INTEGER CHECK (time_ms >= 0),
confidence REAL CHECK (confidence >= 0.0 AND confidence <= 1.0),
answered_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT quiz_responses_attempt_question_key UNIQUE (attempt_id, question_index)
);

-- No separate attempt_id index: Postgres backs the UNIQUE above with a btree
-- whose LEADING column is attempt_id, which already serves both access
-- patterns (submit's per-attempt scan and the answer endpoint's
-- (attempt_id, question_index) lookup). A standalone index would just add a
-- second write to the per-answer hot path.
25 changes: 25 additions & 0 deletions backend/models/__init__.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -60,6 +60,31 @@ class GenerateQuizBody(BaseModel):
# through to whatever SAPLING_MODEL_QUIZ resolves to (default
# gemini-2.5-flash-lite per ADR 0008).
model_pref: Optional[Literal["fast", "smart"]] = None
# DEPRECATED (#541 C3, removal tracked in #546): when true (the default
# the current QuizPanel needs), the response's per-option dicts carry
# `correct` booleans — the full answer key, client-side. Removing the
# key is a hard requirement of the #537 revamp: the new client grades
# through POST /api/quiz/attempts/{id}/answer instead. Every keyed
# response is logged so we can see when usage reaches zero.
include_answer_key: bool = True


class AnswerQuestionBody(BaseModel):
"""One answer for POST /api/quiz/attempts/{attempt_id}/answer (#541 C1).

Indexes are 0-based positions into the attempt's stored questions and
the question's options. The wire questions carry 1-based `id`s (what
/submit keys on), so `question_index = id - 1` — two addressing schemes
for one question. `question_id` is the guard against confusing them:
send the id you displayed and the route rejects a mismatch instead of
silently grading the neighbouring question (which the idempotency rule
would then lock in). The response echoes both either way."""

question_index: int = Field(ge=0)
selected_index: int = Field(ge=0)
question_id: Optional[int] = None
time_ms: Optional[int] = Field(default=None, ge=0)
confidence: Optional[float] = Field(default=None, ge=0.0, le=1.0)


class AnswerItem(BaseModel):
Expand Down
195 changes: 190 additions & 5 deletions backend/routes/quiz.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -18,7 +18,7 @@
from agents.quiz_context import quiz_context_agent
from agents.usage import record_agent_usage
from db.connection import table
from models import GenerateQuizBody, SubmitQuizBody
from models import AnswerQuestionBody, GenerateQuizBody, SubmitQuizBody
from routes.learn import _get_catalog_chunk
from services import events_service
from services.auth_guard import require_self
Expand DownExpand Up@@ -78,6 +78,21 @@ def _load_prompt(name: str) -> str:
_DIFFICULTY_RANK = {d: i for i, d in enumerate(CONCRETE_DIFFICULTIES)}


def _strip_answer_key(wire_questions: list[dict]) -> list[dict]:
"""Deep-enough copy of the wire questions without per-option `correct`
booleans (#541 C3). Storage always keeps the key — grading is
server-side; only the RESPONSE is stripped."""
stripped = []
for q in wire_questions:
q2 = dict(q)
q2["options"] = [
{k: v for k, v in o.items() if k != "correct"}
for o in q.get("options", [])
]
stripped.append(q2)
return stripped


def _resolved_difficulty(wire_questions: list[dict]) -> str:
"""The overall difficulty generation actually produced (#540 A1).

Expand DownExpand Up@@ -441,19 +456,153 @@ async def generate_quiz(body: GenerateQuizBody, request: Request):
"difficulty": body.difficulty,
},
)
# #541 C3: the answer key (per-option `correct` booleans) ships to the
# client only behind the deprecated include_answer_key flag — default
# true for the current QuizPanel, removed with #546 once the #537
# client grades via /attempts/{id}/answer. Log every keyed response so
# zero-usage is observable before the default flips.
if body.include_answer_key:
logger.info(
"quiz: generate served the client-side answer key "
"(include_answer_key=true, deprecated — #546) quiz_id=%s", quiz_id,
)
response_questions = questions
else:
response_questions = _strip_answer_key(questions)

# #540 A1: echo what generation actually chose. requested_difficulty
# is what the student asked for (may be 'adaptive');
# resolved_difficulty is the overall mix the agent produced (always
# concrete) — so the client can say "we picked hard for you" instead
# of repeating the request back.
return {
"quiz_id": quiz_id,
"questions": questions,
"questions": response_questions,
"requested_difficulty": body.difficulty,
"resolved_difficulty": _resolved_difficulty(questions),
}


@router.post("/attempts/{attempt_id}/answer")
def answer_question(attempt_id: str, body: AnswerQuestionBody, request: Request):
"""#541 C1: grade one question server-side and record the response.

Idempotent on (attempt_id, question_index): re-answering returns the
FIRST recorded response (`recorded: false` marks the replay) rather
than overwriting — no revision, decided for the #537 revamp flow.
"""
attempt_rows = table("quiz_attempts").select(
"*", filters={"id": f"eq.{attempt_id}"}
)
if not attempt_rows:
raise QuizAPIError(
status_code=404,
code=QuizErrorCode.QUIZ_ATTEMPT_NOT_FOUND,
message="We couldn't find that quiz.",
)
attempt = attempt_rows[0]
require_self(attempt["user_id"], request)

if attempt.get("completed_at"):
raise QuizAPIError(
status_code=409,
code=QuizErrorCode.QUIZ_ATTEMPT_ALREADY_COMPLETED,
message="This quiz has already been submitted.",
)

questions = decrypt_json_column(attempt["questions_json"]) or []
if body.question_index >= len(questions):
raise QuizAPIError(
status_code=400,
code=QuizErrorCode.QUIZ_QUESTION_INVALID,
message="That question isn't part of this quiz.",
)
question = questions[body.question_index]
options = question.get("options", [])
if body.selected_index >= len(options):
raise QuizAPIError(
status_code=400,
code=QuizErrorCode.QUIZ_QUESTION_INVALID,
message="That answer choice isn't part of this question.",
)
# Wire ids are 1-based, question_index is 0-based. When the client sends
# both, they must agree — otherwise passing the displayed id as the index
# silently grades the NEXT question and idempotency locks that in.
if body.question_id is not None and body.question_id != question.get("id"):
raise QuizAPIError(
status_code=400,
code=QuizErrorCode.QUIZ_QUESTION_INVALID,
message="That answer doesn't match the question it was sent for.",
)

# The correct option is a property of the question, not of the answer —
# resolve it once. -1 means a malformed item with no correct option,
# which must never grade correct (same rule as submit's #129 fix).
correct_index = next(
(i for i, o in enumerate(options) if o.get("correct")), -1
)

def _is_correct(selected_index: int) -> bool:
return correct_index >= 0 and correct_index == selected_index

recorded = True
response_row = None
existing = table("quiz_responses").select(
"*",
filters={
"attempt_id": f"eq.{attempt_id}",
"question_index": f"eq.{body.question_index}",
},
)
if existing:
recorded = False
response_row = existing[0]
else:
row = {
"attempt_id": attempt_id,
"question_index": body.question_index,
"selected_index": body.selected_index,
"is_correct": _is_correct(body.selected_index),
"time_ms": body.time_ms,
"confidence": body.confidence,
}
try:
table("quiz_responses").insert(row)
response_row = row
except Exception:
# Lost a race with a concurrent answer for the same index — the
# UNIQUE arbitrates; return whatever won.
recorded = False
raced = table("quiz_responses").select(
"*",
filters={
"attempt_id": f"eq.{attempt_id}",
"question_index": f"eq.{body.question_index}",
},
)
if not raced:
raise
response_row = raced[0]

next_index = body.question_index + 1
next_question = (
_strip_answer_key([questions[next_index]])[0]
if next_index < len(questions)
else None
)
return {
# Echo both addressing schemes so a client that mixed them up sees
# it immediately rather than discovering it at submit time.
"question_index": body.question_index,
"question_id": question.get("id"),
"is_correct": _is_correct(response_row["selected_index"]),
"correct_index": correct_index,
"explanation": question.get("explanation", ""),
"next_question": next_question,
"recorded": recorded,
}


@router.post("/submit")
def submit_quiz(body: SubmitQuizBody, background_tasks: BackgroundTasks, request: Request):
attempt_rows = table("quiz_attempts").select("*", filters={"id": f"eq.{body.quiz_id}"})
Expand DownExpand Up@@ -503,12 +652,45 @@ def submit_quiz(body: SubmitQuizBody, background_tasks: BackgroundTasks, request

concept_node_id = attempt["concept_node_id"]

# #541 C4: responses recorded through /attempts/{id}/answer are the
# source of truth — a payload answer for the same question is ignored
# (the recorded response was graded at answer time; letting the final
# POST override it would reopen the client-side-grading hole C exists
# to close). Questions never answered through C1 fall back to the
# submitted payload, so the current all-at-the-end client keeps working.
recorded_rows = table("quiz_responses").select(
"question_index,selected_index",
filters={"attempt_id": f"eq.{body.quiz_id}"},
) or []
recorded_by_index = {r["question_index"]: r for r in recorded_rows}

answer_map = {str(a.question_id): a.selected_label for a in body.answers}
results = []
# The reconciled answer set — what was ACTUALLY graded, which is what
# answers_json must persist. Storing the raw payload instead left a
# recorded-only submit with a full score beside an empty answer list,
# and a contradicted payload answer stored despite losing to the
# recorded response.
graded_answers: list[dict] = []
score = 0
for q in questions:
for q_index, q in enumerate(questions):
qid = str(q["id"])
selected = answer_map.get(qid, "")
recorded = recorded_by_index.get(q_index)
if recorded is not None:
sel_idx = recorded.get("selected_index")
options = q.get("options", [])
selected = (
options[sel_idx]["label"]
if isinstance(sel_idx, int) and 0 <= sel_idx < len(options)
else ""
)
else:
selected = answer_map.get(qid, "")
if selected:
graded_answers.append({
"question_id": q["id"],
"selected_label": selected,
})
correct_opt = next((o for o in q["options"] if o.get("correct")), None)
correct_label = correct_opt["label"] if correct_opt else ""
# #129: a malformed item with NO correct option must never grade as
Expand DownExpand Up@@ -580,7 +762,10 @@ def submit_quiz(body: SubmitQuizBody, background_tasks: BackgroundTasks, request
{
"score": score,
"total": total,
"answers_json": encrypt_json([a.model_dump() for a in body.answers]),
# The reconciled set (recorded responses winning over payload),
# not the raw request — the attempt's stored answers must agree
# with the score computed from them.
"answers_json": encrypt_json(graded_answers),
# completed_at was already stamped by the atomic claim above.
},
filters={"id": f"eq.{body.quiz_id}"},
Expand Down
3 changes: 3 additions & 0 deletions backend/services/quiz_errors.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -34,6 +34,9 @@ class QuizErrorCode(str, Enum):
QUIZ_CONCEPT_NOT_FOUND = "QUIZ_CONCEPT_NOT_FOUND"
QUIZ_ATTEMPT_NOT_FOUND = "QUIZ_ATTEMPT_NOT_FOUND"
QUIZ_ATTEMPT_ALREADY_COMPLETED = "QUIZ_ATTEMPT_ALREADY_COMPLETED"
# #541 C1: the answer endpoint got an index that doesn't exist on this
# attempt (question_index past the quiz, selected_index past the options).
QUIZ_QUESTION_INVALID = "QUIZ_QUESTION_INVALID"
QUIZ_NOT_AUTHORIZED = "QUIZ_NOT_AUTHORIZED"
QUIZ_GENERATION_FAILED = "QUIZ_GENERATION_FAILED"
QUIZ_INTERNAL_ERROR = "QUIZ_INTERNAL_ERROR"
Expand Down
74 changes: 74 additions & 0 deletions backend/tests/integration/test_quiz_responses_db.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
"""#541 C2 real-DB half: quiz_responses storage against local Supabase.

Proves the migration's shape actually holds in Postgres — the UNIQUE
arbitrates duplicate answers, the FK cascades with the attempt — the
exact class of constraint behavior MagicMock suites cannot see (#529's
lesson). #397 seam: writes through the app layer, raw reads via psycopg.
"""
import uuid

import pytest

pytestmark = pytest.mark.integration

USER = "rich-user-active"


def _make_attempt(db_conn) -> str:
from db.connection import table

node = db_conn.execute(
"SELECT id FROM graph_nodes WHERE user_id = %s ORDER BY id LIMIT 1",
(USER,),
).fetchone()
assert node is not None
attempt_id = str(uuid.uuid4())
table("quiz_attempts").insert({
"id": attempt_id,
"user_id": USER,
"concept_node_id": node["id"],
"difficulty": "adaptive", # also exercises the #540 CHECK widening
"questions_json": [],
})
return attempt_id


def test_unique_arbitrates_duplicate_answers(db_conn):
from db.connection import table

attempt_id = _make_attempt(db_conn)
table("quiz_responses").insert({
"attempt_id": attempt_id, "question_index": 0,
"selected_index": 1, "is_correct": True, "time_ms": 1200,
})
with pytest.raises(Exception):
table("quiz_responses").insert({
"attempt_id": attempt_id, "question_index": 0,
"selected_index": 0, "is_correct": False,
})

rows = db_conn.execute(
"SELECT selected_index, is_correct, time_ms FROM quiz_responses "
"WHERE attempt_id = %s",
(attempt_id,),
).fetchall()
assert len(rows) == 1
assert rows[0]["selected_index"] == 1 # the first write won
assert rows[0]["is_correct"] is True
assert rows[0]["time_ms"] == 1200


def test_responses_cascade_with_their_attempt(db_conn):
from db.connection import table

attempt_id = _make_attempt(db_conn)
table("quiz_responses").insert({
"attempt_id": attempt_id, "question_index": 0,
"selected_index": 0, "is_correct": False,
})
db_conn.execute("DELETE FROM quiz_attempts WHERE id = %s", (attempt_id,))
left = db_conn.execute(
"SELECT count(*) AS n FROM quiz_responses WHERE attempt_id = %s",
(attempt_id,),
).fetchone()
assert left["n"] == 0
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } 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
30 changes: 30 additions & 0 deletions backend/db/migrations/20260812214402_quiz_responses.sql
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
-- #541 C2: per-question quiz responses. This is the table that makes item
-- statistics, timing analysis, and misconception mining possible — one row
-- per answered question, written by POST /api/quiz/attempts/{id}/answer.
--
-- Everything here is a plaintext analytics scalar (same rationale as
-- quiz_attempts.score/total in #521): indexes and booleans carry no student
-- free text. The question/option TEXT lives encrypted in
-- quiz_attempts.questions_json; this table references it only by position.
--
-- The UNIQUE is the C1 idempotency contract: one response per
-- (attempt, question); re-answering returns the first recorded response
-- (no revision — the #537 revamp decides if that changes).

CREATE TABLE quiz_responses (
id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
attempt_id TEXT NOT NULL REFERENCES quiz_attempts(id) ON DELETE CASCADE,
question_index INTEGER NOT NULL CHECK (question_index >= 0),
selected_index INTEGER NOT NULL CHECK (selected_index >= 0),
is_correct BOOLEAN NOT NULL,
time_ms INTEGER CHECK (time_ms >= 0),
confidence REAL CHECK (confidence >= 0.0 AND confidence <= 1.0),
answered_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT quiz_responses_attempt_question_key UNIQUE (attempt_id, question_index)
);

-- No separate attempt_id index: Postgres backs the UNIQUE above with a btree
-- whose LEADING column is attempt_id, which already serves both access
-- patterns (submit's per-attempt scan and the answer endpoint's
-- (attempt_id, question_index) lookup). A standalone index would just add a
-- second write to the per-answer hot path.
25 changes: 25 additions & 0 deletions backend/models/__init__.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -60,6 +60,31 @@ class GenerateQuizBody(BaseModel):
# through to whatever SAPLING_MODEL_QUIZ resolves to (default
# gemini-2.5-flash-lite per ADR 0008).
model_pref: Optional[Literal["fast", "smart"]] = None
# DEPRECATED (#541 C3, removal tracked in #546): when true (the default
# the current QuizPanel needs), the response's per-option dicts carry
# `correct` booleans — the full answer key, client-side. Removing the
# key is a hard requirement of the #537 revamp: the new client grades
# through POST /api/quiz/attempts/{id}/answer instead. Every keyed
# response is logged so we can see when usage reaches zero.
include_answer_key: bool = True


class AnswerQuestionBody(BaseModel):
"""One answer for POST /api/quiz/attempts/{attempt_id}/answer (#541 C1).

Indexes are 0-based positions into the attempt's stored questions and
the question's options. The wire questions carry 1-based `id`s (what
/submit keys on), so `question_index = id - 1` — two addressing schemes
for one question. `question_id` is the guard against confusing them:
send the id you displayed and the route rejects a mismatch instead of
silently grading the neighbouring question (which the idempotency rule
would then lock in). The response echoes both either way."""

question_index: int = Field(ge=0)
selected_index: int = Field(ge=0)
question_id: Optional[int] = None
time_ms: Optional[int] = Field(default=None, ge=0)
confidence: Optional[float] = Field(default=None, ge=0.0, le=1.0)


class AnswerItem(BaseModel):
Expand Down
195 changes: 190 additions & 5 deletions backend/routes/quiz.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -18,7 +18,7 @@
from agents.quiz_context import quiz_context_agent
from agents.usage import record_agent_usage
from db.connection import table
from models import GenerateQuizBody, SubmitQuizBody
from models import AnswerQuestionBody, GenerateQuizBody, SubmitQuizBody
from routes.learn import _get_catalog_chunk
from services import events_service
from services.auth_guard import require_self
Expand DownExpand Up@@ -78,6 +78,21 @@ def _load_prompt(name: str) -> str:
_DIFFICULTY_RANK = {d: i for i, d in enumerate(CONCRETE_DIFFICULTIES)}


def _strip_answer_key(wire_questions: list[dict]) -> list[dict]:
"""Deep-enough copy of the wire questions without per-option `correct`
booleans (#541 C3). Storage always keeps the key — grading is
server-side; only the RESPONSE is stripped."""
stripped = []
for q in wire_questions:
q2 = dict(q)
q2["options"] = [
{k: v for k, v in o.items() if k != "correct"}
for o in q.get("options", [])
]
stripped.append(q2)
return stripped


def _resolved_difficulty(wire_questions: list[dict]) -> str:
"""The overall difficulty generation actually produced (#540 A1).

Expand DownExpand Up@@ -441,19 +456,153 @@ async def generate_quiz(body: GenerateQuizBody, request: Request):
"difficulty": body.difficulty,
},
)
# #541 C3: the answer key (per-option `correct` booleans) ships to the
# client only behind the deprecated include_answer_key flag — default
# true for the current QuizPanel, removed with #546 once the #537
# client grades via /attempts/{id}/answer. Log every keyed response so
# zero-usage is observable before the default flips.
if body.include_answer_key:
logger.info(
"quiz: generate served the client-side answer key "
"(include_answer_key=true, deprecated — #546) quiz_id=%s", quiz_id,
)
response_questions = questions
else:
response_questions = _strip_answer_key(questions)

# #540 A1: echo what generation actually chose. requested_difficulty
# is what the student asked for (may be 'adaptive');
# resolved_difficulty is the overall mix the agent produced (always
# concrete) — so the client can say "we picked hard for you" instead
# of repeating the request back.
return {
"quiz_id": quiz_id,
"questions": questions,
"questions": response_questions,
"requested_difficulty": body.difficulty,
"resolved_difficulty": _resolved_difficulty(questions),
}


@router.post("/attempts/{attempt_id}/answer")
def answer_question(attempt_id: str, body: AnswerQuestionBody, request: Request):
"""#541 C1: grade one question server-side and record the response.

Idempotent on (attempt_id, question_index): re-answering returns the
FIRST recorded response (`recorded: false` marks the replay) rather
than overwriting — no revision, decided for the #537 revamp flow.
"""
attempt_rows = table("quiz_attempts").select(
"*", filters={"id": f"eq.{attempt_id}"}
)
if not attempt_rows:
raise QuizAPIError(
status_code=404,
code=QuizErrorCode.QUIZ_ATTEMPT_NOT_FOUND,
message="We couldn't find that quiz.",
)
attempt = attempt_rows[0]
require_self(attempt["user_id"], request)

if attempt.get("completed_at"):
raise QuizAPIError(
status_code=409,
code=QuizErrorCode.QUIZ_ATTEMPT_ALREADY_COMPLETED,
message="This quiz has already been submitted.",
)

questions = decrypt_json_column(attempt["questions_json"]) or []
if body.question_index >= len(questions):
raise QuizAPIError(
status_code=400,
code=QuizErrorCode.QUIZ_QUESTION_INVALID,
message="That question isn't part of this quiz.",
)
question = questions[body.question_index]
options = question.get("options", [])
if body.selected_index >= len(options):
raise QuizAPIError(
status_code=400,
code=QuizErrorCode.QUIZ_QUESTION_INVALID,
message="That answer choice isn't part of this question.",
)
# Wire ids are 1-based, question_index is 0-based. When the client sends
# both, they must agree — otherwise passing the displayed id as the index
# silently grades the NEXT question and idempotency locks that in.
if body.question_id is not None and body.question_id != question.get("id"):
raise QuizAPIError(
status_code=400,
code=QuizErrorCode.QUIZ_QUESTION_INVALID,
message="That answer doesn't match the question it was sent for.",
)

# The correct option is a property of the question, not of the answer —
# resolve it once. -1 means a malformed item with no correct option,
# which must never grade correct (same rule as submit's #129 fix).
correct_index = next(
(i for i, o in enumerate(options) if o.get("correct")), -1
)

def _is_correct(selected_index: int) -> bool:
return correct_index >= 0 and correct_index == selected_index

recorded = True
response_row = None
existing = table("quiz_responses").select(
"*",
filters={
"attempt_id": f"eq.{attempt_id}",
"question_index": f"eq.{body.question_index}",
},
)
if existing:
recorded = False
response_row = existing[0]
else:
row = {
"attempt_id": attempt_id,
"question_index": body.question_index,
"selected_index": body.selected_index,
"is_correct": _is_correct(body.selected_index),
"time_ms": body.time_ms,
"confidence": body.confidence,
}
try:
table("quiz_responses").insert(row)
response_row = row
except Exception:
# Lost a race with a concurrent answer for the same index — the
# UNIQUE arbitrates; return whatever won.
recorded = False
raced = table("quiz_responses").select(
"*",
filters={
"attempt_id": f"eq.{attempt_id}",
"question_index": f"eq.{body.question_index}",
},
)
if not raced:
raise
response_row = raced[0]

next_index = body.question_index + 1
next_question = (
_strip_answer_key([questions[next_index]])[0]
if next_index < len(questions)
else None
)
return {
# Echo both addressing schemes so a client that mixed them up sees
# it immediately rather than discovering it at submit time.
"question_index": body.question_index,
"question_id": question.get("id"),
"is_correct": _is_correct(response_row["selected_index"]),
"correct_index": correct_index,
"explanation": question.get("explanation", ""),
"next_question": next_question,
"recorded": recorded,
}


@router.post("/submit")
def submit_quiz(body: SubmitQuizBody, background_tasks: BackgroundTasks, request: Request):
attempt_rows = table("quiz_attempts").select("*", filters={"id": f"eq.{body.quiz_id}"})
Expand DownExpand Up@@ -503,12 +652,45 @@ def submit_quiz(body: SubmitQuizBody, background_tasks: BackgroundTasks, request

concept_node_id = attempt["concept_node_id"]

# #541 C4: responses recorded through /attempts/{id}/answer are the
# source of truth — a payload answer for the same question is ignored
# (the recorded response was graded at answer time; letting the final
# POST override it would reopen the client-side-grading hole C exists
# to close). Questions never answered through C1 fall back to the
# submitted payload, so the current all-at-the-end client keeps working.
recorded_rows = table("quiz_responses").select(
"question_index,selected_index",
filters={"attempt_id": f"eq.{body.quiz_id}"},
) or []
recorded_by_index = {r["question_index"]: r for r in recorded_rows}

answer_map = {str(a.question_id): a.selected_label for a in body.answers}
results = []
# The reconciled answer set — what was ACTUALLY graded, which is what
# answers_json must persist. Storing the raw payload instead left a
# recorded-only submit with a full score beside an empty answer list,
# and a contradicted payload answer stored despite losing to the
# recorded response.
graded_answers: list[dict] = []
score = 0
for q in questions:
for q_index, q in enumerate(questions):
qid = str(q["id"])
selected = answer_map.get(qid, "")
recorded = recorded_by_index.get(q_index)
if recorded is not None:
sel_idx = recorded.get("selected_index")
options = q.get("options", [])
selected = (
options[sel_idx]["label"]
if isinstance(sel_idx, int) and 0 <= sel_idx < len(options)
else ""
)
else:
selected = answer_map.get(qid, "")
if selected:
graded_answers.append({
"question_id": q["id"],
"selected_label": selected,
})
correct_opt = next((o for o in q["options"] if o.get("correct")), None)
correct_label = correct_opt["label"] if correct_opt else ""
# #129: a malformed item with NO correct option must never grade as
Expand DownExpand Up@@ -580,7 +762,10 @@ def submit_quiz(body: SubmitQuizBody, background_tasks: BackgroundTasks, request
{
"score": score,
"total": total,
"answers_json": encrypt_json([a.model_dump() for a in body.answers]),
# The reconciled set (recorded responses winning over payload),
# not the raw request — the attempt's stored answers must agree
# with the score computed from them.
"answers_json": encrypt_json(graded_answers),
# completed_at was already stamped by the atomic claim above.
},
filters={"id": f"eq.{body.quiz_id}"},
Expand Down
3 changes: 3 additions & 0 deletions backend/services/quiz_errors.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -34,6 +34,9 @@ class QuizErrorCode(str, Enum):
QUIZ_CONCEPT_NOT_FOUND = "QUIZ_CONCEPT_NOT_FOUND"
QUIZ_ATTEMPT_NOT_FOUND = "QUIZ_ATTEMPT_NOT_FOUND"
QUIZ_ATTEMPT_ALREADY_COMPLETED = "QUIZ_ATTEMPT_ALREADY_COMPLETED"
# #541 C1: the answer endpoint got an index that doesn't exist on this
# attempt (question_index past the quiz, selected_index past the options).
QUIZ_QUESTION_INVALID = "QUIZ_QUESTION_INVALID"
QUIZ_NOT_AUTHORIZED = "QUIZ_NOT_AUTHORIZED"
QUIZ_GENERATION_FAILED = "QUIZ_GENERATION_FAILED"
QUIZ_INTERNAL_ERROR = "QUIZ_INTERNAL_ERROR"
Expand Down
74 changes: 74 additions & 0 deletions backend/tests/integration/test_quiz_responses_db.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
"""#541 C2 real-DB half: quiz_responses storage against local Supabase.

Proves the migration's shape actually holds in Postgres — the UNIQUE
arbitrates duplicate answers, the FK cascades with the attempt — the
exact class of constraint behavior MagicMock suites cannot see (#529's
lesson). #397 seam: writes through the app layer, raw reads via psycopg.
"""
import uuid

import pytest

pytestmark = pytest.mark.integration

USER = "rich-user-active"


def _make_attempt(db_conn) -> str:
from db.connection import table

node = db_conn.execute(
"SELECT id FROM graph_nodes WHERE user_id = %s ORDER BY id LIMIT 1",
(USER,),
).fetchone()
assert node is not None
attempt_id = str(uuid.uuid4())
table("quiz_attempts").insert({
"id": attempt_id,
"user_id": USER,
"concept_node_id": node["id"],
"difficulty": "adaptive", # also exercises the #540 CHECK widening
"questions_json": [],
})
return attempt_id


def test_unique_arbitrates_duplicate_answers(db_conn):
from db.connection import table

attempt_id = _make_attempt(db_conn)
table("quiz_responses").insert({
"attempt_id": attempt_id, "question_index": 0,
"selected_index": 1, "is_correct": True, "time_ms": 1200,
})
with pytest.raises(Exception):
table("quiz_responses").insert({
"attempt_id": attempt_id, "question_index": 0,
"selected_index": 0, "is_correct": False,
})

rows = db_conn.execute(
"SELECT selected_index, is_correct, time_ms FROM quiz_responses "
"WHERE attempt_id = %s",
(attempt_id,),
).fetchall()
assert len(rows) == 1
assert rows[0]["selected_index"] == 1 # the first write won
assert rows[0]["is_correct"] is True
assert rows[0]["time_ms"] == 1200


def test_responses_cascade_with_their_attempt(db_conn):
from db.connection import table

attempt_id = _make_attempt(db_conn)
table("quiz_responses").insert({
"attempt_id": attempt_id, "question_index": 0,
"selected_index": 0, "is_correct": False,
})
db_conn.execute("DELETE FROM quiz_attempts WHERE id = %s", (attempt_id,))
left = db_conn.execute(
"SELECT count(*) AS n FROM quiz_responses WHERE attempt_id = %s",
(attempt_id,),
).fetchone()
assert left["n"] == 0
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } 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
30 changes: 30 additions & 0 deletions backend/db/migrations/20260812214402_quiz_responses.sql
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
-- #541 C2: per-question quiz responses. This is the table that makes item
-- statistics, timing analysis, and misconception mining possible — one row
-- per answered question, written by POST /api/quiz/attempts/{id}/answer.
--
-- Everything here is a plaintext analytics scalar (same rationale as
-- quiz_attempts.score/total in #521): indexes and booleans carry no student
-- free text. The question/option TEXT lives encrypted in
-- quiz_attempts.questions_json; this table references it only by position.
--
-- The UNIQUE is the C1 idempotency contract: one response per
-- (attempt, question); re-answering returns the first recorded response
-- (no revision — the #537 revamp decides if that changes).

CREATE TABLE quiz_responses (
id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
attempt_id TEXT NOT NULL REFERENCES quiz_attempts(id) ON DELETE CASCADE,
question_index INTEGER NOT NULL CHECK (question_index >= 0),
selected_index INTEGER NOT NULL CHECK (selected_index >= 0),
is_correct BOOLEAN NOT NULL,
time_ms INTEGER CHECK (time_ms >= 0),
confidence REAL CHECK (confidence >= 0.0 AND confidence <= 1.0),
answered_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT quiz_responses_attempt_question_key UNIQUE (attempt_id, question_index)
);

-- No separate attempt_id index: Postgres backs the UNIQUE above with a btree
-- whose LEADING column is attempt_id, which already serves both access
-- patterns (submit's per-attempt scan and the answer endpoint's
-- (attempt_id, question_index) lookup). A standalone index would just add a
-- second write to the per-answer hot path.
25 changes: 25 additions & 0 deletions backend/models/__init__.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -60,6 +60,31 @@ class GenerateQuizBody(BaseModel):
# through to whatever SAPLING_MODEL_QUIZ resolves to (default
# gemini-2.5-flash-lite per ADR 0008).
model_pref: Optional[Literal["fast", "smart"]] = None
# DEPRECATED (#541 C3, removal tracked in #546): when true (the default
# the current QuizPanel needs), the response's per-option dicts carry
# `correct` booleans — the full answer key, client-side. Removing the
# key is a hard requirement of the #537 revamp: the new client grades
# through POST /api/quiz/attempts/{id}/answer instead. Every keyed
# response is logged so we can see when usage reaches zero.
include_answer_key: bool = True


class AnswerQuestionBody(BaseModel):
"""One answer for POST /api/quiz/attempts/{attempt_id}/answer (#541 C1).

Indexes are 0-based positions into the attempt's stored questions and
the question's options. The wire questions carry 1-based `id`s (what
/submit keys on), so `question_index = id - 1` — two addressing schemes
for one question. `question_id` is the guard against confusing them:
send the id you displayed and the route rejects a mismatch instead of
silently grading the neighbouring question (which the idempotency rule
would then lock in). The response echoes both either way."""

question_index: int = Field(ge=0)
selected_index: int = Field(ge=0)
question_id: Optional[int] = None
time_ms: Optional[int] = Field(default=None, ge=0)
confidence: Optional[float] = Field(default=None, ge=0.0, le=1.0)


class AnswerItem(BaseModel):
Expand Down
195 changes: 190 additions & 5 deletions backend/routes/quiz.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -18,7 +18,7 @@
from agents.quiz_context import quiz_context_agent
from agents.usage import record_agent_usage
from db.connection import table
from models import GenerateQuizBody, SubmitQuizBody
from models import AnswerQuestionBody, GenerateQuizBody, SubmitQuizBody
from routes.learn import _get_catalog_chunk
from services import events_service
from services.auth_guard import require_self
Expand DownExpand Up@@ -78,6 +78,21 @@ def _load_prompt(name: str) -> str:
_DIFFICULTY_RANK = {d: i for i, d in enumerate(CONCRETE_DIFFICULTIES)}


def _strip_answer_key(wire_questions: list[dict]) -> list[dict]:
"""Deep-enough copy of the wire questions without per-option `correct`
booleans (#541 C3). Storage always keeps the key — grading is
server-side; only the RESPONSE is stripped."""
stripped = []
for q in wire_questions:
q2 = dict(q)
q2["options"] = [
{k: v for k, v in o.items() if k != "correct"}
for o in q.get("options", [])
]
stripped.append(q2)
return stripped


def _resolved_difficulty(wire_questions: list[dict]) -> str:
"""The overall difficulty generation actually produced (#540 A1).

Expand DownExpand Up@@ -441,19 +456,153 @@ async def generate_quiz(body: GenerateQuizBody, request: Request):
"difficulty": body.difficulty,
},
)
# #541 C3: the answer key (per-option `correct` booleans) ships to the
# client only behind the deprecated include_answer_key flag — default
# true for the current QuizPanel, removed with #546 once the #537
# client grades via /attempts/{id}/answer. Log every keyed response so
# zero-usage is observable before the default flips.
if body.include_answer_key:
logger.info(
"quiz: generate served the client-side answer key "
"(include_answer_key=true, deprecated — #546) quiz_id=%s", quiz_id,
)
response_questions = questions
else:
response_questions = _strip_answer_key(questions)

# #540 A1: echo what generation actually chose. requested_difficulty
# is what the student asked for (may be 'adaptive');
# resolved_difficulty is the overall mix the agent produced (always
# concrete) — so the client can say "we picked hard for you" instead
# of repeating the request back.
return {
"quiz_id": quiz_id,
"questions": questions,
"questions": response_questions,
"requested_difficulty": body.difficulty,
"resolved_difficulty": _resolved_difficulty(questions),
}


@router.post("/attempts/{attempt_id}/answer")
def answer_question(attempt_id: str, body: AnswerQuestionBody, request: Request):
"""#541 C1: grade one question server-side and record the response.

Idempotent on (attempt_id, question_index): re-answering returns the
FIRST recorded response (`recorded: false` marks the replay) rather
than overwriting — no revision, decided for the #537 revamp flow.
"""
attempt_rows = table("quiz_attempts").select(
"*", filters={"id": f"eq.{attempt_id}"}
)
if not attempt_rows:
raise QuizAPIError(
status_code=404,
code=QuizErrorCode.QUIZ_ATTEMPT_NOT_FOUND,
message="We couldn't find that quiz.",
)
attempt = attempt_rows[0]
require_self(attempt["user_id"], request)

if attempt.get("completed_at"):
raise QuizAPIError(
status_code=409,
code=QuizErrorCode.QUIZ_ATTEMPT_ALREADY_COMPLETED,
message="This quiz has already been submitted.",
)

questions = decrypt_json_column(attempt["questions_json"]) or []
if body.question_index >= len(questions):
raise QuizAPIError(
status_code=400,
code=QuizErrorCode.QUIZ_QUESTION_INVALID,
message="That question isn't part of this quiz.",
)
question = questions[body.question_index]
options = question.get("options", [])
if body.selected_index >= len(options):
raise QuizAPIError(
status_code=400,
code=QuizErrorCode.QUIZ_QUESTION_INVALID,
message="That answer choice isn't part of this question.",
)
# Wire ids are 1-based, question_index is 0-based. When the client sends
# both, they must agree — otherwise passing the displayed id as the index
# silently grades the NEXT question and idempotency locks that in.
if body.question_id is not None and body.question_id != question.get("id"):
raise QuizAPIError(
status_code=400,
code=QuizErrorCode.QUIZ_QUESTION_INVALID,
message="That answer doesn't match the question it was sent for.",
)

# The correct option is a property of the question, not of the answer —
# resolve it once. -1 means a malformed item with no correct option,
# which must never grade correct (same rule as submit's #129 fix).
correct_index = next(
(i for i, o in enumerate(options) if o.get("correct")), -1
)

def _is_correct(selected_index: int) -> bool:
return correct_index >= 0 and correct_index == selected_index

recorded = True
response_row = None
existing = table("quiz_responses").select(
"*",
filters={
"attempt_id": f"eq.{attempt_id}",
"question_index": f"eq.{body.question_index}",
},
)
if existing:
recorded = False
response_row = existing[0]
else:
row = {
"attempt_id": attempt_id,
"question_index": body.question_index,
"selected_index": body.selected_index,
"is_correct": _is_correct(body.selected_index),
"time_ms": body.time_ms,
"confidence": body.confidence,
}
try:
table("quiz_responses").insert(row)
response_row = row
except Exception:
# Lost a race with a concurrent answer for the same index — the
# UNIQUE arbitrates; return whatever won.
recorded = False
raced = table("quiz_responses").select(
"*",
filters={
"attempt_id": f"eq.{attempt_id}",
"question_index": f"eq.{body.question_index}",
},
)
if not raced:
raise
response_row = raced[0]

next_index = body.question_index + 1
next_question = (
_strip_answer_key([questions[next_index]])[0]
if next_index < len(questions)
else None
)
return {
# Echo both addressing schemes so a client that mixed them up sees
# it immediately rather than discovering it at submit time.
"question_index": body.question_index,
"question_id": question.get("id"),
"is_correct": _is_correct(response_row["selected_index"]),
"correct_index": correct_index,
"explanation": question.get("explanation", ""),
"next_question": next_question,
"recorded": recorded,
}


@router.post("/submit")
def submit_quiz(body: SubmitQuizBody, background_tasks: BackgroundTasks, request: Request):
attempt_rows = table("quiz_attempts").select("*", filters={"id": f"eq.{body.quiz_id}"})
Expand DownExpand Up@@ -503,12 +652,45 @@ def submit_quiz(body: SubmitQuizBody, background_tasks: BackgroundTasks, request

concept_node_id = attempt["concept_node_id"]

# #541 C4: responses recorded through /attempts/{id}/answer are the
# source of truth — a payload answer for the same question is ignored
# (the recorded response was graded at answer time; letting the final
# POST override it would reopen the client-side-grading hole C exists
# to close). Questions never answered through C1 fall back to the
# submitted payload, so the current all-at-the-end client keeps working.
recorded_rows = table("quiz_responses").select(
"question_index,selected_index",
filters={"attempt_id": f"eq.{body.quiz_id}"},
) or []
recorded_by_index = {r["question_index"]: r for r in recorded_rows}

answer_map = {str(a.question_id): a.selected_label for a in body.answers}
results = []
# The reconciled answer set — what was ACTUALLY graded, which is what
# answers_json must persist. Storing the raw payload instead left a
# recorded-only submit with a full score beside an empty answer list,
# and a contradicted payload answer stored despite losing to the
# recorded response.
graded_answers: list[dict] = []
score = 0
for q in questions:
for q_index, q in enumerate(questions):
qid = str(q["id"])
selected = answer_map.get(qid, "")
recorded = recorded_by_index.get(q_index)
if recorded is not None:
sel_idx = recorded.get("selected_index")
options = q.get("options", [])
selected = (
options[sel_idx]["label"]
if isinstance(sel_idx, int) and 0 <= sel_idx < len(options)
else ""
)
else:
selected = answer_map.get(qid, "")
if selected:
graded_answers.append({
"question_id": q["id"],
"selected_label": selected,
})
correct_opt = next((o for o in q["options"] if o.get("correct")), None)
correct_label = correct_opt["label"] if correct_opt else ""
# #129: a malformed item with NO correct option must never grade as
Expand DownExpand Up@@ -580,7 +762,10 @@ def submit_quiz(body: SubmitQuizBody, background_tasks: BackgroundTasks, request
{
"score": score,
"total": total,
"answers_json": encrypt_json([a.model_dump() for a in body.answers]),
# The reconciled set (recorded responses winning over payload),
# not the raw request — the attempt's stored answers must agree
# with the score computed from them.
"answers_json": encrypt_json(graded_answers),
# completed_at was already stamped by the atomic claim above.
},
filters={"id": f"eq.{body.quiz_id}"},
Expand Down
3 changes: 3 additions & 0 deletions backend/services/quiz_errors.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -34,6 +34,9 @@ class QuizErrorCode(str, Enum):
QUIZ_CONCEPT_NOT_FOUND = "QUIZ_CONCEPT_NOT_FOUND"
QUIZ_ATTEMPT_NOT_FOUND = "QUIZ_ATTEMPT_NOT_FOUND"
QUIZ_ATTEMPT_ALREADY_COMPLETED = "QUIZ_ATTEMPT_ALREADY_COMPLETED"
# #541 C1: the answer endpoint got an index that doesn't exist on this
# attempt (question_index past the quiz, selected_index past the options).
QUIZ_QUESTION_INVALID = "QUIZ_QUESTION_INVALID"
QUIZ_NOT_AUTHORIZED = "QUIZ_NOT_AUTHORIZED"
QUIZ_GENERATION_FAILED = "QUIZ_GENERATION_FAILED"
QUIZ_INTERNAL_ERROR = "QUIZ_INTERNAL_ERROR"
Expand Down
74 changes: 74 additions & 0 deletions backend/tests/integration/test_quiz_responses_db.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
"""#541 C2 real-DB half: quiz_responses storage against local Supabase.

Proves the migration's shape actually holds in Postgres — the UNIQUE
arbitrates duplicate answers, the FK cascades with the attempt — the
exact class of constraint behavior MagicMock suites cannot see (#529's
lesson). #397 seam: writes through the app layer, raw reads via psycopg.
"""
import uuid

import pytest

pytestmark = pytest.mark.integration

USER = "rich-user-active"


def _make_attempt(db_conn) -> str:
from db.connection import table

node = db_conn.execute(
"SELECT id FROM graph_nodes WHERE user_id = %s ORDER BY id LIMIT 1",
(USER,),
).fetchone()
assert node is not None
attempt_id = str(uuid.uuid4())
table("quiz_attempts").insert({
"id": attempt_id,
"user_id": USER,
"concept_node_id": node["id"],
"difficulty": "adaptive", # also exercises the #540 CHECK widening
"questions_json": [],
})
return attempt_id


def test_unique_arbitrates_duplicate_answers(db_conn):
from db.connection import table

attempt_id = _make_attempt(db_conn)
table("quiz_responses").insert({
"attempt_id": attempt_id, "question_index": 0,
"selected_index": 1, "is_correct": True, "time_ms": 1200,
})
with pytest.raises(Exception):
table("quiz_responses").insert({
"attempt_id": attempt_id, "question_index": 0,
"selected_index": 0, "is_correct": False,
})

rows = db_conn.execute(
"SELECT selected_index, is_correct, time_ms FROM quiz_responses "
"WHERE attempt_id = %s",
(attempt_id,),
).fetchall()
assert len(rows) == 1
assert rows[0]["selected_index"] == 1 # the first write won
assert rows[0]["is_correct"] is True
assert rows[0]["time_ms"] == 1200


def test_responses_cascade_with_their_attempt(db_conn):
from db.connection import table

attempt_id = _make_attempt(db_conn)
table("quiz_responses").insert({
"attempt_id": attempt_id, "question_index": 0,
"selected_index": 0, "is_correct": False,
})
db_conn.execute("DELETE FROM quiz_attempts WHERE id = %s", (attempt_id,))
left = db_conn.execute(
"SELECT count(*) AS n FROM quiz_responses WHERE attempt_id = %s",
(attempt_id,),
).fetchone()
assert left["n"] == 0
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } 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
30 changes: 30 additions & 0 deletions backend/db/migrations/20260812214402_quiz_responses.sql
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
-- #541 C2: per-question quiz responses. This is the table that makes item
-- statistics, timing analysis, and misconception mining possible — one row
-- per answered question, written by POST /api/quiz/attempts/{id}/answer.
--
-- Everything here is a plaintext analytics scalar (same rationale as
-- quiz_attempts.score/total in #521): indexes and booleans carry no student
-- free text. The question/option TEXT lives encrypted in
-- quiz_attempts.questions_json; this table references it only by position.
--
-- The UNIQUE is the C1 idempotency contract: one response per
-- (attempt, question); re-answering returns the first recorded response
-- (no revision — the #537 revamp decides if that changes).

CREATE TABLE quiz_responses (
id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
attempt_id TEXT NOT NULL REFERENCES quiz_attempts(id) ON DELETE CASCADE,
question_index INTEGER NOT NULL CHECK (question_index >= 0),
selected_index INTEGER NOT NULL CHECK (selected_index >= 0),
is_correct BOOLEAN NOT NULL,
time_ms INTEGER CHECK (time_ms >= 0),
confidence REAL CHECK (confidence >= 0.0 AND confidence <= 1.0),
answered_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT quiz_responses_attempt_question_key UNIQUE (attempt_id, question_index)
);

-- No separate attempt_id index: Postgres backs the UNIQUE above with a btree
-- whose LEADING column is attempt_id, which already serves both access
-- patterns (submit's per-attempt scan and the answer endpoint's
-- (attempt_id, question_index) lookup). A standalone index would just add a
-- second write to the per-answer hot path.
25 changes: 25 additions & 0 deletions backend/models/__init__.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -60,6 +60,31 @@ class GenerateQuizBody(BaseModel):
# through to whatever SAPLING_MODEL_QUIZ resolves to (default
# gemini-2.5-flash-lite per ADR 0008).
model_pref: Optional[Literal["fast", "smart"]] = None
# DEPRECATED (#541 C3, removal tracked in #546): when true (the default
# the current QuizPanel needs), the response's per-option dicts carry
# `correct` booleans — the full answer key, client-side. Removing the
# key is a hard requirement of the #537 revamp: the new client grades
# through POST /api/quiz/attempts/{id}/answer instead. Every keyed
# response is logged so we can see when usage reaches zero.
include_answer_key: bool = True


class AnswerQuestionBody(BaseModel):
"""One answer for POST /api/quiz/attempts/{attempt_id}/answer (#541 C1).

Indexes are 0-based positions into the attempt's stored questions and
the question's options. The wire questions carry 1-based `id`s (what
/submit keys on), so `question_index = id - 1` — two addressing schemes
for one question. `question_id` is the guard against confusing them:
send the id you displayed and the route rejects a mismatch instead of
silently grading the neighbouring question (which the idempotency rule
would then lock in). The response echoes both either way."""

question_index: int = Field(ge=0)
selected_index: int = Field(ge=0)
question_id: Optional[int] = None
time_ms: Optional[int] = Field(default=None, ge=0)
confidence: Optional[float] = Field(default=None, ge=0.0, le=1.0)


class AnswerItem(BaseModel):
Expand Down
195 changes: 190 additions & 5 deletions backend/routes/quiz.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -18,7 +18,7 @@
from agents.quiz_context import quiz_context_agent
from agents.usage import record_agent_usage
from db.connection import table
from models import GenerateQuizBody, SubmitQuizBody
from models import AnswerQuestionBody, GenerateQuizBody, SubmitQuizBody
from routes.learn import _get_catalog_chunk
from services import events_service
from services.auth_guard import require_self
Expand DownExpand Up@@ -78,6 +78,21 @@ def _load_prompt(name: str) -> str:
_DIFFICULTY_RANK = {d: i for i, d in enumerate(CONCRETE_DIFFICULTIES)}


def _strip_answer_key(wire_questions: list[dict]) -> list[dict]:
"""Deep-enough copy of the wire questions without per-option `correct`
booleans (#541 C3). Storage always keeps the key — grading is
server-side; only the RESPONSE is stripped."""
stripped = []
for q in wire_questions:
q2 = dict(q)
q2["options"] = [
{k: v for k, v in o.items() if k != "correct"}
for o in q.get("options", [])
]
stripped.append(q2)
return stripped


def _resolved_difficulty(wire_questions: list[dict]) -> str:
"""The overall difficulty generation actually produced (#540 A1).

Expand DownExpand Up@@ -441,19 +456,153 @@ async def generate_quiz(body: GenerateQuizBody, request: Request):
"difficulty": body.difficulty,
},
)
# #541 C3: the answer key (per-option `correct` booleans) ships to the
# client only behind the deprecated include_answer_key flag — default
# true for the current QuizPanel, removed with #546 once the #537
# client grades via /attempts/{id}/answer. Log every keyed response so
# zero-usage is observable before the default flips.
if body.include_answer_key:
logger.info(
"quiz: generate served the client-side answer key "
"(include_answer_key=true, deprecated — #546) quiz_id=%s", quiz_id,
)
response_questions = questions
else:
response_questions = _strip_answer_key(questions)

# #540 A1: echo what generation actually chose. requested_difficulty
# is what the student asked for (may be 'adaptive');
# resolved_difficulty is the overall mix the agent produced (always
# concrete) — so the client can say "we picked hard for you" instead
# of repeating the request back.
return {
"quiz_id": quiz_id,
"questions": questions,
"questions": response_questions,
"requested_difficulty": body.difficulty,
"resolved_difficulty": _resolved_difficulty(questions),
}


@router.post("/attempts/{attempt_id}/answer")
def answer_question(attempt_id: str, body: AnswerQuestionBody, request: Request):
"""#541 C1: grade one question server-side and record the response.

Idempotent on (attempt_id, question_index): re-answering returns the
FIRST recorded response (`recorded: false` marks the replay) rather
than overwriting — no revision, decided for the #537 revamp flow.
"""
attempt_rows = table("quiz_attempts").select(
"*", filters={"id": f"eq.{attempt_id}"}
)
if not attempt_rows:
raise QuizAPIError(
status_code=404,
code=QuizErrorCode.QUIZ_ATTEMPT_NOT_FOUND,
message="We couldn't find that quiz.",
)
attempt = attempt_rows[0]
require_self(attempt["user_id"], request)

if attempt.get("completed_at"):
raise QuizAPIError(
status_code=409,
code=QuizErrorCode.QUIZ_ATTEMPT_ALREADY_COMPLETED,
message="This quiz has already been submitted.",
)

questions = decrypt_json_column(attempt["questions_json"]) or []
if body.question_index >= len(questions):
raise QuizAPIError(
status_code=400,
code=QuizErrorCode.QUIZ_QUESTION_INVALID,
message="That question isn't part of this quiz.",
)
question = questions[body.question_index]
options = question.get("options", [])
if body.selected_index >= len(options):
raise QuizAPIError(
status_code=400,
code=QuizErrorCode.QUIZ_QUESTION_INVALID,
message="That answer choice isn't part of this question.",
)
# Wire ids are 1-based, question_index is 0-based. When the client sends
# both, they must agree — otherwise passing the displayed id as the index
# silently grades the NEXT question and idempotency locks that in.
if body.question_id is not None and body.question_id != question.get("id"):
raise QuizAPIError(
status_code=400,
code=QuizErrorCode.QUIZ_QUESTION_INVALID,
message="That answer doesn't match the question it was sent for.",
)

# The correct option is a property of the question, not of the answer —
# resolve it once. -1 means a malformed item with no correct option,
# which must never grade correct (same rule as submit's #129 fix).
correct_index = next(
(i for i, o in enumerate(options) if o.get("correct")), -1
)

def _is_correct(selected_index: int) -> bool:
return correct_index >= 0 and correct_index == selected_index

recorded = True
response_row = None
existing = table("quiz_responses").select(
"*",
filters={
"attempt_id": f"eq.{attempt_id}",
"question_index": f"eq.{body.question_index}",
},
)
if existing:
recorded = False
response_row = existing[0]
else:
row = {
"attempt_id": attempt_id,
"question_index": body.question_index,
"selected_index": body.selected_index,
"is_correct": _is_correct(body.selected_index),
"time_ms": body.time_ms,
"confidence": body.confidence,
}
try:
table("quiz_responses").insert(row)
response_row = row
except Exception:
# Lost a race with a concurrent answer for the same index — the
# UNIQUE arbitrates; return whatever won.
recorded = False
raced = table("quiz_responses").select(
"*",
filters={
"attempt_id": f"eq.{attempt_id}",
"question_index": f"eq.{body.question_index}",
},
)
if not raced:
raise
response_row = raced[0]

next_index = body.question_index + 1
next_question = (
_strip_answer_key([questions[next_index]])[0]
if next_index < len(questions)
else None
)
return {
# Echo both addressing schemes so a client that mixed them up sees
# it immediately rather than discovering it at submit time.
"question_index": body.question_index,
"question_id": question.get("id"),
"is_correct": _is_correct(response_row["selected_index"]),
"correct_index": correct_index,
"explanation": question.get("explanation", ""),
"next_question": next_question,
"recorded": recorded,
}


@router.post("/submit")
def submit_quiz(body: SubmitQuizBody, background_tasks: BackgroundTasks, request: Request):
attempt_rows = table("quiz_attempts").select("*", filters={"id": f"eq.{body.quiz_id}"})
Expand DownExpand Up@@ -503,12 +652,45 @@ def submit_quiz(body: SubmitQuizBody, background_tasks: BackgroundTasks, request

concept_node_id = attempt["concept_node_id"]

# #541 C4: responses recorded through /attempts/{id}/answer are the
# source of truth — a payload answer for the same question is ignored
# (the recorded response was graded at answer time; letting the final
# POST override it would reopen the client-side-grading hole C exists
# to close). Questions never answered through C1 fall back to the
# submitted payload, so the current all-at-the-end client keeps working.
recorded_rows = table("quiz_responses").select(
"question_index,selected_index",
filters={"attempt_id": f"eq.{body.quiz_id}"},
) or []
recorded_by_index = {r["question_index"]: r for r in recorded_rows}

answer_map = {str(a.question_id): a.selected_label for a in body.answers}
results = []
# The reconciled answer set — what was ACTUALLY graded, which is what
# answers_json must persist. Storing the raw payload instead left a
# recorded-only submit with a full score beside an empty answer list,
# and a contradicted payload answer stored despite losing to the
# recorded response.
graded_answers: list[dict] = []
score = 0
for q in questions:
for q_index, q in enumerate(questions):
qid = str(q["id"])
selected = answer_map.get(qid, "")
recorded = recorded_by_index.get(q_index)
if recorded is not None:
sel_idx = recorded.get("selected_index")
options = q.get("options", [])
selected = (
options[sel_idx]["label"]
if isinstance(sel_idx, int) and 0 <= sel_idx < len(options)
else ""
)
else:
selected = answer_map.get(qid, "")
if selected:
graded_answers.append({
"question_id": q["id"],
"selected_label": selected,
})
correct_opt = next((o for o in q["options"] if o.get("correct")), None)
correct_label = correct_opt["label"] if correct_opt else ""
# #129: a malformed item with NO correct option must never grade as
Expand DownExpand Up@@ -580,7 +762,10 @@ def submit_quiz(body: SubmitQuizBody, background_tasks: BackgroundTasks, request
{
"score": score,
"total": total,
"answers_json": encrypt_json([a.model_dump() for a in body.answers]),
# The reconciled set (recorded responses winning over payload),
# not the raw request — the attempt's stored answers must agree
# with the score computed from them.
"answers_json": encrypt_json(graded_answers),
# completed_at was already stamped by the atomic claim above.
},
filters={"id": f"eq.{body.quiz_id}"},
Expand Down
3 changes: 3 additions & 0 deletions backend/services/quiz_errors.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -34,6 +34,9 @@ class QuizErrorCode(str, Enum):
QUIZ_CONCEPT_NOT_FOUND = "QUIZ_CONCEPT_NOT_FOUND"
QUIZ_ATTEMPT_NOT_FOUND = "QUIZ_ATTEMPT_NOT_FOUND"
QUIZ_ATTEMPT_ALREADY_COMPLETED = "QUIZ_ATTEMPT_ALREADY_COMPLETED"
# #541 C1: the answer endpoint got an index that doesn't exist on this
# attempt (question_index past the quiz, selected_index past the options).
QUIZ_QUESTION_INVALID = "QUIZ_QUESTION_INVALID"
QUIZ_NOT_AUTHORIZED = "QUIZ_NOT_AUTHORIZED"
QUIZ_GENERATION_FAILED = "QUIZ_GENERATION_FAILED"
QUIZ_INTERNAL_ERROR = "QUIZ_INTERNAL_ERROR"
Expand Down
74 changes: 74 additions & 0 deletions backend/tests/integration/test_quiz_responses_db.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
"""#541 C2 real-DB half: quiz_responses storage against local Supabase.

Proves the migration's shape actually holds in Postgres — the UNIQUE
arbitrates duplicate answers, the FK cascades with the attempt — the
exact class of constraint behavior MagicMock suites cannot see (#529's
lesson). #397 seam: writes through the app layer, raw reads via psycopg.
"""
import uuid

import pytest

pytestmark = pytest.mark.integration

USER = "rich-user-active"


def _make_attempt(db_conn) -> str:
from db.connection import table

node = db_conn.execute(
"SELECT id FROM graph_nodes WHERE user_id = %s ORDER BY id LIMIT 1",
(USER,),
).fetchone()
assert node is not None
attempt_id = str(uuid.uuid4())
table("quiz_attempts").insert({
"id": attempt_id,
"user_id": USER,
"concept_node_id": node["id"],
"difficulty": "adaptive", # also exercises the #540 CHECK widening
"questions_json": [],
})
return attempt_id


def test_unique_arbitrates_duplicate_answers(db_conn):
from db.connection import table

attempt_id = _make_attempt(db_conn)
table("quiz_responses").insert({
"attempt_id": attempt_id, "question_index": 0,
"selected_index": 1, "is_correct": True, "time_ms": 1200,
})
with pytest.raises(Exception):
table("quiz_responses").insert({
"attempt_id": attempt_id, "question_index": 0,
"selected_index": 0, "is_correct": False,
})

rows = db_conn.execute(
"SELECT selected_index, is_correct, time_ms FROM quiz_responses "
"WHERE attempt_id = %s",
(attempt_id,),
).fetchall()
assert len(rows) == 1
assert rows[0]["selected_index"] == 1 # the first write won
assert rows[0]["is_correct"] is True
assert rows[0]["time_ms"] == 1200


def test_responses_cascade_with_their_attempt(db_conn):
from db.connection import table

attempt_id = _make_attempt(db_conn)
table("quiz_responses").insert({
"attempt_id": attempt_id, "question_index": 0,
"selected_index": 0, "is_correct": False,
})
db_conn.execute("DELETE FROM quiz_attempts WHERE id = %s", (attempt_id,))
left = db_conn.execute(
"SELECT count(*) AS n FROM quiz_responses WHERE attempt_id = %s",
(attempt_id,),
).fetchone()
assert left["n"] == 0
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } 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
30 changes: 30 additions & 0 deletions backend/db/migrations/20260812214402_quiz_responses.sql
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
-- #541 C2: per-question quiz responses. This is the table that makes item
-- statistics, timing analysis, and misconception mining possible — one row
-- per answered question, written by POST /api/quiz/attempts/{id}/answer.
--
-- Everything here is a plaintext analytics scalar (same rationale as
-- quiz_attempts.score/total in #521): indexes and booleans carry no student
-- free text. The question/option TEXT lives encrypted in
-- quiz_attempts.questions_json; this table references it only by position.
--
-- The UNIQUE is the C1 idempotency contract: one response per
-- (attempt, question); re-answering returns the first recorded response
-- (no revision — the #537 revamp decides if that changes).

CREATE TABLE quiz_responses (
id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
attempt_id TEXT NOT NULL REFERENCES quiz_attempts(id) ON DELETE CASCADE,
question_index INTEGER NOT NULL CHECK (question_index >= 0),
selected_index INTEGER NOT NULL CHECK (selected_index >= 0),
is_correct BOOLEAN NOT NULL,
time_ms INTEGER CHECK (time_ms >= 0),
confidence REAL CHECK (confidence >= 0.0 AND confidence <= 1.0),
answered_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT quiz_responses_attempt_question_key UNIQUE (attempt_id, question_index)
);

-- No separate attempt_id index: Postgres backs the UNIQUE above with a btree
-- whose LEADING column is attempt_id, which already serves both access
-- patterns (submit's per-attempt scan and the answer endpoint's
-- (attempt_id, question_index) lookup). A standalone index would just add a
-- second write to the per-answer hot path.
25 changes: 25 additions & 0 deletions backend/models/__init__.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -60,6 +60,31 @@ class GenerateQuizBody(BaseModel):
# through to whatever SAPLING_MODEL_QUIZ resolves to (default
# gemini-2.5-flash-lite per ADR 0008).
model_pref: Optional[Literal["fast", "smart"]] = None
# DEPRECATED (#541 C3, removal tracked in #546): when true (the default
# the current QuizPanel needs), the response's per-option dicts carry
# `correct` booleans — the full answer key, client-side. Removing the
# key is a hard requirement of the #537 revamp: the new client grades
# through POST /api/quiz/attempts/{id}/answer instead. Every keyed
# response is logged so we can see when usage reaches zero.
include_answer_key: bool = True


class AnswerQuestionBody(BaseModel):
"""One answer for POST /api/quiz/attempts/{attempt_id}/answer (#541 C1).

Indexes are 0-based positions into the attempt's stored questions and
the question's options. The wire questions carry 1-based `id`s (what
/submit keys on), so `question_index = id - 1` — two addressing schemes
for one question. `question_id` is the guard against confusing them:
send the id you displayed and the route rejects a mismatch instead of
silently grading the neighbouring question (which the idempotency rule
would then lock in). The response echoes both either way."""

question_index: int = Field(ge=0)
selected_index: int = Field(ge=0)
question_id: Optional[int] = None
time_ms: Optional[int] = Field(default=None, ge=0)
confidence: Optional[float] = Field(default=None, ge=0.0, le=1.0)


class AnswerItem(BaseModel):
Expand Down
195 changes: 190 additions & 5 deletions backend/routes/quiz.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -18,7 +18,7 @@
from agents.quiz_context import quiz_context_agent
from agents.usage import record_agent_usage
from db.connection import table
from models import GenerateQuizBody, SubmitQuizBody
from models import AnswerQuestionBody, GenerateQuizBody, SubmitQuizBody
from routes.learn import _get_catalog_chunk
from services import events_service
from services.auth_guard import require_self
Expand DownExpand Up@@ -78,6 +78,21 @@ def _load_prompt(name: str) -> str:
_DIFFICULTY_RANK = {d: i for i, d in enumerate(CONCRETE_DIFFICULTIES)}


def _strip_answer_key(wire_questions: list[dict]) -> list[dict]:
"""Deep-enough copy of the wire questions without per-option `correct`
booleans (#541 C3). Storage always keeps the key — grading is
server-side; only the RESPONSE is stripped."""
stripped = []
for q in wire_questions:
q2 = dict(q)
q2["options"] = [
{k: v for k, v in o.items() if k != "correct"}
for o in q.get("options", [])
]
stripped.append(q2)
return stripped


def _resolved_difficulty(wire_questions: list[dict]) -> str:
"""The overall difficulty generation actually produced (#540 A1).

Expand DownExpand Up@@ -441,19 +456,153 @@ async def generate_quiz(body: GenerateQuizBody, request: Request):
"difficulty": body.difficulty,
},
)
# #541 C3: the answer key (per-option `correct` booleans) ships to the
# client only behind the deprecated include_answer_key flag — default
# true for the current QuizPanel, removed with #546 once the #537
# client grades via /attempts/{id}/answer. Log every keyed response so
# zero-usage is observable before the default flips.
if body.include_answer_key:
logger.info(
"quiz: generate served the client-side answer key "
"(include_answer_key=true, deprecated — #546) quiz_id=%s", quiz_id,
)
response_questions = questions
else:
response_questions = _strip_answer_key(questions)

# #540 A1: echo what generation actually chose. requested_difficulty
# is what the student asked for (may be 'adaptive');
# resolved_difficulty is the overall mix the agent produced (always
# concrete) — so the client can say "we picked hard for you" instead
# of repeating the request back.
return {
"quiz_id": quiz_id,
"questions": questions,
"questions": response_questions,
"requested_difficulty": body.difficulty,
"resolved_difficulty": _resolved_difficulty(questions),
}


@router.post("/attempts/{attempt_id}/answer")
def answer_question(attempt_id: str, body: AnswerQuestionBody, request: Request):
"""#541 C1: grade one question server-side and record the response.

Idempotent on (attempt_id, question_index): re-answering returns the
FIRST recorded response (`recorded: false` marks the replay) rather
than overwriting — no revision, decided for the #537 revamp flow.
"""
attempt_rows = table("quiz_attempts").select(
"*", filters={"id": f"eq.{attempt_id}"}
)
if not attempt_rows:
raise QuizAPIError(
status_code=404,
code=QuizErrorCode.QUIZ_ATTEMPT_NOT_FOUND,
message="We couldn't find that quiz.",
)
attempt = attempt_rows[0]
require_self(attempt["user_id"], request)

if attempt.get("completed_at"):
raise QuizAPIError(
status_code=409,
code=QuizErrorCode.QUIZ_ATTEMPT_ALREADY_COMPLETED,
message="This quiz has already been submitted.",
)

questions = decrypt_json_column(attempt["questions_json"]) or []
if body.question_index >= len(questions):
raise QuizAPIError(
status_code=400,
code=QuizErrorCode.QUIZ_QUESTION_INVALID,
message="That question isn't part of this quiz.",
)
question = questions[body.question_index]
options = question.get("options", [])
if body.selected_index >= len(options):
raise QuizAPIError(
status_code=400,
code=QuizErrorCode.QUIZ_QUESTION_INVALID,
message="That answer choice isn't part of this question.",
)
# Wire ids are 1-based, question_index is 0-based. When the client sends
# both, they must agree — otherwise passing the displayed id as the index
# silently grades the NEXT question and idempotency locks that in.
if body.question_id is not None and body.question_id != question.get("id"):
raise QuizAPIError(
status_code=400,
code=QuizErrorCode.QUIZ_QUESTION_INVALID,
message="That answer doesn't match the question it was sent for.",
)

# The correct option is a property of the question, not of the answer —
# resolve it once. -1 means a malformed item with no correct option,
# which must never grade correct (same rule as submit's #129 fix).
correct_index = next(
(i for i, o in enumerate(options) if o.get("correct")), -1
)

def _is_correct(selected_index: int) -> bool:
return correct_index >= 0 and correct_index == selected_index

recorded = True
response_row = None
existing = table("quiz_responses").select(
"*",
filters={
"attempt_id": f"eq.{attempt_id}",
"question_index": f"eq.{body.question_index}",
},
)
if existing:
recorded = False
response_row = existing[0]
else:
row = {
"attempt_id": attempt_id,
"question_index": body.question_index,
"selected_index": body.selected_index,
"is_correct": _is_correct(body.selected_index),
"time_ms": body.time_ms,
"confidence": body.confidence,
}
try:
table("quiz_responses").insert(row)
response_row = row
except Exception:
# Lost a race with a concurrent answer for the same index — the
# UNIQUE arbitrates; return whatever won.
recorded = False
raced = table("quiz_responses").select(
"*",
filters={
"attempt_id": f"eq.{attempt_id}",
"question_index": f"eq.{body.question_index}",
},
)
if not raced:
raise
response_row = raced[0]

next_index = body.question_index + 1
next_question = (
_strip_answer_key([questions[next_index]])[0]
if next_index < len(questions)
else None
)
return {
# Echo both addressing schemes so a client that mixed them up sees
# it immediately rather than discovering it at submit time.
"question_index": body.question_index,
"question_id": question.get("id"),
"is_correct": _is_correct(response_row["selected_index"]),
"correct_index": correct_index,
"explanation": question.get("explanation", ""),
"next_question": next_question,
"recorded": recorded,
}


@router.post("/submit")
def submit_quiz(body: SubmitQuizBody, background_tasks: BackgroundTasks, request: Request):
attempt_rows = table("quiz_attempts").select("*", filters={"id": f"eq.{body.quiz_id}"})
Expand DownExpand Up@@ -503,12 +652,45 @@ def submit_quiz(body: SubmitQuizBody, background_tasks: BackgroundTasks, request

concept_node_id = attempt["concept_node_id"]

# #541 C4: responses recorded through /attempts/{id}/answer are the
# source of truth — a payload answer for the same question is ignored
# (the recorded response was graded at answer time; letting the final
# POST override it would reopen the client-side-grading hole C exists
# to close). Questions never answered through C1 fall back to the
# submitted payload, so the current all-at-the-end client keeps working.
recorded_rows = table("quiz_responses").select(
"question_index,selected_index",
filters={"attempt_id": f"eq.{body.quiz_id}"},
) or []
recorded_by_index = {r["question_index"]: r for r in recorded_rows}

answer_map = {str(a.question_id): a.selected_label for a in body.answers}
results = []
# The reconciled answer set — what was ACTUALLY graded, which is what
# answers_json must persist. Storing the raw payload instead left a
# recorded-only submit with a full score beside an empty answer list,
# and a contradicted payload answer stored despite losing to the
# recorded response.
graded_answers: list[dict] = []
score = 0
for q in questions:
for q_index, q in enumerate(questions):
qid = str(q["id"])
selected = answer_map.get(qid, "")
recorded = recorded_by_index.get(q_index)
if recorded is not None:
sel_idx = recorded.get("selected_index")
options = q.get("options", [])
selected = (
options[sel_idx]["label"]
if isinstance(sel_idx, int) and 0 <= sel_idx < len(options)
else ""
)
else:
selected = answer_map.get(qid, "")
if selected:
graded_answers.append({
"question_id": q["id"],
"selected_label": selected,
})
correct_opt = next((o for o in q["options"] if o.get("correct")), None)
correct_label = correct_opt["label"] if correct_opt else ""
# #129: a malformed item with NO correct option must never grade as
Expand DownExpand Up@@ -580,7 +762,10 @@ def submit_quiz(body: SubmitQuizBody, background_tasks: BackgroundTasks, request
{
"score": score,
"total": total,
"answers_json": encrypt_json([a.model_dump() for a in body.answers]),
# The reconciled set (recorded responses winning over payload),
# not the raw request — the attempt's stored answers must agree
# with the score computed from them.
"answers_json": encrypt_json(graded_answers),
# completed_at was already stamped by the atomic claim above.
},
filters={"id": f"eq.{body.quiz_id}"},
Expand Down
3 changes: 3 additions & 0 deletions backend/services/quiz_errors.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -34,6 +34,9 @@ class QuizErrorCode(str, Enum):
QUIZ_CONCEPT_NOT_FOUND = "QUIZ_CONCEPT_NOT_FOUND"
QUIZ_ATTEMPT_NOT_FOUND = "QUIZ_ATTEMPT_NOT_FOUND"
QUIZ_ATTEMPT_ALREADY_COMPLETED = "QUIZ_ATTEMPT_ALREADY_COMPLETED"
# #541 C1: the answer endpoint got an index that doesn't exist on this
# attempt (question_index past the quiz, selected_index past the options).
QUIZ_QUESTION_INVALID = "QUIZ_QUESTION_INVALID"
QUIZ_NOT_AUTHORIZED = "QUIZ_NOT_AUTHORIZED"
QUIZ_GENERATION_FAILED = "QUIZ_GENERATION_FAILED"
QUIZ_INTERNAL_ERROR = "QUIZ_INTERNAL_ERROR"
Expand Down
74 changes: 74 additions & 0 deletions backend/tests/integration/test_quiz_responses_db.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
"""#541 C2 real-DB half: quiz_responses storage against local Supabase.

Proves the migration's shape actually holds in Postgres — the UNIQUE
arbitrates duplicate answers, the FK cascades with the attempt — the
exact class of constraint behavior MagicMock suites cannot see (#529's
lesson). #397 seam: writes through the app layer, raw reads via psycopg.
"""
import uuid

import pytest

pytestmark = pytest.mark.integration

USER = "rich-user-active"


def _make_attempt(db_conn) -> str:
from db.connection import table

node = db_conn.execute(
"SELECT id FROM graph_nodes WHERE user_id = %s ORDER BY id LIMIT 1",
(USER,),
).fetchone()
assert node is not None
attempt_id = str(uuid.uuid4())
table("quiz_attempts").insert({
"id": attempt_id,
"user_id": USER,
"concept_node_id": node["id"],
"difficulty": "adaptive", # also exercises the #540 CHECK widening
"questions_json": [],
})
return attempt_id


def test_unique_arbitrates_duplicate_answers(db_conn):
from db.connection import table

attempt_id = _make_attempt(db_conn)
table("quiz_responses").insert({
"attempt_id": attempt_id, "question_index": 0,
"selected_index": 1, "is_correct": True, "time_ms": 1200,
})
with pytest.raises(Exception):
table("quiz_responses").insert({
"attempt_id": attempt_id, "question_index": 0,
"selected_index": 0, "is_correct": False,
})

rows = db_conn.execute(
"SELECT selected_index, is_correct, time_ms FROM quiz_responses "
"WHERE attempt_id = %s",
(attempt_id,),
).fetchall()
assert len(rows) == 1
assert rows[0]["selected_index"] == 1 # the first write won
assert rows[0]["is_correct"] is True
assert rows[0]["time_ms"] == 1200


def test_responses_cascade_with_their_attempt(db_conn):
from db.connection import table

attempt_id = _make_attempt(db_conn)
table("quiz_responses").insert({
"attempt_id": attempt_id, "question_index": 0,
"selected_index": 0, "is_correct": False,
})
db_conn.execute("DELETE FROM quiz_attempts WHERE id = %s", (attempt_id,))
left = db_conn.execute(
"SELECT count(*) AS n FROM quiz_responses WHERE attempt_id = %s",
(attempt_id,),
).fetchone()
assert left["n"] == 0
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } 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
30 changes: 30 additions & 0 deletions backend/db/migrations/20260812214402_quiz_responses.sql
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
-- #541 C2: per-question quiz responses. This is the table that makes item
-- statistics, timing analysis, and misconception mining possible — one row
-- per answered question, written by POST /api/quiz/attempts/{id}/answer.
--
-- Everything here is a plaintext analytics scalar (same rationale as
-- quiz_attempts.score/total in #521): indexes and booleans carry no student
-- free text. The question/option TEXT lives encrypted in
-- quiz_attempts.questions_json; this table references it only by position.
--
-- The UNIQUE is the C1 idempotency contract: one response per
-- (attempt, question); re-answering returns the first recorded response
-- (no revision — the #537 revamp decides if that changes).

CREATE TABLE quiz_responses (
id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
attempt_id TEXT NOT NULL REFERENCES quiz_attempts(id) ON DELETE CASCADE,
question_index INTEGER NOT NULL CHECK (question_index >= 0),
selected_index INTEGER NOT NULL CHECK (selected_index >= 0),
is_correct BOOLEAN NOT NULL,
time_ms INTEGER CHECK (time_ms >= 0),
confidence REAL CHECK (confidence >= 0.0 AND confidence <= 1.0),
answered_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT quiz_responses_attempt_question_key UNIQUE (attempt_id, question_index)
);

-- No separate attempt_id index: Postgres backs the UNIQUE above with a btree
-- whose LEADING column is attempt_id, which already serves both access
-- patterns (submit's per-attempt scan and the answer endpoint's
-- (attempt_id, question_index) lookup). A standalone index would just add a
-- second write to the per-answer hot path.
25 changes: 25 additions & 0 deletions backend/models/__init__.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -60,6 +60,31 @@ class GenerateQuizBody(BaseModel):
# through to whatever SAPLING_MODEL_QUIZ resolves to (default
# gemini-2.5-flash-lite per ADR 0008).
model_pref: Optional[Literal["fast", "smart"]] = None
# DEPRECATED (#541 C3, removal tracked in #546): when true (the default
# the current QuizPanel needs), the response's per-option dicts carry
# `correct` booleans — the full answer key, client-side. Removing the
# key is a hard requirement of the #537 revamp: the new client grades
# through POST /api/quiz/attempts/{id}/answer instead. Every keyed
# response is logged so we can see when usage reaches zero.
include_answer_key: bool = True


class AnswerQuestionBody(BaseModel):
"""One answer for POST /api/quiz/attempts/{attempt_id}/answer (#541 C1).

Indexes are 0-based positions into the attempt's stored questions and
the question's options. The wire questions carry 1-based `id`s (what
/submit keys on), so `question_index = id - 1` — two addressing schemes
for one question. `question_id` is the guard against confusing them:
send the id you displayed and the route rejects a mismatch instead of
silently grading the neighbouring question (which the idempotency rule
would then lock in). The response echoes both either way."""

question_index: int = Field(ge=0)
selected_index: int = Field(ge=0)
question_id: Optional[int] = None
time_ms: Optional[int] = Field(default=None, ge=0)
confidence: Optional[float] = Field(default=None, ge=0.0, le=1.0)


class AnswerItem(BaseModel):
Expand Down
195 changes: 190 additions & 5 deletions backend/routes/quiz.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -18,7 +18,7 @@
from agents.quiz_context import quiz_context_agent
from agents.usage import record_agent_usage
from db.connection import table
from models import GenerateQuizBody, SubmitQuizBody
from models import AnswerQuestionBody, GenerateQuizBody, SubmitQuizBody
from routes.learn import _get_catalog_chunk
from services import events_service
from services.auth_guard import require_self
Expand DownExpand Up@@ -78,6 +78,21 @@ def _load_prompt(name: str) -> str:
_DIFFICULTY_RANK = {d: i for i, d in enumerate(CONCRETE_DIFFICULTIES)}


def _strip_answer_key(wire_questions: list[dict]) -> list[dict]:
"""Deep-enough copy of the wire questions without per-option `correct`
booleans (#541 C3). Storage always keeps the key — grading is
server-side; only the RESPONSE is stripped."""
stripped = []
for q in wire_questions:
q2 = dict(q)
q2["options"] = [
{k: v for k, v in o.items() if k != "correct"}
for o in q.get("options", [])
]
stripped.append(q2)
return stripped


def _resolved_difficulty(wire_questions: list[dict]) -> str:
"""The overall difficulty generation actually produced (#540 A1).

Expand DownExpand Up@@ -441,19 +456,153 @@ async def generate_quiz(body: GenerateQuizBody, request: Request):
"difficulty": body.difficulty,
},
)
# #541 C3: the answer key (per-option `correct` booleans) ships to the
# client only behind the deprecated include_answer_key flag — default
# true for the current QuizPanel, removed with #546 once the #537
# client grades via /attempts/{id}/answer. Log every keyed response so
# zero-usage is observable before the default flips.
if body.include_answer_key:
logger.info(
"quiz: generate served the client-side answer key "
"(include_answer_key=true, deprecated — #546) quiz_id=%s", quiz_id,
)
response_questions = questions
else:
response_questions = _strip_answer_key(questions)

# #540 A1: echo what generation actually chose. requested_difficulty
# is what the student asked for (may be 'adaptive');
# resolved_difficulty is the overall mix the agent produced (always
# concrete) — so the client can say "we picked hard for you" instead
# of repeating the request back.
return {
"quiz_id": quiz_id,
"questions": questions,
"questions": response_questions,
"requested_difficulty": body.difficulty,
"resolved_difficulty": _resolved_difficulty(questions),
}


@router.post("/attempts/{attempt_id}/answer")
def answer_question(attempt_id: str, body: AnswerQuestionBody, request: Request):
"""#541 C1: grade one question server-side and record the response.

Idempotent on (attempt_id, question_index): re-answering returns the
FIRST recorded response (`recorded: false` marks the replay) rather
than overwriting — no revision, decided for the #537 revamp flow.
"""
attempt_rows = table("quiz_attempts").select(
"*", filters={"id": f"eq.{attempt_id}"}
)
if not attempt_rows:
raise QuizAPIError(
status_code=404,
code=QuizErrorCode.QUIZ_ATTEMPT_NOT_FOUND,
message="We couldn't find that quiz.",
)
attempt = attempt_rows[0]
require_self(attempt["user_id"], request)

if attempt.get("completed_at"):
raise QuizAPIError(
status_code=409,
code=QuizErrorCode.QUIZ_ATTEMPT_ALREADY_COMPLETED,
message="This quiz has already been submitted.",
)

questions = decrypt_json_column(attempt["questions_json"]) or []
if body.question_index >= len(questions):
raise QuizAPIError(
status_code=400,
code=QuizErrorCode.QUIZ_QUESTION_INVALID,
message="That question isn't part of this quiz.",
)
question = questions[body.question_index]
options = question.get("options", [])
if body.selected_index >= len(options):
raise QuizAPIError(
status_code=400,
code=QuizErrorCode.QUIZ_QUESTION_INVALID,
message="That answer choice isn't part of this question.",
)
# Wire ids are 1-based, question_index is 0-based. When the client sends
# both, they must agree — otherwise passing the displayed id as the index
# silently grades the NEXT question and idempotency locks that in.
if body.question_id is not None and body.question_id != question.get("id"):
raise QuizAPIError(
status_code=400,
code=QuizErrorCode.QUIZ_QUESTION_INVALID,
message="That answer doesn't match the question it was sent for.",
)

# The correct option is a property of the question, not of the answer —
# resolve it once. -1 means a malformed item with no correct option,
# which must never grade correct (same rule as submit's #129 fix).
correct_index = next(
(i for i, o in enumerate(options) if o.get("correct")), -1
)

def _is_correct(selected_index: int) -> bool:
return correct_index >= 0 and correct_index == selected_index

recorded = True
response_row = None
existing = table("quiz_responses").select(
"*",
filters={
"attempt_id": f"eq.{attempt_id}",
"question_index": f"eq.{body.question_index}",
},
)
if existing:
recorded = False
response_row = existing[0]
else:
row = {
"attempt_id": attempt_id,
"question_index": body.question_index,
"selected_index": body.selected_index,
"is_correct": _is_correct(body.selected_index),
"time_ms": body.time_ms,
"confidence": body.confidence,
}
try:
table("quiz_responses").insert(row)
response_row = row
except Exception:
# Lost a race with a concurrent answer for the same index — the
# UNIQUE arbitrates; return whatever won.
recorded = False
raced = table("quiz_responses").select(
"*",
filters={
"attempt_id": f"eq.{attempt_id}",
"question_index": f"eq.{body.question_index}",
},
)
if not raced:
raise
response_row = raced[0]

next_index = body.question_index + 1
next_question = (
_strip_answer_key([questions[next_index]])[0]
if next_index < len(questions)
else None
)
return {
# Echo both addressing schemes so a client that mixed them up sees
# it immediately rather than discovering it at submit time.
"question_index": body.question_index,
"question_id": question.get("id"),
"is_correct": _is_correct(response_row["selected_index"]),
"correct_index": correct_index,
"explanation": question.get("explanation", ""),
"next_question": next_question,
"recorded": recorded,
}


@router.post("/submit")
def submit_quiz(body: SubmitQuizBody, background_tasks: BackgroundTasks, request: Request):
attempt_rows = table("quiz_attempts").select("*", filters={"id": f"eq.{body.quiz_id}"})
Expand DownExpand Up@@ -503,12 +652,45 @@ def submit_quiz(body: SubmitQuizBody, background_tasks: BackgroundTasks, request

concept_node_id = attempt["concept_node_id"]

# #541 C4: responses recorded through /attempts/{id}/answer are the
# source of truth — a payload answer for the same question is ignored
# (the recorded response was graded at answer time; letting the final
# POST override it would reopen the client-side-grading hole C exists
# to close). Questions never answered through C1 fall back to the
# submitted payload, so the current all-at-the-end client keeps working.
recorded_rows = table("quiz_responses").select(
"question_index,selected_index",
filters={"attempt_id": f"eq.{body.quiz_id}"},
) or []
recorded_by_index = {r["question_index"]: r for r in recorded_rows}

answer_map = {str(a.question_id): a.selected_label for a in body.answers}
results = []
# The reconciled answer set — what was ACTUALLY graded, which is what
# answers_json must persist. Storing the raw payload instead left a
# recorded-only submit with a full score beside an empty answer list,
# and a contradicted payload answer stored despite losing to the
# recorded response.
graded_answers: list[dict] = []
score = 0
for q in questions:
for q_index, q in enumerate(questions):
qid = str(q["id"])
selected = answer_map.get(qid, "")
recorded = recorded_by_index.get(q_index)
if recorded is not None:
sel_idx = recorded.get("selected_index")
options = q.get("options", [])
selected = (
options[sel_idx]["label"]
if isinstance(sel_idx, int) and 0 <= sel_idx < len(options)
else ""
)
else:
selected = answer_map.get(qid, "")
if selected:
graded_answers.append({
"question_id": q["id"],
"selected_label": selected,
})
correct_opt = next((o for o in q["options"] if o.get("correct")), None)
correct_label = correct_opt["label"] if correct_opt else ""
# #129: a malformed item with NO correct option must never grade as
Expand DownExpand Up@@ -580,7 +762,10 @@ def submit_quiz(body: SubmitQuizBody, background_tasks: BackgroundTasks, request
{
"score": score,
"total": total,
"answers_json": encrypt_json([a.model_dump() for a in body.answers]),
# The reconciled set (recorded responses winning over payload),
# not the raw request — the attempt's stored answers must agree
# with the score computed from them.
"answers_json": encrypt_json(graded_answers),
# completed_at was already stamped by the atomic claim above.
},
filters={"id": f"eq.{body.quiz_id}"},
Expand Down
3 changes: 3 additions & 0 deletions backend/services/quiz_errors.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -34,6 +34,9 @@ class QuizErrorCode(str, Enum):
QUIZ_CONCEPT_NOT_FOUND = "QUIZ_CONCEPT_NOT_FOUND"
QUIZ_ATTEMPT_NOT_FOUND = "QUIZ_ATTEMPT_NOT_FOUND"
QUIZ_ATTEMPT_ALREADY_COMPLETED = "QUIZ_ATTEMPT_ALREADY_COMPLETED"
# #541 C1: the answer endpoint got an index that doesn't exist on this
# attempt (question_index past the quiz, selected_index past the options).
QUIZ_QUESTION_INVALID = "QUIZ_QUESTION_INVALID"
QUIZ_NOT_AUTHORIZED = "QUIZ_NOT_AUTHORIZED"
QUIZ_GENERATION_FAILED = "QUIZ_GENERATION_FAILED"
QUIZ_INTERNAL_ERROR = "QUIZ_INTERNAL_ERROR"
Expand Down
74 changes: 74 additions & 0 deletions backend/tests/integration/test_quiz_responses_db.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
"""#541 C2 real-DB half: quiz_responses storage against local Supabase.

Proves the migration's shape actually holds in Postgres — the UNIQUE
arbitrates duplicate answers, the FK cascades with the attempt — the
exact class of constraint behavior MagicMock suites cannot see (#529's
lesson). #397 seam: writes through the app layer, raw reads via psycopg.
"""
import uuid

import pytest

pytestmark = pytest.mark.integration

USER = "rich-user-active"


def _make_attempt(db_conn) -> str:
from db.connection import table

node = db_conn.execute(
"SELECT id FROM graph_nodes WHERE user_id = %s ORDER BY id LIMIT 1",
(USER,),
).fetchone()
assert node is not None
attempt_id = str(uuid.uuid4())
table("quiz_attempts").insert({
"id": attempt_id,
"user_id": USER,
"concept_node_id": node["id"],
"difficulty": "adaptive", # also exercises the #540 CHECK widening
"questions_json": [],
})
return attempt_id


def test_unique_arbitrates_duplicate_answers(db_conn):
from db.connection import table

attempt_id = _make_attempt(db_conn)
table("quiz_responses").insert({
"attempt_id": attempt_id, "question_index": 0,
"selected_index": 1, "is_correct": True, "time_ms": 1200,
})
with pytest.raises(Exception):
table("quiz_responses").insert({
"attempt_id": attempt_id, "question_index": 0,
"selected_index": 0, "is_correct": False,
})

rows = db_conn.execute(
"SELECT selected_index, is_correct, time_ms FROM quiz_responses "
"WHERE attempt_id = %s",
(attempt_id,),
).fetchall()
assert len(rows) == 1
assert rows[0]["selected_index"] == 1 # the first write won
assert rows[0]["is_correct"] is True
assert rows[0]["time_ms"] == 1200


def test_responses_cascade_with_their_attempt(db_conn):
from db.connection import table

attempt_id = _make_attempt(db_conn)
table("quiz_responses").insert({
"attempt_id": attempt_id, "question_index": 0,
"selected_index": 0, "is_correct": False,
})
db_conn.execute("DELETE FROM quiz_attempts WHERE id = %s", (attempt_id,))
left = db_conn.execute(
"SELECT count(*) AS n FROM quiz_responses WHERE attempt_id = %s",
(attempt_id,),
).fetchone()
assert left["n"] == 0
Loading
Loading