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
10 changes: 10 additions & 0 deletions backend/agents/deps.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -47,6 +47,15 @@ class SaplingDeps:
identical behavior. Evals inject a FixtureRetrieval here so
record/live runs never touch a database. Typed Any to avoid
the circular import deps → retrieval → chat_context → deps.
share_class_context: Whether this student consented to class-derived
data (the "Class intel" toggle, migration 0037). Tools that read
OTHER students' aggregated work — `read_misconceptions_for_course`
— must return nothing when it is False. It lives here rather than
in the prompt because a system-prompt instruction is a request to
a model, and consent is not something to leave to one: the tool is
registered on quiz_agent unconditionally and the prompt tells the
model to call it every run. Defaults True to match the column
default; an explicit False is the only thing that suppresses.
"""

user_id: str
Expand All@@ -55,6 +64,7 @@ class SaplingDeps:
request_id: str
session_id: str | None = None
feature: str = "unknown"
share_class_context: bool = True
graph_updates: list = field(default_factory=list)
mastery_changes: list = field(default_factory=list)
retrieval: Any = None
201 changes: 146 additions & 55 deletions backend/agents/tools/graph_read.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,6 +10,7 @@

import asyncio
import logging
from collections.abc import Sequence
from typing import Any

from pydantic import BaseModel, Field
Expand DownExpand Up@@ -366,12 +367,35 @@ class Misconception(BaseModel):
related_concept: str | None = None


#: Row budget per offering. Applied per-offering rather than shared, so one
#: class cannot starve another when a student holds two offerings of a course.
_ROWS_PER_OFFERING = 20
#: Ceiling on the misconception STRINGS handed to the model. Rows are not the
#: unit that costs prompt tokens; entries are.
_MAX_MISCONCEPTIONS = 40


async def read_misconceptions_for_course(
offering_id: str | None,
offering_ids: Sequence[str] | None,
) -> list[Misconception]:
"""Return aggregated misconception strings for an offering (a class in a
term). Anonymized (sourced from class-wide patterns, not any single student).
Returns [] when offering_id is None or the underlying table is empty.
"""Return aggregated misconception strings for one or more offerings (a
class in a term). Anonymized (sourced from class-wide patterns, not any
single student). Returns [] when no offerings are given or the underlying
table has nothing for them.

Takes OFFERING ids, plural, and the plural is load-bearing twice over.

Keyspace (#553): `offering_concept_stats.offering_id` holds
`course_offerings.id`, which is a different keyspace from the abstract
`courses.id` the graph and the HTTP boundary carry. This function used to
be handed the latter, so it matched nothing for every student
indefinitely. Callers resolve course -> offerings via
`services/academics.py`; that module owns the resolution.

Plural: a student can be enrolled in more than one offering of the same
course (a repeat, or a course spanning terms — the rich seed's active user
holds CS in two). Scoping to a single "current" offering would silently
drop the other class's aggregates.

Source: `offering_concept_stats` rows for the offering. Each row
represents one concept and carries a `common_misconceptions` array
Expand All@@ -382,26 +406,61 @@ async def read_misconceptions_for_course(

The tool contract (returning Misconception[]) is unchanged.
"""
if not offering_id:
# A bare `str` IS a Sequence[str], so an un-guarded comprehension would
# iterate it PER CHARACTER and build `in.(c,a,s,-,c,s,...)` — a filter that
# matches nothing while looking entirely well-formed. The same shape
# already bit this batch once (the quiz_history coercer spraying "- r"/
# "- e"/"- c" into the prompt), and the whole point of #553 is that a
# silently-matching-nothing filter can survive for months.
if isinstance(offering_ids, str):
offering_ids = [offering_ids]
ids = [str(o) for o in (offering_ids or []) if o]
if not ids:
return []

def _fetch() -> list[dict[str, Any]]:
try:
return (
table("offering_concept_stats").select(
"concept_name,common_misconceptions",
filters={"offering_id": f"eq.{offering_id}"},
order="updated_at.desc",
limit=20,
rows: list[dict[str, Any]] = []
# One read PER offering rather than one `in.(...)` read over all of
# them. A single query has to share one LIMIT, and the sort key does
# not break the tie usefully: `course_context_service` stamps every
# row of an aggregation pass with the same `updated_at`, so ordering
# within an offering is arbitrary. An offering with a full window of
# rows would then starve its sibling completely — reintroducing, per
# offering, exactly the silent drop that taking a LIST of offerings
# was meant to prevent. Students hold one or two offerings of a given
# course, so this is one or two indexed reads.
for offering_id in ids:
try:
rows.extend(
table("offering_concept_stats").select(
"concept_name,common_misconceptions",
filters={
"offering_id": f"eq.{offering_id}",
# Spend the row budget only on rows that actually
# carry text. The aggregation writes a stats row
# per concept as soon as a class has activity and
# fills this array only when it has something to
# say, so text-bearing rows are the rare minority
# (0 of 72 rows on staging, 0 of 73 on prod).
# Unfiltered, the window fills with empty rows and
# the tool returns [] for a class that genuinely
# has misconceptions — the very symptom #553 is
# about. It also keeps this read asking the same
# question the F5 probe asks, so a legitimately
# quiet class cannot look like a broken one.
"common_misconceptions": "neq.{}",
},
order="updated_at.desc",
limit=_ROWS_PER_OFFERING,
)
or []
)
or []
)
except Exception:
logger.exception(
"read_misconceptions_for_course failed for offering=%s",
offering_id,
)
return []
except Exception:
logger.exception(
"read_misconceptions_for_course failed for offering=%s",
offering_id,
)
return rows

rows = await asyncio.to_thread(_fetch)
out: list[Misconception] = []
Expand All@@ -417,6 +476,13 @@ def _fetch() -> list[dict[str, Any]]:
continue
seen.add(key)
out.append(Misconception(text=text, related_concept=concept))
# Cap what actually reaches the prompt. The old `limit=20` capped
# ROWS, and each row carries an unbounded array — so the block's
# real size was never bounded at all. F6 measured this tool's
# contribution to the prompt; bounding the unit that costs tokens
# is what makes that number hold.
if len(out) >= _MAX_MISCONCEPTIONS:
return out
return out


Expand All@@ -433,49 +499,74 @@ async def read_misconceptions_for_course_tool(
"""
from services.prompt_safety import neutralize_delimiters

out = await read_misconceptions_for_course(ctx.deps.course_id)
# F5: THE canonical instance of this bug class. This tool passed the
# abstract course id where the query filters `offering_id` — a different
# keyspace, so it returned zero rows for every student, indefinitely,
# and looked exactly like a class that simply had no misconceptions yet.
# (#553 carries the fix; this makes the next one impossible to miss.)
# The probe asks whether aggregates exist for THIS student's offerings of
# THIS course — not merely whether they are enrolled in something.
# "Enrolled somewhere" would fire on every generation in a course whose
# class simply has no aggregated misconceptions yet, which is the normal
# state for the first weeks of any term.
# Class-intel consent, enforced at the tool (#553 review finding 4).
#
# Scoped this way it detects the real failure instead: aggregates exist
# for the class, but this tool's read returned none — the signature of a
# keyspace mismatch, which is precisely how #553 (abstract course id used
# where an offering id is expected) presents.
# This tool is registered on quiz_agent unconditionally and system-prompt
# step 2 tells the model to call it on EVERY run; `use_shared_context`
# only ever APPENDED an extra routing sentence when true. That looked
# correct for as long as the read was keyspace-broken and returned []
# for everyone — fixing #553 would have quietly started feeding other
# students' aggregated misconceptions to a student who opted out.
#
# Gated on the result being EMPTY, not merely on having a course id.
# `user_offering_ids_for_course` is uncached and issues two unbounded
# PostgREST reads (enrollments -> offerings), and this runs on the quiz
# generation request path — resolving it whenever a course id exists made
# every generation pay both round-trips even when the tool returned rows,
# contradicting tool_signals' own documented contract ("one owner-scoped
# indexed read, only on the empty path"). `report_empty_result` would
# short-circuit on a non-zero count anyway, so the work was pure waste.
if not out and ctx.deps.course_id:
offering_ids: list[str] = []
# Enforced here rather than by editing the prompt or the toolset: a
# system-prompt instruction is a request to a model, and consent is not
# something to leave to one. Returning [] (not raising) keeps an opted-out
# run identical to a class with nothing to share.
if not getattr(ctx.deps, "share_class_context", True):
prompt_dimensions.record(misconceptions=0)
return []

# #553: resolve course -> the student's offerings BEFORE reading. The
# stats table is keyed on `course_offerings.id`; `ctx.deps.course_id` is
# the abstract `courses.id` the graph carries. Handing the second to a
# filter expecting the first matched nothing for every student since the
# tool was written, and looked exactly like a class with no misconceptions
# yet. Verified live 2026-08-22: staging 72/72 stats rows key on an
# offering id and 0 on a course id (prod 73/73); filtering by course id
# returned 0 in both, filtering by the student's offerings returned 68+4
# and 73.
#
# The resolution is now unconditional rather than probe-only: it is what
# the READ needs, not merely what the probe needs. It stays a single
# `academics` call whose two reads are the price of asking the right
# question at all.
offering_ids: list[str] = []
if ctx.deps.course_id:
try:
offering_ids = await asyncio.to_thread(
user_offering_ids_for_course, ctx.deps.user_id, ctx.deps.course_id
)
except Exception:
logger.debug("misconceptions probe: offering resolution failed", exc_info=True)
if offering_ids:
await report_empty_result_async(
"read_misconceptions_for_course",
user_id=ctx.deps.user_id,
count=len(out),
expect=Expect.COURSE_HAS_AGGREGATES,
feature=getattr(ctx.deps, "feature", "unknown"),
scope={"offering_id": f"in.({','.join(offering_ids)})"},
payload={"course_id": ctx.deps.course_id},
# Degrade to "no offerings" rather than raising: this is one
# optional personalization input, and the agent has others.
logger.warning(
"read_misconceptions_for_course: offering resolution failed; "
"returning no class misconceptions", exc_info=True,
)

out = await read_misconceptions_for_course(offering_ids)
# F5: THE canonical instance of this bug class. The probe asks whether
# aggregates CARRYING MISCONCEPTION TEXT exist for this student's
# offerings of this course — not merely whether they are enrolled, and
# not merely whether stats rows exist.
#
# The text qualifier matters as much as the scope. Both live environments
# today hold stats rows whose `common_misconceptions` arrays are all
# empty (0 of 72 on staging, 0 of 73 on prod — the aggregation runs, the
# classes just have no misconception text yet). A probe that fired on
# "any stats row exists" would therefore report a discrepancy on EVERY
# generation for EVERY student the moment #553 was fixed — the precise
# alarm-fatigue failure F5 exists to prevent.
if not out and offering_ids:
await report_empty_result_async(
"read_misconceptions_for_course",
user_id=ctx.deps.user_id,
count=len(out),
expect=Expect.COURSE_HAS_AGGREGATES,
feature=getattr(ctx.deps, "feature", "unknown"),
scope={"offering_id": f"in.({','.join(offering_ids)})"},
payload={"course_id": ctx.deps.course_id},
)
# F6: this block's contribution to the prompt.
prompt_dimensions.record(misconceptions=len(out))
return [
Expand Down
62 changes: 62 additions & 0 deletions backend/db/seed_local_rich.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -662,6 +662,66 @@ def seed_room_summaries() -> None:
]


# ── offering_concept_stats (#553) ─────────────────────────────────────────
#
# Class-level aggregates, keyed on `course_offerings.id` — a DIFFERENT
# keyspace from the abstract `courses.id` the graph carries. #553 was the
# quiz's misconceptions tool filtering this table's `offering_id` with a
# course id, which matched nothing for every student indefinitely while
# looking exactly like a class that had no misconceptions yet.
#
# The rows are shaped so a test can tell a real fix from a coincidence:
#
# * OFF_CS_F25 and OFF_CS_S26 are BOTH offerings of the same abstract CS
# course, and the active user is enrolled in both — so a fix that
# resolves only one "current" offering still loses half the rows.
# * OFF_HIST_F25 belongs to a course the active user is NOT enrolled in.
# Its misconception text must never reach them; that is the negative
# half of the assertion, and it is what stops a fix from "working" by
# simply dropping the offering filter altogether.
# * One row carries an EMPTY array: the aggregation writes a stats row per
# concept as soon as a class has activity and only fills the array when
# it has something to say (0 of 72 rows on staging and 0 of 73 on prod
# carried text on 2026-08-22). Seeding that state keeps the empty-vs-
# absent distinction exercised.
#
# (stats_id, offering_id, concept_name, misconceptions)
_OFFERING_CONCEPT_STATS = [
("rich-ocs-cs-f25-recursion", OFF_CS_F25, "Recursion",
["Recursion always costs more memory than a loop",
"A base case is optional if the input shrinks"]),
("rich-ocs-cs-f25-pointers", OFF_CS_F25, "Pointers and Memory",
["Freeing a pointer also clears the variable holding it"]),
("rich-ocs-cs-s26-controlflow", OFF_CS_S26, "Control Flow",
["`else if` evaluates every branch before choosing one"]),
# Same class, no text yet — a stats row is not the same as a finding.
("rich-ocs-cs-s26-variables", OFF_CS_S26, "Variables and Types", []),
# A class the active user is NOT in. Must never leak into their prompt.
("rich-ocs-hist-f25-sources", OFF_HIST_F25, "Primary Sources",
["A primary source is any source written by a historian"]),
]


def seed_offering_concept_stats() -> None:
for stats_id, off_id, concept, misconceptions in _OFFERING_CONCEPT_STATS:
h.insert_if_absent(
"offering_concept_stats",
stats_id,
{
"offering_id": off_id,
"concept_name": concept,
"student_count": 4,
"avg_mastery_score": 0.55,
"pct_mastered": 0.25,
"pct_struggling": 0.5,
"pct_unexplored": 0.25,
"common_misconceptions": misconceptions,
"effective_explanations": [],
"prerequisite_gaps": [],
},
)


def seed_quiz() -> None:
for qa_id, node_id, difficulty, score, total, questions, answers, completed_at in _QUIZ_ATTEMPTS:
h.insert_if_absent(
Expand DownExpand Up@@ -785,6 +845,7 @@ def seed_sessions() -> None:
"room_summaries",
"notes", "documents", "flashcards", "study_guides", "quiz_attempts", "quiz_context",
"sessions", "messages", "feedback", "issue_reports",
"offering_concept_stats",
]


Expand All@@ -804,6 +865,7 @@ def main() -> None:
seed_study_guides()
seed_room_summaries()
seed_quiz()
seed_offering_concept_stats()
seed_feedback()
seed_sessions()
h.print_summary(_SUMMARY_ORDER, "Seed summary (rich local dataset):")
Expand Down
6 changes: 6 additions & 0 deletions backend/routes/quiz.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -822,6 +822,12 @@ async def _quiz_via_agent(
supabase=None,
request_id=request_id,
feature="quiz",
# The Class-intel opt-out reaches the misconceptions tool through
# deps, not through the prompt: the tool is registered on the agent
# unconditionally and step 2 of the system prompt tells the model to
# call it every run, so the routing sentence below can only ever ADD
# emphasis — it cannot withhold the data (#553 review).
share_class_context=use_shared_context,
)
# Keep this message routing-only; the workflow + adaptive rules
# live in the system prompt. We just hand the agent the inputs it
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
fix(quiz): read misconceptions from the offering keyspace (#553) by AndresL230 · Pull Request #567 · SaplingLearn/Sapling · GitHub
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
10 changes: 10 additions & 0 deletions backend/agents/deps.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -47,6 +47,15 @@ class SaplingDeps:
identical behavior. Evals inject a FixtureRetrieval here so
record/live runs never touch a database. Typed Any to avoid
the circular import deps → retrieval → chat_context → deps.
share_class_context: Whether this student consented to class-derived
data (the "Class intel" toggle, migration 0037). Tools that read
OTHER students' aggregated work — `read_misconceptions_for_course`
— must return nothing when it is False. It lives here rather than
in the prompt because a system-prompt instruction is a request to
a model, and consent is not something to leave to one: the tool is
registered on quiz_agent unconditionally and the prompt tells the
model to call it every run. Defaults True to match the column
default; an explicit False is the only thing that suppresses.
"""

user_id: str
Expand All@@ -55,6 +64,7 @@ class SaplingDeps:
request_id: str
session_id: str | None = None
feature: str = "unknown"
share_class_context: bool = True
graph_updates: list = field(default_factory=list)
mastery_changes: list = field(default_factory=list)
retrieval: Any = None
201 changes: 146 additions & 55 deletions backend/agents/tools/graph_read.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,6 +10,7 @@

import asyncio
import logging
from collections.abc import Sequence
from typing import Any

from pydantic import BaseModel, Field
Expand DownExpand Up@@ -366,12 +367,35 @@ class Misconception(BaseModel):
related_concept: str | None = None


#: Row budget per offering. Applied per-offering rather than shared, so one
#: class cannot starve another when a student holds two offerings of a course.
_ROWS_PER_OFFERING = 20
#: Ceiling on the misconception STRINGS handed to the model. Rows are not the
#: unit that costs prompt tokens; entries are.
_MAX_MISCONCEPTIONS = 40


async def read_misconceptions_for_course(
offering_id: str | None,
offering_ids: Sequence[str] | None,
) -> list[Misconception]:
"""Return aggregated misconception strings for an offering (a class in a
term). Anonymized (sourced from class-wide patterns, not any single student).
Returns [] when offering_id is None or the underlying table is empty.
"""Return aggregated misconception strings for one or more offerings (a
class in a term). Anonymized (sourced from class-wide patterns, not any
single student). Returns [] when no offerings are given or the underlying
table has nothing for them.

Takes OFFERING ids, plural, and the plural is load-bearing twice over.

Keyspace (#553): `offering_concept_stats.offering_id` holds
`course_offerings.id`, which is a different keyspace from the abstract
`courses.id` the graph and the HTTP boundary carry. This function used to
be handed the latter, so it matched nothing for every student
indefinitely. Callers resolve course -> offerings via
`services/academics.py`; that module owns the resolution.

Plural: a student can be enrolled in more than one offering of the same
course (a repeat, or a course spanning terms — the rich seed's active user
holds CS in two). Scoping to a single "current" offering would silently
drop the other class's aggregates.

Source: `offering_concept_stats` rows for the offering. Each row
represents one concept and carries a `common_misconceptions` array
Expand All@@ -382,26 +406,61 @@ async def read_misconceptions_for_course(

The tool contract (returning Misconception[]) is unchanged.
"""
if not offering_id:
# A bare `str` IS a Sequence[str], so an un-guarded comprehension would
# iterate it PER CHARACTER and build `in.(c,a,s,-,c,s,...)` — a filter that
# matches nothing while looking entirely well-formed. The same shape
# already bit this batch once (the quiz_history coercer spraying "- r"/
# "- e"/"- c" into the prompt), and the whole point of #553 is that a
# silently-matching-nothing filter can survive for months.
if isinstance(offering_ids, str):
offering_ids = [offering_ids]
ids = [str(o) for o in (offering_ids or []) if o]
if not ids:
return []

def _fetch() -> list[dict[str, Any]]:
try:
return (
table("offering_concept_stats").select(
"concept_name,common_misconceptions",
filters={"offering_id": f"eq.{offering_id}"},
order="updated_at.desc",
limit=20,
rows: list[dict[str, Any]] = []
# One read PER offering rather than one `in.(...)` read over all of
# them. A single query has to share one LIMIT, and the sort key does
# not break the tie usefully: `course_context_service` stamps every
# row of an aggregation pass with the same `updated_at`, so ordering
# within an offering is arbitrary. An offering with a full window of
# rows would then starve its sibling completely — reintroducing, per
# offering, exactly the silent drop that taking a LIST of offerings
# was meant to prevent. Students hold one or two offerings of a given
# course, so this is one or two indexed reads.
for offering_id in ids:
try:
rows.extend(
table("offering_concept_stats").select(
"concept_name,common_misconceptions",
filters={
"offering_id": f"eq.{offering_id}",
# Spend the row budget only on rows that actually
# carry text. The aggregation writes a stats row
# per concept as soon as a class has activity and
# fills this array only when it has something to
# say, so text-bearing rows are the rare minority
# (0 of 72 rows on staging, 0 of 73 on prod).
# Unfiltered, the window fills with empty rows and
# the tool returns [] for a class that genuinely
# has misconceptions — the very symptom #553 is
# about. It also keeps this read asking the same
# question the F5 probe asks, so a legitimately
# quiet class cannot look like a broken one.
"common_misconceptions": "neq.{}",
},
order="updated_at.desc",
limit=_ROWS_PER_OFFERING,
)
or []
)
or []
)
except Exception:
logger.exception(
"read_misconceptions_for_course failed for offering=%s",
offering_id,
)
return []
except Exception:
logger.exception(
"read_misconceptions_for_course failed for offering=%s",
offering_id,
)
return rows

rows = await asyncio.to_thread(_fetch)
out: list[Misconception] = []
Expand All@@ -417,6 +476,13 @@ def _fetch() -> list[dict[str, Any]]:
continue
seen.add(key)
out.append(Misconception(text=text, related_concept=concept))
# Cap what actually reaches the prompt. The old `limit=20` capped
# ROWS, and each row carries an unbounded array — so the block's
# real size was never bounded at all. F6 measured this tool's
# contribution to the prompt; bounding the unit that costs tokens
# is what makes that number hold.
if len(out) >= _MAX_MISCONCEPTIONS:
return out
return out


Expand All@@ -433,49 +499,74 @@ async def read_misconceptions_for_course_tool(
"""
from services.prompt_safety import neutralize_delimiters

out = await read_misconceptions_for_course(ctx.deps.course_id)
# F5: THE canonical instance of this bug class. This tool passed the
# abstract course id where the query filters `offering_id` — a different
# keyspace, so it returned zero rows for every student, indefinitely,
# and looked exactly like a class that simply had no misconceptions yet.
# (#553 carries the fix; this makes the next one impossible to miss.)
# The probe asks whether aggregates exist for THIS student's offerings of
# THIS course — not merely whether they are enrolled in something.
# "Enrolled somewhere" would fire on every generation in a course whose
# class simply has no aggregated misconceptions yet, which is the normal
# state for the first weeks of any term.
# Class-intel consent, enforced at the tool (#553 review finding 4).
#
# Scoped this way it detects the real failure instead: aggregates exist
# for the class, but this tool's read returned none — the signature of a
# keyspace mismatch, which is precisely how #553 (abstract course id used
# where an offering id is expected) presents.
# This tool is registered on quiz_agent unconditionally and system-prompt
# step 2 tells the model to call it on EVERY run; `use_shared_context`
# only ever APPENDED an extra routing sentence when true. That looked
# correct for as long as the read was keyspace-broken and returned []
# for everyone — fixing #553 would have quietly started feeding other
# students' aggregated misconceptions to a student who opted out.
#
# Gated on the result being EMPTY, not merely on having a course id.
# `user_offering_ids_for_course` is uncached and issues two unbounded
# PostgREST reads (enrollments -> offerings), and this runs on the quiz
# generation request path — resolving it whenever a course id exists made
# every generation pay both round-trips even when the tool returned rows,
# contradicting tool_signals' own documented contract ("one owner-scoped
# indexed read, only on the empty path"). `report_empty_result` would
# short-circuit on a non-zero count anyway, so the work was pure waste.
if not out and ctx.deps.course_id:
offering_ids: list[str] = []
# Enforced here rather than by editing the prompt or the toolset: a
# system-prompt instruction is a request to a model, and consent is not
# something to leave to one. Returning [] (not raising) keeps an opted-out
# run identical to a class with nothing to share.
if not getattr(ctx.deps, "share_class_context", True):
prompt_dimensions.record(misconceptions=0)
return []

# #553: resolve course -> the student's offerings BEFORE reading. The
# stats table is keyed on `course_offerings.id`; `ctx.deps.course_id` is
# the abstract `courses.id` the graph carries. Handing the second to a
# filter expecting the first matched nothing for every student since the
# tool was written, and looked exactly like a class with no misconceptions
# yet. Verified live 2026-08-22: staging 72/72 stats rows key on an
# offering id and 0 on a course id (prod 73/73); filtering by course id
# returned 0 in both, filtering by the student's offerings returned 68+4
# and 73.
#
# The resolution is now unconditional rather than probe-only: it is what
# the READ needs, not merely what the probe needs. It stays a single
# `academics` call whose two reads are the price of asking the right
# question at all.
offering_ids: list[str] = []
if ctx.deps.course_id:
try:
offering_ids = await asyncio.to_thread(
user_offering_ids_for_course, ctx.deps.user_id, ctx.deps.course_id
)
except Exception:
logger.debug("misconceptions probe: offering resolution failed", exc_info=True)
if offering_ids:
await report_empty_result_async(
"read_misconceptions_for_course",
user_id=ctx.deps.user_id,
count=len(out),
expect=Expect.COURSE_HAS_AGGREGATES,
feature=getattr(ctx.deps, "feature", "unknown"),
scope={"offering_id": f"in.({','.join(offering_ids)})"},
payload={"course_id": ctx.deps.course_id},
# Degrade to "no offerings" rather than raising: this is one
# optional personalization input, and the agent has others.
logger.warning(
"read_misconceptions_for_course: offering resolution failed; "
"returning no class misconceptions", exc_info=True,
)

out = await read_misconceptions_for_course(offering_ids)
# F5: THE canonical instance of this bug class. The probe asks whether
# aggregates CARRYING MISCONCEPTION TEXT exist for this student's
# offerings of this course — not merely whether they are enrolled, and
# not merely whether stats rows exist.
#
# The text qualifier matters as much as the scope. Both live environments
# today hold stats rows whose `common_misconceptions` arrays are all
# empty (0 of 72 on staging, 0 of 73 on prod — the aggregation runs, the
# classes just have no misconception text yet). A probe that fired on
# "any stats row exists" would therefore report a discrepancy on EVERY
# generation for EVERY student the moment #553 was fixed — the precise
# alarm-fatigue failure F5 exists to prevent.
if not out and offering_ids:
await report_empty_result_async(
"read_misconceptions_for_course",
user_id=ctx.deps.user_id,
count=len(out),
expect=Expect.COURSE_HAS_AGGREGATES,
feature=getattr(ctx.deps, "feature", "unknown"),
scope={"offering_id": f"in.({','.join(offering_ids)})"},
payload={"course_id": ctx.deps.course_id},
)
# F6: this block's contribution to the prompt.
prompt_dimensions.record(misconceptions=len(out))
return [
Expand Down
62 changes: 62 additions & 0 deletions backend/db/seed_local_rich.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -662,6 +662,66 @@ def seed_room_summaries() -> None:
]


# ── offering_concept_stats (#553) ─────────────────────────────────────────
#
# Class-level aggregates, keyed on `course_offerings.id` — a DIFFERENT
# keyspace from the abstract `courses.id` the graph carries. #553 was the
# quiz's misconceptions tool filtering this table's `offering_id` with a
# course id, which matched nothing for every student indefinitely while
# looking exactly like a class that had no misconceptions yet.
#
# The rows are shaped so a test can tell a real fix from a coincidence:
#
# * OFF_CS_F25 and OFF_CS_S26 are BOTH offerings of the same abstract CS
# course, and the active user is enrolled in both — so a fix that
# resolves only one "current" offering still loses half the rows.
# * OFF_HIST_F25 belongs to a course the active user is NOT enrolled in.
# Its misconception text must never reach them; that is the negative
# half of the assertion, and it is what stops a fix from "working" by
# simply dropping the offering filter altogether.
# * One row carries an EMPTY array: the aggregation writes a stats row per
# concept as soon as a class has activity and only fills the array when
# it has something to say (0 of 72 rows on staging and 0 of 73 on prod
# carried text on 2026-08-22). Seeding that state keeps the empty-vs-
# absent distinction exercised.
#
# (stats_id, offering_id, concept_name, misconceptions)
_OFFERING_CONCEPT_STATS = [
("rich-ocs-cs-f25-recursion", OFF_CS_F25, "Recursion",
["Recursion always costs more memory than a loop",
"A base case is optional if the input shrinks"]),
("rich-ocs-cs-f25-pointers", OFF_CS_F25, "Pointers and Memory",
["Freeing a pointer also clears the variable holding it"]),
("rich-ocs-cs-s26-controlflow", OFF_CS_S26, "Control Flow",
["`else if` evaluates every branch before choosing one"]),
# Same class, no text yet — a stats row is not the same as a finding.
("rich-ocs-cs-s26-variables", OFF_CS_S26, "Variables and Types", []),
# A class the active user is NOT in. Must never leak into their prompt.
("rich-ocs-hist-f25-sources", OFF_HIST_F25, "Primary Sources",
["A primary source is any source written by a historian"]),
]


def seed_offering_concept_stats() -> None:
for stats_id, off_id, concept, misconceptions in _OFFERING_CONCEPT_STATS:
h.insert_if_absent(
"offering_concept_stats",
stats_id,
{
"offering_id": off_id,
"concept_name": concept,
"student_count": 4,
"avg_mastery_score": 0.55,
"pct_mastered": 0.25,
"pct_struggling": 0.5,
"pct_unexplored": 0.25,
"common_misconceptions": misconceptions,
"effective_explanations": [],
"prerequisite_gaps": [],
},
)


def seed_quiz() -> None:
for qa_id, node_id, difficulty, score, total, questions, answers, completed_at in _QUIZ_ATTEMPTS:
h.insert_if_absent(
Expand DownExpand Up@@ -785,6 +845,7 @@ def seed_sessions() -> None:
"room_summaries",
"notes", "documents", "flashcards", "study_guides", "quiz_attempts", "quiz_context",
"sessions", "messages", "feedback", "issue_reports",
"offering_concept_stats",
]


Expand All@@ -804,6 +865,7 @@ def main() -> None:
seed_study_guides()
seed_room_summaries()
seed_quiz()
seed_offering_concept_stats()
seed_feedback()
seed_sessions()
h.print_summary(_SUMMARY_ORDER, "Seed summary (rich local dataset):")
Expand Down
6 changes: 6 additions & 0 deletions backend/routes/quiz.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -822,6 +822,12 @@ async def _quiz_via_agent(
supabase=None,
request_id=request_id,
feature="quiz",
# The Class-intel opt-out reaches the misconceptions tool through
# deps, not through the prompt: the tool is registered on the agent
# unconditionally and step 2 of the system prompt tells the model to
# call it every run, so the routing sentence below can only ever ADD
# emphasis — it cannot withhold the data (#553 review).
share_class_context=use_shared_context,
)
# Keep this message routing-only; the workflow + adaptive rules
# live in the system prompt. We just hand the agent the inputs it
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix(quiz): read misconceptions from the offering keyspace (#553) by AndresL230 · Pull Request #567 · SaplingLearn/Sapling · GitHub
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
10 changes: 10 additions & 0 deletions backend/agents/deps.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -47,6 +47,15 @@ class SaplingDeps:
identical behavior. Evals inject a FixtureRetrieval here so
record/live runs never touch a database. Typed Any to avoid
the circular import deps → retrieval → chat_context → deps.
share_class_context: Whether this student consented to class-derived
data (the "Class intel" toggle, migration 0037). Tools that read
OTHER students' aggregated work — `read_misconceptions_for_course`
— must return nothing when it is False. It lives here rather than
in the prompt because a system-prompt instruction is a request to
a model, and consent is not something to leave to one: the tool is
registered on quiz_agent unconditionally and the prompt tells the
model to call it every run. Defaults True to match the column
default; an explicit False is the only thing that suppresses.
"""

user_id: str
Expand All@@ -55,6 +64,7 @@ class SaplingDeps:
request_id: str
session_id: str | None = None
feature: str = "unknown"
share_class_context: bool = True
graph_updates: list = field(default_factory=list)
mastery_changes: list = field(default_factory=list)
retrieval: Any = None
201 changes: 146 additions & 55 deletions backend/agents/tools/graph_read.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,6 +10,7 @@

import asyncio
import logging
from collections.abc import Sequence
from typing import Any

from pydantic import BaseModel, Field
Expand DownExpand Up@@ -366,12 +367,35 @@ class Misconception(BaseModel):
related_concept: str | None = None


#: Row budget per offering. Applied per-offering rather than shared, so one
#: class cannot starve another when a student holds two offerings of a course.
_ROWS_PER_OFFERING = 20
#: Ceiling on the misconception STRINGS handed to the model. Rows are not the
#: unit that costs prompt tokens; entries are.
_MAX_MISCONCEPTIONS = 40


async def read_misconceptions_for_course(
offering_id: str | None,
offering_ids: Sequence[str] | None,
) -> list[Misconception]:
"""Return aggregated misconception strings for an offering (a class in a
term). Anonymized (sourced from class-wide patterns, not any single student).
Returns [] when offering_id is None or the underlying table is empty.
"""Return aggregated misconception strings for one or more offerings (a
class in a term). Anonymized (sourced from class-wide patterns, not any
single student). Returns [] when no offerings are given or the underlying
table has nothing for them.

Takes OFFERING ids, plural, and the plural is load-bearing twice over.

Keyspace (#553): `offering_concept_stats.offering_id` holds
`course_offerings.id`, which is a different keyspace from the abstract
`courses.id` the graph and the HTTP boundary carry. This function used to
be handed the latter, so it matched nothing for every student
indefinitely. Callers resolve course -> offerings via
`services/academics.py`; that module owns the resolution.

Plural: a student can be enrolled in more than one offering of the same
course (a repeat, or a course spanning terms — the rich seed's active user
holds CS in two). Scoping to a single "current" offering would silently
drop the other class's aggregates.

Source: `offering_concept_stats` rows for the offering. Each row
represents one concept and carries a `common_misconceptions` array
Expand All@@ -382,26 +406,61 @@ async def read_misconceptions_for_course(

The tool contract (returning Misconception[]) is unchanged.
"""
if not offering_id:
# A bare `str` IS a Sequence[str], so an un-guarded comprehension would
# iterate it PER CHARACTER and build `in.(c,a,s,-,c,s,...)` — a filter that
# matches nothing while looking entirely well-formed. The same shape
# already bit this batch once (the quiz_history coercer spraying "- r"/
# "- e"/"- c" into the prompt), and the whole point of #553 is that a
# silently-matching-nothing filter can survive for months.
if isinstance(offering_ids, str):
offering_ids = [offering_ids]
ids = [str(o) for o in (offering_ids or []) if o]
if not ids:
return []

def _fetch() -> list[dict[str, Any]]:
try:
return (
table("offering_concept_stats").select(
"concept_name,common_misconceptions",
filters={"offering_id": f"eq.{offering_id}"},
order="updated_at.desc",
limit=20,
rows: list[dict[str, Any]] = []
# One read PER offering rather than one `in.(...)` read over all of
# them. A single query has to share one LIMIT, and the sort key does
# not break the tie usefully: `course_context_service` stamps every
# row of an aggregation pass with the same `updated_at`, so ordering
# within an offering is arbitrary. An offering with a full window of
# rows would then starve its sibling completely — reintroducing, per
# offering, exactly the silent drop that taking a LIST of offerings
# was meant to prevent. Students hold one or two offerings of a given
# course, so this is one or two indexed reads.
for offering_id in ids:
try:
rows.extend(
table("offering_concept_stats").select(
"concept_name,common_misconceptions",
filters={
"offering_id": f"eq.{offering_id}",
# Spend the row budget only on rows that actually
# carry text. The aggregation writes a stats row
# per concept as soon as a class has activity and
# fills this array only when it has something to
# say, so text-bearing rows are the rare minority
# (0 of 72 rows on staging, 0 of 73 on prod).
# Unfiltered, the window fills with empty rows and
# the tool returns [] for a class that genuinely
# has misconceptions — the very symptom #553 is
# about. It also keeps this read asking the same
# question the F5 probe asks, so a legitimately
# quiet class cannot look like a broken one.
"common_misconceptions": "neq.{}",
},
order="updated_at.desc",
limit=_ROWS_PER_OFFERING,
)
or []
)
or []
)
except Exception:
logger.exception(
"read_misconceptions_for_course failed for offering=%s",
offering_id,
)
return []
except Exception:
logger.exception(
"read_misconceptions_for_course failed for offering=%s",
offering_id,
)
return rows

rows = await asyncio.to_thread(_fetch)
out: list[Misconception] = []
Expand All@@ -417,6 +476,13 @@ def _fetch() -> list[dict[str, Any]]:
continue
seen.add(key)
out.append(Misconception(text=text, related_concept=concept))
# Cap what actually reaches the prompt. The old `limit=20` capped
# ROWS, and each row carries an unbounded array — so the block's
# real size was never bounded at all. F6 measured this tool's
# contribution to the prompt; bounding the unit that costs tokens
# is what makes that number hold.
if len(out) >= _MAX_MISCONCEPTIONS:
return out
return out


Expand All@@ -433,49 +499,74 @@ async def read_misconceptions_for_course_tool(
"""
from services.prompt_safety import neutralize_delimiters

out = await read_misconceptions_for_course(ctx.deps.course_id)
# F5: THE canonical instance of this bug class. This tool passed the
# abstract course id where the query filters `offering_id` — a different
# keyspace, so it returned zero rows for every student, indefinitely,
# and looked exactly like a class that simply had no misconceptions yet.
# (#553 carries the fix; this makes the next one impossible to miss.)
# The probe asks whether aggregates exist for THIS student's offerings of
# THIS course — not merely whether they are enrolled in something.
# "Enrolled somewhere" would fire on every generation in a course whose
# class simply has no aggregated misconceptions yet, which is the normal
# state for the first weeks of any term.
# Class-intel consent, enforced at the tool (#553 review finding 4).
#
# Scoped this way it detects the real failure instead: aggregates exist
# for the class, but this tool's read returned none — the signature of a
# keyspace mismatch, which is precisely how #553 (abstract course id used
# where an offering id is expected) presents.
# This tool is registered on quiz_agent unconditionally and system-prompt
# step 2 tells the model to call it on EVERY run; `use_shared_context`
# only ever APPENDED an extra routing sentence when true. That looked
# correct for as long as the read was keyspace-broken and returned []
# for everyone — fixing #553 would have quietly started feeding other
# students' aggregated misconceptions to a student who opted out.
#
# Gated on the result being EMPTY, not merely on having a course id.
# `user_offering_ids_for_course` is uncached and issues two unbounded
# PostgREST reads (enrollments -> offerings), and this runs on the quiz
# generation request path — resolving it whenever a course id exists made
# every generation pay both round-trips even when the tool returned rows,
# contradicting tool_signals' own documented contract ("one owner-scoped
# indexed read, only on the empty path"). `report_empty_result` would
# short-circuit on a non-zero count anyway, so the work was pure waste.
if not out and ctx.deps.course_id:
offering_ids: list[str] = []
# Enforced here rather than by editing the prompt or the toolset: a
# system-prompt instruction is a request to a model, and consent is not
# something to leave to one. Returning [] (not raising) keeps an opted-out
# run identical to a class with nothing to share.
if not getattr(ctx.deps, "share_class_context", True):
prompt_dimensions.record(misconceptions=0)
return []

# #553: resolve course -> the student's offerings BEFORE reading. The
# stats table is keyed on `course_offerings.id`; `ctx.deps.course_id` is
# the abstract `courses.id` the graph carries. Handing the second to a
# filter expecting the first matched nothing for every student since the
# tool was written, and looked exactly like a class with no misconceptions
# yet. Verified live 2026-08-22: staging 72/72 stats rows key on an
# offering id and 0 on a course id (prod 73/73); filtering by course id
# returned 0 in both, filtering by the student's offerings returned 68+4
# and 73.
#
# The resolution is now unconditional rather than probe-only: it is what
# the READ needs, not merely what the probe needs. It stays a single
# `academics` call whose two reads are the price of asking the right
# question at all.
offering_ids: list[str] = []
if ctx.deps.course_id:
try:
offering_ids = await asyncio.to_thread(
user_offering_ids_for_course, ctx.deps.user_id, ctx.deps.course_id
)
except Exception:
logger.debug("misconceptions probe: offering resolution failed", exc_info=True)
if offering_ids:
await report_empty_result_async(
"read_misconceptions_for_course",
user_id=ctx.deps.user_id,
count=len(out),
expect=Expect.COURSE_HAS_AGGREGATES,
feature=getattr(ctx.deps, "feature", "unknown"),
scope={"offering_id": f"in.({','.join(offering_ids)})"},
payload={"course_id": ctx.deps.course_id},
# Degrade to "no offerings" rather than raising: this is one
# optional personalization input, and the agent has others.
logger.warning(
"read_misconceptions_for_course: offering resolution failed; "
"returning no class misconceptions", exc_info=True,
)

out = await read_misconceptions_for_course(offering_ids)
# F5: THE canonical instance of this bug class. The probe asks whether
# aggregates CARRYING MISCONCEPTION TEXT exist for this student's
# offerings of this course — not merely whether they are enrolled, and
# not merely whether stats rows exist.
#
# The text qualifier matters as much as the scope. Both live environments
# today hold stats rows whose `common_misconceptions` arrays are all
# empty (0 of 72 on staging, 0 of 73 on prod — the aggregation runs, the
# classes just have no misconception text yet). A probe that fired on
# "any stats row exists" would therefore report a discrepancy on EVERY
# generation for EVERY student the moment #553 was fixed — the precise
# alarm-fatigue failure F5 exists to prevent.
if not out and offering_ids:
await report_empty_result_async(
"read_misconceptions_for_course",
user_id=ctx.deps.user_id,
count=len(out),
expect=Expect.COURSE_HAS_AGGREGATES,
feature=getattr(ctx.deps, "feature", "unknown"),
scope={"offering_id": f"in.({','.join(offering_ids)})"},
payload={"course_id": ctx.deps.course_id},
)
# F6: this block's contribution to the prompt.
prompt_dimensions.record(misconceptions=len(out))
return [
Expand Down
62 changes: 62 additions & 0 deletions backend/db/seed_local_rich.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -662,6 +662,66 @@ def seed_room_summaries() -> None:
]


# ── offering_concept_stats (#553) ─────────────────────────────────────────
#
# Class-level aggregates, keyed on `course_offerings.id` — a DIFFERENT
# keyspace from the abstract `courses.id` the graph carries. #553 was the
# quiz's misconceptions tool filtering this table's `offering_id` with a
# course id, which matched nothing for every student indefinitely while
# looking exactly like a class that had no misconceptions yet.
#
# The rows are shaped so a test can tell a real fix from a coincidence:
#
# * OFF_CS_F25 and OFF_CS_S26 are BOTH offerings of the same abstract CS
# course, and the active user is enrolled in both — so a fix that
# resolves only one "current" offering still loses half the rows.
# * OFF_HIST_F25 belongs to a course the active user is NOT enrolled in.
# Its misconception text must never reach them; that is the negative
# half of the assertion, and it is what stops a fix from "working" by
# simply dropping the offering filter altogether.
# * One row carries an EMPTY array: the aggregation writes a stats row per
# concept as soon as a class has activity and only fills the array when
# it has something to say (0 of 72 rows on staging and 0 of 73 on prod
# carried text on 2026-08-22). Seeding that state keeps the empty-vs-
# absent distinction exercised.
#
# (stats_id, offering_id, concept_name, misconceptions)
_OFFERING_CONCEPT_STATS = [
("rich-ocs-cs-f25-recursion", OFF_CS_F25, "Recursion",
["Recursion always costs more memory than a loop",
"A base case is optional if the input shrinks"]),
("rich-ocs-cs-f25-pointers", OFF_CS_F25, "Pointers and Memory",
["Freeing a pointer also clears the variable holding it"]),
("rich-ocs-cs-s26-controlflow", OFF_CS_S26, "Control Flow",
["`else if` evaluates every branch before choosing one"]),
# Same class, no text yet — a stats row is not the same as a finding.
("rich-ocs-cs-s26-variables", OFF_CS_S26, "Variables and Types", []),
# A class the active user is NOT in. Must never leak into their prompt.
("rich-ocs-hist-f25-sources", OFF_HIST_F25, "Primary Sources",
["A primary source is any source written by a historian"]),
]


def seed_offering_concept_stats() -> None:
for stats_id, off_id, concept, misconceptions in _OFFERING_CONCEPT_STATS:
h.insert_if_absent(
"offering_concept_stats",
stats_id,
{
"offering_id": off_id,
"concept_name": concept,
"student_count": 4,
"avg_mastery_score": 0.55,
"pct_mastered": 0.25,
"pct_struggling": 0.5,
"pct_unexplored": 0.25,
"common_misconceptions": misconceptions,
"effective_explanations": [],
"prerequisite_gaps": [],
},
)


def seed_quiz() -> None:
for qa_id, node_id, difficulty, score, total, questions, answers, completed_at in _QUIZ_ATTEMPTS:
h.insert_if_absent(
Expand DownExpand Up@@ -785,6 +845,7 @@ def seed_sessions() -> None:
"room_summaries",
"notes", "documents", "flashcards", "study_guides", "quiz_attempts", "quiz_context",
"sessions", "messages", "feedback", "issue_reports",
"offering_concept_stats",
]


Expand All@@ -804,6 +865,7 @@ def main() -> None:
seed_study_guides()
seed_room_summaries()
seed_quiz()
seed_offering_concept_stats()
seed_feedback()
seed_sessions()
h.print_summary(_SUMMARY_ORDER, "Seed summary (rich local dataset):")
Expand Down
6 changes: 6 additions & 0 deletions backend/routes/quiz.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -822,6 +822,12 @@ async def _quiz_via_agent(
supabase=None,
request_id=request_id,
feature="quiz",
# The Class-intel opt-out reaches the misconceptions tool through
# deps, not through the prompt: the tool is registered on the agent
# unconditionally and step 2 of the system prompt tells the model to
# call it every run, so the routing sentence below can only ever ADD
# emphasis — it cannot withhold the data (#553 review).
share_class_context=use_shared_context,
)
# Keep this message routing-only; the workflow + adaptive rules
# live in the system prompt. We just hand the agent the inputs it
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix(quiz): read misconceptions from the offering keyspace (#553) by AndresL230 · Pull Request #567 · SaplingLearn/Sapling · GitHub
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
10 changes: 10 additions & 0 deletions backend/agents/deps.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -47,6 +47,15 @@ class SaplingDeps:
identical behavior. Evals inject a FixtureRetrieval here so
record/live runs never touch a database. Typed Any to avoid
the circular import deps → retrieval → chat_context → deps.
share_class_context: Whether this student consented to class-derived
data (the "Class intel" toggle, migration 0037). Tools that read
OTHER students' aggregated work — `read_misconceptions_for_course`
— must return nothing when it is False. It lives here rather than
in the prompt because a system-prompt instruction is a request to
a model, and consent is not something to leave to one: the tool is
registered on quiz_agent unconditionally and the prompt tells the
model to call it every run. Defaults True to match the column
default; an explicit False is the only thing that suppresses.
"""

user_id: str
Expand All@@ -55,6 +64,7 @@ class SaplingDeps:
request_id: str
session_id: str | None = None
feature: str = "unknown"
share_class_context: bool = True
graph_updates: list = field(default_factory=list)
mastery_changes: list = field(default_factory=list)
retrieval: Any = None
201 changes: 146 additions & 55 deletions backend/agents/tools/graph_read.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,6 +10,7 @@

import asyncio
import logging
from collections.abc import Sequence
from typing import Any

from pydantic import BaseModel, Field
Expand DownExpand Up@@ -366,12 +367,35 @@ class Misconception(BaseModel):
related_concept: str | None = None


#: Row budget per offering. Applied per-offering rather than shared, so one
#: class cannot starve another when a student holds two offerings of a course.
_ROWS_PER_OFFERING = 20
#: Ceiling on the misconception STRINGS handed to the model. Rows are not the
#: unit that costs prompt tokens; entries are.
_MAX_MISCONCEPTIONS = 40


async def read_misconceptions_for_course(
offering_id: str | None,
offering_ids: Sequence[str] | None,
) -> list[Misconception]:
"""Return aggregated misconception strings for an offering (a class in a
term). Anonymized (sourced from class-wide patterns, not any single student).
Returns [] when offering_id is None or the underlying table is empty.
"""Return aggregated misconception strings for one or more offerings (a
class in a term). Anonymized (sourced from class-wide patterns, not any
single student). Returns [] when no offerings are given or the underlying
table has nothing for them.

Takes OFFERING ids, plural, and the plural is load-bearing twice over.

Keyspace (#553): `offering_concept_stats.offering_id` holds
`course_offerings.id`, which is a different keyspace from the abstract
`courses.id` the graph and the HTTP boundary carry. This function used to
be handed the latter, so it matched nothing for every student
indefinitely. Callers resolve course -> offerings via
`services/academics.py`; that module owns the resolution.

Plural: a student can be enrolled in more than one offering of the same
course (a repeat, or a course spanning terms — the rich seed's active user
holds CS in two). Scoping to a single "current" offering would silently
drop the other class's aggregates.

Source: `offering_concept_stats` rows for the offering. Each row
represents one concept and carries a `common_misconceptions` array
Expand All@@ -382,26 +406,61 @@ async def read_misconceptions_for_course(

The tool contract (returning Misconception[]) is unchanged.
"""
if not offering_id:
# A bare `str` IS a Sequence[str], so an un-guarded comprehension would
# iterate it PER CHARACTER and build `in.(c,a,s,-,c,s,...)` — a filter that
# matches nothing while looking entirely well-formed. The same shape
# already bit this batch once (the quiz_history coercer spraying "- r"/
# "- e"/"- c" into the prompt), and the whole point of #553 is that a
# silently-matching-nothing filter can survive for months.
if isinstance(offering_ids, str):
offering_ids = [offering_ids]
ids = [str(o) for o in (offering_ids or []) if o]
if not ids:
return []

def _fetch() -> list[dict[str, Any]]:
try:
return (
table("offering_concept_stats").select(
"concept_name,common_misconceptions",
filters={"offering_id": f"eq.{offering_id}"},
order="updated_at.desc",
limit=20,
rows: list[dict[str, Any]] = []
# One read PER offering rather than one `in.(...)` read over all of
# them. A single query has to share one LIMIT, and the sort key does
# not break the tie usefully: `course_context_service` stamps every
# row of an aggregation pass with the same `updated_at`, so ordering
# within an offering is arbitrary. An offering with a full window of
# rows would then starve its sibling completely — reintroducing, per
# offering, exactly the silent drop that taking a LIST of offerings
# was meant to prevent. Students hold one or two offerings of a given
# course, so this is one or two indexed reads.
for offering_id in ids:
try:
rows.extend(
table("offering_concept_stats").select(
"concept_name,common_misconceptions",
filters={
"offering_id": f"eq.{offering_id}",
# Spend the row budget only on rows that actually
# carry text. The aggregation writes a stats row
# per concept as soon as a class has activity and
# fills this array only when it has something to
# say, so text-bearing rows are the rare minority
# (0 of 72 rows on staging, 0 of 73 on prod).
# Unfiltered, the window fills with empty rows and
# the tool returns [] for a class that genuinely
# has misconceptions — the very symptom #553 is
# about. It also keeps this read asking the same
# question the F5 probe asks, so a legitimately
# quiet class cannot look like a broken one.
"common_misconceptions": "neq.{}",
},
order="updated_at.desc",
limit=_ROWS_PER_OFFERING,
)
or []
)
or []
)
except Exception:
logger.exception(
"read_misconceptions_for_course failed for offering=%s",
offering_id,
)
return []
except Exception:
logger.exception(
"read_misconceptions_for_course failed for offering=%s",
offering_id,
)
return rows

rows = await asyncio.to_thread(_fetch)
out: list[Misconception] = []
Expand All@@ -417,6 +476,13 @@ def _fetch() -> list[dict[str, Any]]:
continue
seen.add(key)
out.append(Misconception(text=text, related_concept=concept))
# Cap what actually reaches the prompt. The old `limit=20` capped
# ROWS, and each row carries an unbounded array — so the block's
# real size was never bounded at all. F6 measured this tool's
# contribution to the prompt; bounding the unit that costs tokens
# is what makes that number hold.
if len(out) >= _MAX_MISCONCEPTIONS:
return out
return out


Expand All@@ -433,49 +499,74 @@ async def read_misconceptions_for_course_tool(
"""
from services.prompt_safety import neutralize_delimiters

out = await read_misconceptions_for_course(ctx.deps.course_id)
# F5: THE canonical instance of this bug class. This tool passed the
# abstract course id where the query filters `offering_id` — a different
# keyspace, so it returned zero rows for every student, indefinitely,
# and looked exactly like a class that simply had no misconceptions yet.
# (#553 carries the fix; this makes the next one impossible to miss.)
# The probe asks whether aggregates exist for THIS student's offerings of
# THIS course — not merely whether they are enrolled in something.
# "Enrolled somewhere" would fire on every generation in a course whose
# class simply has no aggregated misconceptions yet, which is the normal
# state for the first weeks of any term.
# Class-intel consent, enforced at the tool (#553 review finding 4).
#
# Scoped this way it detects the real failure instead: aggregates exist
# for the class, but this tool's read returned none — the signature of a
# keyspace mismatch, which is precisely how #553 (abstract course id used
# where an offering id is expected) presents.
# This tool is registered on quiz_agent unconditionally and system-prompt
# step 2 tells the model to call it on EVERY run; `use_shared_context`
# only ever APPENDED an extra routing sentence when true. That looked
# correct for as long as the read was keyspace-broken and returned []
# for everyone — fixing #553 would have quietly started feeding other
# students' aggregated misconceptions to a student who opted out.
#
# Gated on the result being EMPTY, not merely on having a course id.
# `user_offering_ids_for_course` is uncached and issues two unbounded
# PostgREST reads (enrollments -> offerings), and this runs on the quiz
# generation request path — resolving it whenever a course id exists made
# every generation pay both round-trips even when the tool returned rows,
# contradicting tool_signals' own documented contract ("one owner-scoped
# indexed read, only on the empty path"). `report_empty_result` would
# short-circuit on a non-zero count anyway, so the work was pure waste.
if not out and ctx.deps.course_id:
offering_ids: list[str] = []
# Enforced here rather than by editing the prompt or the toolset: a
# system-prompt instruction is a request to a model, and consent is not
# something to leave to one. Returning [] (not raising) keeps an opted-out
# run identical to a class with nothing to share.
if not getattr(ctx.deps, "share_class_context", True):
prompt_dimensions.record(misconceptions=0)
return []

# #553: resolve course -> the student's offerings BEFORE reading. The
# stats table is keyed on `course_offerings.id`; `ctx.deps.course_id` is
# the abstract `courses.id` the graph carries. Handing the second to a
# filter expecting the first matched nothing for every student since the
# tool was written, and looked exactly like a class with no misconceptions
# yet. Verified live 2026-08-22: staging 72/72 stats rows key on an
# offering id and 0 on a course id (prod 73/73); filtering by course id
# returned 0 in both, filtering by the student's offerings returned 68+4
# and 73.
#
# The resolution is now unconditional rather than probe-only: it is what
# the READ needs, not merely what the probe needs. It stays a single
# `academics` call whose two reads are the price of asking the right
# question at all.
offering_ids: list[str] = []
if ctx.deps.course_id:
try:
offering_ids = await asyncio.to_thread(
user_offering_ids_for_course, ctx.deps.user_id, ctx.deps.course_id
)
except Exception:
logger.debug("misconceptions probe: offering resolution failed", exc_info=True)
if offering_ids:
await report_empty_result_async(
"read_misconceptions_for_course",
user_id=ctx.deps.user_id,
count=len(out),
expect=Expect.COURSE_HAS_AGGREGATES,
feature=getattr(ctx.deps, "feature", "unknown"),
scope={"offering_id": f"in.({','.join(offering_ids)})"},
payload={"course_id": ctx.deps.course_id},
# Degrade to "no offerings" rather than raising: this is one
# optional personalization input, and the agent has others.
logger.warning(
"read_misconceptions_for_course: offering resolution failed; "
"returning no class misconceptions", exc_info=True,
)

out = await read_misconceptions_for_course(offering_ids)
# F5: THE canonical instance of this bug class. The probe asks whether
# aggregates CARRYING MISCONCEPTION TEXT exist for this student's
# offerings of this course — not merely whether they are enrolled, and
# not merely whether stats rows exist.
#
# The text qualifier matters as much as the scope. Both live environments
# today hold stats rows whose `common_misconceptions` arrays are all
# empty (0 of 72 on staging, 0 of 73 on prod — the aggregation runs, the
# classes just have no misconception text yet). A probe that fired on
# "any stats row exists" would therefore report a discrepancy on EVERY
# generation for EVERY student the moment #553 was fixed — the precise
# alarm-fatigue failure F5 exists to prevent.
if not out and offering_ids:
await report_empty_result_async(
"read_misconceptions_for_course",
user_id=ctx.deps.user_id,
count=len(out),
expect=Expect.COURSE_HAS_AGGREGATES,
feature=getattr(ctx.deps, "feature", "unknown"),
scope={"offering_id": f"in.({','.join(offering_ids)})"},
payload={"course_id": ctx.deps.course_id},
)
# F6: this block's contribution to the prompt.
prompt_dimensions.record(misconceptions=len(out))
return [
Expand Down
62 changes: 62 additions & 0 deletions backend/db/seed_local_rich.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -662,6 +662,66 @@ def seed_room_summaries() -> None:
]


# ── offering_concept_stats (#553) ─────────────────────────────────────────
#
# Class-level aggregates, keyed on `course_offerings.id` — a DIFFERENT
# keyspace from the abstract `courses.id` the graph carries. #553 was the
# quiz's misconceptions tool filtering this table's `offering_id` with a
# course id, which matched nothing for every student indefinitely while
# looking exactly like a class that had no misconceptions yet.
#
# The rows are shaped so a test can tell a real fix from a coincidence:
#
# * OFF_CS_F25 and OFF_CS_S26 are BOTH offerings of the same abstract CS
# course, and the active user is enrolled in both — so a fix that
# resolves only one "current" offering still loses half the rows.
# * OFF_HIST_F25 belongs to a course the active user is NOT enrolled in.
# Its misconception text must never reach them; that is the negative
# half of the assertion, and it is what stops a fix from "working" by
# simply dropping the offering filter altogether.
# * One row carries an EMPTY array: the aggregation writes a stats row per
# concept as soon as a class has activity and only fills the array when
# it has something to say (0 of 72 rows on staging and 0 of 73 on prod
# carried text on 2026-08-22). Seeding that state keeps the empty-vs-
# absent distinction exercised.
#
# (stats_id, offering_id, concept_name, misconceptions)
_OFFERING_CONCEPT_STATS = [
("rich-ocs-cs-f25-recursion", OFF_CS_F25, "Recursion",
["Recursion always costs more memory than a loop",
"A base case is optional if the input shrinks"]),
("rich-ocs-cs-f25-pointers", OFF_CS_F25, "Pointers and Memory",
["Freeing a pointer also clears the variable holding it"]),
("rich-ocs-cs-s26-controlflow", OFF_CS_S26, "Control Flow",
["`else if` evaluates every branch before choosing one"]),
# Same class, no text yet — a stats row is not the same as a finding.
("rich-ocs-cs-s26-variables", OFF_CS_S26, "Variables and Types", []),
# A class the active user is NOT in. Must never leak into their prompt.
("rich-ocs-hist-f25-sources", OFF_HIST_F25, "Primary Sources",
["A primary source is any source written by a historian"]),
]


def seed_offering_concept_stats() -> None:
for stats_id, off_id, concept, misconceptions in _OFFERING_CONCEPT_STATS:
h.insert_if_absent(
"offering_concept_stats",
stats_id,
{
"offering_id": off_id,
"concept_name": concept,
"student_count": 4,
"avg_mastery_score": 0.55,
"pct_mastered": 0.25,
"pct_struggling": 0.5,
"pct_unexplored": 0.25,
"common_misconceptions": misconceptions,
"effective_explanations": [],
"prerequisite_gaps": [],
},
)


def seed_quiz() -> None:
for qa_id, node_id, difficulty, score, total, questions, answers, completed_at in _QUIZ_ATTEMPTS:
h.insert_if_absent(
Expand DownExpand Up@@ -785,6 +845,7 @@ def seed_sessions() -> None:
"room_summaries",
"notes", "documents", "flashcards", "study_guides", "quiz_attempts", "quiz_context",
"sessions", "messages", "feedback", "issue_reports",
"offering_concept_stats",
]


Expand All@@ -804,6 +865,7 @@ def main() -> None:
seed_study_guides()
seed_room_summaries()
seed_quiz()
seed_offering_concept_stats()
seed_feedback()
seed_sessions()
h.print_summary(_SUMMARY_ORDER, "Seed summary (rich local dataset):")
Expand Down
6 changes: 6 additions & 0 deletions backend/routes/quiz.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -822,6 +822,12 @@ async def _quiz_via_agent(
supabase=None,
request_id=request_id,
feature="quiz",
# The Class-intel opt-out reaches the misconceptions tool through
# deps, not through the prompt: the tool is registered on the agent
# unconditionally and step 2 of the system prompt tells the model to
# call it every run, so the routing sentence below can only ever ADD
# emphasis — it cannot withhold the data (#553 review).
share_class_context=use_shared_context,
)
# Keep this message routing-only; the workflow + adaptive rules
# live in the system prompt. We just hand the agent the inputs it
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' fix(quiz): read misconceptions from the offering keyspace (#553) by AndresL230 · Pull Request #567 · SaplingLearn/Sapling · GitHub
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
10 changes: 10 additions & 0 deletions backend/agents/deps.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -47,6 +47,15 @@ class SaplingDeps:
identical behavior. Evals inject a FixtureRetrieval here so
record/live runs never touch a database. Typed Any to avoid
the circular import deps → retrieval → chat_context → deps.
share_class_context: Whether this student consented to class-derived
data (the "Class intel" toggle, migration 0037). Tools that read
OTHER students' aggregated work — `read_misconceptions_for_course`
— must return nothing when it is False. It lives here rather than
in the prompt because a system-prompt instruction is a request to
a model, and consent is not something to leave to one: the tool is
registered on quiz_agent unconditionally and the prompt tells the
model to call it every run. Defaults True to match the column
default; an explicit False is the only thing that suppresses.
"""

user_id: str
Expand All@@ -55,6 +64,7 @@ class SaplingDeps:
request_id: str
session_id: str | None = None
feature: str = "unknown"
share_class_context: bool = True
graph_updates: list = field(default_factory=list)
mastery_changes: list = field(default_factory=list)
retrieval: Any = None
201 changes: 146 additions & 55 deletions backend/agents/tools/graph_read.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,6 +10,7 @@

import asyncio
import logging
from collections.abc import Sequence
from typing import Any

from pydantic import BaseModel, Field
Expand DownExpand Up@@ -366,12 +367,35 @@ class Misconception(BaseModel):
related_concept: str | None = None


#: Row budget per offering. Applied per-offering rather than shared, so one
#: class cannot starve another when a student holds two offerings of a course.
_ROWS_PER_OFFERING = 20
#: Ceiling on the misconception STRINGS handed to the model. Rows are not the
#: unit that costs prompt tokens; entries are.
_MAX_MISCONCEPTIONS = 40


async def read_misconceptions_for_course(
offering_id: str | None,
offering_ids: Sequence[str] | None,
) -> list[Misconception]:
"""Return aggregated misconception strings for an offering (a class in a
term). Anonymized (sourced from class-wide patterns, not any single student).
Returns [] when offering_id is None or the underlying table is empty.
"""Return aggregated misconception strings for one or more offerings (a
class in a term). Anonymized (sourced from class-wide patterns, not any
single student). Returns [] when no offerings are given or the underlying
table has nothing for them.

Takes OFFERING ids, plural, and the plural is load-bearing twice over.

Keyspace (#553): `offering_concept_stats.offering_id` holds
`course_offerings.id`, which is a different keyspace from the abstract
`courses.id` the graph and the HTTP boundary carry. This function used to
be handed the latter, so it matched nothing for every student
indefinitely. Callers resolve course -> offerings via
`services/academics.py`; that module owns the resolution.

Plural: a student can be enrolled in more than one offering of the same
course (a repeat, or a course spanning terms — the rich seed's active user
holds CS in two). Scoping to a single "current" offering would silently
drop the other class's aggregates.

Source: `offering_concept_stats` rows for the offering. Each row
represents one concept and carries a `common_misconceptions` array
Expand All@@ -382,26 +406,61 @@ async def read_misconceptions_for_course(

The tool contract (returning Misconception[]) is unchanged.
"""
if not offering_id:
# A bare `str` IS a Sequence[str], so an un-guarded comprehension would
# iterate it PER CHARACTER and build `in.(c,a,s,-,c,s,...)` — a filter that
# matches nothing while looking entirely well-formed. The same shape
# already bit this batch once (the quiz_history coercer spraying "- r"/
# "- e"/"- c" into the prompt), and the whole point of #553 is that a
# silently-matching-nothing filter can survive for months.
if isinstance(offering_ids, str):
offering_ids = [offering_ids]
ids = [str(o) for o in (offering_ids or []) if o]
if not ids:
return []

def _fetch() -> list[dict[str, Any]]:
try:
return (
table("offering_concept_stats").select(
"concept_name,common_misconceptions",
filters={"offering_id": f"eq.{offering_id}"},
order="updated_at.desc",
limit=20,
rows: list[dict[str, Any]] = []
# One read PER offering rather than one `in.(...)` read over all of
# them. A single query has to share one LIMIT, and the sort key does
# not break the tie usefully: `course_context_service` stamps every
# row of an aggregation pass with the same `updated_at`, so ordering
# within an offering is arbitrary. An offering with a full window of
# rows would then starve its sibling completely — reintroducing, per
# offering, exactly the silent drop that taking a LIST of offerings
# was meant to prevent. Students hold one or two offerings of a given
# course, so this is one or two indexed reads.
for offering_id in ids:
try:
rows.extend(
table("offering_concept_stats").select(
"concept_name,common_misconceptions",
filters={
"offering_id": f"eq.{offering_id}",
# Spend the row budget only on rows that actually
# carry text. The aggregation writes a stats row
# per concept as soon as a class has activity and
# fills this array only when it has something to
# say, so text-bearing rows are the rare minority
# (0 of 72 rows on staging, 0 of 73 on prod).
# Unfiltered, the window fills with empty rows and
# the tool returns [] for a class that genuinely
# has misconceptions — the very symptom #553 is
# about. It also keeps this read asking the same
# question the F5 probe asks, so a legitimately
# quiet class cannot look like a broken one.
"common_misconceptions": "neq.{}",
},
order="updated_at.desc",
limit=_ROWS_PER_OFFERING,
)
or []
)
or []
)
except Exception:
logger.exception(
"read_misconceptions_for_course failed for offering=%s",
offering_id,
)
return []
except Exception:
logger.exception(
"read_misconceptions_for_course failed for offering=%s",
offering_id,
)
return rows

rows = await asyncio.to_thread(_fetch)
out: list[Misconception] = []
Expand All@@ -417,6 +476,13 @@ def _fetch() -> list[dict[str, Any]]:
continue
seen.add(key)
out.append(Misconception(text=text, related_concept=concept))
# Cap what actually reaches the prompt. The old `limit=20` capped
# ROWS, and each row carries an unbounded array — so the block's
# real size was never bounded at all. F6 measured this tool's
# contribution to the prompt; bounding the unit that costs tokens
# is what makes that number hold.
if len(out) >= _MAX_MISCONCEPTIONS:
return out
return out


Expand All@@ -433,49 +499,74 @@ async def read_misconceptions_for_course_tool(
"""
from services.prompt_safety import neutralize_delimiters

out = await read_misconceptions_for_course(ctx.deps.course_id)
# F5: THE canonical instance of this bug class. This tool passed the
# abstract course id where the query filters `offering_id` — a different
# keyspace, so it returned zero rows for every student, indefinitely,
# and looked exactly like a class that simply had no misconceptions yet.
# (#553 carries the fix; this makes the next one impossible to miss.)
# The probe asks whether aggregates exist for THIS student's offerings of
# THIS course — not merely whether they are enrolled in something.
# "Enrolled somewhere" would fire on every generation in a course whose
# class simply has no aggregated misconceptions yet, which is the normal
# state for the first weeks of any term.
# Class-intel consent, enforced at the tool (#553 review finding 4).
#
# Scoped this way it detects the real failure instead: aggregates exist
# for the class, but this tool's read returned none — the signature of a
# keyspace mismatch, which is precisely how #553 (abstract course id used
# where an offering id is expected) presents.
# This tool is registered on quiz_agent unconditionally and system-prompt
# step 2 tells the model to call it on EVERY run; `use_shared_context`
# only ever APPENDED an extra routing sentence when true. That looked
# correct for as long as the read was keyspace-broken and returned []
# for everyone — fixing #553 would have quietly started feeding other
# students' aggregated misconceptions to a student who opted out.
#
# Gated on the result being EMPTY, not merely on having a course id.
# `user_offering_ids_for_course` is uncached and issues two unbounded
# PostgREST reads (enrollments -> offerings), and this runs on the quiz
# generation request path — resolving it whenever a course id exists made
# every generation pay both round-trips even when the tool returned rows,
# contradicting tool_signals' own documented contract ("one owner-scoped
# indexed read, only on the empty path"). `report_empty_result` would
# short-circuit on a non-zero count anyway, so the work was pure waste.
if not out and ctx.deps.course_id:
offering_ids: list[str] = []
# Enforced here rather than by editing the prompt or the toolset: a
# system-prompt instruction is a request to a model, and consent is not
# something to leave to one. Returning [] (not raising) keeps an opted-out
# run identical to a class with nothing to share.
if not getattr(ctx.deps, "share_class_context", True):
prompt_dimensions.record(misconceptions=0)
return []

# #553: resolve course -> the student's offerings BEFORE reading. The
# stats table is keyed on `course_offerings.id`; `ctx.deps.course_id` is
# the abstract `courses.id` the graph carries. Handing the second to a
# filter expecting the first matched nothing for every student since the
# tool was written, and looked exactly like a class with no misconceptions
# yet. Verified live 2026-08-22: staging 72/72 stats rows key on an
# offering id and 0 on a course id (prod 73/73); filtering by course id
# returned 0 in both, filtering by the student's offerings returned 68+4
# and 73.
#
# The resolution is now unconditional rather than probe-only: it is what
# the READ needs, not merely what the probe needs. It stays a single
# `academics` call whose two reads are the price of asking the right
# question at all.
offering_ids: list[str] = []
if ctx.deps.course_id:
try:
offering_ids = await asyncio.to_thread(
user_offering_ids_for_course, ctx.deps.user_id, ctx.deps.course_id
)
except Exception:
logger.debug("misconceptions probe: offering resolution failed", exc_info=True)
if offering_ids:
await report_empty_result_async(
"read_misconceptions_for_course",
user_id=ctx.deps.user_id,
count=len(out),
expect=Expect.COURSE_HAS_AGGREGATES,
feature=getattr(ctx.deps, "feature", "unknown"),
scope={"offering_id": f"in.({','.join(offering_ids)})"},
payload={"course_id": ctx.deps.course_id},
# Degrade to "no offerings" rather than raising: this is one
# optional personalization input, and the agent has others.
logger.warning(
"read_misconceptions_for_course: offering resolution failed; "
"returning no class misconceptions", exc_info=True,
)

out = await read_misconceptions_for_course(offering_ids)
# F5: THE canonical instance of this bug class. The probe asks whether
# aggregates CARRYING MISCONCEPTION TEXT exist for this student's
# offerings of this course — not merely whether they are enrolled, and
# not merely whether stats rows exist.
#
# The text qualifier matters as much as the scope. Both live environments
# today hold stats rows whose `common_misconceptions` arrays are all
# empty (0 of 72 on staging, 0 of 73 on prod — the aggregation runs, the
# classes just have no misconception text yet). A probe that fired on
# "any stats row exists" would therefore report a discrepancy on EVERY
# generation for EVERY student the moment #553 was fixed — the precise
# alarm-fatigue failure F5 exists to prevent.
if not out and offering_ids:
await report_empty_result_async(
"read_misconceptions_for_course",
user_id=ctx.deps.user_id,
count=len(out),
expect=Expect.COURSE_HAS_AGGREGATES,
feature=getattr(ctx.deps, "feature", "unknown"),
scope={"offering_id": f"in.({','.join(offering_ids)})"},
payload={"course_id": ctx.deps.course_id},
)
# F6: this block's contribution to the prompt.
prompt_dimensions.record(misconceptions=len(out))
return [
Expand Down
62 changes: 62 additions & 0 deletions backend/db/seed_local_rich.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -662,6 +662,66 @@ def seed_room_summaries() -> None:
]


# ── offering_concept_stats (#553) ─────────────────────────────────────────
#
# Class-level aggregates, keyed on `course_offerings.id` — a DIFFERENT
# keyspace from the abstract `courses.id` the graph carries. #553 was the
# quiz's misconceptions tool filtering this table's `offering_id` with a
# course id, which matched nothing for every student indefinitely while
# looking exactly like a class that had no misconceptions yet.
#
# The rows are shaped so a test can tell a real fix from a coincidence:
#
# * OFF_CS_F25 and OFF_CS_S26 are BOTH offerings of the same abstract CS
# course, and the active user is enrolled in both — so a fix that
# resolves only one "current" offering still loses half the rows.
# * OFF_HIST_F25 belongs to a course the active user is NOT enrolled in.
# Its misconception text must never reach them; that is the negative
# half of the assertion, and it is what stops a fix from "working" by
# simply dropping the offering filter altogether.
# * One row carries an EMPTY array: the aggregation writes a stats row per
# concept as soon as a class has activity and only fills the array when
# it has something to say (0 of 72 rows on staging and 0 of 73 on prod
# carried text on 2026-08-22). Seeding that state keeps the empty-vs-
# absent distinction exercised.
#
# (stats_id, offering_id, concept_name, misconceptions)
_OFFERING_CONCEPT_STATS = [
("rich-ocs-cs-f25-recursion", OFF_CS_F25, "Recursion",
["Recursion always costs more memory than a loop",
"A base case is optional if the input shrinks"]),
("rich-ocs-cs-f25-pointers", OFF_CS_F25, "Pointers and Memory",
["Freeing a pointer also clears the variable holding it"]),
("rich-ocs-cs-s26-controlflow", OFF_CS_S26, "Control Flow",
["`else if` evaluates every branch before choosing one"]),
# Same class, no text yet — a stats row is not the same as a finding.
("rich-ocs-cs-s26-variables", OFF_CS_S26, "Variables and Types", []),
# A class the active user is NOT in. Must never leak into their prompt.
("rich-ocs-hist-f25-sources", OFF_HIST_F25, "Primary Sources",
["A primary source is any source written by a historian"]),
]


def seed_offering_concept_stats() -> None:
for stats_id, off_id, concept, misconceptions in _OFFERING_CONCEPT_STATS:
h.insert_if_absent(
"offering_concept_stats",
stats_id,
{
"offering_id": off_id,
"concept_name": concept,
"student_count": 4,
"avg_mastery_score": 0.55,
"pct_mastered": 0.25,
"pct_struggling": 0.5,
"pct_unexplored": 0.25,
"common_misconceptions": misconceptions,
"effective_explanations": [],
"prerequisite_gaps": [],
},
)


def seed_quiz() -> None:
for qa_id, node_id, difficulty, score, total, questions, answers, completed_at in _QUIZ_ATTEMPTS:
h.insert_if_absent(
Expand DownExpand Up@@ -785,6 +845,7 @@ def seed_sessions() -> None:
"room_summaries",
"notes", "documents", "flashcards", "study_guides", "quiz_attempts", "quiz_context",
"sessions", "messages", "feedback", "issue_reports",
"offering_concept_stats",
]


Expand All@@ -804,6 +865,7 @@ def main() -> None:
seed_study_guides()
seed_room_summaries()
seed_quiz()
seed_offering_concept_stats()
seed_feedback()
seed_sessions()
h.print_summary(_SUMMARY_ORDER, "Seed summary (rich local dataset):")
Expand Down
6 changes: 6 additions & 0 deletions backend/routes/quiz.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -822,6 +822,12 @@ async def _quiz_via_agent(
supabase=None,
request_id=request_id,
feature="quiz",
# The Class-intel opt-out reaches the misconceptions tool through
# deps, not through the prompt: the tool is registered on the agent
# unconditionally and step 2 of the system prompt tells the model to
# call it every run, so the routing sentence below can only ever ADD
# emphasis — it cannot withhold the data (#553 review).
share_class_context=use_shared_context,
)
# Keep this message routing-only; the workflow + adaptive rules
# live in the system prompt. We just hand the agent the inputs it
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix(quiz): read misconceptions from the offering keyspace (#553) by AndresL230 · Pull Request #567 · SaplingLearn/Sapling · GitHub
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
10 changes: 10 additions & 0 deletions backend/agents/deps.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -47,6 +47,15 @@ class SaplingDeps:
identical behavior. Evals inject a FixtureRetrieval here so
record/live runs never touch a database. Typed Any to avoid
the circular import deps → retrieval → chat_context → deps.
share_class_context: Whether this student consented to class-derived
data (the "Class intel" toggle, migration 0037). Tools that read
OTHER students' aggregated work — `read_misconceptions_for_course`
— must return nothing when it is False. It lives here rather than
in the prompt because a system-prompt instruction is a request to
a model, and consent is not something to leave to one: the tool is
registered on quiz_agent unconditionally and the prompt tells the
model to call it every run. Defaults True to match the column
default; an explicit False is the only thing that suppresses.
"""

user_id: str
Expand All@@ -55,6 +64,7 @@ class SaplingDeps:
request_id: str
session_id: str | None = None
feature: str = "unknown"
share_class_context: bool = True
graph_updates: list = field(default_factory=list)
mastery_changes: list = field(default_factory=list)
retrieval: Any = None
201 changes: 146 additions & 55 deletions backend/agents/tools/graph_read.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,6 +10,7 @@

import asyncio
import logging
from collections.abc import Sequence
from typing import Any

from pydantic import BaseModel, Field
Expand DownExpand Up@@ -366,12 +367,35 @@ class Misconception(BaseModel):
related_concept: str | None = None


#: Row budget per offering. Applied per-offering rather than shared, so one
#: class cannot starve another when a student holds two offerings of a course.
_ROWS_PER_OFFERING = 20
#: Ceiling on the misconception STRINGS handed to the model. Rows are not the
#: unit that costs prompt tokens; entries are.
_MAX_MISCONCEPTIONS = 40


async def read_misconceptions_for_course(
offering_id: str | None,
offering_ids: Sequence[str] | None,
) -> list[Misconception]:
"""Return aggregated misconception strings for an offering (a class in a
term). Anonymized (sourced from class-wide patterns, not any single student).
Returns [] when offering_id is None or the underlying table is empty.
"""Return aggregated misconception strings for one or more offerings (a
class in a term). Anonymized (sourced from class-wide patterns, not any
single student). Returns [] when no offerings are given or the underlying
table has nothing for them.

Takes OFFERING ids, plural, and the plural is load-bearing twice over.

Keyspace (#553): `offering_concept_stats.offering_id` holds
`course_offerings.id`, which is a different keyspace from the abstract
`courses.id` the graph and the HTTP boundary carry. This function used to
be handed the latter, so it matched nothing for every student
indefinitely. Callers resolve course -> offerings via
`services/academics.py`; that module owns the resolution.

Plural: a student can be enrolled in more than one offering of the same
course (a repeat, or a course spanning terms — the rich seed's active user
holds CS in two). Scoping to a single "current" offering would silently
drop the other class's aggregates.

Source: `offering_concept_stats` rows for the offering. Each row
represents one concept and carries a `common_misconceptions` array
Expand All@@ -382,26 +406,61 @@ async def read_misconceptions_for_course(

The tool contract (returning Misconception[]) is unchanged.
"""
if not offering_id:
# A bare `str` IS a Sequence[str], so an un-guarded comprehension would
# iterate it PER CHARACTER and build `in.(c,a,s,-,c,s,...)` — a filter that
# matches nothing while looking entirely well-formed. The same shape
# already bit this batch once (the quiz_history coercer spraying "- r"/
# "- e"/"- c" into the prompt), and the whole point of #553 is that a
# silently-matching-nothing filter can survive for months.
if isinstance(offering_ids, str):
offering_ids = [offering_ids]
ids = [str(o) for o in (offering_ids or []) if o]
if not ids:
return []

def _fetch() -> list[dict[str, Any]]:
try:
return (
table("offering_concept_stats").select(
"concept_name,common_misconceptions",
filters={"offering_id": f"eq.{offering_id}"},
order="updated_at.desc",
limit=20,
rows: list[dict[str, Any]] = []
# One read PER offering rather than one `in.(...)` read over all of
# them. A single query has to share one LIMIT, and the sort key does
# not break the tie usefully: `course_context_service` stamps every
# row of an aggregation pass with the same `updated_at`, so ordering
# within an offering is arbitrary. An offering with a full window of
# rows would then starve its sibling completely — reintroducing, per
# offering, exactly the silent drop that taking a LIST of offerings
# was meant to prevent. Students hold one or two offerings of a given
# course, so this is one or two indexed reads.
for offering_id in ids:
try:
rows.extend(
table("offering_concept_stats").select(
"concept_name,common_misconceptions",
filters={
"offering_id": f"eq.{offering_id}",
# Spend the row budget only on rows that actually
# carry text. The aggregation writes a stats row
# per concept as soon as a class has activity and
# fills this array only when it has something to
# say, so text-bearing rows are the rare minority
# (0 of 72 rows on staging, 0 of 73 on prod).
# Unfiltered, the window fills with empty rows and
# the tool returns [] for a class that genuinely
# has misconceptions — the very symptom #553 is
# about. It also keeps this read asking the same
# question the F5 probe asks, so a legitimately
# quiet class cannot look like a broken one.
"common_misconceptions": "neq.{}",
},
order="updated_at.desc",
limit=_ROWS_PER_OFFERING,
)
or []
)
or []
)
except Exception:
logger.exception(
"read_misconceptions_for_course failed for offering=%s",
offering_id,
)
return []
except Exception:
logger.exception(
"read_misconceptions_for_course failed for offering=%s",
offering_id,
)
return rows

rows = await asyncio.to_thread(_fetch)
out: list[Misconception] = []
Expand All@@ -417,6 +476,13 @@ def _fetch() -> list[dict[str, Any]]:
continue
seen.add(key)
out.append(Misconception(text=text, related_concept=concept))
# Cap what actually reaches the prompt. The old `limit=20` capped
# ROWS, and each row carries an unbounded array — so the block's
# real size was never bounded at all. F6 measured this tool's
# contribution to the prompt; bounding the unit that costs tokens
# is what makes that number hold.
if len(out) >= _MAX_MISCONCEPTIONS:
return out
return out


Expand All@@ -433,49 +499,74 @@ async def read_misconceptions_for_course_tool(
"""
from services.prompt_safety import neutralize_delimiters

out = await read_misconceptions_for_course(ctx.deps.course_id)
# F5: THE canonical instance of this bug class. This tool passed the
# abstract course id where the query filters `offering_id` — a different
# keyspace, so it returned zero rows for every student, indefinitely,
# and looked exactly like a class that simply had no misconceptions yet.
# (#553 carries the fix; this makes the next one impossible to miss.)
# The probe asks whether aggregates exist for THIS student's offerings of
# THIS course — not merely whether they are enrolled in something.
# "Enrolled somewhere" would fire on every generation in a course whose
# class simply has no aggregated misconceptions yet, which is the normal
# state for the first weeks of any term.
# Class-intel consent, enforced at the tool (#553 review finding 4).
#
# Scoped this way it detects the real failure instead: aggregates exist
# for the class, but this tool's read returned none — the signature of a
# keyspace mismatch, which is precisely how #553 (abstract course id used
# where an offering id is expected) presents.
# This tool is registered on quiz_agent unconditionally and system-prompt
# step 2 tells the model to call it on EVERY run; `use_shared_context`
# only ever APPENDED an extra routing sentence when true. That looked
# correct for as long as the read was keyspace-broken and returned []
# for everyone — fixing #553 would have quietly started feeding other
# students' aggregated misconceptions to a student who opted out.
#
# Gated on the result being EMPTY, not merely on having a course id.
# `user_offering_ids_for_course` is uncached and issues two unbounded
# PostgREST reads (enrollments -> offerings), and this runs on the quiz
# generation request path — resolving it whenever a course id exists made
# every generation pay both round-trips even when the tool returned rows,
# contradicting tool_signals' own documented contract ("one owner-scoped
# indexed read, only on the empty path"). `report_empty_result` would
# short-circuit on a non-zero count anyway, so the work was pure waste.
if not out and ctx.deps.course_id:
offering_ids: list[str] = []
# Enforced here rather than by editing the prompt or the toolset: a
# system-prompt instruction is a request to a model, and consent is not
# something to leave to one. Returning [] (not raising) keeps an opted-out
# run identical to a class with nothing to share.
if not getattr(ctx.deps, "share_class_context", True):
prompt_dimensions.record(misconceptions=0)
return []

# #553: resolve course -> the student's offerings BEFORE reading. The
# stats table is keyed on `course_offerings.id`; `ctx.deps.course_id` is
# the abstract `courses.id` the graph carries. Handing the second to a
# filter expecting the first matched nothing for every student since the
# tool was written, and looked exactly like a class with no misconceptions
# yet. Verified live 2026-08-22: staging 72/72 stats rows key on an
# offering id and 0 on a course id (prod 73/73); filtering by course id
# returned 0 in both, filtering by the student's offerings returned 68+4
# and 73.
#
# The resolution is now unconditional rather than probe-only: it is what
# the READ needs, not merely what the probe needs. It stays a single
# `academics` call whose two reads are the price of asking the right
# question at all.
offering_ids: list[str] = []
if ctx.deps.course_id:
try:
offering_ids = await asyncio.to_thread(
user_offering_ids_for_course, ctx.deps.user_id, ctx.deps.course_id
)
except Exception:
logger.debug("misconceptions probe: offering resolution failed", exc_info=True)
if offering_ids:
await report_empty_result_async(
"read_misconceptions_for_course",
user_id=ctx.deps.user_id,
count=len(out),
expect=Expect.COURSE_HAS_AGGREGATES,
feature=getattr(ctx.deps, "feature", "unknown"),
scope={"offering_id": f"in.({','.join(offering_ids)})"},
payload={"course_id": ctx.deps.course_id},
# Degrade to "no offerings" rather than raising: this is one
# optional personalization input, and the agent has others.
logger.warning(
"read_misconceptions_for_course: offering resolution failed; "
"returning no class misconceptions", exc_info=True,
)

out = await read_misconceptions_for_course(offering_ids)
# F5: THE canonical instance of this bug class. The probe asks whether
# aggregates CARRYING MISCONCEPTION TEXT exist for this student's
# offerings of this course — not merely whether they are enrolled, and
# not merely whether stats rows exist.
#
# The text qualifier matters as much as the scope. Both live environments
# today hold stats rows whose `common_misconceptions` arrays are all
# empty (0 of 72 on staging, 0 of 73 on prod — the aggregation runs, the
# classes just have no misconception text yet). A probe that fired on
# "any stats row exists" would therefore report a discrepancy on EVERY
# generation for EVERY student the moment #553 was fixed — the precise
# alarm-fatigue failure F5 exists to prevent.
if not out and offering_ids:
await report_empty_result_async(
"read_misconceptions_for_course",
user_id=ctx.deps.user_id,
count=len(out),
expect=Expect.COURSE_HAS_AGGREGATES,
feature=getattr(ctx.deps, "feature", "unknown"),
scope={"offering_id": f"in.({','.join(offering_ids)})"},
payload={"course_id": ctx.deps.course_id},
)
# F6: this block's contribution to the prompt.
prompt_dimensions.record(misconceptions=len(out))
return [
Expand Down
62 changes: 62 additions & 0 deletions backend/db/seed_local_rich.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -662,6 +662,66 @@ def seed_room_summaries() -> None:
]


# ── offering_concept_stats (#553) ─────────────────────────────────────────
#
# Class-level aggregates, keyed on `course_offerings.id` — a DIFFERENT
# keyspace from the abstract `courses.id` the graph carries. #553 was the
# quiz's misconceptions tool filtering this table's `offering_id` with a
# course id, which matched nothing for every student indefinitely while
# looking exactly like a class that had no misconceptions yet.
#
# The rows are shaped so a test can tell a real fix from a coincidence:
#
# * OFF_CS_F25 and OFF_CS_S26 are BOTH offerings of the same abstract CS
# course, and the active user is enrolled in both — so a fix that
# resolves only one "current" offering still loses half the rows.
# * OFF_HIST_F25 belongs to a course the active user is NOT enrolled in.
# Its misconception text must never reach them; that is the negative
# half of the assertion, and it is what stops a fix from "working" by
# simply dropping the offering filter altogether.
# * One row carries an EMPTY array: the aggregation writes a stats row per
# concept as soon as a class has activity and only fills the array when
# it has something to say (0 of 72 rows on staging and 0 of 73 on prod
# carried text on 2026-08-22). Seeding that state keeps the empty-vs-
# absent distinction exercised.
#
# (stats_id, offering_id, concept_name, misconceptions)
_OFFERING_CONCEPT_STATS = [
("rich-ocs-cs-f25-recursion", OFF_CS_F25, "Recursion",
["Recursion always costs more memory than a loop",
"A base case is optional if the input shrinks"]),
("rich-ocs-cs-f25-pointers", OFF_CS_F25, "Pointers and Memory",
["Freeing a pointer also clears the variable holding it"]),
("rich-ocs-cs-s26-controlflow", OFF_CS_S26, "Control Flow",
["`else if` evaluates every branch before choosing one"]),
# Same class, no text yet — a stats row is not the same as a finding.
("rich-ocs-cs-s26-variables", OFF_CS_S26, "Variables and Types", []),
# A class the active user is NOT in. Must never leak into their prompt.
("rich-ocs-hist-f25-sources", OFF_HIST_F25, "Primary Sources",
["A primary source is any source written by a historian"]),
]


def seed_offering_concept_stats() -> None:
for stats_id, off_id, concept, misconceptions in _OFFERING_CONCEPT_STATS:
h.insert_if_absent(
"offering_concept_stats",
stats_id,
{
"offering_id": off_id,
"concept_name": concept,
"student_count": 4,
"avg_mastery_score": 0.55,
"pct_mastered": 0.25,
"pct_struggling": 0.5,
"pct_unexplored": 0.25,
"common_misconceptions": misconceptions,
"effective_explanations": [],
"prerequisite_gaps": [],
},
)


def seed_quiz() -> None:
for qa_id, node_id, difficulty, score, total, questions, answers, completed_at in _QUIZ_ATTEMPTS:
h.insert_if_absent(
Expand DownExpand Up@@ -785,6 +845,7 @@ def seed_sessions() -> None:
"room_summaries",
"notes", "documents", "flashcards", "study_guides", "quiz_attempts", "quiz_context",
"sessions", "messages", "feedback", "issue_reports",
"offering_concept_stats",
]


Expand All@@ -804,6 +865,7 @@ def main() -> None:
seed_study_guides()
seed_room_summaries()
seed_quiz()
seed_offering_concept_stats()
seed_feedback()
seed_sessions()
h.print_summary(_SUMMARY_ORDER, "Seed summary (rich local dataset):")
Expand Down
6 changes: 6 additions & 0 deletions backend/routes/quiz.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -822,6 +822,12 @@ async def _quiz_via_agent(
supabase=None,
request_id=request_id,
feature="quiz",
# The Class-intel opt-out reaches the misconceptions tool through
# deps, not through the prompt: the tool is registered on the agent
# unconditionally and step 2 of the system prompt tells the model to
# call it every run, so the routing sentence below can only ever ADD
# emphasis — it cannot withhold the data (#553 review).
share_class_context=use_shared_context,
)
# Keep this message routing-only; the workflow + adaptive rules
# live in the system prompt. We just hand the agent the inputs it
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix(quiz): read misconceptions from the offering keyspace (#553) by AndresL230 · Pull Request #567 · SaplingLearn/Sapling · GitHub
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
10 changes: 10 additions & 0 deletions backend/agents/deps.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -47,6 +47,15 @@ class SaplingDeps:
identical behavior. Evals inject a FixtureRetrieval here so
record/live runs never touch a database. Typed Any to avoid
the circular import deps → retrieval → chat_context → deps.
share_class_context: Whether this student consented to class-derived
data (the "Class intel" toggle, migration 0037). Tools that read
OTHER students' aggregated work — `read_misconceptions_for_course`
— must return nothing when it is False. It lives here rather than
in the prompt because a system-prompt instruction is a request to
a model, and consent is not something to leave to one: the tool is
registered on quiz_agent unconditionally and the prompt tells the
model to call it every run. Defaults True to match the column
default; an explicit False is the only thing that suppresses.
"""

user_id: str
Expand All@@ -55,6 +64,7 @@ class SaplingDeps:
request_id: str
session_id: str | None = None
feature: str = "unknown"
share_class_context: bool = True
graph_updates: list = field(default_factory=list)
mastery_changes: list = field(default_factory=list)
retrieval: Any = None
201 changes: 146 additions & 55 deletions backend/agents/tools/graph_read.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,6 +10,7 @@

import asyncio
import logging
from collections.abc import Sequence
from typing import Any

from pydantic import BaseModel, Field
Expand DownExpand Up@@ -366,12 +367,35 @@ class Misconception(BaseModel):
related_concept: str | None = None


#: Row budget per offering. Applied per-offering rather than shared, so one
#: class cannot starve another when a student holds two offerings of a course.
_ROWS_PER_OFFERING = 20
#: Ceiling on the misconception STRINGS handed to the model. Rows are not the
#: unit that costs prompt tokens; entries are.
_MAX_MISCONCEPTIONS = 40


async def read_misconceptions_for_course(
offering_id: str | None,
offering_ids: Sequence[str] | None,
) -> list[Misconception]:
"""Return aggregated misconception strings for an offering (a class in a
term). Anonymized (sourced from class-wide patterns, not any single student).
Returns [] when offering_id is None or the underlying table is empty.
"""Return aggregated misconception strings for one or more offerings (a
class in a term). Anonymized (sourced from class-wide patterns, not any
single student). Returns [] when no offerings are given or the underlying
table has nothing for them.

Takes OFFERING ids, plural, and the plural is load-bearing twice over.

Keyspace (#553): `offering_concept_stats.offering_id` holds
`course_offerings.id`, which is a different keyspace from the abstract
`courses.id` the graph and the HTTP boundary carry. This function used to
be handed the latter, so it matched nothing for every student
indefinitely. Callers resolve course -> offerings via
`services/academics.py`; that module owns the resolution.

Plural: a student can be enrolled in more than one offering of the same
course (a repeat, or a course spanning terms — the rich seed's active user
holds CS in two). Scoping to a single "current" offering would silently
drop the other class's aggregates.

Source: `offering_concept_stats` rows for the offering. Each row
represents one concept and carries a `common_misconceptions` array
Expand All@@ -382,26 +406,61 @@ async def read_misconceptions_for_course(

The tool contract (returning Misconception[]) is unchanged.
"""
if not offering_id:
# A bare `str` IS a Sequence[str], so an un-guarded comprehension would
# iterate it PER CHARACTER and build `in.(c,a,s,-,c,s,...)` — a filter that
# matches nothing while looking entirely well-formed. The same shape
# already bit this batch once (the quiz_history coercer spraying "- r"/
# "- e"/"- c" into the prompt), and the whole point of #553 is that a
# silently-matching-nothing filter can survive for months.
if isinstance(offering_ids, str):
offering_ids = [offering_ids]
ids = [str(o) for o in (offering_ids or []) if o]
if not ids:
return []

def _fetch() -> list[dict[str, Any]]:
try:
return (
table("offering_concept_stats").select(
"concept_name,common_misconceptions",
filters={"offering_id": f"eq.{offering_id}"},
order="updated_at.desc",
limit=20,
rows: list[dict[str, Any]] = []
# One read PER offering rather than one `in.(...)` read over all of
# them. A single query has to share one LIMIT, and the sort key does
# not break the tie usefully: `course_context_service` stamps every
# row of an aggregation pass with the same `updated_at`, so ordering
# within an offering is arbitrary. An offering with a full window of
# rows would then starve its sibling completely — reintroducing, per
# offering, exactly the silent drop that taking a LIST of offerings
# was meant to prevent. Students hold one or two offerings of a given
# course, so this is one or two indexed reads.
for offering_id in ids:
try:
rows.extend(
table("offering_concept_stats").select(
"concept_name,common_misconceptions",
filters={
"offering_id": f"eq.{offering_id}",
# Spend the row budget only on rows that actually
# carry text. The aggregation writes a stats row
# per concept as soon as a class has activity and
# fills this array only when it has something to
# say, so text-bearing rows are the rare minority
# (0 of 72 rows on staging, 0 of 73 on prod).
# Unfiltered, the window fills with empty rows and
# the tool returns [] for a class that genuinely
# has misconceptions — the very symptom #553 is
# about. It also keeps this read asking the same
# question the F5 probe asks, so a legitimately
# quiet class cannot look like a broken one.
"common_misconceptions": "neq.{}",
},
order="updated_at.desc",
limit=_ROWS_PER_OFFERING,
)
or []
)
or []
)
except Exception:
logger.exception(
"read_misconceptions_for_course failed for offering=%s",
offering_id,
)
return []
except Exception:
logger.exception(
"read_misconceptions_for_course failed for offering=%s",
offering_id,
)
return rows

rows = await asyncio.to_thread(_fetch)
out: list[Misconception] = []
Expand All@@ -417,6 +476,13 @@ def _fetch() -> list[dict[str, Any]]:
continue
seen.add(key)
out.append(Misconception(text=text, related_concept=concept))
# Cap what actually reaches the prompt. The old `limit=20` capped
# ROWS, and each row carries an unbounded array — so the block's
# real size was never bounded at all. F6 measured this tool's
# contribution to the prompt; bounding the unit that costs tokens
# is what makes that number hold.
if len(out) >= _MAX_MISCONCEPTIONS:
return out
return out


Expand All@@ -433,49 +499,74 @@ async def read_misconceptions_for_course_tool(
"""
from services.prompt_safety import neutralize_delimiters

out = await read_misconceptions_for_course(ctx.deps.course_id)
# F5: THE canonical instance of this bug class. This tool passed the
# abstract course id where the query filters `offering_id` — a different
# keyspace, so it returned zero rows for every student, indefinitely,
# and looked exactly like a class that simply had no misconceptions yet.
# (#553 carries the fix; this makes the next one impossible to miss.)
# The probe asks whether aggregates exist for THIS student's offerings of
# THIS course — not merely whether they are enrolled in something.
# "Enrolled somewhere" would fire on every generation in a course whose
# class simply has no aggregated misconceptions yet, which is the normal
# state for the first weeks of any term.
# Class-intel consent, enforced at the tool (#553 review finding 4).
#
# Scoped this way it detects the real failure instead: aggregates exist
# for the class, but this tool's read returned none — the signature of a
# keyspace mismatch, which is precisely how #553 (abstract course id used
# where an offering id is expected) presents.
# This tool is registered on quiz_agent unconditionally and system-prompt
# step 2 tells the model to call it on EVERY run; `use_shared_context`
# only ever APPENDED an extra routing sentence when true. That looked
# correct for as long as the read was keyspace-broken and returned []
# for everyone — fixing #553 would have quietly started feeding other
# students' aggregated misconceptions to a student who opted out.
#
# Gated on the result being EMPTY, not merely on having a course id.
# `user_offering_ids_for_course` is uncached and issues two unbounded
# PostgREST reads (enrollments -> offerings), and this runs on the quiz
# generation request path — resolving it whenever a course id exists made
# every generation pay both round-trips even when the tool returned rows,
# contradicting tool_signals' own documented contract ("one owner-scoped
# indexed read, only on the empty path"). `report_empty_result` would
# short-circuit on a non-zero count anyway, so the work was pure waste.
if not out and ctx.deps.course_id:
offering_ids: list[str] = []
# Enforced here rather than by editing the prompt or the toolset: a
# system-prompt instruction is a request to a model, and consent is not
# something to leave to one. Returning [] (not raising) keeps an opted-out
# run identical to a class with nothing to share.
if not getattr(ctx.deps, "share_class_context", True):
prompt_dimensions.record(misconceptions=0)
return []

# #553: resolve course -> the student's offerings BEFORE reading. The
# stats table is keyed on `course_offerings.id`; `ctx.deps.course_id` is
# the abstract `courses.id` the graph carries. Handing the second to a
# filter expecting the first matched nothing for every student since the
# tool was written, and looked exactly like a class with no misconceptions
# yet. Verified live 2026-08-22: staging 72/72 stats rows key on an
# offering id and 0 on a course id (prod 73/73); filtering by course id
# returned 0 in both, filtering by the student's offerings returned 68+4
# and 73.
#
# The resolution is now unconditional rather than probe-only: it is what
# the READ needs, not merely what the probe needs. It stays a single
# `academics` call whose two reads are the price of asking the right
# question at all.
offering_ids: list[str] = []
if ctx.deps.course_id:
try:
offering_ids = await asyncio.to_thread(
user_offering_ids_for_course, ctx.deps.user_id, ctx.deps.course_id
)
except Exception:
logger.debug("misconceptions probe: offering resolution failed", exc_info=True)
if offering_ids:
await report_empty_result_async(
"read_misconceptions_for_course",
user_id=ctx.deps.user_id,
count=len(out),
expect=Expect.COURSE_HAS_AGGREGATES,
feature=getattr(ctx.deps, "feature", "unknown"),
scope={"offering_id": f"in.({','.join(offering_ids)})"},
payload={"course_id": ctx.deps.course_id},
# Degrade to "no offerings" rather than raising: this is one
# optional personalization input, and the agent has others.
logger.warning(
"read_misconceptions_for_course: offering resolution failed; "
"returning no class misconceptions", exc_info=True,
)

out = await read_misconceptions_for_course(offering_ids)
# F5: THE canonical instance of this bug class. The probe asks whether
# aggregates CARRYING MISCONCEPTION TEXT exist for this student's
# offerings of this course — not merely whether they are enrolled, and
# not merely whether stats rows exist.
#
# The text qualifier matters as much as the scope. Both live environments
# today hold stats rows whose `common_misconceptions` arrays are all
# empty (0 of 72 on staging, 0 of 73 on prod — the aggregation runs, the
# classes just have no misconception text yet). A probe that fired on
# "any stats row exists" would therefore report a discrepancy on EVERY
# generation for EVERY student the moment #553 was fixed — the precise
# alarm-fatigue failure F5 exists to prevent.
if not out and offering_ids:
await report_empty_result_async(
"read_misconceptions_for_course",
user_id=ctx.deps.user_id,
count=len(out),
expect=Expect.COURSE_HAS_AGGREGATES,
feature=getattr(ctx.deps, "feature", "unknown"),
scope={"offering_id": f"in.({','.join(offering_ids)})"},
payload={"course_id": ctx.deps.course_id},
)
# F6: this block's contribution to the prompt.
prompt_dimensions.record(misconceptions=len(out))
return [
Expand Down
62 changes: 62 additions & 0 deletions backend/db/seed_local_rich.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -662,6 +662,66 @@ def seed_room_summaries() -> None:
]


# ── offering_concept_stats (#553) ─────────────────────────────────────────
#
# Class-level aggregates, keyed on `course_offerings.id` — a DIFFERENT
# keyspace from the abstract `courses.id` the graph carries. #553 was the
# quiz's misconceptions tool filtering this table's `offering_id` with a
# course id, which matched nothing for every student indefinitely while
# looking exactly like a class that had no misconceptions yet.
#
# The rows are shaped so a test can tell a real fix from a coincidence:
#
# * OFF_CS_F25 and OFF_CS_S26 are BOTH offerings of the same abstract CS
# course, and the active user is enrolled in both — so a fix that
# resolves only one "current" offering still loses half the rows.
# * OFF_HIST_F25 belongs to a course the active user is NOT enrolled in.
# Its misconception text must never reach them; that is the negative
# half of the assertion, and it is what stops a fix from "working" by
# simply dropping the offering filter altogether.
# * One row carries an EMPTY array: the aggregation writes a stats row per
# concept as soon as a class has activity and only fills the array when
# it has something to say (0 of 72 rows on staging and 0 of 73 on prod
# carried text on 2026-08-22). Seeding that state keeps the empty-vs-
# absent distinction exercised.
#
# (stats_id, offering_id, concept_name, misconceptions)
_OFFERING_CONCEPT_STATS = [
("rich-ocs-cs-f25-recursion", OFF_CS_F25, "Recursion",
["Recursion always costs more memory than a loop",
"A base case is optional if the input shrinks"]),
("rich-ocs-cs-f25-pointers", OFF_CS_F25, "Pointers and Memory",
["Freeing a pointer also clears the variable holding it"]),
("rich-ocs-cs-s26-controlflow", OFF_CS_S26, "Control Flow",
["`else if` evaluates every branch before choosing one"]),
# Same class, no text yet — a stats row is not the same as a finding.
("rich-ocs-cs-s26-variables", OFF_CS_S26, "Variables and Types", []),
# A class the active user is NOT in. Must never leak into their prompt.
("rich-ocs-hist-f25-sources", OFF_HIST_F25, "Primary Sources",
["A primary source is any source written by a historian"]),
]


def seed_offering_concept_stats() -> None:
for stats_id, off_id, concept, misconceptions in _OFFERING_CONCEPT_STATS:
h.insert_if_absent(
"offering_concept_stats",
stats_id,
{
"offering_id": off_id,
"concept_name": concept,
"student_count": 4,
"avg_mastery_score": 0.55,
"pct_mastered": 0.25,
"pct_struggling": 0.5,
"pct_unexplored": 0.25,
"common_misconceptions": misconceptions,
"effective_explanations": [],
"prerequisite_gaps": [],
},
)


def seed_quiz() -> None:
for qa_id, node_id, difficulty, score, total, questions, answers, completed_at in _QUIZ_ATTEMPTS:
h.insert_if_absent(
Expand DownExpand Up@@ -785,6 +845,7 @@ def seed_sessions() -> None:
"room_summaries",
"notes", "documents", "flashcards", "study_guides", "quiz_attempts", "quiz_context",
"sessions", "messages", "feedback", "issue_reports",
"offering_concept_stats",
]


Expand All@@ -804,6 +865,7 @@ def main() -> None:
seed_study_guides()
seed_room_summaries()
seed_quiz()
seed_offering_concept_stats()
seed_feedback()
seed_sessions()
h.print_summary(_SUMMARY_ORDER, "Seed summary (rich local dataset):")
Expand Down
6 changes: 6 additions & 0 deletions backend/routes/quiz.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -822,6 +822,12 @@ async def _quiz_via_agent(
supabase=None,
request_id=request_id,
feature="quiz",
# The Class-intel opt-out reaches the misconceptions tool through
# deps, not through the prompt: the tool is registered on the agent
# unconditionally and step 2 of the system prompt tells the model to
# call it every run, so the routing sentence below can only ever ADD
# emphasis — it cannot withhold the data (#553 review).
share_class_context=use_shared_context,
)
# Keep this message routing-only; the workflow + adaptive rules
# live in the system prompt. We just hand the agent the inputs it
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })(); fix(quiz): read misconceptions from the offering keyspace (#553) by AndresL230 · Pull Request #567 · SaplingLearn/Sapling · GitHub
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
10 changes: 10 additions & 0 deletions backend/agents/deps.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -47,6 +47,15 @@ class SaplingDeps:
identical behavior. Evals inject a FixtureRetrieval here so
record/live runs never touch a database. Typed Any to avoid
the circular import deps → retrieval → chat_context → deps.
share_class_context: Whether this student consented to class-derived
data (the "Class intel" toggle, migration 0037). Tools that read
OTHER students' aggregated work — `read_misconceptions_for_course`
— must return nothing when it is False. It lives here rather than
in the prompt because a system-prompt instruction is a request to
a model, and consent is not something to leave to one: the tool is
registered on quiz_agent unconditionally and the prompt tells the
model to call it every run. Defaults True to match the column
default; an explicit False is the only thing that suppresses.
"""

user_id: str
Expand All@@ -55,6 +64,7 @@ class SaplingDeps:
request_id: str
session_id: str | None = None
feature: str = "unknown"
share_class_context: bool = True
graph_updates: list = field(default_factory=list)
mastery_changes: list = field(default_factory=list)
retrieval: Any = None
201 changes: 146 additions & 55 deletions backend/agents/tools/graph_read.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,6 +10,7 @@

import asyncio
import logging
from collections.abc import Sequence
from typing import Any

from pydantic import BaseModel, Field
Expand DownExpand Up@@ -366,12 +367,35 @@ class Misconception(BaseModel):
related_concept: str | None = None


#: Row budget per offering. Applied per-offering rather than shared, so one
#: class cannot starve another when a student holds two offerings of a course.
_ROWS_PER_OFFERING = 20
#: Ceiling on the misconception STRINGS handed to the model. Rows are not the
#: unit that costs prompt tokens; entries are.
_MAX_MISCONCEPTIONS = 40


async def read_misconceptions_for_course(
offering_id: str | None,
offering_ids: Sequence[str] | None,
) -> list[Misconception]:
"""Return aggregated misconception strings for an offering (a class in a
term). Anonymized (sourced from class-wide patterns, not any single student).
Returns [] when offering_id is None or the underlying table is empty.
"""Return aggregated misconception strings for one or more offerings (a
class in a term). Anonymized (sourced from class-wide patterns, not any
single student). Returns [] when no offerings are given or the underlying
table has nothing for them.

Takes OFFERING ids, plural, and the plural is load-bearing twice over.

Keyspace (#553): `offering_concept_stats.offering_id` holds
`course_offerings.id`, which is a different keyspace from the abstract
`courses.id` the graph and the HTTP boundary carry. This function used to
be handed the latter, so it matched nothing for every student
indefinitely. Callers resolve course -> offerings via
`services/academics.py`; that module owns the resolution.

Plural: a student can be enrolled in more than one offering of the same
course (a repeat, or a course spanning terms — the rich seed's active user
holds CS in two). Scoping to a single "current" offering would silently
drop the other class's aggregates.

Source: `offering_concept_stats` rows for the offering. Each row
represents one concept and carries a `common_misconceptions` array
Expand All@@ -382,26 +406,61 @@ async def read_misconceptions_for_course(

The tool contract (returning Misconception[]) is unchanged.
"""
if not offering_id:
# A bare `str` IS a Sequence[str], so an un-guarded comprehension would
# iterate it PER CHARACTER and build `in.(c,a,s,-,c,s,...)` — a filter that
# matches nothing while looking entirely well-formed. The same shape
# already bit this batch once (the quiz_history coercer spraying "- r"/
# "- e"/"- c" into the prompt), and the whole point of #553 is that a
# silently-matching-nothing filter can survive for months.
if isinstance(offering_ids, str):
offering_ids = [offering_ids]
ids = [str(o) for o in (offering_ids or []) if o]
if not ids:
return []

def _fetch() -> list[dict[str, Any]]:
try:
return (
table("offering_concept_stats").select(
"concept_name,common_misconceptions",
filters={"offering_id": f"eq.{offering_id}"},
order="updated_at.desc",
limit=20,
rows: list[dict[str, Any]] = []
# One read PER offering rather than one `in.(...)` read over all of
# them. A single query has to share one LIMIT, and the sort key does
# not break the tie usefully: `course_context_service` stamps every
# row of an aggregation pass with the same `updated_at`, so ordering
# within an offering is arbitrary. An offering with a full window of
# rows would then starve its sibling completely — reintroducing, per
# offering, exactly the silent drop that taking a LIST of offerings
# was meant to prevent. Students hold one or two offerings of a given
# course, so this is one or two indexed reads.
for offering_id in ids:
try:
rows.extend(
table("offering_concept_stats").select(
"concept_name,common_misconceptions",
filters={
"offering_id": f"eq.{offering_id}",
# Spend the row budget only on rows that actually
# carry text. The aggregation writes a stats row
# per concept as soon as a class has activity and
# fills this array only when it has something to
# say, so text-bearing rows are the rare minority
# (0 of 72 rows on staging, 0 of 73 on prod).
# Unfiltered, the window fills with empty rows and
# the tool returns [] for a class that genuinely
# has misconceptions — the very symptom #553 is
# about. It also keeps this read asking the same
# question the F5 probe asks, so a legitimately
# quiet class cannot look like a broken one.
"common_misconceptions": "neq.{}",
},
order="updated_at.desc",
limit=_ROWS_PER_OFFERING,
)
or []
)
or []
)
except Exception:
logger.exception(
"read_misconceptions_for_course failed for offering=%s",
offering_id,
)
return []
except Exception:
logger.exception(
"read_misconceptions_for_course failed for offering=%s",
offering_id,
)
return rows

rows = await asyncio.to_thread(_fetch)
out: list[Misconception] = []
Expand All@@ -417,6 +476,13 @@ def _fetch() -> list[dict[str, Any]]:
continue
seen.add(key)
out.append(Misconception(text=text, related_concept=concept))
# Cap what actually reaches the prompt. The old `limit=20` capped
# ROWS, and each row carries an unbounded array — so the block's
# real size was never bounded at all. F6 measured this tool's
# contribution to the prompt; bounding the unit that costs tokens
# is what makes that number hold.
if len(out) >= _MAX_MISCONCEPTIONS:
return out
return out


Expand All@@ -433,49 +499,74 @@ async def read_misconceptions_for_course_tool(
"""
from services.prompt_safety import neutralize_delimiters

out = await read_misconceptions_for_course(ctx.deps.course_id)
# F5: THE canonical instance of this bug class. This tool passed the
# abstract course id where the query filters `offering_id` — a different
# keyspace, so it returned zero rows for every student, indefinitely,
# and looked exactly like a class that simply had no misconceptions yet.
# (#553 carries the fix; this makes the next one impossible to miss.)
# The probe asks whether aggregates exist for THIS student's offerings of
# THIS course — not merely whether they are enrolled in something.
# "Enrolled somewhere" would fire on every generation in a course whose
# class simply has no aggregated misconceptions yet, which is the normal
# state for the first weeks of any term.
# Class-intel consent, enforced at the tool (#553 review finding 4).
#
# Scoped this way it detects the real failure instead: aggregates exist
# for the class, but this tool's read returned none — the signature of a
# keyspace mismatch, which is precisely how #553 (abstract course id used
# where an offering id is expected) presents.
# This tool is registered on quiz_agent unconditionally and system-prompt
# step 2 tells the model to call it on EVERY run; `use_shared_context`
# only ever APPENDED an extra routing sentence when true. That looked
# correct for as long as the read was keyspace-broken and returned []
# for everyone — fixing #553 would have quietly started feeding other
# students' aggregated misconceptions to a student who opted out.
#
# Gated on the result being EMPTY, not merely on having a course id.
# `user_offering_ids_for_course` is uncached and issues two unbounded
# PostgREST reads (enrollments -> offerings), and this runs on the quiz
# generation request path — resolving it whenever a course id exists made
# every generation pay both round-trips even when the tool returned rows,
# contradicting tool_signals' own documented contract ("one owner-scoped
# indexed read, only on the empty path"). `report_empty_result` would
# short-circuit on a non-zero count anyway, so the work was pure waste.
if not out and ctx.deps.course_id:
offering_ids: list[str] = []
# Enforced here rather than by editing the prompt or the toolset: a
# system-prompt instruction is a request to a model, and consent is not
# something to leave to one. Returning [] (not raising) keeps an opted-out
# run identical to a class with nothing to share.
if not getattr(ctx.deps, "share_class_context", True):
prompt_dimensions.record(misconceptions=0)
return []

# #553: resolve course -> the student's offerings BEFORE reading. The
# stats table is keyed on `course_offerings.id`; `ctx.deps.course_id` is
# the abstract `courses.id` the graph carries. Handing the second to a
# filter expecting the first matched nothing for every student since the
# tool was written, and looked exactly like a class with no misconceptions
# yet. Verified live 2026-08-22: staging 72/72 stats rows key on an
# offering id and 0 on a course id (prod 73/73); filtering by course id
# returned 0 in both, filtering by the student's offerings returned 68+4
# and 73.
#
# The resolution is now unconditional rather than probe-only: it is what
# the READ needs, not merely what the probe needs. It stays a single
# `academics` call whose two reads are the price of asking the right
# question at all.
offering_ids: list[str] = []
if ctx.deps.course_id:
try:
offering_ids = await asyncio.to_thread(
user_offering_ids_for_course, ctx.deps.user_id, ctx.deps.course_id
)
except Exception:
logger.debug("misconceptions probe: offering resolution failed", exc_info=True)
if offering_ids:
await report_empty_result_async(
"read_misconceptions_for_course",
user_id=ctx.deps.user_id,
count=len(out),
expect=Expect.COURSE_HAS_AGGREGATES,
feature=getattr(ctx.deps, "feature", "unknown"),
scope={"offering_id": f"in.({','.join(offering_ids)})"},
payload={"course_id": ctx.deps.course_id},
# Degrade to "no offerings" rather than raising: this is one
# optional personalization input, and the agent has others.
logger.warning(
"read_misconceptions_for_course: offering resolution failed; "
"returning no class misconceptions", exc_info=True,
)

out = await read_misconceptions_for_course(offering_ids)
# F5: THE canonical instance of this bug class. The probe asks whether
# aggregates CARRYING MISCONCEPTION TEXT exist for this student's
# offerings of this course — not merely whether they are enrolled, and
# not merely whether stats rows exist.
#
# The text qualifier matters as much as the scope. Both live environments
# today hold stats rows whose `common_misconceptions` arrays are all
# empty (0 of 72 on staging, 0 of 73 on prod — the aggregation runs, the
# classes just have no misconception text yet). A probe that fired on
# "any stats row exists" would therefore report a discrepancy on EVERY
# generation for EVERY student the moment #553 was fixed — the precise
# alarm-fatigue failure F5 exists to prevent.
if not out and offering_ids:
await report_empty_result_async(
"read_misconceptions_for_course",
user_id=ctx.deps.user_id,
count=len(out),
expect=Expect.COURSE_HAS_AGGREGATES,
feature=getattr(ctx.deps, "feature", "unknown"),
scope={"offering_id": f"in.({','.join(offering_ids)})"},
payload={"course_id": ctx.deps.course_id},
)
# F6: this block's contribution to the prompt.
prompt_dimensions.record(misconceptions=len(out))
return [
Expand Down
62 changes: 62 additions & 0 deletions backend/db/seed_local_rich.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -662,6 +662,66 @@ def seed_room_summaries() -> None:
]


# ── offering_concept_stats (#553) ─────────────────────────────────────────
#
# Class-level aggregates, keyed on `course_offerings.id` — a DIFFERENT
# keyspace from the abstract `courses.id` the graph carries. #553 was the
# quiz's misconceptions tool filtering this table's `offering_id` with a
# course id, which matched nothing for every student indefinitely while
# looking exactly like a class that had no misconceptions yet.
#
# The rows are shaped so a test can tell a real fix from a coincidence:
#
# * OFF_CS_F25 and OFF_CS_S26 are BOTH offerings of the same abstract CS
# course, and the active user is enrolled in both — so a fix that
# resolves only one "current" offering still loses half the rows.
# * OFF_HIST_F25 belongs to a course the active user is NOT enrolled in.
# Its misconception text must never reach them; that is the negative
# half of the assertion, and it is what stops a fix from "working" by
# simply dropping the offering filter altogether.
# * One row carries an EMPTY array: the aggregation writes a stats row per
# concept as soon as a class has activity and only fills the array when
# it has something to say (0 of 72 rows on staging and 0 of 73 on prod
# carried text on 2026-08-22). Seeding that state keeps the empty-vs-
# absent distinction exercised.
#
# (stats_id, offering_id, concept_name, misconceptions)
_OFFERING_CONCEPT_STATS = [
("rich-ocs-cs-f25-recursion", OFF_CS_F25, "Recursion",
["Recursion always costs more memory than a loop",
"A base case is optional if the input shrinks"]),
("rich-ocs-cs-f25-pointers", OFF_CS_F25, "Pointers and Memory",
["Freeing a pointer also clears the variable holding it"]),
("rich-ocs-cs-s26-controlflow", OFF_CS_S26, "Control Flow",
["`else if` evaluates every branch before choosing one"]),
# Same class, no text yet — a stats row is not the same as a finding.
("rich-ocs-cs-s26-variables", OFF_CS_S26, "Variables and Types", []),
# A class the active user is NOT in. Must never leak into their prompt.
("rich-ocs-hist-f25-sources", OFF_HIST_F25, "Primary Sources",
["A primary source is any source written by a historian"]),
]


def seed_offering_concept_stats() -> None:
for stats_id, off_id, concept, misconceptions in _OFFERING_CONCEPT_STATS:
h.insert_if_absent(
"offering_concept_stats",
stats_id,
{
"offering_id": off_id,
"concept_name": concept,
"student_count": 4,
"avg_mastery_score": 0.55,
"pct_mastered": 0.25,
"pct_struggling": 0.5,
"pct_unexplored": 0.25,
"common_misconceptions": misconceptions,
"effective_explanations": [],
"prerequisite_gaps": [],
},
)


def seed_quiz() -> None:
for qa_id, node_id, difficulty, score, total, questions, answers, completed_at in _QUIZ_ATTEMPTS:
h.insert_if_absent(
Expand DownExpand Up@@ -785,6 +845,7 @@ def seed_sessions() -> None:
"room_summaries",
"notes", "documents", "flashcards", "study_guides", "quiz_attempts", "quiz_context",
"sessions", "messages", "feedback", "issue_reports",
"offering_concept_stats",
]


Expand All@@ -804,6 +865,7 @@ def main() -> None:
seed_study_guides()
seed_room_summaries()
seed_quiz()
seed_offering_concept_stats()
seed_feedback()
seed_sessions()
h.print_summary(_SUMMARY_ORDER, "Seed summary (rich local dataset):")
Expand Down
6 changes: 6 additions & 0 deletions backend/routes/quiz.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -822,6 +822,12 @@ async def _quiz_via_agent(
supabase=None,
request_id=request_id,
feature="quiz",
# The Class-intel opt-out reaches the misconceptions tool through
# deps, not through the prompt: the tool is registered on the agent
# unconditionally and step 2 of the system prompt tells the model to
# call it every run, so the routing sentence below can only ever ADD
# emphasis — it cannot withhold the data (#553 review).
share_class_context=use_shared_context,
)
# Keep this message routing-only; the workflow + adaptive rules
# live in the system prompt. We just hand the agent the inputs it
Expand Down
Loading
Loading