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
65 changes: 49 additions & 16 deletions backend/agents/tools/quiz_history.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -57,32 +57,65 @@ class QuizHistory(BaseModel):
recent_attempts: list[RecentQuizAttempt] = Field(default_factory=list)


# String fields worth surfacing, in display order. `questions_seen_summary`
# and `notes` are what agents/quiz_context.py::QuizContext actually writes;
# summary/context/digest cover older free-form rows.
_SUMMARY_STRING_KEYS = ("summary", "questions_seen_summary", "notes", "context", "digest")

# List-of-strings fields, with a label so the agent knows what each block is.
# `weak_areas`/`common_mistakes` are the live QuizContext field names;
# misconceptions/common_errors cover older rows.
_SUMMARY_LIST_KEYS = (
("weak_areas", "Weak areas"),
("common_mistakes", "Common mistakes"),
("misconceptions", "Misconceptions"),
("common_errors", "Common errors"),
)


def _coerce_summary(ctx: Any) -> str | None:
"""quiz_context.context_json is free-form (whatever the post-submit
LLM produced). Different prompt versions have stored either a flat
string or a small dict. Coerce to a single string the agent can
reason over, or None if there's nothing useful."""
LLM produced). Coerce to a single string the agent can reason over,
or None if there's nothing useful.

#529/B4: this must consume the WHOLE QuizContext shape. The old
version returned the first matching string key — for a live
QuizContext row that was `notes` alone, silently dropping
weak_areas / common_mistakes / questions_seen_summary (and its list
fallback looked for `common_errors`, a key QuizContext never writes).
"""
if not ctx:
return None
if isinstance(ctx, str):
text = ctx.strip()
return text or None
if isinstance(ctx, dict):
# Common shapes: {"summary": "..."}, {"notes": "..."},
# {"misconceptions": [...], "weak_areas": [...]}.
for key in ("summary", "notes", "context", "digest"):
parts: list[str] = []
for key in _SUMMARY_STRING_KEYS:
v = ctx.get(key)
if isinstance(v, str) and v.strip():
return v.strip()
# Fall back to flattening list-of-strings entries so the agent
# at least sees the misconceptions/weak_areas the prior job
# extracted, even when no top-level summary string exists.
parts: list[str] = []
for key in ("misconceptions", "weak_areas", "common_errors"):
for item in ctx.get(key) or []:
if isinstance(item, str) and item.strip():
parts.append(f"- {item.strip()}")
return "\n".join(parts) or None
parts.append(v.strip())
for key, label in _SUMMARY_LIST_KEYS:
raw = ctx.get(key)
if not isinstance(raw, list):
# Legacy free-form rows can hold a string (or dict) under a
# list-shaped key; iterating those element-wise would spray
# per-character bullets / dict keys into the agent's prompt.
continue
items = [
item.strip()
for item in raw
if isinstance(item, str) and item.strip()
]
if items:
parts.append(label + ":\n" + "\n".join(f"- {i}" for i in items))
rec = ctx.get("recommended_difficulty")
if isinstance(rec, str) and rec.strip():
# The post-submit agent's difficulty recommendation — surfaced
# here or it rots encrypted-and-unread (its only other mention
# is the dead legacy prompt template).
parts.append(f"Recommended next difficulty: {rec.strip()}")
return "\n\n".join(parts) or None
return None


Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
-- Repairs 0025 (#529): quiz_context lost UNIQUE (user_id, concept_node_id).
--
-- 0001_baseline_schema.sql created the table with that UNIQUE inline;
-- 0025_study_integrity.sql dropped and recreated quiz_context (lines 108-114)
-- WITHOUT it. services/quiz_context_service.py's upsert names those columns in
-- on_conflict, so PostgREST rejected every write with 42P10 — and because the
-- caller swallowed the exception, the adaptive-context loop was silently dead
-- from 2026-06-23 until this repair. Staging and prod both measured 0 rows and
-- 0 duplicate pairs on 2026-08-12 (the very first write already failed, so
-- nothing accumulated), but local replicas replay independently — dedup anyway.

-- Keep the newest row per (user_id, concept_node_id); report what was removed.
DO $$
DECLARE removed integer;
BEGIN
DELETE FROM quiz_context qc
USING quiz_context newer
WHERE qc.user_id = newer.user_id
AND qc.concept_node_id = newer.concept_node_id
AND (qc.updated_at < newer.updated_at
OR (qc.updated_at = newer.updated_at AND qc.id < newer.id));
GET DIAGNOSTICS removed = ROW_COUNT;
RAISE NOTICE 'quiz_context dedup before UNIQUE restore: % duplicate row(s) removed', removed;
END $$;

-- Idempotent restore. Named explicitly (0001's inline UNIQUE got the default
-- name quiz_context_user_id_concept_node_id_key; this repair gets its own so
-- its origin is greppable).
ALTER TABLE quiz_context
DROP CONSTRAINT IF EXISTS quiz_context_user_concept_key;
ALTER TABLE quiz_context
ADD CONSTRAINT quiz_context_user_concept_key UNIQUE (user_id, concept_node_id);
12 changes: 8 additions & 4 deletions backend/routes/admin_analytics.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -441,22 +441,26 @@ def errors(
offset: int = Query(0, ge=0),
bucket: Bucket | None = Query(None),
) -> ErrorsPage:
"""Paginated error.* event feed; `?bucket=day` adds a per-day series (its own capped scan)."""
"""Paginated error-category event feed; `?bucket=day` adds a per-day series (its own capped scan)."""
require_admin(request)
response.headers["Cache-Control"] = "private"
from_iso, to_iso = _resolve_range(from_, to)
# error.* events, newest first — paginated server-side (no aggregation).
# ALL category="error" events, newest first — not just the error.* HTTP
# names. Backend failures like quiz.context_write_failed (#529/B3) and
# the rag.* pair (#482) carry the error category without the name
# prefix; filtering by name hid exactly the events whose invisibility
# they were added to end. Non-HTTP rows simply null the payload fields.
rows, total = table("events").select_with_count(
"created_at,event_type,request_id,user_id,payload",
filters={"created_at": [f"gte.{from_iso}", f"lte.{to_iso}"], "event_type": "like.error.*"},
filters={"created_at": [f"gte.{from_iso}", f"lte.{to_iso}"], "category": "eq.error"},
order="created_at.desc", limit=limit, offset=offset,
)
series = None
series_truncated = False
if bucket:
scan_rows, series_truncated = _scan_range(
"events", "created_at", from_iso, to_iso,
extra_filters={"event_type": "like.error.*"},
extra_filters={"category": "eq.error"},
)
series = _count_series(scan_rows)
items = []
Expand Down
41 changes: 38 additions & 3 deletions backend/routes/quiz.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,7 +9,9 @@

from pydantic_ai.exceptions import UsageLimitExceeded, UnexpectedModelBehavior

import config
from agents import ORCHESTRATOR_LIMITS
from agents._providers import UnregisteredHandlerError
from agents.quiz import quiz_agent, Quiz, QuizQuestion
from agents.deps import SaplingDeps
from agents._run import run_agent_sync
Expand DownExpand Up@@ -603,17 +605,50 @@ def submit_quiz(body: SubmitQuizBody, background_tasks: BackgroundTasks, request
.replace("{quiz_results_json}", json.dumps(results, indent=2))
)

def _update_context(prompt: str, uid: str, node_id: str):
# Correlate the background write with this request's trace.
ctx_request_id = getattr(request.state, "request_id", None) or current_request_id()

def _update_context(prompt: str, uid: str, node_id: str, quiz_id: str,
request_id: str | None):
# #529/B3: this write was `except Exception: pass` for months while
# every attempt 42P10'd — the adaptive loop died silently. Failures
# are loud now: ERROR log with the attempt id + request id, a
# `quiz.context_write_failed` analytics event, and a re-raise in
# local/test envs so a regression fails CI instead of going quiet.
try:
result = record_agent_usage(
run_agent_sync(quiz_context_agent.run(prompt)),
feature="quiz", task="quiz_context", user_id=uid,
)
save_quiz_context(uid, node_id, result.output.model_dump())
except UnregisteredHandlerError:
# E2E function mode leaves quiz_context deliberately
# unregistered (agents/function_handlers_e2e.py) so no
# post-response DB write races the next test's re-seed. One
# WARNING, no traceback: the logscan oracle reports tracebacks.
logger.warning(
"quiz: context update skipped — quiz_context handler "
"unregistered (function-mode seam) quiz_id=%s", quiz_id,
)
except Exception:
pass
logger.exception(
"quiz: context update failed quiz_id=%s concept=%s "
"request_id=%s", quiz_id, node_id, request_id,
)
events_service.log_event(
"quiz.context_write_failed",
category="error",
user_id=uid,
request_id=request_id,
payload={"quiz_id": quiz_id, "concept_node_id": node_id},
)
if config.IS_LOCAL:
raise

background_tasks.add_task(_update_context, ctx_prompt, user_id, concept_node_id)
background_tasks.add_task(
_update_context, ctx_prompt, user_id, concept_node_id,
body.quiz_id, ctx_request_id,
)

# XP + achievements: after the attempt row (score/total/answers_json) is
# persisted above (the atomic completed_at claim + the update at :486-494
Expand Down
4 changes: 4 additions & 0 deletions backend/services/events_service.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -82,6 +82,10 @@
"document.processed",
"quiz.started",
"quiz.completed",
# #529/B3: the post-submit context write failed. category="error" so it
# surfaces in admin analytics — this failure was invisible for months
# precisely because nothing emitted when the background task died.
"quiz.context_write_failed",
"chat.message_sent",
"note.created",
"session.started",
Expand Down
6 changes: 4 additions & 2 deletions backend/services/quiz_context_service.py
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
import logging
import uuid
from datetime import datetime, timezone

from db.connection import table
Expand All@@ -26,9 +25,12 @@ def get_quiz_context(user_id: str, concept_node_id: str):


def save_quiz_context(user_id: str, concept_node_id: str, context: dict):
# No client-generated id: PostgREST's merge-duplicates upsert updates
# every column in the payload on conflict, so an id here would rewrite
# the existing row's PRIMARY KEY on each refresh. Fresh inserts get the
# column's DB default (gen_random_uuid).
table("quiz_context").upsert(
{
"id": str(uuid.uuid4()),
"user_id": user_id,
"concept_node_id": concept_node_id,
"context_json": encrypt_json(context),
Expand Down
78 changes: 78 additions & 0 deletions backend/tests/integration/test_quiz_context_repair_db.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
"""#529 repair, real-DB half (Workstream B, epic #537).

The class of bug this file exists to catch: the hermetic suite mocked
`table()` and so never saw that quiz_context's UNIQUE was gone — the
upsert 42P10'd in every real environment for ~7.5 weeks while tests
stayed green. These assertions run against the local Supabase stack
(#397 seam: writes through the app, raw reads through psycopg).
"""
import pytest

pytestmark = pytest.mark.integration

USER = "rich-user-active"


def _seeded_node_id(db_conn) -> str:
row = db_conn.execute(
"SELECT id FROM graph_nodes WHERE user_id = %s ORDER BY id LIMIT 1",
(USER,),
).fetchone()
assert row is not None, "rich seed should provide graph nodes for the active user"
return row["id"]


def test_quiz_context_unique_constraint_is_restored(db_conn):
"""The #529 repair migration must leave a UNIQUE covering exactly
(user_id, concept_node_id) — the columns save_quiz_context's
on_conflict names."""
rows = db_conn.execute(
"""
SELECT c.conname,
array_agg(a.attname ORDER BY k.ord) AS cols
FROM pg_constraint c
CROSS JOIN LATERAL unnest(c.conkey) WITH ORDINALITY AS k(attnum, ord)
JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = k.attnum
WHERE c.conrelid = 'quiz_context'::regclass AND c.contype = 'u'
GROUP BY c.conname
"""
).fetchall()
col_sets = [tuple(r["cols"]) for r in rows]
assert ("user_id", "concept_node_id") in col_sets, (
f"no UNIQUE on (user_id, concept_node_id); found: {col_sets!r} — "
"the 0025 regression (#529) is back"
)


def test_save_quiz_context_upserts_one_row_and_encrypts(db_conn):
"""Two writes for the same (user, concept): before the repair the FIRST
write already failed with 42P10; after it, the second must replace the
first (one row), the raw column must be ciphertext, and the app read
must round-trip the latest payload."""
from services.quiz_context_service import get_quiz_context, save_quiz_context

node_id = _seeded_node_id(db_conn)
save_quiz_context(USER, node_id, {"weak_areas": ["first write"]})
first = db_conn.execute(
"SELECT id FROM quiz_context WHERE user_id = %s AND concept_node_id = %s",
(USER, node_id),
).fetchone()
save_quiz_context(USER, node_id, {"weak_areas": ["second write"]})

rows = db_conn.execute(
"SELECT id, context_json FROM quiz_context "
"WHERE user_id = %s AND concept_node_id = %s",
(USER, node_id),
).fetchall()
assert len(rows) == 1, f"upsert must keep exactly one row, found {len(rows)}"
# The refresh must not rewrite the row's PRIMARY KEY (merge-duplicates
# updates every payload column — an id in the payload would churn here).
assert rows[0]["id"] == first["id"]

raw = rows[0]["context_json"]
# #521: ciphertext stored as a JSONB string scalar — a dict here means
# the encrypt-at-write path regressed to plaintext.
assert isinstance(raw, str), f"context_json at rest should be ciphertext str, got {type(raw)}"
assert "second write" not in raw

assert get_quiz_context(USER, node_id) == {"weak_areas": ["second write"]}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 49 additions & 16 deletions backend/agents/tools/quiz_history.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -57,32 +57,65 @@ class QuizHistory(BaseModel):
recent_attempts: list[RecentQuizAttempt] = Field(default_factory=list)


# String fields worth surfacing, in display order. `questions_seen_summary`
# and `notes` are what agents/quiz_context.py::QuizContext actually writes;
# summary/context/digest cover older free-form rows.
_SUMMARY_STRING_KEYS = ("summary", "questions_seen_summary", "notes", "context", "digest")

# List-of-strings fields, with a label so the agent knows what each block is.
# `weak_areas`/`common_mistakes` are the live QuizContext field names;
# misconceptions/common_errors cover older rows.
_SUMMARY_LIST_KEYS = (
("weak_areas", "Weak areas"),
("common_mistakes", "Common mistakes"),
("misconceptions", "Misconceptions"),
("common_errors", "Common errors"),
)


def _coerce_summary(ctx: Any) -> str | None:
"""quiz_context.context_json is free-form (whatever the post-submit
LLM produced). Different prompt versions have stored either a flat
string or a small dict. Coerce to a single string the agent can
reason over, or None if there's nothing useful."""
LLM produced). Coerce to a single string the agent can reason over,
or None if there's nothing useful.

#529/B4: this must consume the WHOLE QuizContext shape. The old
version returned the first matching string key — for a live
QuizContext row that was `notes` alone, silently dropping
weak_areas / common_mistakes / questions_seen_summary (and its list
fallback looked for `common_errors`, a key QuizContext never writes).
"""
if not ctx:
return None
if isinstance(ctx, str):
text = ctx.strip()
return text or None
if isinstance(ctx, dict):
# Common shapes: {"summary": "..."}, {"notes": "..."},
# {"misconceptions": [...], "weak_areas": [...]}.
for key in ("summary", "notes", "context", "digest"):
parts: list[str] = []
for key in _SUMMARY_STRING_KEYS:
v = ctx.get(key)
if isinstance(v, str) and v.strip():
return v.strip()
# Fall back to flattening list-of-strings entries so the agent
# at least sees the misconceptions/weak_areas the prior job
# extracted, even when no top-level summary string exists.
parts: list[str] = []
for key in ("misconceptions", "weak_areas", "common_errors"):
for item in ctx.get(key) or []:
if isinstance(item, str) and item.strip():
parts.append(f"- {item.strip()}")
return "\n".join(parts) or None
parts.append(v.strip())
for key, label in _SUMMARY_LIST_KEYS:
raw = ctx.get(key)
if not isinstance(raw, list):
# Legacy free-form rows can hold a string (or dict) under a
# list-shaped key; iterating those element-wise would spray
# per-character bullets / dict keys into the agent's prompt.
continue
items = [
item.strip()
for item in raw
if isinstance(item, str) and item.strip()
]
if items:
parts.append(label + ":\n" + "\n".join(f"- {i}" for i in items))
rec = ctx.get("recommended_difficulty")
if isinstance(rec, str) and rec.strip():
# The post-submit agent's difficulty recommendation — surfaced
# here or it rots encrypted-and-unread (its only other mention
# is the dead legacy prompt template).
parts.append(f"Recommended next difficulty: {rec.strip()}")
return "\n\n".join(parts) or None
return None


Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
-- Repairs 0025 (#529): quiz_context lost UNIQUE (user_id, concept_node_id).
--
-- 0001_baseline_schema.sql created the table with that UNIQUE inline;
-- 0025_study_integrity.sql dropped and recreated quiz_context (lines 108-114)
-- WITHOUT it. services/quiz_context_service.py's upsert names those columns in
-- on_conflict, so PostgREST rejected every write with 42P10 — and because the
-- caller swallowed the exception, the adaptive-context loop was silently dead
-- from 2026-06-23 until this repair. Staging and prod both measured 0 rows and
-- 0 duplicate pairs on 2026-08-12 (the very first write already failed, so
-- nothing accumulated), but local replicas replay independently — dedup anyway.

-- Keep the newest row per (user_id, concept_node_id); report what was removed.
DO $$
DECLARE removed integer;
BEGIN
DELETE FROM quiz_context qc
USING quiz_context newer
WHERE qc.user_id = newer.user_id
AND qc.concept_node_id = newer.concept_node_id
AND (qc.updated_at < newer.updated_at
OR (qc.updated_at = newer.updated_at AND qc.id < newer.id));
GET DIAGNOSTICS removed = ROW_COUNT;
RAISE NOTICE 'quiz_context dedup before UNIQUE restore: % duplicate row(s) removed', removed;
END $$;

-- Idempotent restore. Named explicitly (0001's inline UNIQUE got the default
-- name quiz_context_user_id_concept_node_id_key; this repair gets its own so
-- its origin is greppable).
ALTER TABLE quiz_context
DROP CONSTRAINT IF EXISTS quiz_context_user_concept_key;
ALTER TABLE quiz_context
ADD CONSTRAINT quiz_context_user_concept_key UNIQUE (user_id, concept_node_id);
12 changes: 8 additions & 4 deletions backend/routes/admin_analytics.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -441,22 +441,26 @@ def errors(
offset: int = Query(0, ge=0),
bucket: Bucket | None = Query(None),
) -> ErrorsPage:
"""Paginated error.* event feed; `?bucket=day` adds a per-day series (its own capped scan)."""
"""Paginated error-category event feed; `?bucket=day` adds a per-day series (its own capped scan)."""
require_admin(request)
response.headers["Cache-Control"] = "private"
from_iso, to_iso = _resolve_range(from_, to)
# error.* events, newest first — paginated server-side (no aggregation).
# ALL category="error" events, newest first — not just the error.* HTTP
# names. Backend failures like quiz.context_write_failed (#529/B3) and
# the rag.* pair (#482) carry the error category without the name
# prefix; filtering by name hid exactly the events whose invisibility
# they were added to end. Non-HTTP rows simply null the payload fields.
rows, total = table("events").select_with_count(
"created_at,event_type,request_id,user_id,payload",
filters={"created_at": [f"gte.{from_iso}", f"lte.{to_iso}"], "event_type": "like.error.*"},
filters={"created_at": [f"gte.{from_iso}", f"lte.{to_iso}"], "category": "eq.error"},
order="created_at.desc", limit=limit, offset=offset,
)
series = None
series_truncated = False
if bucket:
scan_rows, series_truncated = _scan_range(
"events", "created_at", from_iso, to_iso,
extra_filters={"event_type": "like.error.*"},
extra_filters={"category": "eq.error"},
)
series = _count_series(scan_rows)
items = []
Expand Down
41 changes: 38 additions & 3 deletions backend/routes/quiz.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,7 +9,9 @@

from pydantic_ai.exceptions import UsageLimitExceeded, UnexpectedModelBehavior

import config
from agents import ORCHESTRATOR_LIMITS
from agents._providers import UnregisteredHandlerError
from agents.quiz import quiz_agent, Quiz, QuizQuestion
from agents.deps import SaplingDeps
from agents._run import run_agent_sync
Expand DownExpand Up@@ -603,17 +605,50 @@ def submit_quiz(body: SubmitQuizBody, background_tasks: BackgroundTasks, request
.replace("{quiz_results_json}", json.dumps(results, indent=2))
)

def _update_context(prompt: str, uid: str, node_id: str):
# Correlate the background write with this request's trace.
ctx_request_id = getattr(request.state, "request_id", None) or current_request_id()

def _update_context(prompt: str, uid: str, node_id: str, quiz_id: str,
request_id: str | None):
# #529/B3: this write was `except Exception: pass` for months while
# every attempt 42P10'd — the adaptive loop died silently. Failures
# are loud now: ERROR log with the attempt id + request id, a
# `quiz.context_write_failed` analytics event, and a re-raise in
# local/test envs so a regression fails CI instead of going quiet.
try:
result = record_agent_usage(
run_agent_sync(quiz_context_agent.run(prompt)),
feature="quiz", task="quiz_context", user_id=uid,
)
save_quiz_context(uid, node_id, result.output.model_dump())
except UnregisteredHandlerError:
# E2E function mode leaves quiz_context deliberately
# unregistered (agents/function_handlers_e2e.py) so no
# post-response DB write races the next test's re-seed. One
# WARNING, no traceback: the logscan oracle reports tracebacks.
logger.warning(
"quiz: context update skipped — quiz_context handler "
"unregistered (function-mode seam) quiz_id=%s", quiz_id,
)
except Exception:
pass
logger.exception(
"quiz: context update failed quiz_id=%s concept=%s "
"request_id=%s", quiz_id, node_id, request_id,
)
events_service.log_event(
"quiz.context_write_failed",
category="error",
user_id=uid,
request_id=request_id,
payload={"quiz_id": quiz_id, "concept_node_id": node_id},
)
if config.IS_LOCAL:
raise

background_tasks.add_task(_update_context, ctx_prompt, user_id, concept_node_id)
background_tasks.add_task(
_update_context, ctx_prompt, user_id, concept_node_id,
body.quiz_id, ctx_request_id,
)

# XP + achievements: after the attempt row (score/total/answers_json) is
# persisted above (the atomic completed_at claim + the update at :486-494
Expand Down
4 changes: 4 additions & 0 deletions backend/services/events_service.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -82,6 +82,10 @@
"document.processed",
"quiz.started",
"quiz.completed",
# #529/B3: the post-submit context write failed. category="error" so it
# surfaces in admin analytics — this failure was invisible for months
# precisely because nothing emitted when the background task died.
"quiz.context_write_failed",
"chat.message_sent",
"note.created",
"session.started",
Expand Down
6 changes: 4 additions & 2 deletions backend/services/quiz_context_service.py
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
import logging
import uuid
from datetime import datetime, timezone

from db.connection import table
Expand All@@ -26,9 +25,12 @@ def get_quiz_context(user_id: str, concept_node_id: str):


def save_quiz_context(user_id: str, concept_node_id: str, context: dict):
# No client-generated id: PostgREST's merge-duplicates upsert updates
# every column in the payload on conflict, so an id here would rewrite
# the existing row's PRIMARY KEY on each refresh. Fresh inserts get the
# column's DB default (gen_random_uuid).
table("quiz_context").upsert(
{
"id": str(uuid.uuid4()),
"user_id": user_id,
"concept_node_id": concept_node_id,
"context_json": encrypt_json(context),
Expand Down
78 changes: 78 additions & 0 deletions backend/tests/integration/test_quiz_context_repair_db.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
"""#529 repair, real-DB half (Workstream B, epic #537).

The class of bug this file exists to catch: the hermetic suite mocked
`table()` and so never saw that quiz_context's UNIQUE was gone — the
upsert 42P10'd in every real environment for ~7.5 weeks while tests
stayed green. These assertions run against the local Supabase stack
(#397 seam: writes through the app, raw reads through psycopg).
"""
import pytest

pytestmark = pytest.mark.integration

USER = "rich-user-active"


def _seeded_node_id(db_conn) -> str:
row = db_conn.execute(
"SELECT id FROM graph_nodes WHERE user_id = %s ORDER BY id LIMIT 1",
(USER,),
).fetchone()
assert row is not None, "rich seed should provide graph nodes for the active user"
return row["id"]


def test_quiz_context_unique_constraint_is_restored(db_conn):
"""The #529 repair migration must leave a UNIQUE covering exactly
(user_id, concept_node_id) — the columns save_quiz_context's
on_conflict names."""
rows = db_conn.execute(
"""
SELECT c.conname,
array_agg(a.attname ORDER BY k.ord) AS cols
FROM pg_constraint c
CROSS JOIN LATERAL unnest(c.conkey) WITH ORDINALITY AS k(attnum, ord)
JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = k.attnum
WHERE c.conrelid = 'quiz_context'::regclass AND c.contype = 'u'
GROUP BY c.conname
"""
).fetchall()
col_sets = [tuple(r["cols"]) for r in rows]
assert ("user_id", "concept_node_id") in col_sets, (
f"no UNIQUE on (user_id, concept_node_id); found: {col_sets!r} — "
"the 0025 regression (#529) is back"
)


def test_save_quiz_context_upserts_one_row_and_encrypts(db_conn):
"""Two writes for the same (user, concept): before the repair the FIRST
write already failed with 42P10; after it, the second must replace the
first (one row), the raw column must be ciphertext, and the app read
must round-trip the latest payload."""
from services.quiz_context_service import get_quiz_context, save_quiz_context

node_id = _seeded_node_id(db_conn)
save_quiz_context(USER, node_id, {"weak_areas": ["first write"]})
first = db_conn.execute(
"SELECT id FROM quiz_context WHERE user_id = %s AND concept_node_id = %s",
(USER, node_id),
).fetchone()
save_quiz_context(USER, node_id, {"weak_areas": ["second write"]})

rows = db_conn.execute(
"SELECT id, context_json FROM quiz_context "
"WHERE user_id = %s AND concept_node_id = %s",
(USER, node_id),
).fetchall()
assert len(rows) == 1, f"upsert must keep exactly one row, found {len(rows)}"
# The refresh must not rewrite the row's PRIMARY KEY (merge-duplicates
# updates every payload column — an id in the payload would churn here).
assert rows[0]["id"] == first["id"]

raw = rows[0]["context_json"]
# #521: ciphertext stored as a JSONB string scalar — a dict here means
# the encrypt-at-write path regressed to plaintext.
assert isinstance(raw, str), f"context_json at rest should be ciphertext str, got {type(raw)}"
assert "second write" not in raw

assert get_quiz_context(USER, node_id) == {"weak_areas": ["second write"]}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 49 additions & 16 deletions backend/agents/tools/quiz_history.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -57,32 +57,65 @@ class QuizHistory(BaseModel):
recent_attempts: list[RecentQuizAttempt] = Field(default_factory=list)


# String fields worth surfacing, in display order. `questions_seen_summary`
# and `notes` are what agents/quiz_context.py::QuizContext actually writes;
# summary/context/digest cover older free-form rows.
_SUMMARY_STRING_KEYS = ("summary", "questions_seen_summary", "notes", "context", "digest")

# List-of-strings fields, with a label so the agent knows what each block is.
# `weak_areas`/`common_mistakes` are the live QuizContext field names;
# misconceptions/common_errors cover older rows.
_SUMMARY_LIST_KEYS = (
("weak_areas", "Weak areas"),
("common_mistakes", "Common mistakes"),
("misconceptions", "Misconceptions"),
("common_errors", "Common errors"),
)


def _coerce_summary(ctx: Any) -> str | None:
"""quiz_context.context_json is free-form (whatever the post-submit
LLM produced). Different prompt versions have stored either a flat
string or a small dict. Coerce to a single string the agent can
reason over, or None if there's nothing useful."""
LLM produced). Coerce to a single string the agent can reason over,
or None if there's nothing useful.

#529/B4: this must consume the WHOLE QuizContext shape. The old
version returned the first matching string key — for a live
QuizContext row that was `notes` alone, silently dropping
weak_areas / common_mistakes / questions_seen_summary (and its list
fallback looked for `common_errors`, a key QuizContext never writes).
"""
if not ctx:
return None
if isinstance(ctx, str):
text = ctx.strip()
return text or None
if isinstance(ctx, dict):
# Common shapes: {"summary": "..."}, {"notes": "..."},
# {"misconceptions": [...], "weak_areas": [...]}.
for key in ("summary", "notes", "context", "digest"):
parts: list[str] = []
for key in _SUMMARY_STRING_KEYS:
v = ctx.get(key)
if isinstance(v, str) and v.strip():
return v.strip()
# Fall back to flattening list-of-strings entries so the agent
# at least sees the misconceptions/weak_areas the prior job
# extracted, even when no top-level summary string exists.
parts: list[str] = []
for key in ("misconceptions", "weak_areas", "common_errors"):
for item in ctx.get(key) or []:
if isinstance(item, str) and item.strip():
parts.append(f"- {item.strip()}")
return "\n".join(parts) or None
parts.append(v.strip())
for key, label in _SUMMARY_LIST_KEYS:
raw = ctx.get(key)
if not isinstance(raw, list):
# Legacy free-form rows can hold a string (or dict) under a
# list-shaped key; iterating those element-wise would spray
# per-character bullets / dict keys into the agent's prompt.
continue
items = [
item.strip()
for item in raw
if isinstance(item, str) and item.strip()
]
if items:
parts.append(label + ":\n" + "\n".join(f"- {i}" for i in items))
rec = ctx.get("recommended_difficulty")
if isinstance(rec, str) and rec.strip():
# The post-submit agent's difficulty recommendation — surfaced
# here or it rots encrypted-and-unread (its only other mention
# is the dead legacy prompt template).
parts.append(f"Recommended next difficulty: {rec.strip()}")
return "\n\n".join(parts) or None
return None


Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
-- Repairs 0025 (#529): quiz_context lost UNIQUE (user_id, concept_node_id).
--
-- 0001_baseline_schema.sql created the table with that UNIQUE inline;
-- 0025_study_integrity.sql dropped and recreated quiz_context (lines 108-114)
-- WITHOUT it. services/quiz_context_service.py's upsert names those columns in
-- on_conflict, so PostgREST rejected every write with 42P10 — and because the
-- caller swallowed the exception, the adaptive-context loop was silently dead
-- from 2026-06-23 until this repair. Staging and prod both measured 0 rows and
-- 0 duplicate pairs on 2026-08-12 (the very first write already failed, so
-- nothing accumulated), but local replicas replay independently — dedup anyway.

-- Keep the newest row per (user_id, concept_node_id); report what was removed.
DO $$
DECLARE removed integer;
BEGIN
DELETE FROM quiz_context qc
USING quiz_context newer
WHERE qc.user_id = newer.user_id
AND qc.concept_node_id = newer.concept_node_id
AND (qc.updated_at < newer.updated_at
OR (qc.updated_at = newer.updated_at AND qc.id < newer.id));
GET DIAGNOSTICS removed = ROW_COUNT;
RAISE NOTICE 'quiz_context dedup before UNIQUE restore: % duplicate row(s) removed', removed;
END $$;

-- Idempotent restore. Named explicitly (0001's inline UNIQUE got the default
-- name quiz_context_user_id_concept_node_id_key; this repair gets its own so
-- its origin is greppable).
ALTER TABLE quiz_context
DROP CONSTRAINT IF EXISTS quiz_context_user_concept_key;
ALTER TABLE quiz_context
ADD CONSTRAINT quiz_context_user_concept_key UNIQUE (user_id, concept_node_id);
12 changes: 8 additions & 4 deletions backend/routes/admin_analytics.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -441,22 +441,26 @@ def errors(
offset: int = Query(0, ge=0),
bucket: Bucket | None = Query(None),
) -> ErrorsPage:
"""Paginated error.* event feed; `?bucket=day` adds a per-day series (its own capped scan)."""
"""Paginated error-category event feed; `?bucket=day` adds a per-day series (its own capped scan)."""
require_admin(request)
response.headers["Cache-Control"] = "private"
from_iso, to_iso = _resolve_range(from_, to)
# error.* events, newest first — paginated server-side (no aggregation).
# ALL category="error" events, newest first — not just the error.* HTTP
# names. Backend failures like quiz.context_write_failed (#529/B3) and
# the rag.* pair (#482) carry the error category without the name
# prefix; filtering by name hid exactly the events whose invisibility
# they were added to end. Non-HTTP rows simply null the payload fields.
rows, total = table("events").select_with_count(
"created_at,event_type,request_id,user_id,payload",
filters={"created_at": [f"gte.{from_iso}", f"lte.{to_iso}"], "event_type": "like.error.*"},
filters={"created_at": [f"gte.{from_iso}", f"lte.{to_iso}"], "category": "eq.error"},
order="created_at.desc", limit=limit, offset=offset,
)
series = None
series_truncated = False
if bucket:
scan_rows, series_truncated = _scan_range(
"events", "created_at", from_iso, to_iso,
extra_filters={"event_type": "like.error.*"},
extra_filters={"category": "eq.error"},
)
series = _count_series(scan_rows)
items = []
Expand Down
41 changes: 38 additions & 3 deletions backend/routes/quiz.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,7 +9,9 @@

from pydantic_ai.exceptions import UsageLimitExceeded, UnexpectedModelBehavior

import config
from agents import ORCHESTRATOR_LIMITS
from agents._providers import UnregisteredHandlerError
from agents.quiz import quiz_agent, Quiz, QuizQuestion
from agents.deps import SaplingDeps
from agents._run import run_agent_sync
Expand DownExpand Up@@ -603,17 +605,50 @@ def submit_quiz(body: SubmitQuizBody, background_tasks: BackgroundTasks, request
.replace("{quiz_results_json}", json.dumps(results, indent=2))
)

def _update_context(prompt: str, uid: str, node_id: str):
# Correlate the background write with this request's trace.
ctx_request_id = getattr(request.state, "request_id", None) or current_request_id()

def _update_context(prompt: str, uid: str, node_id: str, quiz_id: str,
request_id: str | None):
# #529/B3: this write was `except Exception: pass` for months while
# every attempt 42P10'd — the adaptive loop died silently. Failures
# are loud now: ERROR log with the attempt id + request id, a
# `quiz.context_write_failed` analytics event, and a re-raise in
# local/test envs so a regression fails CI instead of going quiet.
try:
result = record_agent_usage(
run_agent_sync(quiz_context_agent.run(prompt)),
feature="quiz", task="quiz_context", user_id=uid,
)
save_quiz_context(uid, node_id, result.output.model_dump())
except UnregisteredHandlerError:
# E2E function mode leaves quiz_context deliberately
# unregistered (agents/function_handlers_e2e.py) so no
# post-response DB write races the next test's re-seed. One
# WARNING, no traceback: the logscan oracle reports tracebacks.
logger.warning(
"quiz: context update skipped — quiz_context handler "
"unregistered (function-mode seam) quiz_id=%s", quiz_id,
)
except Exception:
pass
logger.exception(
"quiz: context update failed quiz_id=%s concept=%s "
"request_id=%s", quiz_id, node_id, request_id,
)
events_service.log_event(
"quiz.context_write_failed",
category="error",
user_id=uid,
request_id=request_id,
payload={"quiz_id": quiz_id, "concept_node_id": node_id},
)
if config.IS_LOCAL:
raise

background_tasks.add_task(_update_context, ctx_prompt, user_id, concept_node_id)
background_tasks.add_task(
_update_context, ctx_prompt, user_id, concept_node_id,
body.quiz_id, ctx_request_id,
)

# XP + achievements: after the attempt row (score/total/answers_json) is
# persisted above (the atomic completed_at claim + the update at :486-494
Expand Down
4 changes: 4 additions & 0 deletions backend/services/events_service.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -82,6 +82,10 @@
"document.processed",
"quiz.started",
"quiz.completed",
# #529/B3: the post-submit context write failed. category="error" so it
# surfaces in admin analytics — this failure was invisible for months
# precisely because nothing emitted when the background task died.
"quiz.context_write_failed",
"chat.message_sent",
"note.created",
"session.started",
Expand Down
6 changes: 4 additions & 2 deletions backend/services/quiz_context_service.py
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
import logging
import uuid
from datetime import datetime, timezone

from db.connection import table
Expand All@@ -26,9 +25,12 @@ def get_quiz_context(user_id: str, concept_node_id: str):


def save_quiz_context(user_id: str, concept_node_id: str, context: dict):
# No client-generated id: PostgREST's merge-duplicates upsert updates
# every column in the payload on conflict, so an id here would rewrite
# the existing row's PRIMARY KEY on each refresh. Fresh inserts get the
# column's DB default (gen_random_uuid).
table("quiz_context").upsert(
{
"id": str(uuid.uuid4()),
"user_id": user_id,
"concept_node_id": concept_node_id,
"context_json": encrypt_json(context),
Expand Down
78 changes: 78 additions & 0 deletions backend/tests/integration/test_quiz_context_repair_db.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
"""#529 repair, real-DB half (Workstream B, epic #537).

The class of bug this file exists to catch: the hermetic suite mocked
`table()` and so never saw that quiz_context's UNIQUE was gone — the
upsert 42P10'd in every real environment for ~7.5 weeks while tests
stayed green. These assertions run against the local Supabase stack
(#397 seam: writes through the app, raw reads through psycopg).
"""
import pytest

pytestmark = pytest.mark.integration

USER = "rich-user-active"


def _seeded_node_id(db_conn) -> str:
row = db_conn.execute(
"SELECT id FROM graph_nodes WHERE user_id = %s ORDER BY id LIMIT 1",
(USER,),
).fetchone()
assert row is not None, "rich seed should provide graph nodes for the active user"
return row["id"]


def test_quiz_context_unique_constraint_is_restored(db_conn):
"""The #529 repair migration must leave a UNIQUE covering exactly
(user_id, concept_node_id) — the columns save_quiz_context's
on_conflict names."""
rows = db_conn.execute(
"""
SELECT c.conname,
array_agg(a.attname ORDER BY k.ord) AS cols
FROM pg_constraint c
CROSS JOIN LATERAL unnest(c.conkey) WITH ORDINALITY AS k(attnum, ord)
JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = k.attnum
WHERE c.conrelid = 'quiz_context'::regclass AND c.contype = 'u'
GROUP BY c.conname
"""
).fetchall()
col_sets = [tuple(r["cols"]) for r in rows]
assert ("user_id", "concept_node_id") in col_sets, (
f"no UNIQUE on (user_id, concept_node_id); found: {col_sets!r} — "
"the 0025 regression (#529) is back"
)


def test_save_quiz_context_upserts_one_row_and_encrypts(db_conn):
"""Two writes for the same (user, concept): before the repair the FIRST
write already failed with 42P10; after it, the second must replace the
first (one row), the raw column must be ciphertext, and the app read
must round-trip the latest payload."""
from services.quiz_context_service import get_quiz_context, save_quiz_context

node_id = _seeded_node_id(db_conn)
save_quiz_context(USER, node_id, {"weak_areas": ["first write"]})
first = db_conn.execute(
"SELECT id FROM quiz_context WHERE user_id = %s AND concept_node_id = %s",
(USER, node_id),
).fetchone()
save_quiz_context(USER, node_id, {"weak_areas": ["second write"]})

rows = db_conn.execute(
"SELECT id, context_json FROM quiz_context "
"WHERE user_id = %s AND concept_node_id = %s",
(USER, node_id),
).fetchall()
assert len(rows) == 1, f"upsert must keep exactly one row, found {len(rows)}"
# The refresh must not rewrite the row's PRIMARY KEY (merge-duplicates
# updates every payload column — an id in the payload would churn here).
assert rows[0]["id"] == first["id"]

raw = rows[0]["context_json"]
# #521: ciphertext stored as a JSONB string scalar — a dict here means
# the encrypt-at-write path regressed to plaintext.
assert isinstance(raw, str), f"context_json at rest should be ciphertext str, got {type(raw)}"
assert "second write" not in raw

assert get_quiz_context(USER, node_id) == {"weak_areas": ["second write"]}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 49 additions & 16 deletions backend/agents/tools/quiz_history.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -57,32 +57,65 @@ class QuizHistory(BaseModel):
recent_attempts: list[RecentQuizAttempt] = Field(default_factory=list)


# String fields worth surfacing, in display order. `questions_seen_summary`
# and `notes` are what agents/quiz_context.py::QuizContext actually writes;
# summary/context/digest cover older free-form rows.
_SUMMARY_STRING_KEYS = ("summary", "questions_seen_summary", "notes", "context", "digest")

# List-of-strings fields, with a label so the agent knows what each block is.
# `weak_areas`/`common_mistakes` are the live QuizContext field names;
# misconceptions/common_errors cover older rows.
_SUMMARY_LIST_KEYS = (
("weak_areas", "Weak areas"),
("common_mistakes", "Common mistakes"),
("misconceptions", "Misconceptions"),
("common_errors", "Common errors"),
)


def _coerce_summary(ctx: Any) -> str | None:
"""quiz_context.context_json is free-form (whatever the post-submit
LLM produced). Different prompt versions have stored either a flat
string or a small dict. Coerce to a single string the agent can
reason over, or None if there's nothing useful."""
LLM produced). Coerce to a single string the agent can reason over,
or None if there's nothing useful.

#529/B4: this must consume the WHOLE QuizContext shape. The old
version returned the first matching string key — for a live
QuizContext row that was `notes` alone, silently dropping
weak_areas / common_mistakes / questions_seen_summary (and its list
fallback looked for `common_errors`, a key QuizContext never writes).
"""
if not ctx:
return None
if isinstance(ctx, str):
text = ctx.strip()
return text or None
if isinstance(ctx, dict):
# Common shapes: {"summary": "..."}, {"notes": "..."},
# {"misconceptions": [...], "weak_areas": [...]}.
for key in ("summary", "notes", "context", "digest"):
parts: list[str] = []
for key in _SUMMARY_STRING_KEYS:
v = ctx.get(key)
if isinstance(v, str) and v.strip():
return v.strip()
# Fall back to flattening list-of-strings entries so the agent
# at least sees the misconceptions/weak_areas the prior job
# extracted, even when no top-level summary string exists.
parts: list[str] = []
for key in ("misconceptions", "weak_areas", "common_errors"):
for item in ctx.get(key) or []:
if isinstance(item, str) and item.strip():
parts.append(f"- {item.strip()}")
return "\n".join(parts) or None
parts.append(v.strip())
for key, label in _SUMMARY_LIST_KEYS:
raw = ctx.get(key)
if not isinstance(raw, list):
# Legacy free-form rows can hold a string (or dict) under a
# list-shaped key; iterating those element-wise would spray
# per-character bullets / dict keys into the agent's prompt.
continue
items = [
item.strip()
for item in raw
if isinstance(item, str) and item.strip()
]
if items:
parts.append(label + ":\n" + "\n".join(f"- {i}" for i in items))
rec = ctx.get("recommended_difficulty")
if isinstance(rec, str) and rec.strip():
# The post-submit agent's difficulty recommendation — surfaced
# here or it rots encrypted-and-unread (its only other mention
# is the dead legacy prompt template).
parts.append(f"Recommended next difficulty: {rec.strip()}")
return "\n\n".join(parts) or None
return None


Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
-- Repairs 0025 (#529): quiz_context lost UNIQUE (user_id, concept_node_id).
--
-- 0001_baseline_schema.sql created the table with that UNIQUE inline;
-- 0025_study_integrity.sql dropped and recreated quiz_context (lines 108-114)
-- WITHOUT it. services/quiz_context_service.py's upsert names those columns in
-- on_conflict, so PostgREST rejected every write with 42P10 — and because the
-- caller swallowed the exception, the adaptive-context loop was silently dead
-- from 2026-06-23 until this repair. Staging and prod both measured 0 rows and
-- 0 duplicate pairs on 2026-08-12 (the very first write already failed, so
-- nothing accumulated), but local replicas replay independently — dedup anyway.

-- Keep the newest row per (user_id, concept_node_id); report what was removed.
DO $$
DECLARE removed integer;
BEGIN
DELETE FROM quiz_context qc
USING quiz_context newer
WHERE qc.user_id = newer.user_id
AND qc.concept_node_id = newer.concept_node_id
AND (qc.updated_at < newer.updated_at
OR (qc.updated_at = newer.updated_at AND qc.id < newer.id));
GET DIAGNOSTICS removed = ROW_COUNT;
RAISE NOTICE 'quiz_context dedup before UNIQUE restore: % duplicate row(s) removed', removed;
END $$;

-- Idempotent restore. Named explicitly (0001's inline UNIQUE got the default
-- name quiz_context_user_id_concept_node_id_key; this repair gets its own so
-- its origin is greppable).
ALTER TABLE quiz_context
DROP CONSTRAINT IF EXISTS quiz_context_user_concept_key;
ALTER TABLE quiz_context
ADD CONSTRAINT quiz_context_user_concept_key UNIQUE (user_id, concept_node_id);
12 changes: 8 additions & 4 deletions backend/routes/admin_analytics.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -441,22 +441,26 @@ def errors(
offset: int = Query(0, ge=0),
bucket: Bucket | None = Query(None),
) -> ErrorsPage:
"""Paginated error.* event feed; `?bucket=day` adds a per-day series (its own capped scan)."""
"""Paginated error-category event feed; `?bucket=day` adds a per-day series (its own capped scan)."""
require_admin(request)
response.headers["Cache-Control"] = "private"
from_iso, to_iso = _resolve_range(from_, to)
# error.* events, newest first — paginated server-side (no aggregation).
# ALL category="error" events, newest first — not just the error.* HTTP
# names. Backend failures like quiz.context_write_failed (#529/B3) and
# the rag.* pair (#482) carry the error category without the name
# prefix; filtering by name hid exactly the events whose invisibility
# they were added to end. Non-HTTP rows simply null the payload fields.
rows, total = table("events").select_with_count(
"created_at,event_type,request_id,user_id,payload",
filters={"created_at": [f"gte.{from_iso}", f"lte.{to_iso}"], "event_type": "like.error.*"},
filters={"created_at": [f"gte.{from_iso}", f"lte.{to_iso}"], "category": "eq.error"},
order="created_at.desc", limit=limit, offset=offset,
)
series = None
series_truncated = False
if bucket:
scan_rows, series_truncated = _scan_range(
"events", "created_at", from_iso, to_iso,
extra_filters={"event_type": "like.error.*"},
extra_filters={"category": "eq.error"},
)
series = _count_series(scan_rows)
items = []
Expand Down
41 changes: 38 additions & 3 deletions backend/routes/quiz.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,7 +9,9 @@

from pydantic_ai.exceptions import UsageLimitExceeded, UnexpectedModelBehavior

import config
from agents import ORCHESTRATOR_LIMITS
from agents._providers import UnregisteredHandlerError
from agents.quiz import quiz_agent, Quiz, QuizQuestion
from agents.deps import SaplingDeps
from agents._run import run_agent_sync
Expand DownExpand Up@@ -603,17 +605,50 @@ def submit_quiz(body: SubmitQuizBody, background_tasks: BackgroundTasks, request
.replace("{quiz_results_json}", json.dumps(results, indent=2))
)

def _update_context(prompt: str, uid: str, node_id: str):
# Correlate the background write with this request's trace.
ctx_request_id = getattr(request.state, "request_id", None) or current_request_id()

def _update_context(prompt: str, uid: str, node_id: str, quiz_id: str,
request_id: str | None):
# #529/B3: this write was `except Exception: pass` for months while
# every attempt 42P10'd — the adaptive loop died silently. Failures
# are loud now: ERROR log with the attempt id + request id, a
# `quiz.context_write_failed` analytics event, and a re-raise in
# local/test envs so a regression fails CI instead of going quiet.
try:
result = record_agent_usage(
run_agent_sync(quiz_context_agent.run(prompt)),
feature="quiz", task="quiz_context", user_id=uid,
)
save_quiz_context(uid, node_id, result.output.model_dump())
except UnregisteredHandlerError:
# E2E function mode leaves quiz_context deliberately
# unregistered (agents/function_handlers_e2e.py) so no
# post-response DB write races the next test's re-seed. One
# WARNING, no traceback: the logscan oracle reports tracebacks.
logger.warning(
"quiz: context update skipped — quiz_context handler "
"unregistered (function-mode seam) quiz_id=%s", quiz_id,
)
except Exception:
pass
logger.exception(
"quiz: context update failed quiz_id=%s concept=%s "
"request_id=%s", quiz_id, node_id, request_id,
)
events_service.log_event(
"quiz.context_write_failed",
category="error",
user_id=uid,
request_id=request_id,
payload={"quiz_id": quiz_id, "concept_node_id": node_id},
)
if config.IS_LOCAL:
raise

background_tasks.add_task(_update_context, ctx_prompt, user_id, concept_node_id)
background_tasks.add_task(
_update_context, ctx_prompt, user_id, concept_node_id,
body.quiz_id, ctx_request_id,
)

# XP + achievements: after the attempt row (score/total/answers_json) is
# persisted above (the atomic completed_at claim + the update at :486-494
Expand Down
4 changes: 4 additions & 0 deletions backend/services/events_service.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -82,6 +82,10 @@
"document.processed",
"quiz.started",
"quiz.completed",
# #529/B3: the post-submit context write failed. category="error" so it
# surfaces in admin analytics — this failure was invisible for months
# precisely because nothing emitted when the background task died.
"quiz.context_write_failed",
"chat.message_sent",
"note.created",
"session.started",
Expand Down
6 changes: 4 additions & 2 deletions backend/services/quiz_context_service.py
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
import logging
import uuid
from datetime import datetime, timezone

from db.connection import table
Expand All@@ -26,9 +25,12 @@ def get_quiz_context(user_id: str, concept_node_id: str):


def save_quiz_context(user_id: str, concept_node_id: str, context: dict):
# No client-generated id: PostgREST's merge-duplicates upsert updates
# every column in the payload on conflict, so an id here would rewrite
# the existing row's PRIMARY KEY on each refresh. Fresh inserts get the
# column's DB default (gen_random_uuid).
table("quiz_context").upsert(
{
"id": str(uuid.uuid4()),
"user_id": user_id,
"concept_node_id": concept_node_id,
"context_json": encrypt_json(context),
Expand Down
78 changes: 78 additions & 0 deletions backend/tests/integration/test_quiz_context_repair_db.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
"""#529 repair, real-DB half (Workstream B, epic #537).

The class of bug this file exists to catch: the hermetic suite mocked
`table()` and so never saw that quiz_context's UNIQUE was gone — the
upsert 42P10'd in every real environment for ~7.5 weeks while tests
stayed green. These assertions run against the local Supabase stack
(#397 seam: writes through the app, raw reads through psycopg).
"""
import pytest

pytestmark = pytest.mark.integration

USER = "rich-user-active"


def _seeded_node_id(db_conn) -> str:
row = db_conn.execute(
"SELECT id FROM graph_nodes WHERE user_id = %s ORDER BY id LIMIT 1",
(USER,),
).fetchone()
assert row is not None, "rich seed should provide graph nodes for the active user"
return row["id"]


def test_quiz_context_unique_constraint_is_restored(db_conn):
"""The #529 repair migration must leave a UNIQUE covering exactly
(user_id, concept_node_id) — the columns save_quiz_context's
on_conflict names."""
rows = db_conn.execute(
"""
SELECT c.conname,
array_agg(a.attname ORDER BY k.ord) AS cols
FROM pg_constraint c
CROSS JOIN LATERAL unnest(c.conkey) WITH ORDINALITY AS k(attnum, ord)
JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = k.attnum
WHERE c.conrelid = 'quiz_context'::regclass AND c.contype = 'u'
GROUP BY c.conname
"""
).fetchall()
col_sets = [tuple(r["cols"]) for r in rows]
assert ("user_id", "concept_node_id") in col_sets, (
f"no UNIQUE on (user_id, concept_node_id); found: {col_sets!r} — "
"the 0025 regression (#529) is back"
)


def test_save_quiz_context_upserts_one_row_and_encrypts(db_conn):
"""Two writes for the same (user, concept): before the repair the FIRST
write already failed with 42P10; after it, the second must replace the
first (one row), the raw column must be ciphertext, and the app read
must round-trip the latest payload."""
from services.quiz_context_service import get_quiz_context, save_quiz_context

node_id = _seeded_node_id(db_conn)
save_quiz_context(USER, node_id, {"weak_areas": ["first write"]})
first = db_conn.execute(
"SELECT id FROM quiz_context WHERE user_id = %s AND concept_node_id = %s",
(USER, node_id),
).fetchone()
save_quiz_context(USER, node_id, {"weak_areas": ["second write"]})

rows = db_conn.execute(
"SELECT id, context_json FROM quiz_context "
"WHERE user_id = %s AND concept_node_id = %s",
(USER, node_id),
).fetchall()
assert len(rows) == 1, f"upsert must keep exactly one row, found {len(rows)}"
# The refresh must not rewrite the row's PRIMARY KEY (merge-duplicates
# updates every payload column — an id in the payload would churn here).
assert rows[0]["id"] == first["id"]

raw = rows[0]["context_json"]
# #521: ciphertext stored as a JSONB string scalar — a dict here means
# the encrypt-at-write path regressed to plaintext.
assert isinstance(raw, str), f"context_json at rest should be ciphertext str, got {type(raw)}"
assert "second write" not in raw

assert get_quiz_context(USER, node_id) == {"weak_areas": ["second write"]}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 49 additions & 16 deletions backend/agents/tools/quiz_history.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -57,32 +57,65 @@ class QuizHistory(BaseModel):
recent_attempts: list[RecentQuizAttempt] = Field(default_factory=list)


# String fields worth surfacing, in display order. `questions_seen_summary`
# and `notes` are what agents/quiz_context.py::QuizContext actually writes;
# summary/context/digest cover older free-form rows.
_SUMMARY_STRING_KEYS = ("summary", "questions_seen_summary", "notes", "context", "digest")

# List-of-strings fields, with a label so the agent knows what each block is.
# `weak_areas`/`common_mistakes` are the live QuizContext field names;
# misconceptions/common_errors cover older rows.
_SUMMARY_LIST_KEYS = (
("weak_areas", "Weak areas"),
("common_mistakes", "Common mistakes"),
("misconceptions", "Misconceptions"),
("common_errors", "Common errors"),
)


def _coerce_summary(ctx: Any) -> str | None:
"""quiz_context.context_json is free-form (whatever the post-submit
LLM produced). Different prompt versions have stored either a flat
string or a small dict. Coerce to a single string the agent can
reason over, or None if there's nothing useful."""
LLM produced). Coerce to a single string the agent can reason over,
or None if there's nothing useful.

#529/B4: this must consume the WHOLE QuizContext shape. The old
version returned the first matching string key — for a live
QuizContext row that was `notes` alone, silently dropping
weak_areas / common_mistakes / questions_seen_summary (and its list
fallback looked for `common_errors`, a key QuizContext never writes).
"""
if not ctx:
return None
if isinstance(ctx, str):
text = ctx.strip()
return text or None
if isinstance(ctx, dict):
# Common shapes: {"summary": "..."}, {"notes": "..."},
# {"misconceptions": [...], "weak_areas": [...]}.
for key in ("summary", "notes", "context", "digest"):
parts: list[str] = []
for key in _SUMMARY_STRING_KEYS:
v = ctx.get(key)
if isinstance(v, str) and v.strip():
return v.strip()
# Fall back to flattening list-of-strings entries so the agent
# at least sees the misconceptions/weak_areas the prior job
# extracted, even when no top-level summary string exists.
parts: list[str] = []
for key in ("misconceptions", "weak_areas", "common_errors"):
for item in ctx.get(key) or []:
if isinstance(item, str) and item.strip():
parts.append(f"- {item.strip()}")
return "\n".join(parts) or None
parts.append(v.strip())
for key, label in _SUMMARY_LIST_KEYS:
raw = ctx.get(key)
if not isinstance(raw, list):
# Legacy free-form rows can hold a string (or dict) under a
# list-shaped key; iterating those element-wise would spray
# per-character bullets / dict keys into the agent's prompt.
continue
items = [
item.strip()
for item in raw
if isinstance(item, str) and item.strip()
]
if items:
parts.append(label + ":\n" + "\n".join(f"- {i}" for i in items))
rec = ctx.get("recommended_difficulty")
if isinstance(rec, str) and rec.strip():
# The post-submit agent's difficulty recommendation — surfaced
# here or it rots encrypted-and-unread (its only other mention
# is the dead legacy prompt template).
parts.append(f"Recommended next difficulty: {rec.strip()}")
return "\n\n".join(parts) or None
return None


Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
-- Repairs 0025 (#529): quiz_context lost UNIQUE (user_id, concept_node_id).
--
-- 0001_baseline_schema.sql created the table with that UNIQUE inline;
-- 0025_study_integrity.sql dropped and recreated quiz_context (lines 108-114)
-- WITHOUT it. services/quiz_context_service.py's upsert names those columns in
-- on_conflict, so PostgREST rejected every write with 42P10 — and because the
-- caller swallowed the exception, the adaptive-context loop was silently dead
-- from 2026-06-23 until this repair. Staging and prod both measured 0 rows and
-- 0 duplicate pairs on 2026-08-12 (the very first write already failed, so
-- nothing accumulated), but local replicas replay independently — dedup anyway.

-- Keep the newest row per (user_id, concept_node_id); report what was removed.
DO $$
DECLARE removed integer;
BEGIN
DELETE FROM quiz_context qc
USING quiz_context newer
WHERE qc.user_id = newer.user_id
AND qc.concept_node_id = newer.concept_node_id
AND (qc.updated_at < newer.updated_at
OR (qc.updated_at = newer.updated_at AND qc.id < newer.id));
GET DIAGNOSTICS removed = ROW_COUNT;
RAISE NOTICE 'quiz_context dedup before UNIQUE restore: % duplicate row(s) removed', removed;
END $$;

-- Idempotent restore. Named explicitly (0001's inline UNIQUE got the default
-- name quiz_context_user_id_concept_node_id_key; this repair gets its own so
-- its origin is greppable).
ALTER TABLE quiz_context
DROP CONSTRAINT IF EXISTS quiz_context_user_concept_key;
ALTER TABLE quiz_context
ADD CONSTRAINT quiz_context_user_concept_key UNIQUE (user_id, concept_node_id);
12 changes: 8 additions & 4 deletions backend/routes/admin_analytics.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -441,22 +441,26 @@ def errors(
offset: int = Query(0, ge=0),
bucket: Bucket | None = Query(None),
) -> ErrorsPage:
"""Paginated error.* event feed; `?bucket=day` adds a per-day series (its own capped scan)."""
"""Paginated error-category event feed; `?bucket=day` adds a per-day series (its own capped scan)."""
require_admin(request)
response.headers["Cache-Control"] = "private"
from_iso, to_iso = _resolve_range(from_, to)
# error.* events, newest first — paginated server-side (no aggregation).
# ALL category="error" events, newest first — not just the error.* HTTP
# names. Backend failures like quiz.context_write_failed (#529/B3) and
# the rag.* pair (#482) carry the error category without the name
# prefix; filtering by name hid exactly the events whose invisibility
# they were added to end. Non-HTTP rows simply null the payload fields.
rows, total = table("events").select_with_count(
"created_at,event_type,request_id,user_id,payload",
filters={"created_at": [f"gte.{from_iso}", f"lte.{to_iso}"], "event_type": "like.error.*"},
filters={"created_at": [f"gte.{from_iso}", f"lte.{to_iso}"], "category": "eq.error"},
order="created_at.desc", limit=limit, offset=offset,
)
series = None
series_truncated = False
if bucket:
scan_rows, series_truncated = _scan_range(
"events", "created_at", from_iso, to_iso,
extra_filters={"event_type": "like.error.*"},
extra_filters={"category": "eq.error"},
)
series = _count_series(scan_rows)
items = []
Expand Down
41 changes: 38 additions & 3 deletions backend/routes/quiz.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,7 +9,9 @@

from pydantic_ai.exceptions import UsageLimitExceeded, UnexpectedModelBehavior

import config
from agents import ORCHESTRATOR_LIMITS
from agents._providers import UnregisteredHandlerError
from agents.quiz import quiz_agent, Quiz, QuizQuestion
from agents.deps import SaplingDeps
from agents._run import run_agent_sync
Expand DownExpand Up@@ -603,17 +605,50 @@ def submit_quiz(body: SubmitQuizBody, background_tasks: BackgroundTasks, request
.replace("{quiz_results_json}", json.dumps(results, indent=2))
)

def _update_context(prompt: str, uid: str, node_id: str):
# Correlate the background write with this request's trace.
ctx_request_id = getattr(request.state, "request_id", None) or current_request_id()

def _update_context(prompt: str, uid: str, node_id: str, quiz_id: str,
request_id: str | None):
# #529/B3: this write was `except Exception: pass` for months while
# every attempt 42P10'd — the adaptive loop died silently. Failures
# are loud now: ERROR log with the attempt id + request id, a
# `quiz.context_write_failed` analytics event, and a re-raise in
# local/test envs so a regression fails CI instead of going quiet.
try:
result = record_agent_usage(
run_agent_sync(quiz_context_agent.run(prompt)),
feature="quiz", task="quiz_context", user_id=uid,
)
save_quiz_context(uid, node_id, result.output.model_dump())
except UnregisteredHandlerError:
# E2E function mode leaves quiz_context deliberately
# unregistered (agents/function_handlers_e2e.py) so no
# post-response DB write races the next test's re-seed. One
# WARNING, no traceback: the logscan oracle reports tracebacks.
logger.warning(
"quiz: context update skipped — quiz_context handler "
"unregistered (function-mode seam) quiz_id=%s", quiz_id,
)
except Exception:
pass
logger.exception(
"quiz: context update failed quiz_id=%s concept=%s "
"request_id=%s", quiz_id, node_id, request_id,
)
events_service.log_event(
"quiz.context_write_failed",
category="error",
user_id=uid,
request_id=request_id,
payload={"quiz_id": quiz_id, "concept_node_id": node_id},
)
if config.IS_LOCAL:
raise

background_tasks.add_task(_update_context, ctx_prompt, user_id, concept_node_id)
background_tasks.add_task(
_update_context, ctx_prompt, user_id, concept_node_id,
body.quiz_id, ctx_request_id,
)

# XP + achievements: after the attempt row (score/total/answers_json) is
# persisted above (the atomic completed_at claim + the update at :486-494
Expand Down
4 changes: 4 additions & 0 deletions backend/services/events_service.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -82,6 +82,10 @@
"document.processed",
"quiz.started",
"quiz.completed",
# #529/B3: the post-submit context write failed. category="error" so it
# surfaces in admin analytics — this failure was invisible for months
# precisely because nothing emitted when the background task died.
"quiz.context_write_failed",
"chat.message_sent",
"note.created",
"session.started",
Expand Down
6 changes: 4 additions & 2 deletions backend/services/quiz_context_service.py
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
import logging
import uuid
from datetime import datetime, timezone

from db.connection import table
Expand All@@ -26,9 +25,12 @@ def get_quiz_context(user_id: str, concept_node_id: str):


def save_quiz_context(user_id: str, concept_node_id: str, context: dict):
# No client-generated id: PostgREST's merge-duplicates upsert updates
# every column in the payload on conflict, so an id here would rewrite
# the existing row's PRIMARY KEY on each refresh. Fresh inserts get the
# column's DB default (gen_random_uuid).
table("quiz_context").upsert(
{
"id": str(uuid.uuid4()),
"user_id": user_id,
"concept_node_id": concept_node_id,
"context_json": encrypt_json(context),
Expand Down
78 changes: 78 additions & 0 deletions backend/tests/integration/test_quiz_context_repair_db.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
"""#529 repair, real-DB half (Workstream B, epic #537).

The class of bug this file exists to catch: the hermetic suite mocked
`table()` and so never saw that quiz_context's UNIQUE was gone — the
upsert 42P10'd in every real environment for ~7.5 weeks while tests
stayed green. These assertions run against the local Supabase stack
(#397 seam: writes through the app, raw reads through psycopg).
"""
import pytest

pytestmark = pytest.mark.integration

USER = "rich-user-active"


def _seeded_node_id(db_conn) -> str:
row = db_conn.execute(
"SELECT id FROM graph_nodes WHERE user_id = %s ORDER BY id LIMIT 1",
(USER,),
).fetchone()
assert row is not None, "rich seed should provide graph nodes for the active user"
return row["id"]


def test_quiz_context_unique_constraint_is_restored(db_conn):
"""The #529 repair migration must leave a UNIQUE covering exactly
(user_id, concept_node_id) — the columns save_quiz_context's
on_conflict names."""
rows = db_conn.execute(
"""
SELECT c.conname,
array_agg(a.attname ORDER BY k.ord) AS cols
FROM pg_constraint c
CROSS JOIN LATERAL unnest(c.conkey) WITH ORDINALITY AS k(attnum, ord)
JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = k.attnum
WHERE c.conrelid = 'quiz_context'::regclass AND c.contype = 'u'
GROUP BY c.conname
"""
).fetchall()
col_sets = [tuple(r["cols"]) for r in rows]
assert ("user_id", "concept_node_id") in col_sets, (
f"no UNIQUE on (user_id, concept_node_id); found: {col_sets!r} — "
"the 0025 regression (#529) is back"
)


def test_save_quiz_context_upserts_one_row_and_encrypts(db_conn):
"""Two writes for the same (user, concept): before the repair the FIRST
write already failed with 42P10; after it, the second must replace the
first (one row), the raw column must be ciphertext, and the app read
must round-trip the latest payload."""
from services.quiz_context_service import get_quiz_context, save_quiz_context

node_id = _seeded_node_id(db_conn)
save_quiz_context(USER, node_id, {"weak_areas": ["first write"]})
first = db_conn.execute(
"SELECT id FROM quiz_context WHERE user_id = %s AND concept_node_id = %s",
(USER, node_id),
).fetchone()
save_quiz_context(USER, node_id, {"weak_areas": ["second write"]})

rows = db_conn.execute(
"SELECT id, context_json FROM quiz_context "
"WHERE user_id = %s AND concept_node_id = %s",
(USER, node_id),
).fetchall()
assert len(rows) == 1, f"upsert must keep exactly one row, found {len(rows)}"
# The refresh must not rewrite the row's PRIMARY KEY (merge-duplicates
# updates every payload column — an id in the payload would churn here).
assert rows[0]["id"] == first["id"]

raw = rows[0]["context_json"]
# #521: ciphertext stored as a JSONB string scalar — a dict here means
# the encrypt-at-write path regressed to plaintext.
assert isinstance(raw, str), f"context_json at rest should be ciphertext str, got {type(raw)}"
assert "second write" not in raw

assert get_quiz_context(USER, node_id) == {"weak_areas": ["second write"]}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 49 additions & 16 deletions backend/agents/tools/quiz_history.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -57,32 +57,65 @@ class QuizHistory(BaseModel):
recent_attempts: list[RecentQuizAttempt] = Field(default_factory=list)


# String fields worth surfacing, in display order. `questions_seen_summary`
# and `notes` are what agents/quiz_context.py::QuizContext actually writes;
# summary/context/digest cover older free-form rows.
_SUMMARY_STRING_KEYS = ("summary", "questions_seen_summary", "notes", "context", "digest")

# List-of-strings fields, with a label so the agent knows what each block is.
# `weak_areas`/`common_mistakes` are the live QuizContext field names;
# misconceptions/common_errors cover older rows.
_SUMMARY_LIST_KEYS = (
("weak_areas", "Weak areas"),
("common_mistakes", "Common mistakes"),
("misconceptions", "Misconceptions"),
("common_errors", "Common errors"),
)


def _coerce_summary(ctx: Any) -> str | None:
"""quiz_context.context_json is free-form (whatever the post-submit
LLM produced). Different prompt versions have stored either a flat
string or a small dict. Coerce to a single string the agent can
reason over, or None if there's nothing useful."""
LLM produced). Coerce to a single string the agent can reason over,
or None if there's nothing useful.

#529/B4: this must consume the WHOLE QuizContext shape. The old
version returned the first matching string key — for a live
QuizContext row that was `notes` alone, silently dropping
weak_areas / common_mistakes / questions_seen_summary (and its list
fallback looked for `common_errors`, a key QuizContext never writes).
"""
if not ctx:
return None
if isinstance(ctx, str):
text = ctx.strip()
return text or None
if isinstance(ctx, dict):
# Common shapes: {"summary": "..."}, {"notes": "..."},
# {"misconceptions": [...], "weak_areas": [...]}.
for key in ("summary", "notes", "context", "digest"):
parts: list[str] = []
for key in _SUMMARY_STRING_KEYS:
v = ctx.get(key)
if isinstance(v, str) and v.strip():
return v.strip()
# Fall back to flattening list-of-strings entries so the agent
# at least sees the misconceptions/weak_areas the prior job
# extracted, even when no top-level summary string exists.
parts: list[str] = []
for key in ("misconceptions", "weak_areas", "common_errors"):
for item in ctx.get(key) or []:
if isinstance(item, str) and item.strip():
parts.append(f"- {item.strip()}")
return "\n".join(parts) or None
parts.append(v.strip())
for key, label in _SUMMARY_LIST_KEYS:
raw = ctx.get(key)
if not isinstance(raw, list):
# Legacy free-form rows can hold a string (or dict) under a
# list-shaped key; iterating those element-wise would spray
# per-character bullets / dict keys into the agent's prompt.
continue
items = [
item.strip()
for item in raw
if isinstance(item, str) and item.strip()
]
if items:
parts.append(label + ":\n" + "\n".join(f"- {i}" for i in items))
rec = ctx.get("recommended_difficulty")
if isinstance(rec, str) and rec.strip():
# The post-submit agent's difficulty recommendation — surfaced
# here or it rots encrypted-and-unread (its only other mention
# is the dead legacy prompt template).
parts.append(f"Recommended next difficulty: {rec.strip()}")
return "\n\n".join(parts) or None
return None


Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
-- Repairs 0025 (#529): quiz_context lost UNIQUE (user_id, concept_node_id).
--
-- 0001_baseline_schema.sql created the table with that UNIQUE inline;
-- 0025_study_integrity.sql dropped and recreated quiz_context (lines 108-114)
-- WITHOUT it. services/quiz_context_service.py's upsert names those columns in
-- on_conflict, so PostgREST rejected every write with 42P10 — and because the
-- caller swallowed the exception, the adaptive-context loop was silently dead
-- from 2026-06-23 until this repair. Staging and prod both measured 0 rows and
-- 0 duplicate pairs on 2026-08-12 (the very first write already failed, so
-- nothing accumulated), but local replicas replay independently — dedup anyway.

-- Keep the newest row per (user_id, concept_node_id); report what was removed.
DO $$
DECLARE removed integer;
BEGIN
DELETE FROM quiz_context qc
USING quiz_context newer
WHERE qc.user_id = newer.user_id
AND qc.concept_node_id = newer.concept_node_id
AND (qc.updated_at < newer.updated_at
OR (qc.updated_at = newer.updated_at AND qc.id < newer.id));
GET DIAGNOSTICS removed = ROW_COUNT;
RAISE NOTICE 'quiz_context dedup before UNIQUE restore: % duplicate row(s) removed', removed;
END $$;

-- Idempotent restore. Named explicitly (0001's inline UNIQUE got the default
-- name quiz_context_user_id_concept_node_id_key; this repair gets its own so
-- its origin is greppable).
ALTER TABLE quiz_context
DROP CONSTRAINT IF EXISTS quiz_context_user_concept_key;
ALTER TABLE quiz_context
ADD CONSTRAINT quiz_context_user_concept_key UNIQUE (user_id, concept_node_id);
12 changes: 8 additions & 4 deletions backend/routes/admin_analytics.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -441,22 +441,26 @@ def errors(
offset: int = Query(0, ge=0),
bucket: Bucket | None = Query(None),
) -> ErrorsPage:
"""Paginated error.* event feed; `?bucket=day` adds a per-day series (its own capped scan)."""
"""Paginated error-category event feed; `?bucket=day` adds a per-day series (its own capped scan)."""
require_admin(request)
response.headers["Cache-Control"] = "private"
from_iso, to_iso = _resolve_range(from_, to)
# error.* events, newest first — paginated server-side (no aggregation).
# ALL category="error" events, newest first — not just the error.* HTTP
# names. Backend failures like quiz.context_write_failed (#529/B3) and
# the rag.* pair (#482) carry the error category without the name
# prefix; filtering by name hid exactly the events whose invisibility
# they were added to end. Non-HTTP rows simply null the payload fields.
rows, total = table("events").select_with_count(
"created_at,event_type,request_id,user_id,payload",
filters={"created_at": [f"gte.{from_iso}", f"lte.{to_iso}"], "event_type": "like.error.*"},
filters={"created_at": [f"gte.{from_iso}", f"lte.{to_iso}"], "category": "eq.error"},
order="created_at.desc", limit=limit, offset=offset,
)
series = None
series_truncated = False
if bucket:
scan_rows, series_truncated = _scan_range(
"events", "created_at", from_iso, to_iso,
extra_filters={"event_type": "like.error.*"},
extra_filters={"category": "eq.error"},
)
series = _count_series(scan_rows)
items = []
Expand Down
41 changes: 38 additions & 3 deletions backend/routes/quiz.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,7 +9,9 @@

from pydantic_ai.exceptions import UsageLimitExceeded, UnexpectedModelBehavior

import config
from agents import ORCHESTRATOR_LIMITS
from agents._providers import UnregisteredHandlerError
from agents.quiz import quiz_agent, Quiz, QuizQuestion
from agents.deps import SaplingDeps
from agents._run import run_agent_sync
Expand DownExpand Up@@ -603,17 +605,50 @@ def submit_quiz(body: SubmitQuizBody, background_tasks: BackgroundTasks, request
.replace("{quiz_results_json}", json.dumps(results, indent=2))
)

def _update_context(prompt: str, uid: str, node_id: str):
# Correlate the background write with this request's trace.
ctx_request_id = getattr(request.state, "request_id", None) or current_request_id()

def _update_context(prompt: str, uid: str, node_id: str, quiz_id: str,
request_id: str | None):
# #529/B3: this write was `except Exception: pass` for months while
# every attempt 42P10'd — the adaptive loop died silently. Failures
# are loud now: ERROR log with the attempt id + request id, a
# `quiz.context_write_failed` analytics event, and a re-raise in
# local/test envs so a regression fails CI instead of going quiet.
try:
result = record_agent_usage(
run_agent_sync(quiz_context_agent.run(prompt)),
feature="quiz", task="quiz_context", user_id=uid,
)
save_quiz_context(uid, node_id, result.output.model_dump())
except UnregisteredHandlerError:
# E2E function mode leaves quiz_context deliberately
# unregistered (agents/function_handlers_e2e.py) so no
# post-response DB write races the next test's re-seed. One
# WARNING, no traceback: the logscan oracle reports tracebacks.
logger.warning(
"quiz: context update skipped — quiz_context handler "
"unregistered (function-mode seam) quiz_id=%s", quiz_id,
)
except Exception:
pass
logger.exception(
"quiz: context update failed quiz_id=%s concept=%s "
"request_id=%s", quiz_id, node_id, request_id,
)
events_service.log_event(
"quiz.context_write_failed",
category="error",
user_id=uid,
request_id=request_id,
payload={"quiz_id": quiz_id, "concept_node_id": node_id},
)
if config.IS_LOCAL:
raise

background_tasks.add_task(_update_context, ctx_prompt, user_id, concept_node_id)
background_tasks.add_task(
_update_context, ctx_prompt, user_id, concept_node_id,
body.quiz_id, ctx_request_id,
)

# XP + achievements: after the attempt row (score/total/answers_json) is
# persisted above (the atomic completed_at claim + the update at :486-494
Expand Down
4 changes: 4 additions & 0 deletions backend/services/events_service.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -82,6 +82,10 @@
"document.processed",
"quiz.started",
"quiz.completed",
# #529/B3: the post-submit context write failed. category="error" so it
# surfaces in admin analytics — this failure was invisible for months
# precisely because nothing emitted when the background task died.
"quiz.context_write_failed",
"chat.message_sent",
"note.created",
"session.started",
Expand Down
6 changes: 4 additions & 2 deletions backend/services/quiz_context_service.py
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
import logging
import uuid
from datetime import datetime, timezone

from db.connection import table
Expand All@@ -26,9 +25,12 @@ def get_quiz_context(user_id: str, concept_node_id: str):


def save_quiz_context(user_id: str, concept_node_id: str, context: dict):
# No client-generated id: PostgREST's merge-duplicates upsert updates
# every column in the payload on conflict, so an id here would rewrite
# the existing row's PRIMARY KEY on each refresh. Fresh inserts get the
# column's DB default (gen_random_uuid).
table("quiz_context").upsert(
{
"id": str(uuid.uuid4()),
"user_id": user_id,
"concept_node_id": concept_node_id,
"context_json": encrypt_json(context),
Expand Down
78 changes: 78 additions & 0 deletions backend/tests/integration/test_quiz_context_repair_db.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
"""#529 repair, real-DB half (Workstream B, epic #537).

The class of bug this file exists to catch: the hermetic suite mocked
`table()` and so never saw that quiz_context's UNIQUE was gone — the
upsert 42P10'd in every real environment for ~7.5 weeks while tests
stayed green. These assertions run against the local Supabase stack
(#397 seam: writes through the app, raw reads through psycopg).
"""
import pytest

pytestmark = pytest.mark.integration

USER = "rich-user-active"


def _seeded_node_id(db_conn) -> str:
row = db_conn.execute(
"SELECT id FROM graph_nodes WHERE user_id = %s ORDER BY id LIMIT 1",
(USER,),
).fetchone()
assert row is not None, "rich seed should provide graph nodes for the active user"
return row["id"]


def test_quiz_context_unique_constraint_is_restored(db_conn):
"""The #529 repair migration must leave a UNIQUE covering exactly
(user_id, concept_node_id) — the columns save_quiz_context's
on_conflict names."""
rows = db_conn.execute(
"""
SELECT c.conname,
array_agg(a.attname ORDER BY k.ord) AS cols
FROM pg_constraint c
CROSS JOIN LATERAL unnest(c.conkey) WITH ORDINALITY AS k(attnum, ord)
JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = k.attnum
WHERE c.conrelid = 'quiz_context'::regclass AND c.contype = 'u'
GROUP BY c.conname
"""
).fetchall()
col_sets = [tuple(r["cols"]) for r in rows]
assert ("user_id", "concept_node_id") in col_sets, (
f"no UNIQUE on (user_id, concept_node_id); found: {col_sets!r} — "
"the 0025 regression (#529) is back"
)


def test_save_quiz_context_upserts_one_row_and_encrypts(db_conn):
"""Two writes for the same (user, concept): before the repair the FIRST
write already failed with 42P10; after it, the second must replace the
first (one row), the raw column must be ciphertext, and the app read
must round-trip the latest payload."""
from services.quiz_context_service import get_quiz_context, save_quiz_context

node_id = _seeded_node_id(db_conn)
save_quiz_context(USER, node_id, {"weak_areas": ["first write"]})
first = db_conn.execute(
"SELECT id FROM quiz_context WHERE user_id = %s AND concept_node_id = %s",
(USER, node_id),
).fetchone()
save_quiz_context(USER, node_id, {"weak_areas": ["second write"]})

rows = db_conn.execute(
"SELECT id, context_json FROM quiz_context "
"WHERE user_id = %s AND concept_node_id = %s",
(USER, node_id),
).fetchall()
assert len(rows) == 1, f"upsert must keep exactly one row, found {len(rows)}"
# The refresh must not rewrite the row's PRIMARY KEY (merge-duplicates
# updates every payload column — an id in the payload would churn here).
assert rows[0]["id"] == first["id"]

raw = rows[0]["context_json"]
# #521: ciphertext stored as a JSONB string scalar — a dict here means
# the encrypt-at-write path regressed to plaintext.
assert isinstance(raw, str), f"context_json at rest should be ciphertext str, got {type(raw)}"
assert "second write" not in raw

assert get_quiz_context(USER, node_id) == {"weak_areas": ["second write"]}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 49 additions & 16 deletions backend/agents/tools/quiz_history.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -57,32 +57,65 @@ class QuizHistory(BaseModel):
recent_attempts: list[RecentQuizAttempt] = Field(default_factory=list)


# String fields worth surfacing, in display order. `questions_seen_summary`
# and `notes` are what agents/quiz_context.py::QuizContext actually writes;
# summary/context/digest cover older free-form rows.
_SUMMARY_STRING_KEYS = ("summary", "questions_seen_summary", "notes", "context", "digest")

# List-of-strings fields, with a label so the agent knows what each block is.
# `weak_areas`/`common_mistakes` are the live QuizContext field names;
# misconceptions/common_errors cover older rows.
_SUMMARY_LIST_KEYS = (
("weak_areas", "Weak areas"),
("common_mistakes", "Common mistakes"),
("misconceptions", "Misconceptions"),
("common_errors", "Common errors"),
)


def _coerce_summary(ctx: Any) -> str | None:
"""quiz_context.context_json is free-form (whatever the post-submit
LLM produced). Different prompt versions have stored either a flat
string or a small dict. Coerce to a single string the agent can
reason over, or None if there's nothing useful."""
LLM produced). Coerce to a single string the agent can reason over,
or None if there's nothing useful.

#529/B4: this must consume the WHOLE QuizContext shape. The old
version returned the first matching string key — for a live
QuizContext row that was `notes` alone, silently dropping
weak_areas / common_mistakes / questions_seen_summary (and its list
fallback looked for `common_errors`, a key QuizContext never writes).
"""
if not ctx:
return None
if isinstance(ctx, str):
text = ctx.strip()
return text or None
if isinstance(ctx, dict):
# Common shapes: {"summary": "..."}, {"notes": "..."},
# {"misconceptions": [...], "weak_areas": [...]}.
for key in ("summary", "notes", "context", "digest"):
parts: list[str] = []
for key in _SUMMARY_STRING_KEYS:
v = ctx.get(key)
if isinstance(v, str) and v.strip():
return v.strip()
# Fall back to flattening list-of-strings entries so the agent
# at least sees the misconceptions/weak_areas the prior job
# extracted, even when no top-level summary string exists.
parts: list[str] = []
for key in ("misconceptions", "weak_areas", "common_errors"):
for item in ctx.get(key) or []:
if isinstance(item, str) and item.strip():
parts.append(f"- {item.strip()}")
return "\n".join(parts) or None
parts.append(v.strip())
for key, label in _SUMMARY_LIST_KEYS:
raw = ctx.get(key)
if not isinstance(raw, list):
# Legacy free-form rows can hold a string (or dict) under a
# list-shaped key; iterating those element-wise would spray
# per-character bullets / dict keys into the agent's prompt.
continue
items = [
item.strip()
for item in raw
if isinstance(item, str) and item.strip()
]
if items:
parts.append(label + ":\n" + "\n".join(f"- {i}" for i in items))
rec = ctx.get("recommended_difficulty")
if isinstance(rec, str) and rec.strip():
# The post-submit agent's difficulty recommendation — surfaced
# here or it rots encrypted-and-unread (its only other mention
# is the dead legacy prompt template).
parts.append(f"Recommended next difficulty: {rec.strip()}")
return "\n\n".join(parts) or None
return None


Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
-- Repairs 0025 (#529): quiz_context lost UNIQUE (user_id, concept_node_id).
--
-- 0001_baseline_schema.sql created the table with that UNIQUE inline;
-- 0025_study_integrity.sql dropped and recreated quiz_context (lines 108-114)
-- WITHOUT it. services/quiz_context_service.py's upsert names those columns in
-- on_conflict, so PostgREST rejected every write with 42P10 — and because the
-- caller swallowed the exception, the adaptive-context loop was silently dead
-- from 2026-06-23 until this repair. Staging and prod both measured 0 rows and
-- 0 duplicate pairs on 2026-08-12 (the very first write already failed, so
-- nothing accumulated), but local replicas replay independently — dedup anyway.

-- Keep the newest row per (user_id, concept_node_id); report what was removed.
DO $$
DECLARE removed integer;
BEGIN
DELETE FROM quiz_context qc
USING quiz_context newer
WHERE qc.user_id = newer.user_id
AND qc.concept_node_id = newer.concept_node_id
AND (qc.updated_at < newer.updated_at
OR (qc.updated_at = newer.updated_at AND qc.id < newer.id));
GET DIAGNOSTICS removed = ROW_COUNT;
RAISE NOTICE 'quiz_context dedup before UNIQUE restore: % duplicate row(s) removed', removed;
END $$;

-- Idempotent restore. Named explicitly (0001's inline UNIQUE got the default
-- name quiz_context_user_id_concept_node_id_key; this repair gets its own so
-- its origin is greppable).
ALTER TABLE quiz_context
DROP CONSTRAINT IF EXISTS quiz_context_user_concept_key;
ALTER TABLE quiz_context
ADD CONSTRAINT quiz_context_user_concept_key UNIQUE (user_id, concept_node_id);
12 changes: 8 additions & 4 deletions backend/routes/admin_analytics.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -441,22 +441,26 @@ def errors(
offset: int = Query(0, ge=0),
bucket: Bucket | None = Query(None),
) -> ErrorsPage:
"""Paginated error.* event feed; `?bucket=day` adds a per-day series (its own capped scan)."""
"""Paginated error-category event feed; `?bucket=day` adds a per-day series (its own capped scan)."""
require_admin(request)
response.headers["Cache-Control"] = "private"
from_iso, to_iso = _resolve_range(from_, to)
# error.* events, newest first — paginated server-side (no aggregation).
# ALL category="error" events, newest first — not just the error.* HTTP
# names. Backend failures like quiz.context_write_failed (#529/B3) and
# the rag.* pair (#482) carry the error category without the name
# prefix; filtering by name hid exactly the events whose invisibility
# they were added to end. Non-HTTP rows simply null the payload fields.
rows, total = table("events").select_with_count(
"created_at,event_type,request_id,user_id,payload",
filters={"created_at": [f"gte.{from_iso}", f"lte.{to_iso}"], "event_type": "like.error.*"},
filters={"created_at": [f"gte.{from_iso}", f"lte.{to_iso}"], "category": "eq.error"},
order="created_at.desc", limit=limit, offset=offset,
)
series = None
series_truncated = False
if bucket:
scan_rows, series_truncated = _scan_range(
"events", "created_at", from_iso, to_iso,
extra_filters={"event_type": "like.error.*"},
extra_filters={"category": "eq.error"},
)
series = _count_series(scan_rows)
items = []
Expand Down
41 changes: 38 additions & 3 deletions backend/routes/quiz.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,7 +9,9 @@

from pydantic_ai.exceptions import UsageLimitExceeded, UnexpectedModelBehavior

import config
from agents import ORCHESTRATOR_LIMITS
from agents._providers import UnregisteredHandlerError
from agents.quiz import quiz_agent, Quiz, QuizQuestion
from agents.deps import SaplingDeps
from agents._run import run_agent_sync
Expand DownExpand Up@@ -603,17 +605,50 @@ def submit_quiz(body: SubmitQuizBody, background_tasks: BackgroundTasks, request
.replace("{quiz_results_json}", json.dumps(results, indent=2))
)

def _update_context(prompt: str, uid: str, node_id: str):
# Correlate the background write with this request's trace.
ctx_request_id = getattr(request.state, "request_id", None) or current_request_id()

def _update_context(prompt: str, uid: str, node_id: str, quiz_id: str,
request_id: str | None):
# #529/B3: this write was `except Exception: pass` for months while
# every attempt 42P10'd — the adaptive loop died silently. Failures
# are loud now: ERROR log with the attempt id + request id, a
# `quiz.context_write_failed` analytics event, and a re-raise in
# local/test envs so a regression fails CI instead of going quiet.
try:
result = record_agent_usage(
run_agent_sync(quiz_context_agent.run(prompt)),
feature="quiz", task="quiz_context", user_id=uid,
)
save_quiz_context(uid, node_id, result.output.model_dump())
except UnregisteredHandlerError:
# E2E function mode leaves quiz_context deliberately
# unregistered (agents/function_handlers_e2e.py) so no
# post-response DB write races the next test's re-seed. One
# WARNING, no traceback: the logscan oracle reports tracebacks.
logger.warning(
"quiz: context update skipped — quiz_context handler "
"unregistered (function-mode seam) quiz_id=%s", quiz_id,
)
except Exception:
pass
logger.exception(
"quiz: context update failed quiz_id=%s concept=%s "
"request_id=%s", quiz_id, node_id, request_id,
)
events_service.log_event(
"quiz.context_write_failed",
category="error",
user_id=uid,
request_id=request_id,
payload={"quiz_id": quiz_id, "concept_node_id": node_id},
)
if config.IS_LOCAL:
raise

background_tasks.add_task(_update_context, ctx_prompt, user_id, concept_node_id)
background_tasks.add_task(
_update_context, ctx_prompt, user_id, concept_node_id,
body.quiz_id, ctx_request_id,
)

# XP + achievements: after the attempt row (score/total/answers_json) is
# persisted above (the atomic completed_at claim + the update at :486-494
Expand Down
4 changes: 4 additions & 0 deletions backend/services/events_service.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -82,6 +82,10 @@
"document.processed",
"quiz.started",
"quiz.completed",
# #529/B3: the post-submit context write failed. category="error" so it
# surfaces in admin analytics — this failure was invisible for months
# precisely because nothing emitted when the background task died.
"quiz.context_write_failed",
"chat.message_sent",
"note.created",
"session.started",
Expand Down
6 changes: 4 additions & 2 deletions backend/services/quiz_context_service.py
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
import logging
import uuid
from datetime import datetime, timezone

from db.connection import table
Expand All@@ -26,9 +25,12 @@ def get_quiz_context(user_id: str, concept_node_id: str):


def save_quiz_context(user_id: str, concept_node_id: str, context: dict):
# No client-generated id: PostgREST's merge-duplicates upsert updates
# every column in the payload on conflict, so an id here would rewrite
# the existing row's PRIMARY KEY on each refresh. Fresh inserts get the
# column's DB default (gen_random_uuid).
table("quiz_context").upsert(
{
"id": str(uuid.uuid4()),
"user_id": user_id,
"concept_node_id": concept_node_id,
"context_json": encrypt_json(context),
Expand Down
78 changes: 78 additions & 0 deletions backend/tests/integration/test_quiz_context_repair_db.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
"""#529 repair, real-DB half (Workstream B, epic #537).

The class of bug this file exists to catch: the hermetic suite mocked
`table()` and so never saw that quiz_context's UNIQUE was gone — the
upsert 42P10'd in every real environment for ~7.5 weeks while tests
stayed green. These assertions run against the local Supabase stack
(#397 seam: writes through the app, raw reads through psycopg).
"""
import pytest

pytestmark = pytest.mark.integration

USER = "rich-user-active"


def _seeded_node_id(db_conn) -> str:
row = db_conn.execute(
"SELECT id FROM graph_nodes WHERE user_id = %s ORDER BY id LIMIT 1",
(USER,),
).fetchone()
assert row is not None, "rich seed should provide graph nodes for the active user"
return row["id"]


def test_quiz_context_unique_constraint_is_restored(db_conn):
"""The #529 repair migration must leave a UNIQUE covering exactly
(user_id, concept_node_id) — the columns save_quiz_context's
on_conflict names."""
rows = db_conn.execute(
"""
SELECT c.conname,
array_agg(a.attname ORDER BY k.ord) AS cols
FROM pg_constraint c
CROSS JOIN LATERAL unnest(c.conkey) WITH ORDINALITY AS k(attnum, ord)
JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = k.attnum
WHERE c.conrelid = 'quiz_context'::regclass AND c.contype = 'u'
GROUP BY c.conname
"""
).fetchall()
col_sets = [tuple(r["cols"]) for r in rows]
assert ("user_id", "concept_node_id") in col_sets, (
f"no UNIQUE on (user_id, concept_node_id); found: {col_sets!r} — "
"the 0025 regression (#529) is back"
)


def test_save_quiz_context_upserts_one_row_and_encrypts(db_conn):
"""Two writes for the same (user, concept): before the repair the FIRST
write already failed with 42P10; after it, the second must replace the
first (one row), the raw column must be ciphertext, and the app read
must round-trip the latest payload."""
from services.quiz_context_service import get_quiz_context, save_quiz_context

node_id = _seeded_node_id(db_conn)
save_quiz_context(USER, node_id, {"weak_areas": ["first write"]})
first = db_conn.execute(
"SELECT id FROM quiz_context WHERE user_id = %s AND concept_node_id = %s",
(USER, node_id),
).fetchone()
save_quiz_context(USER, node_id, {"weak_areas": ["second write"]})

rows = db_conn.execute(
"SELECT id, context_json FROM quiz_context "
"WHERE user_id = %s AND concept_node_id = %s",
(USER, node_id),
).fetchall()
assert len(rows) == 1, f"upsert must keep exactly one row, found {len(rows)}"
# The refresh must not rewrite the row's PRIMARY KEY (merge-duplicates
# updates every payload column — an id in the payload would churn here).
assert rows[0]["id"] == first["id"]

raw = rows[0]["context_json"]
# #521: ciphertext stored as a JSONB string scalar — a dict here means
# the encrypt-at-write path regressed to plaintext.
assert isinstance(raw, str), f"context_json at rest should be ciphertext str, got {type(raw)}"
assert "second write" not in raw

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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 49 additions & 16 deletions backend/agents/tools/quiz_history.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -57,32 +57,65 @@ class QuizHistory(BaseModel):
recent_attempts: list[RecentQuizAttempt] = Field(default_factory=list)


# String fields worth surfacing, in display order. `questions_seen_summary`
# and `notes` are what agents/quiz_context.py::QuizContext actually writes;
# summary/context/digest cover older free-form rows.
_SUMMARY_STRING_KEYS = ("summary", "questions_seen_summary", "notes", "context", "digest")

# List-of-strings fields, with a label so the agent knows what each block is.
# `weak_areas`/`common_mistakes` are the live QuizContext field names;
# misconceptions/common_errors cover older rows.
_SUMMARY_LIST_KEYS = (
("weak_areas", "Weak areas"),
("common_mistakes", "Common mistakes"),
("misconceptions", "Misconceptions"),
("common_errors", "Common errors"),
)


def _coerce_summary(ctx: Any) -> str | None:
"""quiz_context.context_json is free-form (whatever the post-submit
LLM produced). Different prompt versions have stored either a flat
string or a small dict. Coerce to a single string the agent can
reason over, or None if there's nothing useful."""
LLM produced). Coerce to a single string the agent can reason over,
or None if there's nothing useful.

#529/B4: this must consume the WHOLE QuizContext shape. The old
version returned the first matching string key — for a live
QuizContext row that was `notes` alone, silently dropping
weak_areas / common_mistakes / questions_seen_summary (and its list
fallback looked for `common_errors`, a key QuizContext never writes).
"""
if not ctx:
return None
if isinstance(ctx, str):
text = ctx.strip()
return text or None
if isinstance(ctx, dict):
# Common shapes: {"summary": "..."}, {"notes": "..."},
# {"misconceptions": [...], "weak_areas": [...]}.
for key in ("summary", "notes", "context", "digest"):
parts: list[str] = []
for key in _SUMMARY_STRING_KEYS:
v = ctx.get(key)
if isinstance(v, str) and v.strip():
return v.strip()
# Fall back to flattening list-of-strings entries so the agent
# at least sees the misconceptions/weak_areas the prior job
# extracted, even when no top-level summary string exists.
parts: list[str] = []
for key in ("misconceptions", "weak_areas", "common_errors"):
for item in ctx.get(key) or []:
if isinstance(item, str) and item.strip():
parts.append(f"- {item.strip()}")
return "\n".join(parts) or None
parts.append(v.strip())
for key, label in _SUMMARY_LIST_KEYS:
raw = ctx.get(key)
if not isinstance(raw, list):
# Legacy free-form rows can hold a string (or dict) under a
# list-shaped key; iterating those element-wise would spray
# per-character bullets / dict keys into the agent's prompt.
continue
items = [
item.strip()
for item in raw
if isinstance(item, str) and item.strip()
]
if items:
parts.append(label + ":\n" + "\n".join(f"- {i}" for i in items))
rec = ctx.get("recommended_difficulty")
if isinstance(rec, str) and rec.strip():
# The post-submit agent's difficulty recommendation — surfaced
# here or it rots encrypted-and-unread (its only other mention
# is the dead legacy prompt template).
parts.append(f"Recommended next difficulty: {rec.strip()}")
return "\n\n".join(parts) or None
return None


Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
-- Repairs 0025 (#529): quiz_context lost UNIQUE (user_id, concept_node_id).
--
-- 0001_baseline_schema.sql created the table with that UNIQUE inline;
-- 0025_study_integrity.sql dropped and recreated quiz_context (lines 108-114)
-- WITHOUT it. services/quiz_context_service.py's upsert names those columns in
-- on_conflict, so PostgREST rejected every write with 42P10 — and because the
-- caller swallowed the exception, the adaptive-context loop was silently dead
-- from 2026-06-23 until this repair. Staging and prod both measured 0 rows and
-- 0 duplicate pairs on 2026-08-12 (the very first write already failed, so
-- nothing accumulated), but local replicas replay independently — dedup anyway.

-- Keep the newest row per (user_id, concept_node_id); report what was removed.
DO $$
DECLARE removed integer;
BEGIN
DELETE FROM quiz_context qc
USING quiz_context newer
WHERE qc.user_id = newer.user_id
AND qc.concept_node_id = newer.concept_node_id
AND (qc.updated_at < newer.updated_at
OR (qc.updated_at = newer.updated_at AND qc.id < newer.id));
GET DIAGNOSTICS removed = ROW_COUNT;
RAISE NOTICE 'quiz_context dedup before UNIQUE restore: % duplicate row(s) removed', removed;
END $$;

-- Idempotent restore. Named explicitly (0001's inline UNIQUE got the default
-- name quiz_context_user_id_concept_node_id_key; this repair gets its own so
-- its origin is greppable).
ALTER TABLE quiz_context
DROP CONSTRAINT IF EXISTS quiz_context_user_concept_key;
ALTER TABLE quiz_context
ADD CONSTRAINT quiz_context_user_concept_key UNIQUE (user_id, concept_node_id);
12 changes: 8 additions & 4 deletions backend/routes/admin_analytics.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -441,22 +441,26 @@ def errors(
offset: int = Query(0, ge=0),
bucket: Bucket | None = Query(None),
) -> ErrorsPage:
"""Paginated error.* event feed; `?bucket=day` adds a per-day series (its own capped scan)."""
"""Paginated error-category event feed; `?bucket=day` adds a per-day series (its own capped scan)."""
require_admin(request)
response.headers["Cache-Control"] = "private"
from_iso, to_iso = _resolve_range(from_, to)
# error.* events, newest first — paginated server-side (no aggregation).
# ALL category="error" events, newest first — not just the error.* HTTP
# names. Backend failures like quiz.context_write_failed (#529/B3) and
# the rag.* pair (#482) carry the error category without the name
# prefix; filtering by name hid exactly the events whose invisibility
# they were added to end. Non-HTTP rows simply null the payload fields.
rows, total = table("events").select_with_count(
"created_at,event_type,request_id,user_id,payload",
filters={"created_at": [f"gte.{from_iso}", f"lte.{to_iso}"], "event_type": "like.error.*"},
filters={"created_at": [f"gte.{from_iso}", f"lte.{to_iso}"], "category": "eq.error"},
order="created_at.desc", limit=limit, offset=offset,
)
series = None
series_truncated = False
if bucket:
scan_rows, series_truncated = _scan_range(
"events", "created_at", from_iso, to_iso,
extra_filters={"event_type": "like.error.*"},
extra_filters={"category": "eq.error"},
)
series = _count_series(scan_rows)
items = []
Expand Down
41 changes: 38 additions & 3 deletions backend/routes/quiz.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,7 +9,9 @@

from pydantic_ai.exceptions import UsageLimitExceeded, UnexpectedModelBehavior

import config
from agents import ORCHESTRATOR_LIMITS
from agents._providers import UnregisteredHandlerError
from agents.quiz import quiz_agent, Quiz, QuizQuestion
from agents.deps import SaplingDeps
from agents._run import run_agent_sync
Expand DownExpand Up@@ -603,17 +605,50 @@ def submit_quiz(body: SubmitQuizBody, background_tasks: BackgroundTasks, request
.replace("{quiz_results_json}", json.dumps(results, indent=2))
)

def _update_context(prompt: str, uid: str, node_id: str):
# Correlate the background write with this request's trace.
ctx_request_id = getattr(request.state, "request_id", None) or current_request_id()

def _update_context(prompt: str, uid: str, node_id: str, quiz_id: str,
request_id: str | None):
# #529/B3: this write was `except Exception: pass` for months while
# every attempt 42P10'd — the adaptive loop died silently. Failures
# are loud now: ERROR log with the attempt id + request id, a
# `quiz.context_write_failed` analytics event, and a re-raise in
# local/test envs so a regression fails CI instead of going quiet.
try:
result = record_agent_usage(
run_agent_sync(quiz_context_agent.run(prompt)),
feature="quiz", task="quiz_context", user_id=uid,
)
save_quiz_context(uid, node_id, result.output.model_dump())
except UnregisteredHandlerError:
# E2E function mode leaves quiz_context deliberately
# unregistered (agents/function_handlers_e2e.py) so no
# post-response DB write races the next test's re-seed. One
# WARNING, no traceback: the logscan oracle reports tracebacks.
logger.warning(
"quiz: context update skipped — quiz_context handler "
"unregistered (function-mode seam) quiz_id=%s", quiz_id,
)
except Exception:
pass
logger.exception(
"quiz: context update failed quiz_id=%s concept=%s "
"request_id=%s", quiz_id, node_id, request_id,
)
events_service.log_event(
"quiz.context_write_failed",
category="error",
user_id=uid,
request_id=request_id,
payload={"quiz_id": quiz_id, "concept_node_id": node_id},
)
if config.IS_LOCAL:
raise

background_tasks.add_task(_update_context, ctx_prompt, user_id, concept_node_id)
background_tasks.add_task(
_update_context, ctx_prompt, user_id, concept_node_id,
body.quiz_id, ctx_request_id,
)

# XP + achievements: after the attempt row (score/total/answers_json) is
# persisted above (the atomic completed_at claim + the update at :486-494
Expand Down
4 changes: 4 additions & 0 deletions backend/services/events_service.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -82,6 +82,10 @@
"document.processed",
"quiz.started",
"quiz.completed",
# #529/B3: the post-submit context write failed. category="error" so it
# surfaces in admin analytics — this failure was invisible for months
# precisely because nothing emitted when the background task died.
"quiz.context_write_failed",
"chat.message_sent",
"note.created",
"session.started",
Expand Down
6 changes: 4 additions & 2 deletions backend/services/quiz_context_service.py
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
import logging
import uuid
from datetime import datetime, timezone

from db.connection import table
Expand All@@ -26,9 +25,12 @@ def get_quiz_context(user_id: str, concept_node_id: str):


def save_quiz_context(user_id: str, concept_node_id: str, context: dict):
# No client-generated id: PostgREST's merge-duplicates upsert updates
# every column in the payload on conflict, so an id here would rewrite
# the existing row's PRIMARY KEY on each refresh. Fresh inserts get the
# column's DB default (gen_random_uuid).
table("quiz_context").upsert(
{
"id": str(uuid.uuid4()),
"user_id": user_id,
"concept_node_id": concept_node_id,
"context_json": encrypt_json(context),
Expand Down
78 changes: 78 additions & 0 deletions backend/tests/integration/test_quiz_context_repair_db.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
"""#529 repair, real-DB half (Workstream B, epic #537).

The class of bug this file exists to catch: the hermetic suite mocked
`table()` and so never saw that quiz_context's UNIQUE was gone — the
upsert 42P10'd in every real environment for ~7.5 weeks while tests
stayed green. These assertions run against the local Supabase stack
(#397 seam: writes through the app, raw reads through psycopg).
"""
import pytest

pytestmark = pytest.mark.integration

USER = "rich-user-active"


def _seeded_node_id(db_conn) -> str:
row = db_conn.execute(
"SELECT id FROM graph_nodes WHERE user_id = %s ORDER BY id LIMIT 1",
(USER,),
).fetchone()
assert row is not None, "rich seed should provide graph nodes for the active user"
return row["id"]


def test_quiz_context_unique_constraint_is_restored(db_conn):
"""The #529 repair migration must leave a UNIQUE covering exactly
(user_id, concept_node_id) — the columns save_quiz_context's
on_conflict names."""
rows = db_conn.execute(
"""
SELECT c.conname,
array_agg(a.attname ORDER BY k.ord) AS cols
FROM pg_constraint c
CROSS JOIN LATERAL unnest(c.conkey) WITH ORDINALITY AS k(attnum, ord)
JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = k.attnum
WHERE c.conrelid = 'quiz_context'::regclass AND c.contype = 'u'
GROUP BY c.conname
"""
).fetchall()
col_sets = [tuple(r["cols"]) for r in rows]
assert ("user_id", "concept_node_id") in col_sets, (
f"no UNIQUE on (user_id, concept_node_id); found: {col_sets!r} — "
"the 0025 regression (#529) is back"
)


def test_save_quiz_context_upserts_one_row_and_encrypts(db_conn):
"""Two writes for the same (user, concept): before the repair the FIRST
write already failed with 42P10; after it, the second must replace the
first (one row), the raw column must be ciphertext, and the app read
must round-trip the latest payload."""
from services.quiz_context_service import get_quiz_context, save_quiz_context

node_id = _seeded_node_id(db_conn)
save_quiz_context(USER, node_id, {"weak_areas": ["first write"]})
first = db_conn.execute(
"SELECT id FROM quiz_context WHERE user_id = %s AND concept_node_id = %s",
(USER, node_id),
).fetchone()
save_quiz_context(USER, node_id, {"weak_areas": ["second write"]})

rows = db_conn.execute(
"SELECT id, context_json FROM quiz_context "
"WHERE user_id = %s AND concept_node_id = %s",
(USER, node_id),
).fetchall()
assert len(rows) == 1, f"upsert must keep exactly one row, found {len(rows)}"
# The refresh must not rewrite the row's PRIMARY KEY (merge-duplicates
# updates every payload column — an id in the payload would churn here).
assert rows[0]["id"] == first["id"]

raw = rows[0]["context_json"]
# #521: ciphertext stored as a JSONB string scalar — a dict here means
# the encrypt-at-write path regressed to plaintext.
assert isinstance(raw, str), f"context_json at rest should be ciphertext str, got {type(raw)}"
assert "second write" not in raw

assert get_quiz_context(USER, node_id) == {"weak_areas": ["second write"]}
Loading
Loading