Skip to content

fix(tutor): stop the tutor repeating itself, and let it actually read course materials - #562

Open
Darkest-Teddy wants to merge 5 commits into
mainfrom
fix/tutor-repeat-and-course-materials
Open

fix(tutor): stop the tutor repeating itself, and let it actually read course materials#562
Darkest-Teddy wants to merge 5 commits into
mainfrom
fix/tutor-repeat-and-course-materials

Conversation

@Darkest-Teddy

@Darkest-TeddyDarkest-Teddy commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator

Two independent bugs in the tutor chat, found while debugging "why does the AI repeat the message instead of responding to it". One commit each.

1. The tutor replayed its previous reply verbatim

stream_agent_turn preferred run_result.output over the streamed chunks:

reply=final_outputiffinal_outputisnotNoneelsejoined

.output resolves out of the run's message list, and that list includes message_history. So a turn whose model response carries no text part — the model ended its turn after tool calls — handed back the previous turn's assistant message. Fully formed and non-blank, so the blank-reply ladder immediately below never saw it. The route persisted it, and the student got a byte-identical copy of the last answer.

Confirmed in two live sessions two days apart: same sha256, same length, including a 1757-character reply reproduced exactly against a completely different question.

Fix: trust final_output only when this turn actually streamed text. Text always arrives as PartStart/PartDelta events, so "nothing streamed" means "this turn produced no text" — degrade through the existing blank-reply ladder rather than replaying history.

Evidence. Replaying a real session turn against gemini-2.5-flash-lite, the correlation is exact:

streamed token/replyoutcomecount
0repeated the prior answer2/8
>0answered correctly6/8

After the fix: 0 repeats in 10 runs.

What this does not fix: the model still ends ~40% of these turns without text. Those now take the Rung-1 JSON fallback (a real answer), or — if graph writes already landed — show the honest "interrupted, please retry". A silent wrong answer becomes a correct one or a visible error. Why the model does this at all is unexplored and worth a follow-up.

2. The tutor never read any course document

search_course_materials filtered documents on course_id. That table keys on offering_id (0025) and has no course_id column, so PostgREST 400s on every call — and the tool's deliberate degrade-silently except turned that into [].

The tutor answered from base knowledge alone, which presents as "it's generic about my class" rather than as a failure. Nothing was logged at the user's level.

Fix: resolve the abstract course to the user's offerings via academics.user_offering_ids_for_course, matching the convention that the API boundary keeps the abstract course while documents key on the offering. The #125 user_id scope is unchanged — documents stay user-scoped within a shared offering.

Tests

Both regression tests were written first and watched fail for the right reason:

  • test_textless_turn_never_replays_the_previous_turns_reply — failed with on_complete receiving the stale prior-turn text.
  • test_documents_are_fetched_by_offering_not_course_id — failed with column documents.course_id does not exist.

The second one needed a schema-faithful fake. The existing mocks accept any filter, which is precisely how bug 2 survived them. The two older search_course_materials test classes now stub the offering lookup, which became a real dependency of that function.

Full backend suite: 1999 passed, 56 skipped, exit 0. ruff check clean on all four files.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes

    • Course material searches now return documents associated with the user’s course offerings while maintaining access restrictions.
    • Prevented tool-only chat turns from displaying or saving stale assistant responses from earlier messages.
    • Ensured empty or non-text streaming responses use a fresh fallback reply.
  • Tests

    • Added coverage for offering-based material searches, access scoping, and stale-response prevention.

Darkest-Teddyand others added 2 commits August 13, 2026 22:21
`stream_agent_turn` preferred `run_result.output` over the streamed
chunks. That output resolves out of the run's message list, and that
list includes `message_history` — so a turn whose model response
carries no text part (the model ended its turn after tool calls) handed
back the PREVIOUS turn's assistant message: fully formed, non-blank, and
therefore invisible to the blank-reply ladder below it.
The route then persisted it, so the tutor answered a follow-up with a
byte-identical copy of its own last answer — same sha256, same length,
including a 1757-char reply reproduced verbatim against a completely
different question.
Trust `final_output` only when this turn actually streamed text. Text
always arrives as PartStart/PartDelta events, so "nothing streamed"
means "this turn produced no text": degrade through the existing
blank-reply ladder instead of replaying history.
Measured on gemini-2.5-flash-lite by replaying a real session turn: the
correlation is exact — `token/reply == 0` repeated (2/8), any streamed
text answered correctly (6/8). After the fix, 0 repeats in 10 runs.
Note this does not stop the model from ending a turn without text; those
turns now take the Rung-1 fallback or a visible interrupted-error rather
than a silent duplicate.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…n't exist
`search_course_materials` filtered `documents` on `course_id`. That
table keys on `offering_id` (0025) and has no `course_id` column, so
PostgREST returned 400 on every call — and the tool's deliberate
degrade-silently `except` turned that into `[]`.
Net effect: the tutor never read a single course document, with no error
surfaced anywhere. It answered from base knowledge alone, which reads as
"the tutor is generic about my class" rather than as a bug.
Resolve the abstract course to the user's offerings via
`academics.user_offering_ids_for_course`, per the convention that the
API boundary keeps the abstract course while documents key on the
offering. The #125 user_id scope is unchanged: documents stay
user-scoped WITHIN a shared offering.
The existing unit tests missed this because they mock `table` loosely
enough to accept any filter; the new test uses a schema-faithful fake
that rejects a column the table does not have, exactly as PostgREST
does. The two older test classes now stub the offering lookup, which is
a real dependency of this function for the first time.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@supabase

supabaseBot commented Aug 14, 2026

Copy link
Copy Markdown

This pull request has been ignored for the connected project ybgqdonkoqftwrmweuyv because there are no changes detected in supabase directory. You can change this behaviour in Project Integrations Settings ↗︎.


Preview Branches by Supabase.
Learn more about Supabase Branching ↗︎.

@coderabbitai

coderabbitaiBot commented Aug 14, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@Jose-Gael-Cruz-Lopez, you've reached your PR review limit, so we couldn't start this review.

Next review available in:8 minutes

Limit details: You’ve used the included review currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

Wait for the limit to reset, then comment @coderabbitai review or push new commits to the PR.

An organization admin can change what happens after included review limits in Billing.

How do review limits work?

CodeRabbit enforces per-developer PR review limits within each organization.

For paid Pro and Pro+ reviews, CodeRabbit uses a developer's included PR review attempts over the past 7 days to set the current hourly allowance. At typical activity levels, the full plan allowance applies. Higher sustained activity can lower the allowance until earlier attempts leave the 7-day window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 0bb76370-0c3c-4d13-a541-292b94564424

📥 Commits

Reviewing files that changed from the base of the PR and between b8aa904 and 4e2949d.

📒 Files selected for processing (8)
  • backend/agents/tools/chat_context.py
  • backend/routes/learn.py
  • backend/tests/agent_run_fakes.py
  • backend/tests/test_chat_context_tools.py
  • backend/tests/test_chat_stream.py
  • backend/tests/test_event_capture_seams.py
  • backend/tests/test_graph_tools_bugs.py
  • backend/tests/test_learn_routes.py

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 06d0ac13-1c32-40b4-a002-18d7e154c3c1

📥 Commits

Reviewing files that changed from the base of the PR and between 9b77819 and b8aa904.

📒 Files selected for processing (4)
  • backend/agents/tools/chat_context.py
  • backend/services/chat_stream.py
  • backend/tests/test_chat_context_tools.py
  • backend/tests/test_chat_stream.py

📝 Walkthrough

Walkthrough

The change scopes course-material searches to the user’s offerings and updates streamed-turn reply selection to avoid stale history output. Tests cover offering resolution, user scoping, document lookup, and tool-only turns.

Changes

Chat behavior corrections

Layer / File(s)Summary
Offering-scoped course material search
backend/agents/tools/chat_context.py, backend/tests/test_chat_context_tools.py
Course searches resolve user-specific offering IDs and filter documents by offering_id while retaining user_id scoping. Tests cover matching offerings and schema-valid document queries.
Textless streamed turn handling
backend/services/chat_stream.py, backend/tests/test_chat_stream.py
Textless turns use joined current-turn chunks instead of stale message-history output. Regression coverage checks streaming, persistence, callbacks, and nonstream fallback behavior.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Mergeability Score:⚪ Minimal · up to b8aa9

The changes address two localized tutor-chat defects, with regression coverage and passing checks reported; no actionable merge-blocking risk remains beyond normal review.

Possibly related PRs

Suggested reviewers:andresl230, jose-gael-cruz-lopez

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 37.50% which is insufficient. The required threshold is 80.00%.Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check nameStatusExplanation
Title check✅ PassedThe title clearly summarizes both primary tutor fixes: preventing repeated replies and enabling course-material retrieval.
Description check✅ PassedThe description clearly explains both bugs, their fixes, regression tests, test results, and reviewer-relevant limitations.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/tutor-repeat-and-course-materials

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Aug 14, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-staging4e2949dCommit Preview URL

Branch Preview URL
Aug 19 2026, 09:02 PM

@Jose-Gael-Cruz-Lopez

Copy link
Copy Markdown
Member

Code review — tutor repetition + course-material retrieval

Two genuinely independent bugs, both diagnosed properly, both with regression tests written first. The documents.course_id finding is correct — migration 0025_study_integrity.sql recreates documents with offering_id TEXT NOT NULL REFERENCES course_offerings(id) and no course_id column, so the old filter really did 400 into the tool's degrade-silently except. The chat_stream guard is a one-line narrowing that is safe for this agent (chat_tutor is output_type=str, agents/chat_tutor.py:174, so its reply always arrives as text parts — there is no output-tool channel the guard could discard). Three things need attention before merge: the now-working documents query no longer honours the soft-delete convention every other reader follows, the offering resolution it picked can only shrink the user's own document set relative to the writer, and the identical stale-run_result.output read on the JSON path — which is exactly the fallback this PR now routes textless turns into — was left unguarded, so the repeat symptom can still be produced.

Findings

P0

[P0] CI is red — Backend (pytest) fails (pre-existing on main, not a regression from this PR) — run 31763538674

FAILED tests/test_quiz_preflight_a.py::TestQuizErrorEnvelope::test_method_not_allowed_gets_generic_code - AttributeError: '_IncludedRouter' object has no attribute 'path'
1 failed, 1965 passed, 56 skipped, 13 warnings in 42.25s
 if match == Match.PARTIAL:
> route = starlette_route.path
^^^^^^^^^^^^^^^^^^^^
E AttributeError: '_IncludedRouter' object has no attribute 'path'
/opt/hostedtoolcache/Python/3.13.14/x64/lib/python3.13/site-packages/opentelemetry/instrumentation/fastapi/__init__.py:495: AttributeError

Not this PR's fault. The identical failure is on main — run 31975087459 (4f5a7f90, "Merge pull request #524 from SaplingLearn/feat/landing-v5-port"): same test, same AttributeError, 1 failed, 1963 passed, 56 skipped. It is dependency drift in opentelemetry-instrumentation-fastapi._get_route_details, which assumes every Match.PARTIAL route object has .path; FastAPI's _IncludedRouter does not. None of this PR's four files touch routing or quiz. It still blocks the merge queue, so it needs a pin/fix on main first — worth noting because the PR body claims "Full backend suite: 1999 passed, 56 skipped, exit 0", which does not match CI.

P1

[P1] Soft-deleted documents now reach the tutorbackend/agents/tools/chat_context.py:154-164

return (
table("documents").select(
"id,file_name,summary,concept_notes",
filters={
# #125 user scope is preserved: documents are# user-scoped WITHIN a shared offering."offering_id": f"in.({','.join(offering_ids)})",
"user_id": f"eq.{user_id}",
},
order="created_at.desc",
)

documents is soft-deleted, not hard-deleted — routes/documents.py:288-293: "Soft delete (0025): stamp deleted_at; reads filter it out." Every other reader honours that: routes/documents.py:241, routes/study_guide.py:137, routes/flashcards.py:153 and :163, routes/documents.py:1230 all pass "deleted_at": "is.null". This query does not. Before this PR that was invisible because the query 400'd and returned [] unconditionally; this PR is what makes it return rows, so it is this PR that first exposes deleted documents. Concretely: a student deletes a document from their Library, documents.deleted_at is stamped, the row stays, and the tutor keeps decrypting its summary + concept_notes into the LLM context on every search_course_materials call. Nothing in backend/agents/ or the tutor tests references deleted_at, so no test catches it.

[P1] The stale run_result.output read is unguarded on the JSON path — the very fallback this PR routes textless turns intobackend/routes/learn.py:636-654

result=record_agent_usage(
awaitagent.run(user_message, **run_kwargs),
feature="chat_tutor", task="chat_tutor", user_id=deps.user_id,
)
reply=result.output# str — chat_tutor agents return plain Markdown.ifnotreply.strip():

_prepare_chat_run puts "message_history": message_history into run_kwargs (routes/learn.py:577), so result.output here resolves out of the same history-bearing message list the PR indicts. The only guard is not reply.strip() — and the PR's whole point is that a replayed prior turn is "fully formed, non-blank, and therefore invisible to the blank-reply ladder". _chat_turn_json is both a first-class route (POST /api/learn/chat, routes/learn.py:745-751) and the streamed route's Rung-1 nonstream_fallback (routes/learn.py:823). So after this change a textless streamed turn correctly refuses the stale text, degrades to _chat_turn_json, and if that re-run is also textless — the PR body measures ~40% — it takes the prior assistant message from message_history, persists it via save_message (routes/learn.py:727) and returns it. The repeat is not eliminated, it is moved one rung down, onto a path this PR sends more traffic to.

[P1] Offering resolution is narrower than the writer's and than every sibling reader'sbackend/agents/tools/chat_context.py:151-153

offering_ids=user_offering_ids_for_course(user_id, course_id)
ifnotoffering_ids:
return []

user_offering_ids_for_course (services/academics.py:185-200) intersects the course's offerings with the user's enrollments rows. Documents are written with a different resolver — offering_id = resolve_offering(course_id, create=True) at routes/documents.py:572 and :710 — which picks the current-term offering (creating it if absent, else falling back to any offering of the course) and never consults enrollments. The two sibling readers use the writer's resolver too: routes/study_guide.py:136 and routes/flashcards.py:144 filter on resolve_offering(...). Divergence is reachable across a term boundary: a student enrolled via routes/onboarding.py:95-112 in Fall-26's offering who uploads in the next term gets documents.offering_id = the new term's offering, has no enrollment row for it, and the tutor silently returns [] — while the Library still lists the file (routes/documents.py:239-241 filters on user_id only). Note the enrollment intersection buys no safety: user_id is already the security boundary (your own #125 comment says so), so this filter can only remove the user's own documents.

P2

[P2] The empty-offering short-circuit is silent — the exact failure mode this PR exists to removebackend/agents/tools/chat_context.py:152-153

ifnotoffering_ids:
return []

The PR body's own indictment of the old bug is "The tutor answered from base knowledge alone, which presents as 'it's generic about my class' rather than as a failure. Nothing was logged at the user's level." This early return reproduces that precisely: no log, no metric, indistinguishable from "this course genuinely has no materials". The except below it does logger.exception; this branch gets nothing. Given the P1 above makes it reachable, a log line here is what turns the next occurrence of this class of bug into a five-minute diagnosis instead of a two-month one.

P3

[P3] Two extra uncached PostgREST round-trips per tool call, in the streaming hot pathbackend/services/academics.py:191-200 via chat_context.py:151

offs=table("course_offerings").select(
"id", filters={"course_id": f"eq.{course_id}"}
) or []
off_ids= {o["id"] foroinoffs}
ifnotoff_ids:
return []
enr=table("enrollments").select(
"offering_id", filters={"user_id": f"eq.{user_id}"}
) or []

user_offering_ids_for_course is deliberately uncached and the second select pulls all of the user's enrollments across every course. search_course_materials is an LLM-callable tool that can fire more than once per turn, on the latency-critical SSE path — so this is +2 sequential round-trips per call. The documents select that follows is also unbounded (no limit, order="created_at.desc" only) and every returned row is AES-decrypted before the list is truncated to limit (5) in Python at chat_context.py:213. Harmless today; both become real once a course accumulates documents.

[P3] No test pins the newly-reachable textless-turn-with-writes branchbackend/tests/test_chat_stream.py:710-750

agent=FakeAgent([
FunctionToolCallEvent("read_graph_neighborhood"),
FunctionToolResultEvent(), # no writes landedAgentRunResultEvent(PRIOR), # stale: from message_history
])

The new test only covers the no-writes path (Rung-1 fallback). But the trigger it describes is "the model ended its turn after tool calls", and every tutor agent registers apply_graph_update_tool and update_mastery_tool (agents/chat_tutor.py:161-162), which populate deps.graph_updates / deps.mastery_changes (agents/tools/graph.py:122, :174). So the likely shape of a textless turn is textless-with-writes, which lands on the terminal retryable: False error at chat_stream.py:362-368 — a user-visible dead end, and the branch with no new coverage. Worth one more FakeAgent case with an on_fire write.

What's good

  • The root-cause work is real and checkable: 0025_study_integrity.sql:15-31 confirms documents has offering_id NOT NULL and no course_id, exactly as claimed.
  • The new TestSearchCourseMaterialsOfferingScope fake raises on a course_id filter instead of accepting any filter — the right response to "the existing mocks accept any filter, which is precisely how bug 2 survived them".
  • The chat_stream.py guard is correctly scoped: chat_tutor is output_type=str (agents/chat_tutor.py:174), so there is no output-tool channel whose reply the joined.strip() condition could throw away. Per the Sapling Engineering Style Guide the new sync user_offering_ids_for_course call also stays off the event loop — it sits inside _fetch, which runs under asyncio.to_thread (chat_context.py:174).

Verdict: request changes — the soft-delete regression and the unguarded result.output on the JSON fallback path both need fixing before merge; the CI failure is pre-existing on main and should be unblocked there.


Review-only pass — no code changed and nothing fixed. Conventions checked against the Canopy live docs (Engineering Style Guide, Architecture, Infrastructure, Backend & AI Agents). Every finding cites a snippet re-read at this PR's head SHA; severity: P0 blocker · P1 major · P2 minor · P3 nit.

…le degrade
Three findings on the course-materials read, all made reachable by the
offering fix in b8aa904 — before it the query 400'd and returned [] on
every call, so none of them could be observed.
1. `documents` is soft-deleted. routes/documents.py stamps `deleted_at`
and every other reader filters on it (study_guide.py, flashcards.py);
this query did not, so a file the student deleted from their Library
kept getting its `summary` + `concept_notes` decrypted into LLM
context forever. Adds `deleted_at is.null`, which also makes the
filter set identical to PR #534's fix of the same bug — the eventual
merge conflict is now trivial.
2. `user_offering_ids_for_course` is narrower than the WRITER.
Documents are written with `resolve_offering(course_id, create=True)`
— current term, `enrollments` never consulted — and the sibling
readers use the writer's resolver too. Across a term boundary a
student enrolled in Fall-26 who uploads next term gets
`documents.offering_id` = the new offering, has no enrollment row for
it, and the tutor silently returned [] while the Library still listed
the file. The intersection bought no security either: `user_id` is
the access boundary on `documents` (#125), so dropping offerings can
only hide the student's OWN uploads. Widened to the union of both
resolvers, order-stable for the `in.(...)` list.
3. The empty-offering short-circuit was silent — no log, no metric,
indistinguishable from "this course has no materials", which is
exactly the failure mode the offering fix exists to remove. It logs
now, without a raw student id.
Also bounds the read. The select was unbounded while every returned row
gets AES-decrypted before Python truncates to `limit`, on the
latency-critical SSE path. The bound is a multiple of `limit`, not
`limit` itself: ranking happens after the fetch, so limiting to exactly
`limit` would silently turn "most relevant" into "most recent".
The new tests use a schema-faithful `table()` fake that rejects filter
columns `documents` does not have. The older mocks in that file accept
any filter and return a canned list, which is precisely how a query
against a non-existent column survived review.
… path
5092a83 narrowed `run_result.output` inside `stream_agent_turn`, but
routes/learn.py reads it in three more places and two of them run with
`message_history` in `run_kwargs` — so `.output` resolves out of the same
history-bearing message list, and a textless turn hands back the
PREVIOUS turn's assistant message: fully formed, non-blank, and
therefore invisible to the `if not reply.strip()` guard sitting right
below it.
That relocated the repeat rather than removing it, onto a path taking
MORE traffic: `_chat_turn_json` is both POST /api/learn/chat and the
streamed route's Rung-1 `nonstream_fallback`, which is exactly where the
streaming fix now sends textless turns. `_action_turn` is the third
reader and persists with `save_message` directly.
`new_messages()` excludes the history that was passed in, so joining the
TextParts of its model responses is exactly "what this turn said" — the
non-streaming twin of the `joined.strip()` check in chat_stream.py.
`_start_session_agent` passes `message_history=[]`, so it has no prior
message to resolve back to and keeps reading `.output` directly; the
comment there names why.
Also adds the missing coverage for the newly-reachable streamed branch.
Every tutor agent registers `apply_graph_update_tool` and
`update_mastery_tool`, so a model that ends its turn after tool calls
usually arrives with `deps.graph_updates` / `deps.mastery_changes`
already populated — the textless-WITH-writes shape, which lands on the
terminal `retryable: False` rung rather than Rung 1. That was untested.
The agent-run fakes had to become shape-faithful (tests/agent_run_fakes.py):
`SimpleNamespace(output=...)` has no `new_messages`, and a bare MagicMock
iterates EMPTY on it — which would have silently turned every mocked
turn into a "textless" one while the assertions still went green through
the 502 catch-all. That is the same class of blind spot as the mock that
accepted a filter on a column PostgREST does not have.
@Jose-Gael-Cruz-Lopez

Copy link
Copy Markdown
Member

Review fixes applied

Every outstanding finding on this PR (human review + CodeRabbit) has been addressed and pushed.

Major

  • Soft-deleted documents reached the tutor. Added deleted_at is.null to the documents select. This PR is what first exposed them (the query used to 400 into the silent degrade), so a deleted file's decrypted summary/concept_notes would have kept flowing into LLM context. Filter set is now byte-identical to fix(tutor): repair course-material retrieval, silence course-scope commentary, make quizzes practical #534's, making that overlap a trivial pick-either.
  • The stale run_result.output read was only narrowed on the streamed path._chat_turn_json is both a first-class route and the streamed route's Rung-1 fallback — i.e. exactly where this PR now sends textless turns — so the repeat was relocated, not removed. Added _new_run_text() (the non-streaming twin of the joined.strip() narrowing) and applied it to both history-bearing .output sites; the message_history=[] site is left alone with a comment saying why.
  • Offering resolution was narrower than the writer's. Now the union of user_offering_ids_for_course and resolve_offering, so a document uploaded across a term boundary is still found. Test covers the cross-term case.

Minor

  • The empty-offering short-circuit logs a warning instead of returning [] silently — the exact failure mode this PR exists to remove.
  • limit pushed into the query so rows are no longer AES-decrypted and then discarded.
  • Added the missing textless-turn-with-writes case (the likely shape, landing on the terminal retryable: False path).

Not fixed here

The red Backend (pytest) is the otel _IncludedRouter failure that is pre-existing on main; #563 carries the shim that fixes it.

Verificationruff check . clean · 1973 passed, 56 skipped

Fixes applied and verified locally against this branch head; each figure above is a command I ran, not an estimate.

@Jose-Gael-Cruz-Lopez

Copy link
Copy Markdown
Member

Verify against the real database before merging

I could not reach a live database while working on this — there are no credentials on this machine (only .env.example files) and the local Supabase stack needs a container runtime that wouldn't start. So everything below was verified statically, by replaying every migration in ledger order to build a schema model and checking this PR's DB access against it. That model found 0 schema mismatches here, and it is trustworthy enough to have independently reproduced the documents.course_id absence that caused #562/#534 — but it cannot see data, and it cannot see whether staging's ledger actually matches the repo.

These are the checks that need a real connection.

No schema change — but the behaviour change is data-dependent

Two fixes here only show their value against real rows, so they are worth confirming on staging.

1. How much was actually leaking

The documents read had no deleted_at filter. Before this PR the query 400'd and returned [], so nothing leaked; this PR is what makes it return rows, which is why the filter had to be added in the same change.

SELECTcount(*) FROM documents WHERE deleted_at IS NOT NULL;

That count is exactly how many soft-deleted documents the tutor would otherwise have decrypted into LLM context. If it is non-zero, this fix is load-bearing rather than theoretical.

2. The cross-term offering gap is real or it isn't

The offering set is now the union of user_offering_ids_for_course (enrollment-derived) and resolve_offering (the writer's resolver), because documents are written with the latter and read with the former.

-- documents sitting on an offering the owner has no enrollment row for:-- these are exactly the files the tutor could not see before this changeSELECTcount(*) FROM documents d
WHEREd.deleted_at IS NULLAND NOT EXISTS (
SELECT1FROM enrollments e
WHEREe.user_id=d.user_idANDe.offering_id=d.offering_id);

Non-zero means the divergence is already live, not just reachable at the next term boundary.

3. Sanity-check the filter set on the wire

The final query filters on exactly offering_id in (...), user_id eq, deleted_at is.null. Worth eyeballing one real request in logs to confirm no fourth filter crept in — this is the query whose column set was wrong for months without anyone noticing.

Static verification only — no live database was reachable from this environment. Schema model built by replaying backend/db/migrations/ in ledger order.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@Darkest-Teddy@Jose-Gael-Cruz-Lopez
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
fix(tutor): stop the tutor repeating itself, and let it actually read course materials by Darkest-Teddy · Pull Request #562 · SaplingLearn/Sapling · GitHub
Skip to content

fix(tutor): stop the tutor repeating itself, and let it actually read course materials - #562

Open
Darkest-Teddy wants to merge 5 commits into
mainfrom
fix/tutor-repeat-and-course-materials
Open

fix(tutor): stop the tutor repeating itself, and let it actually read course materials#562
Darkest-Teddy wants to merge 5 commits into
mainfrom
fix/tutor-repeat-and-course-materials

Conversation

@Darkest-Teddy

@Darkest-TeddyDarkest-Teddy commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator

Two independent bugs in the tutor chat, found while debugging "why does the AI repeat the message instead of responding to it". One commit each.

1. The tutor replayed its previous reply verbatim

stream_agent_turn preferred run_result.output over the streamed chunks:

reply=final_outputiffinal_outputisnotNoneelsejoined

.output resolves out of the run's message list, and that list includes message_history. So a turn whose model response carries no text part — the model ended its turn after tool calls — handed back the previous turn's assistant message. Fully formed and non-blank, so the blank-reply ladder immediately below never saw it. The route persisted it, and the student got a byte-identical copy of the last answer.

Confirmed in two live sessions two days apart: same sha256, same length, including a 1757-character reply reproduced exactly against a completely different question.

Fix: trust final_output only when this turn actually streamed text. Text always arrives as PartStart/PartDelta events, so "nothing streamed" means "this turn produced no text" — degrade through the existing blank-reply ladder rather than replaying history.

Evidence. Replaying a real session turn against gemini-2.5-flash-lite, the correlation is exact:

streamed token/replyoutcomecount
0repeated the prior answer2/8
>0answered correctly6/8

After the fix: 0 repeats in 10 runs.

What this does not fix: the model still ends ~40% of these turns without text. Those now take the Rung-1 JSON fallback (a real answer), or — if graph writes already landed — show the honest "interrupted, please retry". A silent wrong answer becomes a correct one or a visible error. Why the model does this at all is unexplored and worth a follow-up.

2. The tutor never read any course document

search_course_materials filtered documents on course_id. That table keys on offering_id (0025) and has no course_id column, so PostgREST 400s on every call — and the tool's deliberate degrade-silently except turned that into [].

The tutor answered from base knowledge alone, which presents as "it's generic about my class" rather than as a failure. Nothing was logged at the user's level.

Fix: resolve the abstract course to the user's offerings via academics.user_offering_ids_for_course, matching the convention that the API boundary keeps the abstract course while documents key on the offering. The #125 user_id scope is unchanged — documents stay user-scoped within a shared offering.

Tests

Both regression tests were written first and watched fail for the right reason:

  • test_textless_turn_never_replays_the_previous_turns_reply — failed with on_complete receiving the stale prior-turn text.
  • test_documents_are_fetched_by_offering_not_course_id — failed with column documents.course_id does not exist.

The second one needed a schema-faithful fake. The existing mocks accept any filter, which is precisely how bug 2 survived them. The two older search_course_materials test classes now stub the offering lookup, which became a real dependency of that function.

Full backend suite: 1999 passed, 56 skipped, exit 0. ruff check clean on all four files.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes

    • Course material searches now return documents associated with the user’s course offerings while maintaining access restrictions.
    • Prevented tool-only chat turns from displaying or saving stale assistant responses from earlier messages.
    • Ensured empty or non-text streaming responses use a fresh fallback reply.
  • Tests

    • Added coverage for offering-based material searches, access scoping, and stale-response prevention.

Darkest-Teddyand others added 2 commits August 13, 2026 22:21
`stream_agent_turn` preferred `run_result.output` over the streamed
chunks. That output resolves out of the run's message list, and that
list includes `message_history` — so a turn whose model response
carries no text part (the model ended its turn after tool calls) handed
back the PREVIOUS turn's assistant message: fully formed, non-blank, and
therefore invisible to the blank-reply ladder below it.
The route then persisted it, so the tutor answered a follow-up with a
byte-identical copy of its own last answer — same sha256, same length,
including a 1757-char reply reproduced verbatim against a completely
different question.
Trust `final_output` only when this turn actually streamed text. Text
always arrives as PartStart/PartDelta events, so "nothing streamed"
means "this turn produced no text": degrade through the existing
blank-reply ladder instead of replaying history.
Measured on gemini-2.5-flash-lite by replaying a real session turn: the
correlation is exact — `token/reply == 0` repeated (2/8), any streamed
text answered correctly (6/8). After the fix, 0 repeats in 10 runs.
Note this does not stop the model from ending a turn without text; those
turns now take the Rung-1 fallback or a visible interrupted-error rather
than a silent duplicate.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…n't exist
`search_course_materials` filtered `documents` on `course_id`. That
table keys on `offering_id` (0025) and has no `course_id` column, so
PostgREST returned 400 on every call — and the tool's deliberate
degrade-silently `except` turned that into `[]`.
Net effect: the tutor never read a single course document, with no error
surfaced anywhere. It answered from base knowledge alone, which reads as
"the tutor is generic about my class" rather than as a bug.
Resolve the abstract course to the user's offerings via
`academics.user_offering_ids_for_course`, per the convention that the
API boundary keeps the abstract course while documents key on the
offering. The #125 user_id scope is unchanged: documents stay
user-scoped WITHIN a shared offering.
The existing unit tests missed this because they mock `table` loosely
enough to accept any filter; the new test uses a schema-faithful fake
that rejects a column the table does not have, exactly as PostgREST
does. The two older test classes now stub the offering lookup, which is
a real dependency of this function for the first time.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@supabase

supabaseBot commented Aug 14, 2026

Copy link
Copy Markdown

This pull request has been ignored for the connected project ybgqdonkoqftwrmweuyv because there are no changes detected in supabase directory. You can change this behaviour in Project Integrations Settings ↗︎.


Preview Branches by Supabase.
Learn more about Supabase Branching ↗︎.

@coderabbitai

coderabbitaiBot commented Aug 14, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@Jose-Gael-Cruz-Lopez, you've reached your PR review limit, so we couldn't start this review.

Next review available in:8 minutes

Limit details: You’ve used the included review currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

Wait for the limit to reset, then comment @coderabbitai review or push new commits to the PR.

An organization admin can change what happens after included review limits in Billing.

How do review limits work?

CodeRabbit enforces per-developer PR review limits within each organization.

For paid Pro and Pro+ reviews, CodeRabbit uses a developer's included PR review attempts over the past 7 days to set the current hourly allowance. At typical activity levels, the full plan allowance applies. Higher sustained activity can lower the allowance until earlier attempts leave the 7-day window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 0bb76370-0c3c-4d13-a541-292b94564424

📥 Commits

Reviewing files that changed from the base of the PR and between b8aa904 and 4e2949d.

📒 Files selected for processing (8)
  • backend/agents/tools/chat_context.py
  • backend/routes/learn.py
  • backend/tests/agent_run_fakes.py
  • backend/tests/test_chat_context_tools.py
  • backend/tests/test_chat_stream.py
  • backend/tests/test_event_capture_seams.py
  • backend/tests/test_graph_tools_bugs.py
  • backend/tests/test_learn_routes.py

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 06d0ac13-1c32-40b4-a002-18d7e154c3c1

📥 Commits

Reviewing files that changed from the base of the PR and between 9b77819 and b8aa904.

📒 Files selected for processing (4)
  • backend/agents/tools/chat_context.py
  • backend/services/chat_stream.py
  • backend/tests/test_chat_context_tools.py
  • backend/tests/test_chat_stream.py

📝 Walkthrough

Walkthrough

The change scopes course-material searches to the user’s offerings and updates streamed-turn reply selection to avoid stale history output. Tests cover offering resolution, user scoping, document lookup, and tool-only turns.

Changes

Chat behavior corrections

Layer / File(s)Summary
Offering-scoped course material search
backend/agents/tools/chat_context.py, backend/tests/test_chat_context_tools.py
Course searches resolve user-specific offering IDs and filter documents by offering_id while retaining user_id scoping. Tests cover matching offerings and schema-valid document queries.
Textless streamed turn handling
backend/services/chat_stream.py, backend/tests/test_chat_stream.py
Textless turns use joined current-turn chunks instead of stale message-history output. Regression coverage checks streaming, persistence, callbacks, and nonstream fallback behavior.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Mergeability Score:⚪ Minimal · up to b8aa9

The changes address two localized tutor-chat defects, with regression coverage and passing checks reported; no actionable merge-blocking risk remains beyond normal review.

Possibly related PRs

Suggested reviewers:andresl230, jose-gael-cruz-lopez

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 37.50% which is insufficient. The required threshold is 80.00%.Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check nameStatusExplanation
Title check✅ PassedThe title clearly summarizes both primary tutor fixes: preventing repeated replies and enabling course-material retrieval.
Description check✅ PassedThe description clearly explains both bugs, their fixes, regression tests, test results, and reviewer-relevant limitations.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/tutor-repeat-and-course-materials

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Aug 14, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-staging4e2949dCommit Preview URL

Branch Preview URL
Aug 19 2026, 09:02 PM

@Jose-Gael-Cruz-Lopez

Copy link
Copy Markdown
Member

Code review — tutor repetition + course-material retrieval

Two genuinely independent bugs, both diagnosed properly, both with regression tests written first. The documents.course_id finding is correct — migration 0025_study_integrity.sql recreates documents with offering_id TEXT NOT NULL REFERENCES course_offerings(id) and no course_id column, so the old filter really did 400 into the tool's degrade-silently except. The chat_stream guard is a one-line narrowing that is safe for this agent (chat_tutor is output_type=str, agents/chat_tutor.py:174, so its reply always arrives as text parts — there is no output-tool channel the guard could discard). Three things need attention before merge: the now-working documents query no longer honours the soft-delete convention every other reader follows, the offering resolution it picked can only shrink the user's own document set relative to the writer, and the identical stale-run_result.output read on the JSON path — which is exactly the fallback this PR now routes textless turns into — was left unguarded, so the repeat symptom can still be produced.

Findings

P0

[P0] CI is red — Backend (pytest) fails (pre-existing on main, not a regression from this PR) — run 31763538674

FAILED tests/test_quiz_preflight_a.py::TestQuizErrorEnvelope::test_method_not_allowed_gets_generic_code - AttributeError: '_IncludedRouter' object has no attribute 'path'
1 failed, 1965 passed, 56 skipped, 13 warnings in 42.25s
 if match == Match.PARTIAL:
> route = starlette_route.path
^^^^^^^^^^^^^^^^^^^^
E AttributeError: '_IncludedRouter' object has no attribute 'path'
/opt/hostedtoolcache/Python/3.13.14/x64/lib/python3.13/site-packages/opentelemetry/instrumentation/fastapi/__init__.py:495: AttributeError

Not this PR's fault. The identical failure is on main — run 31975087459 (4f5a7f90, "Merge pull request #524 from SaplingLearn/feat/landing-v5-port"): same test, same AttributeError, 1 failed, 1963 passed, 56 skipped. It is dependency drift in opentelemetry-instrumentation-fastapi._get_route_details, which assumes every Match.PARTIAL route object has .path; FastAPI's _IncludedRouter does not. None of this PR's four files touch routing or quiz. It still blocks the merge queue, so it needs a pin/fix on main first — worth noting because the PR body claims "Full backend suite: 1999 passed, 56 skipped, exit 0", which does not match CI.

P1

[P1] Soft-deleted documents now reach the tutorbackend/agents/tools/chat_context.py:154-164

return (
table("documents").select(
"id,file_name,summary,concept_notes",
filters={
# #125 user scope is preserved: documents are# user-scoped WITHIN a shared offering."offering_id": f"in.({','.join(offering_ids)})",
"user_id": f"eq.{user_id}",
},
order="created_at.desc",
)

documents is soft-deleted, not hard-deleted — routes/documents.py:288-293: "Soft delete (0025): stamp deleted_at; reads filter it out." Every other reader honours that: routes/documents.py:241, routes/study_guide.py:137, routes/flashcards.py:153 and :163, routes/documents.py:1230 all pass "deleted_at": "is.null". This query does not. Before this PR that was invisible because the query 400'd and returned [] unconditionally; this PR is what makes it return rows, so it is this PR that first exposes deleted documents. Concretely: a student deletes a document from their Library, documents.deleted_at is stamped, the row stays, and the tutor keeps decrypting its summary + concept_notes into the LLM context on every search_course_materials call. Nothing in backend/agents/ or the tutor tests references deleted_at, so no test catches it.

[P1] The stale run_result.output read is unguarded on the JSON path — the very fallback this PR routes textless turns intobackend/routes/learn.py:636-654

result=record_agent_usage(
awaitagent.run(user_message, **run_kwargs),
feature="chat_tutor", task="chat_tutor", user_id=deps.user_id,
)
reply=result.output# str — chat_tutor agents return plain Markdown.ifnotreply.strip():

_prepare_chat_run puts "message_history": message_history into run_kwargs (routes/learn.py:577), so result.output here resolves out of the same history-bearing message list the PR indicts. The only guard is not reply.strip() — and the PR's whole point is that a replayed prior turn is "fully formed, non-blank, and therefore invisible to the blank-reply ladder". _chat_turn_json is both a first-class route (POST /api/learn/chat, routes/learn.py:745-751) and the streamed route's Rung-1 nonstream_fallback (routes/learn.py:823). So after this change a textless streamed turn correctly refuses the stale text, degrades to _chat_turn_json, and if that re-run is also textless — the PR body measures ~40% — it takes the prior assistant message from message_history, persists it via save_message (routes/learn.py:727) and returns it. The repeat is not eliminated, it is moved one rung down, onto a path this PR sends more traffic to.

[P1] Offering resolution is narrower than the writer's and than every sibling reader'sbackend/agents/tools/chat_context.py:151-153

offering_ids=user_offering_ids_for_course(user_id, course_id)
ifnotoffering_ids:
return []

user_offering_ids_for_course (services/academics.py:185-200) intersects the course's offerings with the user's enrollments rows. Documents are written with a different resolver — offering_id = resolve_offering(course_id, create=True) at routes/documents.py:572 and :710 — which picks the current-term offering (creating it if absent, else falling back to any offering of the course) and never consults enrollments. The two sibling readers use the writer's resolver too: routes/study_guide.py:136 and routes/flashcards.py:144 filter on resolve_offering(...). Divergence is reachable across a term boundary: a student enrolled via routes/onboarding.py:95-112 in Fall-26's offering who uploads in the next term gets documents.offering_id = the new term's offering, has no enrollment row for it, and the tutor silently returns [] — while the Library still lists the file (routes/documents.py:239-241 filters on user_id only). Note the enrollment intersection buys no safety: user_id is already the security boundary (your own #125 comment says so), so this filter can only remove the user's own documents.

P2

[P2] The empty-offering short-circuit is silent — the exact failure mode this PR exists to removebackend/agents/tools/chat_context.py:152-153

ifnotoffering_ids:
return []

The PR body's own indictment of the old bug is "The tutor answered from base knowledge alone, which presents as 'it's generic about my class' rather than as a failure. Nothing was logged at the user's level." This early return reproduces that precisely: no log, no metric, indistinguishable from "this course genuinely has no materials". The except below it does logger.exception; this branch gets nothing. Given the P1 above makes it reachable, a log line here is what turns the next occurrence of this class of bug into a five-minute diagnosis instead of a two-month one.

P3

[P3] Two extra uncached PostgREST round-trips per tool call, in the streaming hot pathbackend/services/academics.py:191-200 via chat_context.py:151

offs=table("course_offerings").select(
"id", filters={"course_id": f"eq.{course_id}"}
) or []
off_ids= {o["id"] foroinoffs}
ifnotoff_ids:
return []
enr=table("enrollments").select(
"offering_id", filters={"user_id": f"eq.{user_id}"}
) or []

user_offering_ids_for_course is deliberately uncached and the second select pulls all of the user's enrollments across every course. search_course_materials is an LLM-callable tool that can fire more than once per turn, on the latency-critical SSE path — so this is +2 sequential round-trips per call. The documents select that follows is also unbounded (no limit, order="created_at.desc" only) and every returned row is AES-decrypted before the list is truncated to limit (5) in Python at chat_context.py:213. Harmless today; both become real once a course accumulates documents.

[P3] No test pins the newly-reachable textless-turn-with-writes branchbackend/tests/test_chat_stream.py:710-750

agent=FakeAgent([
FunctionToolCallEvent("read_graph_neighborhood"),
FunctionToolResultEvent(), # no writes landedAgentRunResultEvent(PRIOR), # stale: from message_history
])

The new test only covers the no-writes path (Rung-1 fallback). But the trigger it describes is "the model ended its turn after tool calls", and every tutor agent registers apply_graph_update_tool and update_mastery_tool (agents/chat_tutor.py:161-162), which populate deps.graph_updates / deps.mastery_changes (agents/tools/graph.py:122, :174). So the likely shape of a textless turn is textless-with-writes, which lands on the terminal retryable: False error at chat_stream.py:362-368 — a user-visible dead end, and the branch with no new coverage. Worth one more FakeAgent case with an on_fire write.

What's good

  • The root-cause work is real and checkable: 0025_study_integrity.sql:15-31 confirms documents has offering_id NOT NULL and no course_id, exactly as claimed.
  • The new TestSearchCourseMaterialsOfferingScope fake raises on a course_id filter instead of accepting any filter — the right response to "the existing mocks accept any filter, which is precisely how bug 2 survived them".
  • The chat_stream.py guard is correctly scoped: chat_tutor is output_type=str (agents/chat_tutor.py:174), so there is no output-tool channel whose reply the joined.strip() condition could throw away. Per the Sapling Engineering Style Guide the new sync user_offering_ids_for_course call also stays off the event loop — it sits inside _fetch, which runs under asyncio.to_thread (chat_context.py:174).

Verdict: request changes — the soft-delete regression and the unguarded result.output on the JSON fallback path both need fixing before merge; the CI failure is pre-existing on main and should be unblocked there.


Review-only pass — no code changed and nothing fixed. Conventions checked against the Canopy live docs (Engineering Style Guide, Architecture, Infrastructure, Backend & AI Agents). Every finding cites a snippet re-read at this PR's head SHA; severity: P0 blocker · P1 major · P2 minor · P3 nit.

…le degrade
Three findings on the course-materials read, all made reachable by the
offering fix in b8aa904 — before it the query 400'd and returned [] on
every call, so none of them could be observed.
1. `documents` is soft-deleted. routes/documents.py stamps `deleted_at`
and every other reader filters on it (study_guide.py, flashcards.py);
this query did not, so a file the student deleted from their Library
kept getting its `summary` + `concept_notes` decrypted into LLM
context forever. Adds `deleted_at is.null`, which also makes the
filter set identical to PR #534's fix of the same bug — the eventual
merge conflict is now trivial.
2. `user_offering_ids_for_course` is narrower than the WRITER.
Documents are written with `resolve_offering(course_id, create=True)`
— current term, `enrollments` never consulted — and the sibling
readers use the writer's resolver too. Across a term boundary a
student enrolled in Fall-26 who uploads next term gets
`documents.offering_id` = the new offering, has no enrollment row for
it, and the tutor silently returned [] while the Library still listed
the file. The intersection bought no security either: `user_id` is
the access boundary on `documents` (#125), so dropping offerings can
only hide the student's OWN uploads. Widened to the union of both
resolvers, order-stable for the `in.(...)` list.
3. The empty-offering short-circuit was silent — no log, no metric,
indistinguishable from "this course has no materials", which is
exactly the failure mode the offering fix exists to remove. It logs
now, without a raw student id.
Also bounds the read. The select was unbounded while every returned row
gets AES-decrypted before Python truncates to `limit`, on the
latency-critical SSE path. The bound is a multiple of `limit`, not
`limit` itself: ranking happens after the fetch, so limiting to exactly
`limit` would silently turn "most relevant" into "most recent".
The new tests use a schema-faithful `table()` fake that rejects filter
columns `documents` does not have. The older mocks in that file accept
any filter and return a canned list, which is precisely how a query
against a non-existent column survived review.
… path
5092a83 narrowed `run_result.output` inside `stream_agent_turn`, but
routes/learn.py reads it in three more places and two of them run with
`message_history` in `run_kwargs` — so `.output` resolves out of the same
history-bearing message list, and a textless turn hands back the
PREVIOUS turn's assistant message: fully formed, non-blank, and
therefore invisible to the `if not reply.strip()` guard sitting right
below it.
That relocated the repeat rather than removing it, onto a path taking
MORE traffic: `_chat_turn_json` is both POST /api/learn/chat and the
streamed route's Rung-1 `nonstream_fallback`, which is exactly where the
streaming fix now sends textless turns. `_action_turn` is the third
reader and persists with `save_message` directly.
`new_messages()` excludes the history that was passed in, so joining the
TextParts of its model responses is exactly "what this turn said" — the
non-streaming twin of the `joined.strip()` check in chat_stream.py.
`_start_session_agent` passes `message_history=[]`, so it has no prior
message to resolve back to and keeps reading `.output` directly; the
comment there names why.
Also adds the missing coverage for the newly-reachable streamed branch.
Every tutor agent registers `apply_graph_update_tool` and
`update_mastery_tool`, so a model that ends its turn after tool calls
usually arrives with `deps.graph_updates` / `deps.mastery_changes`
already populated — the textless-WITH-writes shape, which lands on the
terminal `retryable: False` rung rather than Rung 1. That was untested.
The agent-run fakes had to become shape-faithful (tests/agent_run_fakes.py):
`SimpleNamespace(output=...)` has no `new_messages`, and a bare MagicMock
iterates EMPTY on it — which would have silently turned every mocked
turn into a "textless" one while the assertions still went green through
the 502 catch-all. That is the same class of blind spot as the mock that
accepted a filter on a column PostgREST does not have.
@Jose-Gael-Cruz-Lopez

Copy link
Copy Markdown
Member

Review fixes applied

Every outstanding finding on this PR (human review + CodeRabbit) has been addressed and pushed.

Major

  • Soft-deleted documents reached the tutor. Added deleted_at is.null to the documents select. This PR is what first exposed them (the query used to 400 into the silent degrade), so a deleted file's decrypted summary/concept_notes would have kept flowing into LLM context. Filter set is now byte-identical to fix(tutor): repair course-material retrieval, silence course-scope commentary, make quizzes practical #534's, making that overlap a trivial pick-either.
  • The stale run_result.output read was only narrowed on the streamed path._chat_turn_json is both a first-class route and the streamed route's Rung-1 fallback — i.e. exactly where this PR now sends textless turns — so the repeat was relocated, not removed. Added _new_run_text() (the non-streaming twin of the joined.strip() narrowing) and applied it to both history-bearing .output sites; the message_history=[] site is left alone with a comment saying why.
  • Offering resolution was narrower than the writer's. Now the union of user_offering_ids_for_course and resolve_offering, so a document uploaded across a term boundary is still found. Test covers the cross-term case.

Minor

  • The empty-offering short-circuit logs a warning instead of returning [] silently — the exact failure mode this PR exists to remove.
  • limit pushed into the query so rows are no longer AES-decrypted and then discarded.
  • Added the missing textless-turn-with-writes case (the likely shape, landing on the terminal retryable: False path).

Not fixed here

The red Backend (pytest) is the otel _IncludedRouter failure that is pre-existing on main; #563 carries the shim that fixes it.

Verificationruff check . clean · 1973 passed, 56 skipped

Fixes applied and verified locally against this branch head; each figure above is a command I ran, not an estimate.

@Jose-Gael-Cruz-Lopez

Copy link
Copy Markdown
Member

Verify against the real database before merging

I could not reach a live database while working on this — there are no credentials on this machine (only .env.example files) and the local Supabase stack needs a container runtime that wouldn't start. So everything below was verified statically, by replaying every migration in ledger order to build a schema model and checking this PR's DB access against it. That model found 0 schema mismatches here, and it is trustworthy enough to have independently reproduced the documents.course_id absence that caused #562/#534 — but it cannot see data, and it cannot see whether staging's ledger actually matches the repo.

These are the checks that need a real connection.

No schema change — but the behaviour change is data-dependent

Two fixes here only show their value against real rows, so they are worth confirming on staging.

1. How much was actually leaking

The documents read had no deleted_at filter. Before this PR the query 400'd and returned [], so nothing leaked; this PR is what makes it return rows, which is why the filter had to be added in the same change.

SELECTcount(*) FROM documents WHERE deleted_at IS NOT NULL;

That count is exactly how many soft-deleted documents the tutor would otherwise have decrypted into LLM context. If it is non-zero, this fix is load-bearing rather than theoretical.

2. The cross-term offering gap is real or it isn't

The offering set is now the union of user_offering_ids_for_course (enrollment-derived) and resolve_offering (the writer's resolver), because documents are written with the latter and read with the former.

-- documents sitting on an offering the owner has no enrollment row for:-- these are exactly the files the tutor could not see before this changeSELECTcount(*) FROM documents d
WHEREd.deleted_at IS NULLAND NOT EXISTS (
SELECT1FROM enrollments e
WHEREe.user_id=d.user_idANDe.offering_id=d.offering_id);

Non-zero means the divergence is already live, not just reachable at the next term boundary.

3. Sanity-check the filter set on the wire

The final query filters on exactly offering_id in (...), user_id eq, deleted_at is.null. Worth eyeballing one real request in logs to confirm no fourth filter crept in — this is the query whose column set was wrong for months without anyone noticing.

Static verification only — no live database was reachable from this environment. Schema model built by replaying backend/db/migrations/ in ledger order.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@Darkest-Teddy@Jose-Gael-Cruz-Lopez
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix(tutor): stop the tutor repeating itself, and let it actually read course materials by Darkest-Teddy · Pull Request #562 · SaplingLearn/Sapling · GitHub
Skip to content

fix(tutor): stop the tutor repeating itself, and let it actually read course materials - #562

Open
Darkest-Teddy wants to merge 5 commits into
mainfrom
fix/tutor-repeat-and-course-materials
Open

fix(tutor): stop the tutor repeating itself, and let it actually read course materials#562
Darkest-Teddy wants to merge 5 commits into
mainfrom
fix/tutor-repeat-and-course-materials

Conversation

@Darkest-Teddy

@Darkest-TeddyDarkest-Teddy commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator

Two independent bugs in the tutor chat, found while debugging "why does the AI repeat the message instead of responding to it". One commit each.

1. The tutor replayed its previous reply verbatim

stream_agent_turn preferred run_result.output over the streamed chunks:

reply=final_outputiffinal_outputisnotNoneelsejoined

.output resolves out of the run's message list, and that list includes message_history. So a turn whose model response carries no text part — the model ended its turn after tool calls — handed back the previous turn's assistant message. Fully formed and non-blank, so the blank-reply ladder immediately below never saw it. The route persisted it, and the student got a byte-identical copy of the last answer.

Confirmed in two live sessions two days apart: same sha256, same length, including a 1757-character reply reproduced exactly against a completely different question.

Fix: trust final_output only when this turn actually streamed text. Text always arrives as PartStart/PartDelta events, so "nothing streamed" means "this turn produced no text" — degrade through the existing blank-reply ladder rather than replaying history.

Evidence. Replaying a real session turn against gemini-2.5-flash-lite, the correlation is exact:

streamed token/replyoutcomecount
0repeated the prior answer2/8
>0answered correctly6/8

After the fix: 0 repeats in 10 runs.

What this does not fix: the model still ends ~40% of these turns without text. Those now take the Rung-1 JSON fallback (a real answer), or — if graph writes already landed — show the honest "interrupted, please retry". A silent wrong answer becomes a correct one or a visible error. Why the model does this at all is unexplored and worth a follow-up.

2. The tutor never read any course document

search_course_materials filtered documents on course_id. That table keys on offering_id (0025) and has no course_id column, so PostgREST 400s on every call — and the tool's deliberate degrade-silently except turned that into [].

The tutor answered from base knowledge alone, which presents as "it's generic about my class" rather than as a failure. Nothing was logged at the user's level.

Fix: resolve the abstract course to the user's offerings via academics.user_offering_ids_for_course, matching the convention that the API boundary keeps the abstract course while documents key on the offering. The #125 user_id scope is unchanged — documents stay user-scoped within a shared offering.

Tests

Both regression tests were written first and watched fail for the right reason:

  • test_textless_turn_never_replays_the_previous_turns_reply — failed with on_complete receiving the stale prior-turn text.
  • test_documents_are_fetched_by_offering_not_course_id — failed with column documents.course_id does not exist.

The second one needed a schema-faithful fake. The existing mocks accept any filter, which is precisely how bug 2 survived them. The two older search_course_materials test classes now stub the offering lookup, which became a real dependency of that function.

Full backend suite: 1999 passed, 56 skipped, exit 0. ruff check clean on all four files.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes

    • Course material searches now return documents associated with the user’s course offerings while maintaining access restrictions.
    • Prevented tool-only chat turns from displaying or saving stale assistant responses from earlier messages.
    • Ensured empty or non-text streaming responses use a fresh fallback reply.
  • Tests

    • Added coverage for offering-based material searches, access scoping, and stale-response prevention.

Darkest-Teddyand others added 2 commits August 13, 2026 22:21
`stream_agent_turn` preferred `run_result.output` over the streamed
chunks. That output resolves out of the run's message list, and that
list includes `message_history` — so a turn whose model response
carries no text part (the model ended its turn after tool calls) handed
back the PREVIOUS turn's assistant message: fully formed, non-blank, and
therefore invisible to the blank-reply ladder below it.
The route then persisted it, so the tutor answered a follow-up with a
byte-identical copy of its own last answer — same sha256, same length,
including a 1757-char reply reproduced verbatim against a completely
different question.
Trust `final_output` only when this turn actually streamed text. Text
always arrives as PartStart/PartDelta events, so "nothing streamed"
means "this turn produced no text": degrade through the existing
blank-reply ladder instead of replaying history.
Measured on gemini-2.5-flash-lite by replaying a real session turn: the
correlation is exact — `token/reply == 0` repeated (2/8), any streamed
text answered correctly (6/8). After the fix, 0 repeats in 10 runs.
Note this does not stop the model from ending a turn without text; those
turns now take the Rung-1 fallback or a visible interrupted-error rather
than a silent duplicate.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…n't exist
`search_course_materials` filtered `documents` on `course_id`. That
table keys on `offering_id` (0025) and has no `course_id` column, so
PostgREST returned 400 on every call — and the tool's deliberate
degrade-silently `except` turned that into `[]`.
Net effect: the tutor never read a single course document, with no error
surfaced anywhere. It answered from base knowledge alone, which reads as
"the tutor is generic about my class" rather than as a bug.
Resolve the abstract course to the user's offerings via
`academics.user_offering_ids_for_course`, per the convention that the
API boundary keeps the abstract course while documents key on the
offering. The #125 user_id scope is unchanged: documents stay
user-scoped WITHIN a shared offering.
The existing unit tests missed this because they mock `table` loosely
enough to accept any filter; the new test uses a schema-faithful fake
that rejects a column the table does not have, exactly as PostgREST
does. The two older test classes now stub the offering lookup, which is
a real dependency of this function for the first time.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@supabase

supabaseBot commented Aug 14, 2026

Copy link
Copy Markdown

This pull request has been ignored for the connected project ybgqdonkoqftwrmweuyv because there are no changes detected in supabase directory. You can change this behaviour in Project Integrations Settings ↗︎.


Preview Branches by Supabase.
Learn more about Supabase Branching ↗︎.

@coderabbitai

coderabbitaiBot commented Aug 14, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@Jose-Gael-Cruz-Lopez, you've reached your PR review limit, so we couldn't start this review.

Next review available in:8 minutes

Limit details: You’ve used the included review currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

Wait for the limit to reset, then comment @coderabbitai review or push new commits to the PR.

An organization admin can change what happens after included review limits in Billing.

How do review limits work?

CodeRabbit enforces per-developer PR review limits within each organization.

For paid Pro and Pro+ reviews, CodeRabbit uses a developer's included PR review attempts over the past 7 days to set the current hourly allowance. At typical activity levels, the full plan allowance applies. Higher sustained activity can lower the allowance until earlier attempts leave the 7-day window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 0bb76370-0c3c-4d13-a541-292b94564424

📥 Commits

Reviewing files that changed from the base of the PR and between b8aa904 and 4e2949d.

📒 Files selected for processing (8)
  • backend/agents/tools/chat_context.py
  • backend/routes/learn.py
  • backend/tests/agent_run_fakes.py
  • backend/tests/test_chat_context_tools.py
  • backend/tests/test_chat_stream.py
  • backend/tests/test_event_capture_seams.py
  • backend/tests/test_graph_tools_bugs.py
  • backend/tests/test_learn_routes.py

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 06d0ac13-1c32-40b4-a002-18d7e154c3c1

📥 Commits

Reviewing files that changed from the base of the PR and between 9b77819 and b8aa904.

📒 Files selected for processing (4)
  • backend/agents/tools/chat_context.py
  • backend/services/chat_stream.py
  • backend/tests/test_chat_context_tools.py
  • backend/tests/test_chat_stream.py

📝 Walkthrough

Walkthrough

The change scopes course-material searches to the user’s offerings and updates streamed-turn reply selection to avoid stale history output. Tests cover offering resolution, user scoping, document lookup, and tool-only turns.

Changes

Chat behavior corrections

Layer / File(s)Summary
Offering-scoped course material search
backend/agents/tools/chat_context.py, backend/tests/test_chat_context_tools.py
Course searches resolve user-specific offering IDs and filter documents by offering_id while retaining user_id scoping. Tests cover matching offerings and schema-valid document queries.
Textless streamed turn handling
backend/services/chat_stream.py, backend/tests/test_chat_stream.py
Textless turns use joined current-turn chunks instead of stale message-history output. Regression coverage checks streaming, persistence, callbacks, and nonstream fallback behavior.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Mergeability Score:⚪ Minimal · up to b8aa9

The changes address two localized tutor-chat defects, with regression coverage and passing checks reported; no actionable merge-blocking risk remains beyond normal review.

Possibly related PRs

Suggested reviewers:andresl230, jose-gael-cruz-lopez

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 37.50% which is insufficient. The required threshold is 80.00%.Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check nameStatusExplanation
Title check✅ PassedThe title clearly summarizes both primary tutor fixes: preventing repeated replies and enabling course-material retrieval.
Description check✅ PassedThe description clearly explains both bugs, their fixes, regression tests, test results, and reviewer-relevant limitations.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/tutor-repeat-and-course-materials

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Aug 14, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-staging4e2949dCommit Preview URL

Branch Preview URL
Aug 19 2026, 09:02 PM

@Jose-Gael-Cruz-Lopez

Copy link
Copy Markdown
Member

Code review — tutor repetition + course-material retrieval

Two genuinely independent bugs, both diagnosed properly, both with regression tests written first. The documents.course_id finding is correct — migration 0025_study_integrity.sql recreates documents with offering_id TEXT NOT NULL REFERENCES course_offerings(id) and no course_id column, so the old filter really did 400 into the tool's degrade-silently except. The chat_stream guard is a one-line narrowing that is safe for this agent (chat_tutor is output_type=str, agents/chat_tutor.py:174, so its reply always arrives as text parts — there is no output-tool channel the guard could discard). Three things need attention before merge: the now-working documents query no longer honours the soft-delete convention every other reader follows, the offering resolution it picked can only shrink the user's own document set relative to the writer, and the identical stale-run_result.output read on the JSON path — which is exactly the fallback this PR now routes textless turns into — was left unguarded, so the repeat symptom can still be produced.

Findings

P0

[P0] CI is red — Backend (pytest) fails (pre-existing on main, not a regression from this PR) — run 31763538674

FAILED tests/test_quiz_preflight_a.py::TestQuizErrorEnvelope::test_method_not_allowed_gets_generic_code - AttributeError: '_IncludedRouter' object has no attribute 'path'
1 failed, 1965 passed, 56 skipped, 13 warnings in 42.25s
 if match == Match.PARTIAL:
> route = starlette_route.path
^^^^^^^^^^^^^^^^^^^^
E AttributeError: '_IncludedRouter' object has no attribute 'path'
/opt/hostedtoolcache/Python/3.13.14/x64/lib/python3.13/site-packages/opentelemetry/instrumentation/fastapi/__init__.py:495: AttributeError

Not this PR's fault. The identical failure is on main — run 31975087459 (4f5a7f90, "Merge pull request #524 from SaplingLearn/feat/landing-v5-port"): same test, same AttributeError, 1 failed, 1963 passed, 56 skipped. It is dependency drift in opentelemetry-instrumentation-fastapi._get_route_details, which assumes every Match.PARTIAL route object has .path; FastAPI's _IncludedRouter does not. None of this PR's four files touch routing or quiz. It still blocks the merge queue, so it needs a pin/fix on main first — worth noting because the PR body claims "Full backend suite: 1999 passed, 56 skipped, exit 0", which does not match CI.

P1

[P1] Soft-deleted documents now reach the tutorbackend/agents/tools/chat_context.py:154-164

return (
table("documents").select(
"id,file_name,summary,concept_notes",
filters={
# #125 user scope is preserved: documents are# user-scoped WITHIN a shared offering."offering_id": f"in.({','.join(offering_ids)})",
"user_id": f"eq.{user_id}",
},
order="created_at.desc",
)

documents is soft-deleted, not hard-deleted — routes/documents.py:288-293: "Soft delete (0025): stamp deleted_at; reads filter it out." Every other reader honours that: routes/documents.py:241, routes/study_guide.py:137, routes/flashcards.py:153 and :163, routes/documents.py:1230 all pass "deleted_at": "is.null". This query does not. Before this PR that was invisible because the query 400'd and returned [] unconditionally; this PR is what makes it return rows, so it is this PR that first exposes deleted documents. Concretely: a student deletes a document from their Library, documents.deleted_at is stamped, the row stays, and the tutor keeps decrypting its summary + concept_notes into the LLM context on every search_course_materials call. Nothing in backend/agents/ or the tutor tests references deleted_at, so no test catches it.

[P1] The stale run_result.output read is unguarded on the JSON path — the very fallback this PR routes textless turns intobackend/routes/learn.py:636-654

result=record_agent_usage(
awaitagent.run(user_message, **run_kwargs),
feature="chat_tutor", task="chat_tutor", user_id=deps.user_id,
)
reply=result.output# str — chat_tutor agents return plain Markdown.ifnotreply.strip():

_prepare_chat_run puts "message_history": message_history into run_kwargs (routes/learn.py:577), so result.output here resolves out of the same history-bearing message list the PR indicts. The only guard is not reply.strip() — and the PR's whole point is that a replayed prior turn is "fully formed, non-blank, and therefore invisible to the blank-reply ladder". _chat_turn_json is both a first-class route (POST /api/learn/chat, routes/learn.py:745-751) and the streamed route's Rung-1 nonstream_fallback (routes/learn.py:823). So after this change a textless streamed turn correctly refuses the stale text, degrades to _chat_turn_json, and if that re-run is also textless — the PR body measures ~40% — it takes the prior assistant message from message_history, persists it via save_message (routes/learn.py:727) and returns it. The repeat is not eliminated, it is moved one rung down, onto a path this PR sends more traffic to.

[P1] Offering resolution is narrower than the writer's and than every sibling reader'sbackend/agents/tools/chat_context.py:151-153

offering_ids=user_offering_ids_for_course(user_id, course_id)
ifnotoffering_ids:
return []

user_offering_ids_for_course (services/academics.py:185-200) intersects the course's offerings with the user's enrollments rows. Documents are written with a different resolver — offering_id = resolve_offering(course_id, create=True) at routes/documents.py:572 and :710 — which picks the current-term offering (creating it if absent, else falling back to any offering of the course) and never consults enrollments. The two sibling readers use the writer's resolver too: routes/study_guide.py:136 and routes/flashcards.py:144 filter on resolve_offering(...). Divergence is reachable across a term boundary: a student enrolled via routes/onboarding.py:95-112 in Fall-26's offering who uploads in the next term gets documents.offering_id = the new term's offering, has no enrollment row for it, and the tutor silently returns [] — while the Library still lists the file (routes/documents.py:239-241 filters on user_id only). Note the enrollment intersection buys no safety: user_id is already the security boundary (your own #125 comment says so), so this filter can only remove the user's own documents.

P2

[P2] The empty-offering short-circuit is silent — the exact failure mode this PR exists to removebackend/agents/tools/chat_context.py:152-153

ifnotoffering_ids:
return []

The PR body's own indictment of the old bug is "The tutor answered from base knowledge alone, which presents as 'it's generic about my class' rather than as a failure. Nothing was logged at the user's level." This early return reproduces that precisely: no log, no metric, indistinguishable from "this course genuinely has no materials". The except below it does logger.exception; this branch gets nothing. Given the P1 above makes it reachable, a log line here is what turns the next occurrence of this class of bug into a five-minute diagnosis instead of a two-month one.

P3

[P3] Two extra uncached PostgREST round-trips per tool call, in the streaming hot pathbackend/services/academics.py:191-200 via chat_context.py:151

offs=table("course_offerings").select(
"id", filters={"course_id": f"eq.{course_id}"}
) or []
off_ids= {o["id"] foroinoffs}
ifnotoff_ids:
return []
enr=table("enrollments").select(
"offering_id", filters={"user_id": f"eq.{user_id}"}
) or []

user_offering_ids_for_course is deliberately uncached and the second select pulls all of the user's enrollments across every course. search_course_materials is an LLM-callable tool that can fire more than once per turn, on the latency-critical SSE path — so this is +2 sequential round-trips per call. The documents select that follows is also unbounded (no limit, order="created_at.desc" only) and every returned row is AES-decrypted before the list is truncated to limit (5) in Python at chat_context.py:213. Harmless today; both become real once a course accumulates documents.

[P3] No test pins the newly-reachable textless-turn-with-writes branchbackend/tests/test_chat_stream.py:710-750

agent=FakeAgent([
FunctionToolCallEvent("read_graph_neighborhood"),
FunctionToolResultEvent(), # no writes landedAgentRunResultEvent(PRIOR), # stale: from message_history
])

The new test only covers the no-writes path (Rung-1 fallback). But the trigger it describes is "the model ended its turn after tool calls", and every tutor agent registers apply_graph_update_tool and update_mastery_tool (agents/chat_tutor.py:161-162), which populate deps.graph_updates / deps.mastery_changes (agents/tools/graph.py:122, :174). So the likely shape of a textless turn is textless-with-writes, which lands on the terminal retryable: False error at chat_stream.py:362-368 — a user-visible dead end, and the branch with no new coverage. Worth one more FakeAgent case with an on_fire write.

What's good

  • The root-cause work is real and checkable: 0025_study_integrity.sql:15-31 confirms documents has offering_id NOT NULL and no course_id, exactly as claimed.
  • The new TestSearchCourseMaterialsOfferingScope fake raises on a course_id filter instead of accepting any filter — the right response to "the existing mocks accept any filter, which is precisely how bug 2 survived them".
  • The chat_stream.py guard is correctly scoped: chat_tutor is output_type=str (agents/chat_tutor.py:174), so there is no output-tool channel whose reply the joined.strip() condition could throw away. Per the Sapling Engineering Style Guide the new sync user_offering_ids_for_course call also stays off the event loop — it sits inside _fetch, which runs under asyncio.to_thread (chat_context.py:174).

Verdict: request changes — the soft-delete regression and the unguarded result.output on the JSON fallback path both need fixing before merge; the CI failure is pre-existing on main and should be unblocked there.


Review-only pass — no code changed and nothing fixed. Conventions checked against the Canopy live docs (Engineering Style Guide, Architecture, Infrastructure, Backend & AI Agents). Every finding cites a snippet re-read at this PR's head SHA; severity: P0 blocker · P1 major · P2 minor · P3 nit.

…le degrade
Three findings on the course-materials read, all made reachable by the
offering fix in b8aa904 — before it the query 400'd and returned [] on
every call, so none of them could be observed.
1. `documents` is soft-deleted. routes/documents.py stamps `deleted_at`
and every other reader filters on it (study_guide.py, flashcards.py);
this query did not, so a file the student deleted from their Library
kept getting its `summary` + `concept_notes` decrypted into LLM
context forever. Adds `deleted_at is.null`, which also makes the
filter set identical to PR #534's fix of the same bug — the eventual
merge conflict is now trivial.
2. `user_offering_ids_for_course` is narrower than the WRITER.
Documents are written with `resolve_offering(course_id, create=True)`
— current term, `enrollments` never consulted — and the sibling
readers use the writer's resolver too. Across a term boundary a
student enrolled in Fall-26 who uploads next term gets
`documents.offering_id` = the new offering, has no enrollment row for
it, and the tutor silently returned [] while the Library still listed
the file. The intersection bought no security either: `user_id` is
the access boundary on `documents` (#125), so dropping offerings can
only hide the student's OWN uploads. Widened to the union of both
resolvers, order-stable for the `in.(...)` list.
3. The empty-offering short-circuit was silent — no log, no metric,
indistinguishable from "this course has no materials", which is
exactly the failure mode the offering fix exists to remove. It logs
now, without a raw student id.
Also bounds the read. The select was unbounded while every returned row
gets AES-decrypted before Python truncates to `limit`, on the
latency-critical SSE path. The bound is a multiple of `limit`, not
`limit` itself: ranking happens after the fetch, so limiting to exactly
`limit` would silently turn "most relevant" into "most recent".
The new tests use a schema-faithful `table()` fake that rejects filter
columns `documents` does not have. The older mocks in that file accept
any filter and return a canned list, which is precisely how a query
against a non-existent column survived review.
… path
5092a83 narrowed `run_result.output` inside `stream_agent_turn`, but
routes/learn.py reads it in three more places and two of them run with
`message_history` in `run_kwargs` — so `.output` resolves out of the same
history-bearing message list, and a textless turn hands back the
PREVIOUS turn's assistant message: fully formed, non-blank, and
therefore invisible to the `if not reply.strip()` guard sitting right
below it.
That relocated the repeat rather than removing it, onto a path taking
MORE traffic: `_chat_turn_json` is both POST /api/learn/chat and the
streamed route's Rung-1 `nonstream_fallback`, which is exactly where the
streaming fix now sends textless turns. `_action_turn` is the third
reader and persists with `save_message` directly.
`new_messages()` excludes the history that was passed in, so joining the
TextParts of its model responses is exactly "what this turn said" — the
non-streaming twin of the `joined.strip()` check in chat_stream.py.
`_start_session_agent` passes `message_history=[]`, so it has no prior
message to resolve back to and keeps reading `.output` directly; the
comment there names why.
Also adds the missing coverage for the newly-reachable streamed branch.
Every tutor agent registers `apply_graph_update_tool` and
`update_mastery_tool`, so a model that ends its turn after tool calls
usually arrives with `deps.graph_updates` / `deps.mastery_changes`
already populated — the textless-WITH-writes shape, which lands on the
terminal `retryable: False` rung rather than Rung 1. That was untested.
The agent-run fakes had to become shape-faithful (tests/agent_run_fakes.py):
`SimpleNamespace(output=...)` has no `new_messages`, and a bare MagicMock
iterates EMPTY on it — which would have silently turned every mocked
turn into a "textless" one while the assertions still went green through
the 502 catch-all. That is the same class of blind spot as the mock that
accepted a filter on a column PostgREST does not have.
@Jose-Gael-Cruz-Lopez

Copy link
Copy Markdown
Member

Review fixes applied

Every outstanding finding on this PR (human review + CodeRabbit) has been addressed and pushed.

Major

  • Soft-deleted documents reached the tutor. Added deleted_at is.null to the documents select. This PR is what first exposed them (the query used to 400 into the silent degrade), so a deleted file's decrypted summary/concept_notes would have kept flowing into LLM context. Filter set is now byte-identical to fix(tutor): repair course-material retrieval, silence course-scope commentary, make quizzes practical #534's, making that overlap a trivial pick-either.
  • The stale run_result.output read was only narrowed on the streamed path._chat_turn_json is both a first-class route and the streamed route's Rung-1 fallback — i.e. exactly where this PR now sends textless turns — so the repeat was relocated, not removed. Added _new_run_text() (the non-streaming twin of the joined.strip() narrowing) and applied it to both history-bearing .output sites; the message_history=[] site is left alone with a comment saying why.
  • Offering resolution was narrower than the writer's. Now the union of user_offering_ids_for_course and resolve_offering, so a document uploaded across a term boundary is still found. Test covers the cross-term case.

Minor

  • The empty-offering short-circuit logs a warning instead of returning [] silently — the exact failure mode this PR exists to remove.
  • limit pushed into the query so rows are no longer AES-decrypted and then discarded.
  • Added the missing textless-turn-with-writes case (the likely shape, landing on the terminal retryable: False path).

Not fixed here

The red Backend (pytest) is the otel _IncludedRouter failure that is pre-existing on main; #563 carries the shim that fixes it.

Verificationruff check . clean · 1973 passed, 56 skipped

Fixes applied and verified locally against this branch head; each figure above is a command I ran, not an estimate.

@Jose-Gael-Cruz-Lopez

Copy link
Copy Markdown
Member

Verify against the real database before merging

I could not reach a live database while working on this — there are no credentials on this machine (only .env.example files) and the local Supabase stack needs a container runtime that wouldn't start. So everything below was verified statically, by replaying every migration in ledger order to build a schema model and checking this PR's DB access against it. That model found 0 schema mismatches here, and it is trustworthy enough to have independently reproduced the documents.course_id absence that caused #562/#534 — but it cannot see data, and it cannot see whether staging's ledger actually matches the repo.

These are the checks that need a real connection.

No schema change — but the behaviour change is data-dependent

Two fixes here only show their value against real rows, so they are worth confirming on staging.

1. How much was actually leaking

The documents read had no deleted_at filter. Before this PR the query 400'd and returned [], so nothing leaked; this PR is what makes it return rows, which is why the filter had to be added in the same change.

SELECTcount(*) FROM documents WHERE deleted_at IS NOT NULL;

That count is exactly how many soft-deleted documents the tutor would otherwise have decrypted into LLM context. If it is non-zero, this fix is load-bearing rather than theoretical.

2. The cross-term offering gap is real or it isn't

The offering set is now the union of user_offering_ids_for_course (enrollment-derived) and resolve_offering (the writer's resolver), because documents are written with the latter and read with the former.

-- documents sitting on an offering the owner has no enrollment row for:-- these are exactly the files the tutor could not see before this changeSELECTcount(*) FROM documents d
WHEREd.deleted_at IS NULLAND NOT EXISTS (
SELECT1FROM enrollments e
WHEREe.user_id=d.user_idANDe.offering_id=d.offering_id);

Non-zero means the divergence is already live, not just reachable at the next term boundary.

3. Sanity-check the filter set on the wire

The final query filters on exactly offering_id in (...), user_id eq, deleted_at is.null. Worth eyeballing one real request in logs to confirm no fourth filter crept in — this is the query whose column set was wrong for months without anyone noticing.

Static verification only — no live database was reachable from this environment. Schema model built by replaying backend/db/migrations/ in ledger order.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@Darkest-Teddy@Jose-Gael-Cruz-Lopez
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix(tutor): stop the tutor repeating itself, and let it actually read course materials by Darkest-Teddy · Pull Request #562 · SaplingLearn/Sapling · GitHub
Skip to content

fix(tutor): stop the tutor repeating itself, and let it actually read course materials - #562

Open
Darkest-Teddy wants to merge 5 commits into
mainfrom
fix/tutor-repeat-and-course-materials
Open

fix(tutor): stop the tutor repeating itself, and let it actually read course materials#562
Darkest-Teddy wants to merge 5 commits into
mainfrom
fix/tutor-repeat-and-course-materials

Conversation

@Darkest-Teddy

@Darkest-TeddyDarkest-Teddy commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator

Two independent bugs in the tutor chat, found while debugging "why does the AI repeat the message instead of responding to it". One commit each.

1. The tutor replayed its previous reply verbatim

stream_agent_turn preferred run_result.output over the streamed chunks:

reply=final_outputiffinal_outputisnotNoneelsejoined

.output resolves out of the run's message list, and that list includes message_history. So a turn whose model response carries no text part — the model ended its turn after tool calls — handed back the previous turn's assistant message. Fully formed and non-blank, so the blank-reply ladder immediately below never saw it. The route persisted it, and the student got a byte-identical copy of the last answer.

Confirmed in two live sessions two days apart: same sha256, same length, including a 1757-character reply reproduced exactly against a completely different question.

Fix: trust final_output only when this turn actually streamed text. Text always arrives as PartStart/PartDelta events, so "nothing streamed" means "this turn produced no text" — degrade through the existing blank-reply ladder rather than replaying history.

Evidence. Replaying a real session turn against gemini-2.5-flash-lite, the correlation is exact:

streamed token/replyoutcomecount
0repeated the prior answer2/8
>0answered correctly6/8

After the fix: 0 repeats in 10 runs.

What this does not fix: the model still ends ~40% of these turns without text. Those now take the Rung-1 JSON fallback (a real answer), or — if graph writes already landed — show the honest "interrupted, please retry". A silent wrong answer becomes a correct one or a visible error. Why the model does this at all is unexplored and worth a follow-up.

2. The tutor never read any course document

search_course_materials filtered documents on course_id. That table keys on offering_id (0025) and has no course_id column, so PostgREST 400s on every call — and the tool's deliberate degrade-silently except turned that into [].

The tutor answered from base knowledge alone, which presents as "it's generic about my class" rather than as a failure. Nothing was logged at the user's level.

Fix: resolve the abstract course to the user's offerings via academics.user_offering_ids_for_course, matching the convention that the API boundary keeps the abstract course while documents key on the offering. The #125 user_id scope is unchanged — documents stay user-scoped within a shared offering.

Tests

Both regression tests were written first and watched fail for the right reason:

  • test_textless_turn_never_replays_the_previous_turns_reply — failed with on_complete receiving the stale prior-turn text.
  • test_documents_are_fetched_by_offering_not_course_id — failed with column documents.course_id does not exist.

The second one needed a schema-faithful fake. The existing mocks accept any filter, which is precisely how bug 2 survived them. The two older search_course_materials test classes now stub the offering lookup, which became a real dependency of that function.

Full backend suite: 1999 passed, 56 skipped, exit 0. ruff check clean on all four files.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes

    • Course material searches now return documents associated with the user’s course offerings while maintaining access restrictions.
    • Prevented tool-only chat turns from displaying or saving stale assistant responses from earlier messages.
    • Ensured empty or non-text streaming responses use a fresh fallback reply.
  • Tests

    • Added coverage for offering-based material searches, access scoping, and stale-response prevention.

Darkest-Teddyand others added 2 commits August 13, 2026 22:21
`stream_agent_turn` preferred `run_result.output` over the streamed
chunks. That output resolves out of the run's message list, and that
list includes `message_history` — so a turn whose model response
carries no text part (the model ended its turn after tool calls) handed
back the PREVIOUS turn's assistant message: fully formed, non-blank, and
therefore invisible to the blank-reply ladder below it.
The route then persisted it, so the tutor answered a follow-up with a
byte-identical copy of its own last answer — same sha256, same length,
including a 1757-char reply reproduced verbatim against a completely
different question.
Trust `final_output` only when this turn actually streamed text. Text
always arrives as PartStart/PartDelta events, so "nothing streamed"
means "this turn produced no text": degrade through the existing
blank-reply ladder instead of replaying history.
Measured on gemini-2.5-flash-lite by replaying a real session turn: the
correlation is exact — `token/reply == 0` repeated (2/8), any streamed
text answered correctly (6/8). After the fix, 0 repeats in 10 runs.
Note this does not stop the model from ending a turn without text; those
turns now take the Rung-1 fallback or a visible interrupted-error rather
than a silent duplicate.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…n't exist
`search_course_materials` filtered `documents` on `course_id`. That
table keys on `offering_id` (0025) and has no `course_id` column, so
PostgREST returned 400 on every call — and the tool's deliberate
degrade-silently `except` turned that into `[]`.
Net effect: the tutor never read a single course document, with no error
surfaced anywhere. It answered from base knowledge alone, which reads as
"the tutor is generic about my class" rather than as a bug.
Resolve the abstract course to the user's offerings via
`academics.user_offering_ids_for_course`, per the convention that the
API boundary keeps the abstract course while documents key on the
offering. The #125 user_id scope is unchanged: documents stay
user-scoped WITHIN a shared offering.
The existing unit tests missed this because they mock `table` loosely
enough to accept any filter; the new test uses a schema-faithful fake
that rejects a column the table does not have, exactly as PostgREST
does. The two older test classes now stub the offering lookup, which is
a real dependency of this function for the first time.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@supabase

supabaseBot commented Aug 14, 2026

Copy link
Copy Markdown

This pull request has been ignored for the connected project ybgqdonkoqftwrmweuyv because there are no changes detected in supabase directory. You can change this behaviour in Project Integrations Settings ↗︎.


Preview Branches by Supabase.
Learn more about Supabase Branching ↗︎.

@coderabbitai

coderabbitaiBot commented Aug 14, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@Jose-Gael-Cruz-Lopez, you've reached your PR review limit, so we couldn't start this review.

Next review available in:8 minutes

Limit details: You’ve used the included review currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

Wait for the limit to reset, then comment @coderabbitai review or push new commits to the PR.

An organization admin can change what happens after included review limits in Billing.

How do review limits work?

CodeRabbit enforces per-developer PR review limits within each organization.

For paid Pro and Pro+ reviews, CodeRabbit uses a developer's included PR review attempts over the past 7 days to set the current hourly allowance. At typical activity levels, the full plan allowance applies. Higher sustained activity can lower the allowance until earlier attempts leave the 7-day window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 0bb76370-0c3c-4d13-a541-292b94564424

📥 Commits

Reviewing files that changed from the base of the PR and between b8aa904 and 4e2949d.

📒 Files selected for processing (8)
  • backend/agents/tools/chat_context.py
  • backend/routes/learn.py
  • backend/tests/agent_run_fakes.py
  • backend/tests/test_chat_context_tools.py
  • backend/tests/test_chat_stream.py
  • backend/tests/test_event_capture_seams.py
  • backend/tests/test_graph_tools_bugs.py
  • backend/tests/test_learn_routes.py

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 06d0ac13-1c32-40b4-a002-18d7e154c3c1

📥 Commits

Reviewing files that changed from the base of the PR and between 9b77819 and b8aa904.

📒 Files selected for processing (4)
  • backend/agents/tools/chat_context.py
  • backend/services/chat_stream.py
  • backend/tests/test_chat_context_tools.py
  • backend/tests/test_chat_stream.py

📝 Walkthrough

Walkthrough

The change scopes course-material searches to the user’s offerings and updates streamed-turn reply selection to avoid stale history output. Tests cover offering resolution, user scoping, document lookup, and tool-only turns.

Changes

Chat behavior corrections

Layer / File(s)Summary
Offering-scoped course material search
backend/agents/tools/chat_context.py, backend/tests/test_chat_context_tools.py
Course searches resolve user-specific offering IDs and filter documents by offering_id while retaining user_id scoping. Tests cover matching offerings and schema-valid document queries.
Textless streamed turn handling
backend/services/chat_stream.py, backend/tests/test_chat_stream.py
Textless turns use joined current-turn chunks instead of stale message-history output. Regression coverage checks streaming, persistence, callbacks, and nonstream fallback behavior.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Mergeability Score:⚪ Minimal · up to b8aa9

The changes address two localized tutor-chat defects, with regression coverage and passing checks reported; no actionable merge-blocking risk remains beyond normal review.

Possibly related PRs

Suggested reviewers:andresl230, jose-gael-cruz-lopez

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 37.50% which is insufficient. The required threshold is 80.00%.Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check nameStatusExplanation
Title check✅ PassedThe title clearly summarizes both primary tutor fixes: preventing repeated replies and enabling course-material retrieval.
Description check✅ PassedThe description clearly explains both bugs, their fixes, regression tests, test results, and reviewer-relevant limitations.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/tutor-repeat-and-course-materials

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Aug 14, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-staging4e2949dCommit Preview URL

Branch Preview URL
Aug 19 2026, 09:02 PM

@Jose-Gael-Cruz-Lopez

Copy link
Copy Markdown
Member

Code review — tutor repetition + course-material retrieval

Two genuinely independent bugs, both diagnosed properly, both with regression tests written first. The documents.course_id finding is correct — migration 0025_study_integrity.sql recreates documents with offering_id TEXT NOT NULL REFERENCES course_offerings(id) and no course_id column, so the old filter really did 400 into the tool's degrade-silently except. The chat_stream guard is a one-line narrowing that is safe for this agent (chat_tutor is output_type=str, agents/chat_tutor.py:174, so its reply always arrives as text parts — there is no output-tool channel the guard could discard). Three things need attention before merge: the now-working documents query no longer honours the soft-delete convention every other reader follows, the offering resolution it picked can only shrink the user's own document set relative to the writer, and the identical stale-run_result.output read on the JSON path — which is exactly the fallback this PR now routes textless turns into — was left unguarded, so the repeat symptom can still be produced.

Findings

P0

[P0] CI is red — Backend (pytest) fails (pre-existing on main, not a regression from this PR) — run 31763538674

FAILED tests/test_quiz_preflight_a.py::TestQuizErrorEnvelope::test_method_not_allowed_gets_generic_code - AttributeError: '_IncludedRouter' object has no attribute 'path'
1 failed, 1965 passed, 56 skipped, 13 warnings in 42.25s
 if match == Match.PARTIAL:
> route = starlette_route.path
^^^^^^^^^^^^^^^^^^^^
E AttributeError: '_IncludedRouter' object has no attribute 'path'
/opt/hostedtoolcache/Python/3.13.14/x64/lib/python3.13/site-packages/opentelemetry/instrumentation/fastapi/__init__.py:495: AttributeError

Not this PR's fault. The identical failure is on main — run 31975087459 (4f5a7f90, "Merge pull request #524 from SaplingLearn/feat/landing-v5-port"): same test, same AttributeError, 1 failed, 1963 passed, 56 skipped. It is dependency drift in opentelemetry-instrumentation-fastapi._get_route_details, which assumes every Match.PARTIAL route object has .path; FastAPI's _IncludedRouter does not. None of this PR's four files touch routing or quiz. It still blocks the merge queue, so it needs a pin/fix on main first — worth noting because the PR body claims "Full backend suite: 1999 passed, 56 skipped, exit 0", which does not match CI.

P1

[P1] Soft-deleted documents now reach the tutorbackend/agents/tools/chat_context.py:154-164

return (
table("documents").select(
"id,file_name,summary,concept_notes",
filters={
# #125 user scope is preserved: documents are# user-scoped WITHIN a shared offering."offering_id": f"in.({','.join(offering_ids)})",
"user_id": f"eq.{user_id}",
},
order="created_at.desc",
)

documents is soft-deleted, not hard-deleted — routes/documents.py:288-293: "Soft delete (0025): stamp deleted_at; reads filter it out." Every other reader honours that: routes/documents.py:241, routes/study_guide.py:137, routes/flashcards.py:153 and :163, routes/documents.py:1230 all pass "deleted_at": "is.null". This query does not. Before this PR that was invisible because the query 400'd and returned [] unconditionally; this PR is what makes it return rows, so it is this PR that first exposes deleted documents. Concretely: a student deletes a document from their Library, documents.deleted_at is stamped, the row stays, and the tutor keeps decrypting its summary + concept_notes into the LLM context on every search_course_materials call. Nothing in backend/agents/ or the tutor tests references deleted_at, so no test catches it.

[P1] The stale run_result.output read is unguarded on the JSON path — the very fallback this PR routes textless turns intobackend/routes/learn.py:636-654

result=record_agent_usage(
awaitagent.run(user_message, **run_kwargs),
feature="chat_tutor", task="chat_tutor", user_id=deps.user_id,
)
reply=result.output# str — chat_tutor agents return plain Markdown.ifnotreply.strip():

_prepare_chat_run puts "message_history": message_history into run_kwargs (routes/learn.py:577), so result.output here resolves out of the same history-bearing message list the PR indicts. The only guard is not reply.strip() — and the PR's whole point is that a replayed prior turn is "fully formed, non-blank, and therefore invisible to the blank-reply ladder". _chat_turn_json is both a first-class route (POST /api/learn/chat, routes/learn.py:745-751) and the streamed route's Rung-1 nonstream_fallback (routes/learn.py:823). So after this change a textless streamed turn correctly refuses the stale text, degrades to _chat_turn_json, and if that re-run is also textless — the PR body measures ~40% — it takes the prior assistant message from message_history, persists it via save_message (routes/learn.py:727) and returns it. The repeat is not eliminated, it is moved one rung down, onto a path this PR sends more traffic to.

[P1] Offering resolution is narrower than the writer's and than every sibling reader'sbackend/agents/tools/chat_context.py:151-153

offering_ids=user_offering_ids_for_course(user_id, course_id)
ifnotoffering_ids:
return []

user_offering_ids_for_course (services/academics.py:185-200) intersects the course's offerings with the user's enrollments rows. Documents are written with a different resolver — offering_id = resolve_offering(course_id, create=True) at routes/documents.py:572 and :710 — which picks the current-term offering (creating it if absent, else falling back to any offering of the course) and never consults enrollments. The two sibling readers use the writer's resolver too: routes/study_guide.py:136 and routes/flashcards.py:144 filter on resolve_offering(...). Divergence is reachable across a term boundary: a student enrolled via routes/onboarding.py:95-112 in Fall-26's offering who uploads in the next term gets documents.offering_id = the new term's offering, has no enrollment row for it, and the tutor silently returns [] — while the Library still lists the file (routes/documents.py:239-241 filters on user_id only). Note the enrollment intersection buys no safety: user_id is already the security boundary (your own #125 comment says so), so this filter can only remove the user's own documents.

P2

[P2] The empty-offering short-circuit is silent — the exact failure mode this PR exists to removebackend/agents/tools/chat_context.py:152-153

ifnotoffering_ids:
return []

The PR body's own indictment of the old bug is "The tutor answered from base knowledge alone, which presents as 'it's generic about my class' rather than as a failure. Nothing was logged at the user's level." This early return reproduces that precisely: no log, no metric, indistinguishable from "this course genuinely has no materials". The except below it does logger.exception; this branch gets nothing. Given the P1 above makes it reachable, a log line here is what turns the next occurrence of this class of bug into a five-minute diagnosis instead of a two-month one.

P3

[P3] Two extra uncached PostgREST round-trips per tool call, in the streaming hot pathbackend/services/academics.py:191-200 via chat_context.py:151

offs=table("course_offerings").select(
"id", filters={"course_id": f"eq.{course_id}"}
) or []
off_ids= {o["id"] foroinoffs}
ifnotoff_ids:
return []
enr=table("enrollments").select(
"offering_id", filters={"user_id": f"eq.{user_id}"}
) or []

user_offering_ids_for_course is deliberately uncached and the second select pulls all of the user's enrollments across every course. search_course_materials is an LLM-callable tool that can fire more than once per turn, on the latency-critical SSE path — so this is +2 sequential round-trips per call. The documents select that follows is also unbounded (no limit, order="created_at.desc" only) and every returned row is AES-decrypted before the list is truncated to limit (5) in Python at chat_context.py:213. Harmless today; both become real once a course accumulates documents.

[P3] No test pins the newly-reachable textless-turn-with-writes branchbackend/tests/test_chat_stream.py:710-750

agent=FakeAgent([
FunctionToolCallEvent("read_graph_neighborhood"),
FunctionToolResultEvent(), # no writes landedAgentRunResultEvent(PRIOR), # stale: from message_history
])

The new test only covers the no-writes path (Rung-1 fallback). But the trigger it describes is "the model ended its turn after tool calls", and every tutor agent registers apply_graph_update_tool and update_mastery_tool (agents/chat_tutor.py:161-162), which populate deps.graph_updates / deps.mastery_changes (agents/tools/graph.py:122, :174). So the likely shape of a textless turn is textless-with-writes, which lands on the terminal retryable: False error at chat_stream.py:362-368 — a user-visible dead end, and the branch with no new coverage. Worth one more FakeAgent case with an on_fire write.

What's good

  • The root-cause work is real and checkable: 0025_study_integrity.sql:15-31 confirms documents has offering_id NOT NULL and no course_id, exactly as claimed.
  • The new TestSearchCourseMaterialsOfferingScope fake raises on a course_id filter instead of accepting any filter — the right response to "the existing mocks accept any filter, which is precisely how bug 2 survived them".
  • The chat_stream.py guard is correctly scoped: chat_tutor is output_type=str (agents/chat_tutor.py:174), so there is no output-tool channel whose reply the joined.strip() condition could throw away. Per the Sapling Engineering Style Guide the new sync user_offering_ids_for_course call also stays off the event loop — it sits inside _fetch, which runs under asyncio.to_thread (chat_context.py:174).

Verdict: request changes — the soft-delete regression and the unguarded result.output on the JSON fallback path both need fixing before merge; the CI failure is pre-existing on main and should be unblocked there.


Review-only pass — no code changed and nothing fixed. Conventions checked against the Canopy live docs (Engineering Style Guide, Architecture, Infrastructure, Backend & AI Agents). Every finding cites a snippet re-read at this PR's head SHA; severity: P0 blocker · P1 major · P2 minor · P3 nit.

…le degrade
Three findings on the course-materials read, all made reachable by the
offering fix in b8aa904 — before it the query 400'd and returned [] on
every call, so none of them could be observed.
1. `documents` is soft-deleted. routes/documents.py stamps `deleted_at`
and every other reader filters on it (study_guide.py, flashcards.py);
this query did not, so a file the student deleted from their Library
kept getting its `summary` + `concept_notes` decrypted into LLM
context forever. Adds `deleted_at is.null`, which also makes the
filter set identical to PR #534's fix of the same bug — the eventual
merge conflict is now trivial.
2. `user_offering_ids_for_course` is narrower than the WRITER.
Documents are written with `resolve_offering(course_id, create=True)`
— current term, `enrollments` never consulted — and the sibling
readers use the writer's resolver too. Across a term boundary a
student enrolled in Fall-26 who uploads next term gets
`documents.offering_id` = the new offering, has no enrollment row for
it, and the tutor silently returned [] while the Library still listed
the file. The intersection bought no security either: `user_id` is
the access boundary on `documents` (#125), so dropping offerings can
only hide the student's OWN uploads. Widened to the union of both
resolvers, order-stable for the `in.(...)` list.
3. The empty-offering short-circuit was silent — no log, no metric,
indistinguishable from "this course has no materials", which is
exactly the failure mode the offering fix exists to remove. It logs
now, without a raw student id.
Also bounds the read. The select was unbounded while every returned row
gets AES-decrypted before Python truncates to `limit`, on the
latency-critical SSE path. The bound is a multiple of `limit`, not
`limit` itself: ranking happens after the fetch, so limiting to exactly
`limit` would silently turn "most relevant" into "most recent".
The new tests use a schema-faithful `table()` fake that rejects filter
columns `documents` does not have. The older mocks in that file accept
any filter and return a canned list, which is precisely how a query
against a non-existent column survived review.
… path
5092a83 narrowed `run_result.output` inside `stream_agent_turn`, but
routes/learn.py reads it in three more places and two of them run with
`message_history` in `run_kwargs` — so `.output` resolves out of the same
history-bearing message list, and a textless turn hands back the
PREVIOUS turn's assistant message: fully formed, non-blank, and
therefore invisible to the `if not reply.strip()` guard sitting right
below it.
That relocated the repeat rather than removing it, onto a path taking
MORE traffic: `_chat_turn_json` is both POST /api/learn/chat and the
streamed route's Rung-1 `nonstream_fallback`, which is exactly where the
streaming fix now sends textless turns. `_action_turn` is the third
reader and persists with `save_message` directly.
`new_messages()` excludes the history that was passed in, so joining the
TextParts of its model responses is exactly "what this turn said" — the
non-streaming twin of the `joined.strip()` check in chat_stream.py.
`_start_session_agent` passes `message_history=[]`, so it has no prior
message to resolve back to and keeps reading `.output` directly; the
comment there names why.
Also adds the missing coverage for the newly-reachable streamed branch.
Every tutor agent registers `apply_graph_update_tool` and
`update_mastery_tool`, so a model that ends its turn after tool calls
usually arrives with `deps.graph_updates` / `deps.mastery_changes`
already populated — the textless-WITH-writes shape, which lands on the
terminal `retryable: False` rung rather than Rung 1. That was untested.
The agent-run fakes had to become shape-faithful (tests/agent_run_fakes.py):
`SimpleNamespace(output=...)` has no `new_messages`, and a bare MagicMock
iterates EMPTY on it — which would have silently turned every mocked
turn into a "textless" one while the assertions still went green through
the 502 catch-all. That is the same class of blind spot as the mock that
accepted a filter on a column PostgREST does not have.
@Jose-Gael-Cruz-Lopez

Copy link
Copy Markdown
Member

Review fixes applied

Every outstanding finding on this PR (human review + CodeRabbit) has been addressed and pushed.

Major

  • Soft-deleted documents reached the tutor. Added deleted_at is.null to the documents select. This PR is what first exposed them (the query used to 400 into the silent degrade), so a deleted file's decrypted summary/concept_notes would have kept flowing into LLM context. Filter set is now byte-identical to fix(tutor): repair course-material retrieval, silence course-scope commentary, make quizzes practical #534's, making that overlap a trivial pick-either.
  • The stale run_result.output read was only narrowed on the streamed path._chat_turn_json is both a first-class route and the streamed route's Rung-1 fallback — i.e. exactly where this PR now sends textless turns — so the repeat was relocated, not removed. Added _new_run_text() (the non-streaming twin of the joined.strip() narrowing) and applied it to both history-bearing .output sites; the message_history=[] site is left alone with a comment saying why.
  • Offering resolution was narrower than the writer's. Now the union of user_offering_ids_for_course and resolve_offering, so a document uploaded across a term boundary is still found. Test covers the cross-term case.

Minor

  • The empty-offering short-circuit logs a warning instead of returning [] silently — the exact failure mode this PR exists to remove.
  • limit pushed into the query so rows are no longer AES-decrypted and then discarded.
  • Added the missing textless-turn-with-writes case (the likely shape, landing on the terminal retryable: False path).

Not fixed here

The red Backend (pytest) is the otel _IncludedRouter failure that is pre-existing on main; #563 carries the shim that fixes it.

Verificationruff check . clean · 1973 passed, 56 skipped

Fixes applied and verified locally against this branch head; each figure above is a command I ran, not an estimate.

@Jose-Gael-Cruz-Lopez

Copy link
Copy Markdown
Member

Verify against the real database before merging

I could not reach a live database while working on this — there are no credentials on this machine (only .env.example files) and the local Supabase stack needs a container runtime that wouldn't start. So everything below was verified statically, by replaying every migration in ledger order to build a schema model and checking this PR's DB access against it. That model found 0 schema mismatches here, and it is trustworthy enough to have independently reproduced the documents.course_id absence that caused #562/#534 — but it cannot see data, and it cannot see whether staging's ledger actually matches the repo.

These are the checks that need a real connection.

No schema change — but the behaviour change is data-dependent

Two fixes here only show their value against real rows, so they are worth confirming on staging.

1. How much was actually leaking

The documents read had no deleted_at filter. Before this PR the query 400'd and returned [], so nothing leaked; this PR is what makes it return rows, which is why the filter had to be added in the same change.

SELECTcount(*) FROM documents WHERE deleted_at IS NOT NULL;

That count is exactly how many soft-deleted documents the tutor would otherwise have decrypted into LLM context. If it is non-zero, this fix is load-bearing rather than theoretical.

2. The cross-term offering gap is real or it isn't

The offering set is now the union of user_offering_ids_for_course (enrollment-derived) and resolve_offering (the writer's resolver), because documents are written with the latter and read with the former.

-- documents sitting on an offering the owner has no enrollment row for:-- these are exactly the files the tutor could not see before this changeSELECTcount(*) FROM documents d
WHEREd.deleted_at IS NULLAND NOT EXISTS (
SELECT1FROM enrollments e
WHEREe.user_id=d.user_idANDe.offering_id=d.offering_id);

Non-zero means the divergence is already live, not just reachable at the next term boundary.

3. Sanity-check the filter set on the wire

The final query filters on exactly offering_id in (...), user_id eq, deleted_at is.null. Worth eyeballing one real request in logs to confirm no fourth filter crept in — this is the query whose column set was wrong for months without anyone noticing.

Static verification only — no live database was reachable from this environment. Schema model built by replaying backend/db/migrations/ in ledger order.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@Darkest-Teddy@Jose-Gael-Cruz-Lopez
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' fix(tutor): stop the tutor repeating itself, and let it actually read course materials by Darkest-Teddy · Pull Request #562 · SaplingLearn/Sapling · GitHub
Skip to content

fix(tutor): stop the tutor repeating itself, and let it actually read course materials - #562

Open
Darkest-Teddy wants to merge 5 commits into
mainfrom
fix/tutor-repeat-and-course-materials
Open

fix(tutor): stop the tutor repeating itself, and let it actually read course materials#562
Darkest-Teddy wants to merge 5 commits into
mainfrom
fix/tutor-repeat-and-course-materials

Conversation

@Darkest-Teddy

@Darkest-TeddyDarkest-Teddy commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator

Two independent bugs in the tutor chat, found while debugging "why does the AI repeat the message instead of responding to it". One commit each.

1. The tutor replayed its previous reply verbatim

stream_agent_turn preferred run_result.output over the streamed chunks:

reply=final_outputiffinal_outputisnotNoneelsejoined

.output resolves out of the run's message list, and that list includes message_history. So a turn whose model response carries no text part — the model ended its turn after tool calls — handed back the previous turn's assistant message. Fully formed and non-blank, so the blank-reply ladder immediately below never saw it. The route persisted it, and the student got a byte-identical copy of the last answer.

Confirmed in two live sessions two days apart: same sha256, same length, including a 1757-character reply reproduced exactly against a completely different question.

Fix: trust final_output only when this turn actually streamed text. Text always arrives as PartStart/PartDelta events, so "nothing streamed" means "this turn produced no text" — degrade through the existing blank-reply ladder rather than replaying history.

Evidence. Replaying a real session turn against gemini-2.5-flash-lite, the correlation is exact:

streamed token/replyoutcomecount
0repeated the prior answer2/8
>0answered correctly6/8

After the fix: 0 repeats in 10 runs.

What this does not fix: the model still ends ~40% of these turns without text. Those now take the Rung-1 JSON fallback (a real answer), or — if graph writes already landed — show the honest "interrupted, please retry". A silent wrong answer becomes a correct one or a visible error. Why the model does this at all is unexplored and worth a follow-up.

2. The tutor never read any course document

search_course_materials filtered documents on course_id. That table keys on offering_id (0025) and has no course_id column, so PostgREST 400s on every call — and the tool's deliberate degrade-silently except turned that into [].

The tutor answered from base knowledge alone, which presents as "it's generic about my class" rather than as a failure. Nothing was logged at the user's level.

Fix: resolve the abstract course to the user's offerings via academics.user_offering_ids_for_course, matching the convention that the API boundary keeps the abstract course while documents key on the offering. The #125 user_id scope is unchanged — documents stay user-scoped within a shared offering.

Tests

Both regression tests were written first and watched fail for the right reason:

  • test_textless_turn_never_replays_the_previous_turns_reply — failed with on_complete receiving the stale prior-turn text.
  • test_documents_are_fetched_by_offering_not_course_id — failed with column documents.course_id does not exist.

The second one needed a schema-faithful fake. The existing mocks accept any filter, which is precisely how bug 2 survived them. The two older search_course_materials test classes now stub the offering lookup, which became a real dependency of that function.

Full backend suite: 1999 passed, 56 skipped, exit 0. ruff check clean on all four files.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes

    • Course material searches now return documents associated with the user’s course offerings while maintaining access restrictions.
    • Prevented tool-only chat turns from displaying or saving stale assistant responses from earlier messages.
    • Ensured empty or non-text streaming responses use a fresh fallback reply.
  • Tests

    • Added coverage for offering-based material searches, access scoping, and stale-response prevention.

Darkest-Teddyand others added 2 commits August 13, 2026 22:21
`stream_agent_turn` preferred `run_result.output` over the streamed
chunks. That output resolves out of the run's message list, and that
list includes `message_history` — so a turn whose model response
carries no text part (the model ended its turn after tool calls) handed
back the PREVIOUS turn's assistant message: fully formed, non-blank, and
therefore invisible to the blank-reply ladder below it.
The route then persisted it, so the tutor answered a follow-up with a
byte-identical copy of its own last answer — same sha256, same length,
including a 1757-char reply reproduced verbatim against a completely
different question.
Trust `final_output` only when this turn actually streamed text. Text
always arrives as PartStart/PartDelta events, so "nothing streamed"
means "this turn produced no text": degrade through the existing
blank-reply ladder instead of replaying history.
Measured on gemini-2.5-flash-lite by replaying a real session turn: the
correlation is exact — `token/reply == 0` repeated (2/8), any streamed
text answered correctly (6/8). After the fix, 0 repeats in 10 runs.
Note this does not stop the model from ending a turn without text; those
turns now take the Rung-1 fallback or a visible interrupted-error rather
than a silent duplicate.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…n't exist
`search_course_materials` filtered `documents` on `course_id`. That
table keys on `offering_id` (0025) and has no `course_id` column, so
PostgREST returned 400 on every call — and the tool's deliberate
degrade-silently `except` turned that into `[]`.
Net effect: the tutor never read a single course document, with no error
surfaced anywhere. It answered from base knowledge alone, which reads as
"the tutor is generic about my class" rather than as a bug.
Resolve the abstract course to the user's offerings via
`academics.user_offering_ids_for_course`, per the convention that the
API boundary keeps the abstract course while documents key on the
offering. The #125 user_id scope is unchanged: documents stay
user-scoped WITHIN a shared offering.
The existing unit tests missed this because they mock `table` loosely
enough to accept any filter; the new test uses a schema-faithful fake
that rejects a column the table does not have, exactly as PostgREST
does. The two older test classes now stub the offering lookup, which is
a real dependency of this function for the first time.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@supabase

supabaseBot commented Aug 14, 2026

Copy link
Copy Markdown

This pull request has been ignored for the connected project ybgqdonkoqftwrmweuyv because there are no changes detected in supabase directory. You can change this behaviour in Project Integrations Settings ↗︎.


Preview Branches by Supabase.
Learn more about Supabase Branching ↗︎.

@coderabbitai

coderabbitaiBot commented Aug 14, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@Jose-Gael-Cruz-Lopez, you've reached your PR review limit, so we couldn't start this review.

Next review available in:8 minutes

Limit details: You’ve used the included review currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

Wait for the limit to reset, then comment @coderabbitai review or push new commits to the PR.

An organization admin can change what happens after included review limits in Billing.

How do review limits work?

CodeRabbit enforces per-developer PR review limits within each organization.

For paid Pro and Pro+ reviews, CodeRabbit uses a developer's included PR review attempts over the past 7 days to set the current hourly allowance. At typical activity levels, the full plan allowance applies. Higher sustained activity can lower the allowance until earlier attempts leave the 7-day window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 0bb76370-0c3c-4d13-a541-292b94564424

📥 Commits

Reviewing files that changed from the base of the PR and between b8aa904 and 4e2949d.

📒 Files selected for processing (8)
  • backend/agents/tools/chat_context.py
  • backend/routes/learn.py
  • backend/tests/agent_run_fakes.py
  • backend/tests/test_chat_context_tools.py
  • backend/tests/test_chat_stream.py
  • backend/tests/test_event_capture_seams.py
  • backend/tests/test_graph_tools_bugs.py
  • backend/tests/test_learn_routes.py

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 06d0ac13-1c32-40b4-a002-18d7e154c3c1

📥 Commits

Reviewing files that changed from the base of the PR and between 9b77819 and b8aa904.

📒 Files selected for processing (4)
  • backend/agents/tools/chat_context.py
  • backend/services/chat_stream.py
  • backend/tests/test_chat_context_tools.py
  • backend/tests/test_chat_stream.py

📝 Walkthrough

Walkthrough

The change scopes course-material searches to the user’s offerings and updates streamed-turn reply selection to avoid stale history output. Tests cover offering resolution, user scoping, document lookup, and tool-only turns.

Changes

Chat behavior corrections

Layer / File(s)Summary
Offering-scoped course material search
backend/agents/tools/chat_context.py, backend/tests/test_chat_context_tools.py
Course searches resolve user-specific offering IDs and filter documents by offering_id while retaining user_id scoping. Tests cover matching offerings and schema-valid document queries.
Textless streamed turn handling
backend/services/chat_stream.py, backend/tests/test_chat_stream.py
Textless turns use joined current-turn chunks instead of stale message-history output. Regression coverage checks streaming, persistence, callbacks, and nonstream fallback behavior.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Mergeability Score:⚪ Minimal · up to b8aa9

The changes address two localized tutor-chat defects, with regression coverage and passing checks reported; no actionable merge-blocking risk remains beyond normal review.

Possibly related PRs

Suggested reviewers:andresl230, jose-gael-cruz-lopez

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 37.50% which is insufficient. The required threshold is 80.00%.Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check nameStatusExplanation
Title check✅ PassedThe title clearly summarizes both primary tutor fixes: preventing repeated replies and enabling course-material retrieval.
Description check✅ PassedThe description clearly explains both bugs, their fixes, regression tests, test results, and reviewer-relevant limitations.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/tutor-repeat-and-course-materials

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Aug 14, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-staging4e2949dCommit Preview URL

Branch Preview URL
Aug 19 2026, 09:02 PM

@Jose-Gael-Cruz-Lopez

Copy link
Copy Markdown
Member

Code review — tutor repetition + course-material retrieval

Two genuinely independent bugs, both diagnosed properly, both with regression tests written first. The documents.course_id finding is correct — migration 0025_study_integrity.sql recreates documents with offering_id TEXT NOT NULL REFERENCES course_offerings(id) and no course_id column, so the old filter really did 400 into the tool's degrade-silently except. The chat_stream guard is a one-line narrowing that is safe for this agent (chat_tutor is output_type=str, agents/chat_tutor.py:174, so its reply always arrives as text parts — there is no output-tool channel the guard could discard). Three things need attention before merge: the now-working documents query no longer honours the soft-delete convention every other reader follows, the offering resolution it picked can only shrink the user's own document set relative to the writer, and the identical stale-run_result.output read on the JSON path — which is exactly the fallback this PR now routes textless turns into — was left unguarded, so the repeat symptom can still be produced.

Findings

P0

[P0] CI is red — Backend (pytest) fails (pre-existing on main, not a regression from this PR) — run 31763538674

FAILED tests/test_quiz_preflight_a.py::TestQuizErrorEnvelope::test_method_not_allowed_gets_generic_code - AttributeError: '_IncludedRouter' object has no attribute 'path'
1 failed, 1965 passed, 56 skipped, 13 warnings in 42.25s
 if match == Match.PARTIAL:
> route = starlette_route.path
^^^^^^^^^^^^^^^^^^^^
E AttributeError: '_IncludedRouter' object has no attribute 'path'
/opt/hostedtoolcache/Python/3.13.14/x64/lib/python3.13/site-packages/opentelemetry/instrumentation/fastapi/__init__.py:495: AttributeError

Not this PR's fault. The identical failure is on main — run 31975087459 (4f5a7f90, "Merge pull request #524 from SaplingLearn/feat/landing-v5-port"): same test, same AttributeError, 1 failed, 1963 passed, 56 skipped. It is dependency drift in opentelemetry-instrumentation-fastapi._get_route_details, which assumes every Match.PARTIAL route object has .path; FastAPI's _IncludedRouter does not. None of this PR's four files touch routing or quiz. It still blocks the merge queue, so it needs a pin/fix on main first — worth noting because the PR body claims "Full backend suite: 1999 passed, 56 skipped, exit 0", which does not match CI.

P1

[P1] Soft-deleted documents now reach the tutorbackend/agents/tools/chat_context.py:154-164

return (
table("documents").select(
"id,file_name,summary,concept_notes",
filters={
# #125 user scope is preserved: documents are# user-scoped WITHIN a shared offering."offering_id": f"in.({','.join(offering_ids)})",
"user_id": f"eq.{user_id}",
},
order="created_at.desc",
)

documents is soft-deleted, not hard-deleted — routes/documents.py:288-293: "Soft delete (0025): stamp deleted_at; reads filter it out." Every other reader honours that: routes/documents.py:241, routes/study_guide.py:137, routes/flashcards.py:153 and :163, routes/documents.py:1230 all pass "deleted_at": "is.null". This query does not. Before this PR that was invisible because the query 400'd and returned [] unconditionally; this PR is what makes it return rows, so it is this PR that first exposes deleted documents. Concretely: a student deletes a document from their Library, documents.deleted_at is stamped, the row stays, and the tutor keeps decrypting its summary + concept_notes into the LLM context on every search_course_materials call. Nothing in backend/agents/ or the tutor tests references deleted_at, so no test catches it.

[P1] The stale run_result.output read is unguarded on the JSON path — the very fallback this PR routes textless turns intobackend/routes/learn.py:636-654

result=record_agent_usage(
awaitagent.run(user_message, **run_kwargs),
feature="chat_tutor", task="chat_tutor", user_id=deps.user_id,
)
reply=result.output# str — chat_tutor agents return plain Markdown.ifnotreply.strip():

_prepare_chat_run puts "message_history": message_history into run_kwargs (routes/learn.py:577), so result.output here resolves out of the same history-bearing message list the PR indicts. The only guard is not reply.strip() — and the PR's whole point is that a replayed prior turn is "fully formed, non-blank, and therefore invisible to the blank-reply ladder". _chat_turn_json is both a first-class route (POST /api/learn/chat, routes/learn.py:745-751) and the streamed route's Rung-1 nonstream_fallback (routes/learn.py:823). So after this change a textless streamed turn correctly refuses the stale text, degrades to _chat_turn_json, and if that re-run is also textless — the PR body measures ~40% — it takes the prior assistant message from message_history, persists it via save_message (routes/learn.py:727) and returns it. The repeat is not eliminated, it is moved one rung down, onto a path this PR sends more traffic to.

[P1] Offering resolution is narrower than the writer's and than every sibling reader'sbackend/agents/tools/chat_context.py:151-153

offering_ids=user_offering_ids_for_course(user_id, course_id)
ifnotoffering_ids:
return []

user_offering_ids_for_course (services/academics.py:185-200) intersects the course's offerings with the user's enrollments rows. Documents are written with a different resolver — offering_id = resolve_offering(course_id, create=True) at routes/documents.py:572 and :710 — which picks the current-term offering (creating it if absent, else falling back to any offering of the course) and never consults enrollments. The two sibling readers use the writer's resolver too: routes/study_guide.py:136 and routes/flashcards.py:144 filter on resolve_offering(...). Divergence is reachable across a term boundary: a student enrolled via routes/onboarding.py:95-112 in Fall-26's offering who uploads in the next term gets documents.offering_id = the new term's offering, has no enrollment row for it, and the tutor silently returns [] — while the Library still lists the file (routes/documents.py:239-241 filters on user_id only). Note the enrollment intersection buys no safety: user_id is already the security boundary (your own #125 comment says so), so this filter can only remove the user's own documents.

P2

[P2] The empty-offering short-circuit is silent — the exact failure mode this PR exists to removebackend/agents/tools/chat_context.py:152-153

ifnotoffering_ids:
return []

The PR body's own indictment of the old bug is "The tutor answered from base knowledge alone, which presents as 'it's generic about my class' rather than as a failure. Nothing was logged at the user's level." This early return reproduces that precisely: no log, no metric, indistinguishable from "this course genuinely has no materials". The except below it does logger.exception; this branch gets nothing. Given the P1 above makes it reachable, a log line here is what turns the next occurrence of this class of bug into a five-minute diagnosis instead of a two-month one.

P3

[P3] Two extra uncached PostgREST round-trips per tool call, in the streaming hot pathbackend/services/academics.py:191-200 via chat_context.py:151

offs=table("course_offerings").select(
"id", filters={"course_id": f"eq.{course_id}"}
) or []
off_ids= {o["id"] foroinoffs}
ifnotoff_ids:
return []
enr=table("enrollments").select(
"offering_id", filters={"user_id": f"eq.{user_id}"}
) or []

user_offering_ids_for_course is deliberately uncached and the second select pulls all of the user's enrollments across every course. search_course_materials is an LLM-callable tool that can fire more than once per turn, on the latency-critical SSE path — so this is +2 sequential round-trips per call. The documents select that follows is also unbounded (no limit, order="created_at.desc" only) and every returned row is AES-decrypted before the list is truncated to limit (5) in Python at chat_context.py:213. Harmless today; both become real once a course accumulates documents.

[P3] No test pins the newly-reachable textless-turn-with-writes branchbackend/tests/test_chat_stream.py:710-750

agent=FakeAgent([
FunctionToolCallEvent("read_graph_neighborhood"),
FunctionToolResultEvent(), # no writes landedAgentRunResultEvent(PRIOR), # stale: from message_history
])

The new test only covers the no-writes path (Rung-1 fallback). But the trigger it describes is "the model ended its turn after tool calls", and every tutor agent registers apply_graph_update_tool and update_mastery_tool (agents/chat_tutor.py:161-162), which populate deps.graph_updates / deps.mastery_changes (agents/tools/graph.py:122, :174). So the likely shape of a textless turn is textless-with-writes, which lands on the terminal retryable: False error at chat_stream.py:362-368 — a user-visible dead end, and the branch with no new coverage. Worth one more FakeAgent case with an on_fire write.

What's good

  • The root-cause work is real and checkable: 0025_study_integrity.sql:15-31 confirms documents has offering_id NOT NULL and no course_id, exactly as claimed.
  • The new TestSearchCourseMaterialsOfferingScope fake raises on a course_id filter instead of accepting any filter — the right response to "the existing mocks accept any filter, which is precisely how bug 2 survived them".
  • The chat_stream.py guard is correctly scoped: chat_tutor is output_type=str (agents/chat_tutor.py:174), so there is no output-tool channel whose reply the joined.strip() condition could throw away. Per the Sapling Engineering Style Guide the new sync user_offering_ids_for_course call also stays off the event loop — it sits inside _fetch, which runs under asyncio.to_thread (chat_context.py:174).

Verdict: request changes — the soft-delete regression and the unguarded result.output on the JSON fallback path both need fixing before merge; the CI failure is pre-existing on main and should be unblocked there.


Review-only pass — no code changed and nothing fixed. Conventions checked against the Canopy live docs (Engineering Style Guide, Architecture, Infrastructure, Backend & AI Agents). Every finding cites a snippet re-read at this PR's head SHA; severity: P0 blocker · P1 major · P2 minor · P3 nit.

…le degrade
Three findings on the course-materials read, all made reachable by the
offering fix in b8aa904 — before it the query 400'd and returned [] on
every call, so none of them could be observed.
1. `documents` is soft-deleted. routes/documents.py stamps `deleted_at`
and every other reader filters on it (study_guide.py, flashcards.py);
this query did not, so a file the student deleted from their Library
kept getting its `summary` + `concept_notes` decrypted into LLM
context forever. Adds `deleted_at is.null`, which also makes the
filter set identical to PR #534's fix of the same bug — the eventual
merge conflict is now trivial.
2. `user_offering_ids_for_course` is narrower than the WRITER.
Documents are written with `resolve_offering(course_id, create=True)`
— current term, `enrollments` never consulted — and the sibling
readers use the writer's resolver too. Across a term boundary a
student enrolled in Fall-26 who uploads next term gets
`documents.offering_id` = the new offering, has no enrollment row for
it, and the tutor silently returned [] while the Library still listed
the file. The intersection bought no security either: `user_id` is
the access boundary on `documents` (#125), so dropping offerings can
only hide the student's OWN uploads. Widened to the union of both
resolvers, order-stable for the `in.(...)` list.
3. The empty-offering short-circuit was silent — no log, no metric,
indistinguishable from "this course has no materials", which is
exactly the failure mode the offering fix exists to remove. It logs
now, without a raw student id.
Also bounds the read. The select was unbounded while every returned row
gets AES-decrypted before Python truncates to `limit`, on the
latency-critical SSE path. The bound is a multiple of `limit`, not
`limit` itself: ranking happens after the fetch, so limiting to exactly
`limit` would silently turn "most relevant" into "most recent".
The new tests use a schema-faithful `table()` fake that rejects filter
columns `documents` does not have. The older mocks in that file accept
any filter and return a canned list, which is precisely how a query
against a non-existent column survived review.
… path
5092a83 narrowed `run_result.output` inside `stream_agent_turn`, but
routes/learn.py reads it in three more places and two of them run with
`message_history` in `run_kwargs` — so `.output` resolves out of the same
history-bearing message list, and a textless turn hands back the
PREVIOUS turn's assistant message: fully formed, non-blank, and
therefore invisible to the `if not reply.strip()` guard sitting right
below it.
That relocated the repeat rather than removing it, onto a path taking
MORE traffic: `_chat_turn_json` is both POST /api/learn/chat and the
streamed route's Rung-1 `nonstream_fallback`, which is exactly where the
streaming fix now sends textless turns. `_action_turn` is the third
reader and persists with `save_message` directly.
`new_messages()` excludes the history that was passed in, so joining the
TextParts of its model responses is exactly "what this turn said" — the
non-streaming twin of the `joined.strip()` check in chat_stream.py.
`_start_session_agent` passes `message_history=[]`, so it has no prior
message to resolve back to and keeps reading `.output` directly; the
comment there names why.
Also adds the missing coverage for the newly-reachable streamed branch.
Every tutor agent registers `apply_graph_update_tool` and
`update_mastery_tool`, so a model that ends its turn after tool calls
usually arrives with `deps.graph_updates` / `deps.mastery_changes`
already populated — the textless-WITH-writes shape, which lands on the
terminal `retryable: False` rung rather than Rung 1. That was untested.
The agent-run fakes had to become shape-faithful (tests/agent_run_fakes.py):
`SimpleNamespace(output=...)` has no `new_messages`, and a bare MagicMock
iterates EMPTY on it — which would have silently turned every mocked
turn into a "textless" one while the assertions still went green through
the 502 catch-all. That is the same class of blind spot as the mock that
accepted a filter on a column PostgREST does not have.
@Jose-Gael-Cruz-Lopez

Copy link
Copy Markdown
Member

Review fixes applied

Every outstanding finding on this PR (human review + CodeRabbit) has been addressed and pushed.

Major

  • Soft-deleted documents reached the tutor. Added deleted_at is.null to the documents select. This PR is what first exposed them (the query used to 400 into the silent degrade), so a deleted file's decrypted summary/concept_notes would have kept flowing into LLM context. Filter set is now byte-identical to fix(tutor): repair course-material retrieval, silence course-scope commentary, make quizzes practical #534's, making that overlap a trivial pick-either.
  • The stale run_result.output read was only narrowed on the streamed path._chat_turn_json is both a first-class route and the streamed route's Rung-1 fallback — i.e. exactly where this PR now sends textless turns — so the repeat was relocated, not removed. Added _new_run_text() (the non-streaming twin of the joined.strip() narrowing) and applied it to both history-bearing .output sites; the message_history=[] site is left alone with a comment saying why.
  • Offering resolution was narrower than the writer's. Now the union of user_offering_ids_for_course and resolve_offering, so a document uploaded across a term boundary is still found. Test covers the cross-term case.

Minor

  • The empty-offering short-circuit logs a warning instead of returning [] silently — the exact failure mode this PR exists to remove.
  • limit pushed into the query so rows are no longer AES-decrypted and then discarded.
  • Added the missing textless-turn-with-writes case (the likely shape, landing on the terminal retryable: False path).

Not fixed here

The red Backend (pytest) is the otel _IncludedRouter failure that is pre-existing on main; #563 carries the shim that fixes it.

Verificationruff check . clean · 1973 passed, 56 skipped

Fixes applied and verified locally against this branch head; each figure above is a command I ran, not an estimate.

@Jose-Gael-Cruz-Lopez

Copy link
Copy Markdown
Member

Verify against the real database before merging

I could not reach a live database while working on this — there are no credentials on this machine (only .env.example files) and the local Supabase stack needs a container runtime that wouldn't start. So everything below was verified statically, by replaying every migration in ledger order to build a schema model and checking this PR's DB access against it. That model found 0 schema mismatches here, and it is trustworthy enough to have independently reproduced the documents.course_id absence that caused #562/#534 — but it cannot see data, and it cannot see whether staging's ledger actually matches the repo.

These are the checks that need a real connection.

No schema change — but the behaviour change is data-dependent

Two fixes here only show their value against real rows, so they are worth confirming on staging.

1. How much was actually leaking

The documents read had no deleted_at filter. Before this PR the query 400'd and returned [], so nothing leaked; this PR is what makes it return rows, which is why the filter had to be added in the same change.

SELECTcount(*) FROM documents WHERE deleted_at IS NOT NULL;

That count is exactly how many soft-deleted documents the tutor would otherwise have decrypted into LLM context. If it is non-zero, this fix is load-bearing rather than theoretical.

2. The cross-term offering gap is real or it isn't

The offering set is now the union of user_offering_ids_for_course (enrollment-derived) and resolve_offering (the writer's resolver), because documents are written with the latter and read with the former.

-- documents sitting on an offering the owner has no enrollment row for:-- these are exactly the files the tutor could not see before this changeSELECTcount(*) FROM documents d
WHEREd.deleted_at IS NULLAND NOT EXISTS (
SELECT1FROM enrollments e
WHEREe.user_id=d.user_idANDe.offering_id=d.offering_id);

Non-zero means the divergence is already live, not just reachable at the next term boundary.

3. Sanity-check the filter set on the wire

The final query filters on exactly offering_id in (...), user_id eq, deleted_at is.null. Worth eyeballing one real request in logs to confirm no fourth filter crept in — this is the query whose column set was wrong for months without anyone noticing.

Static verification only — no live database was reachable from this environment. Schema model built by replaying backend/db/migrations/ in ledger order.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@Darkest-Teddy@Jose-Gael-Cruz-Lopez
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix(tutor): stop the tutor repeating itself, and let it actually read course materials by Darkest-Teddy · Pull Request #562 · SaplingLearn/Sapling · GitHub
Skip to content

fix(tutor): stop the tutor repeating itself, and let it actually read course materials - #562

Open
Darkest-Teddy wants to merge 5 commits into
mainfrom
fix/tutor-repeat-and-course-materials
Open

fix(tutor): stop the tutor repeating itself, and let it actually read course materials#562
Darkest-Teddy wants to merge 5 commits into
mainfrom
fix/tutor-repeat-and-course-materials

Conversation

@Darkest-Teddy

@Darkest-TeddyDarkest-Teddy commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator

Two independent bugs in the tutor chat, found while debugging "why does the AI repeat the message instead of responding to it". One commit each.

1. The tutor replayed its previous reply verbatim

stream_agent_turn preferred run_result.output over the streamed chunks:

reply=final_outputiffinal_outputisnotNoneelsejoined

.output resolves out of the run's message list, and that list includes message_history. So a turn whose model response carries no text part — the model ended its turn after tool calls — handed back the previous turn's assistant message. Fully formed and non-blank, so the blank-reply ladder immediately below never saw it. The route persisted it, and the student got a byte-identical copy of the last answer.

Confirmed in two live sessions two days apart: same sha256, same length, including a 1757-character reply reproduced exactly against a completely different question.

Fix: trust final_output only when this turn actually streamed text. Text always arrives as PartStart/PartDelta events, so "nothing streamed" means "this turn produced no text" — degrade through the existing blank-reply ladder rather than replaying history.

Evidence. Replaying a real session turn against gemini-2.5-flash-lite, the correlation is exact:

streamed token/replyoutcomecount
0repeated the prior answer2/8
>0answered correctly6/8

After the fix: 0 repeats in 10 runs.

What this does not fix: the model still ends ~40% of these turns without text. Those now take the Rung-1 JSON fallback (a real answer), or — if graph writes already landed — show the honest "interrupted, please retry". A silent wrong answer becomes a correct one or a visible error. Why the model does this at all is unexplored and worth a follow-up.

2. The tutor never read any course document

search_course_materials filtered documents on course_id. That table keys on offering_id (0025) and has no course_id column, so PostgREST 400s on every call — and the tool's deliberate degrade-silently except turned that into [].

The tutor answered from base knowledge alone, which presents as "it's generic about my class" rather than as a failure. Nothing was logged at the user's level.

Fix: resolve the abstract course to the user's offerings via academics.user_offering_ids_for_course, matching the convention that the API boundary keeps the abstract course while documents key on the offering. The #125 user_id scope is unchanged — documents stay user-scoped within a shared offering.

Tests

Both regression tests were written first and watched fail for the right reason:

  • test_textless_turn_never_replays_the_previous_turns_reply — failed with on_complete receiving the stale prior-turn text.
  • test_documents_are_fetched_by_offering_not_course_id — failed with column documents.course_id does not exist.

The second one needed a schema-faithful fake. The existing mocks accept any filter, which is precisely how bug 2 survived them. The two older search_course_materials test classes now stub the offering lookup, which became a real dependency of that function.

Full backend suite: 1999 passed, 56 skipped, exit 0. ruff check clean on all four files.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes

    • Course material searches now return documents associated with the user’s course offerings while maintaining access restrictions.
    • Prevented tool-only chat turns from displaying or saving stale assistant responses from earlier messages.
    • Ensured empty or non-text streaming responses use a fresh fallback reply.
  • Tests

    • Added coverage for offering-based material searches, access scoping, and stale-response prevention.

Darkest-Teddyand others added 2 commits August 13, 2026 22:21
`stream_agent_turn` preferred `run_result.output` over the streamed
chunks. That output resolves out of the run's message list, and that
list includes `message_history` — so a turn whose model response
carries no text part (the model ended its turn after tool calls) handed
back the PREVIOUS turn's assistant message: fully formed, non-blank, and
therefore invisible to the blank-reply ladder below it.
The route then persisted it, so the tutor answered a follow-up with a
byte-identical copy of its own last answer — same sha256, same length,
including a 1757-char reply reproduced verbatim against a completely
different question.
Trust `final_output` only when this turn actually streamed text. Text
always arrives as PartStart/PartDelta events, so "nothing streamed"
means "this turn produced no text": degrade through the existing
blank-reply ladder instead of replaying history.
Measured on gemini-2.5-flash-lite by replaying a real session turn: the
correlation is exact — `token/reply == 0` repeated (2/8), any streamed
text answered correctly (6/8). After the fix, 0 repeats in 10 runs.
Note this does not stop the model from ending a turn without text; those
turns now take the Rung-1 fallback or a visible interrupted-error rather
than a silent duplicate.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…n't exist
`search_course_materials` filtered `documents` on `course_id`. That
table keys on `offering_id` (0025) and has no `course_id` column, so
PostgREST returned 400 on every call — and the tool's deliberate
degrade-silently `except` turned that into `[]`.
Net effect: the tutor never read a single course document, with no error
surfaced anywhere. It answered from base knowledge alone, which reads as
"the tutor is generic about my class" rather than as a bug.
Resolve the abstract course to the user's offerings via
`academics.user_offering_ids_for_course`, per the convention that the
API boundary keeps the abstract course while documents key on the
offering. The #125 user_id scope is unchanged: documents stay
user-scoped WITHIN a shared offering.
The existing unit tests missed this because they mock `table` loosely
enough to accept any filter; the new test uses a schema-faithful fake
that rejects a column the table does not have, exactly as PostgREST
does. The two older test classes now stub the offering lookup, which is
a real dependency of this function for the first time.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@supabase

supabaseBot commented Aug 14, 2026

Copy link
Copy Markdown

This pull request has been ignored for the connected project ybgqdonkoqftwrmweuyv because there are no changes detected in supabase directory. You can change this behaviour in Project Integrations Settings ↗︎.


Preview Branches by Supabase.
Learn more about Supabase Branching ↗︎.

@coderabbitai

coderabbitaiBot commented Aug 14, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@Jose-Gael-Cruz-Lopez, you've reached your PR review limit, so we couldn't start this review.

Next review available in:8 minutes

Limit details: You’ve used the included review currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

Wait for the limit to reset, then comment @coderabbitai review or push new commits to the PR.

An organization admin can change what happens after included review limits in Billing.

How do review limits work?

CodeRabbit enforces per-developer PR review limits within each organization.

For paid Pro and Pro+ reviews, CodeRabbit uses a developer's included PR review attempts over the past 7 days to set the current hourly allowance. At typical activity levels, the full plan allowance applies. Higher sustained activity can lower the allowance until earlier attempts leave the 7-day window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 0bb76370-0c3c-4d13-a541-292b94564424

📥 Commits

Reviewing files that changed from the base of the PR and between b8aa904 and 4e2949d.

📒 Files selected for processing (8)
  • backend/agents/tools/chat_context.py
  • backend/routes/learn.py
  • backend/tests/agent_run_fakes.py
  • backend/tests/test_chat_context_tools.py
  • backend/tests/test_chat_stream.py
  • backend/tests/test_event_capture_seams.py
  • backend/tests/test_graph_tools_bugs.py
  • backend/tests/test_learn_routes.py

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 06d0ac13-1c32-40b4-a002-18d7e154c3c1

📥 Commits

Reviewing files that changed from the base of the PR and between 9b77819 and b8aa904.

📒 Files selected for processing (4)
  • backend/agents/tools/chat_context.py
  • backend/services/chat_stream.py
  • backend/tests/test_chat_context_tools.py
  • backend/tests/test_chat_stream.py

📝 Walkthrough

Walkthrough

The change scopes course-material searches to the user’s offerings and updates streamed-turn reply selection to avoid stale history output. Tests cover offering resolution, user scoping, document lookup, and tool-only turns.

Changes

Chat behavior corrections

Layer / File(s)Summary
Offering-scoped course material search
backend/agents/tools/chat_context.py, backend/tests/test_chat_context_tools.py
Course searches resolve user-specific offering IDs and filter documents by offering_id while retaining user_id scoping. Tests cover matching offerings and schema-valid document queries.
Textless streamed turn handling
backend/services/chat_stream.py, backend/tests/test_chat_stream.py
Textless turns use joined current-turn chunks instead of stale message-history output. Regression coverage checks streaming, persistence, callbacks, and nonstream fallback behavior.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Mergeability Score:⚪ Minimal · up to b8aa9

The changes address two localized tutor-chat defects, with regression coverage and passing checks reported; no actionable merge-blocking risk remains beyond normal review.

Possibly related PRs

Suggested reviewers:andresl230, jose-gael-cruz-lopez

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 37.50% which is insufficient. The required threshold is 80.00%.Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check nameStatusExplanation
Title check✅ PassedThe title clearly summarizes both primary tutor fixes: preventing repeated replies and enabling course-material retrieval.
Description check✅ PassedThe description clearly explains both bugs, their fixes, regression tests, test results, and reviewer-relevant limitations.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/tutor-repeat-and-course-materials

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Aug 14, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-staging4e2949dCommit Preview URL

Branch Preview URL
Aug 19 2026, 09:02 PM

@Jose-Gael-Cruz-Lopez

Copy link
Copy Markdown
Member

Code review — tutor repetition + course-material retrieval

Two genuinely independent bugs, both diagnosed properly, both with regression tests written first. The documents.course_id finding is correct — migration 0025_study_integrity.sql recreates documents with offering_id TEXT NOT NULL REFERENCES course_offerings(id) and no course_id column, so the old filter really did 400 into the tool's degrade-silently except. The chat_stream guard is a one-line narrowing that is safe for this agent (chat_tutor is output_type=str, agents/chat_tutor.py:174, so its reply always arrives as text parts — there is no output-tool channel the guard could discard). Three things need attention before merge: the now-working documents query no longer honours the soft-delete convention every other reader follows, the offering resolution it picked can only shrink the user's own document set relative to the writer, and the identical stale-run_result.output read on the JSON path — which is exactly the fallback this PR now routes textless turns into — was left unguarded, so the repeat symptom can still be produced.

Findings

P0

[P0] CI is red — Backend (pytest) fails (pre-existing on main, not a regression from this PR) — run 31763538674

FAILED tests/test_quiz_preflight_a.py::TestQuizErrorEnvelope::test_method_not_allowed_gets_generic_code - AttributeError: '_IncludedRouter' object has no attribute 'path'
1 failed, 1965 passed, 56 skipped, 13 warnings in 42.25s
 if match == Match.PARTIAL:
> route = starlette_route.path
^^^^^^^^^^^^^^^^^^^^
E AttributeError: '_IncludedRouter' object has no attribute 'path'
/opt/hostedtoolcache/Python/3.13.14/x64/lib/python3.13/site-packages/opentelemetry/instrumentation/fastapi/__init__.py:495: AttributeError

Not this PR's fault. The identical failure is on main — run 31975087459 (4f5a7f90, "Merge pull request #524 from SaplingLearn/feat/landing-v5-port"): same test, same AttributeError, 1 failed, 1963 passed, 56 skipped. It is dependency drift in opentelemetry-instrumentation-fastapi._get_route_details, which assumes every Match.PARTIAL route object has .path; FastAPI's _IncludedRouter does not. None of this PR's four files touch routing or quiz. It still blocks the merge queue, so it needs a pin/fix on main first — worth noting because the PR body claims "Full backend suite: 1999 passed, 56 skipped, exit 0", which does not match CI.

P1

[P1] Soft-deleted documents now reach the tutorbackend/agents/tools/chat_context.py:154-164

return (
table("documents").select(
"id,file_name,summary,concept_notes",
filters={
# #125 user scope is preserved: documents are# user-scoped WITHIN a shared offering."offering_id": f"in.({','.join(offering_ids)})",
"user_id": f"eq.{user_id}",
},
order="created_at.desc",
)

documents is soft-deleted, not hard-deleted — routes/documents.py:288-293: "Soft delete (0025): stamp deleted_at; reads filter it out." Every other reader honours that: routes/documents.py:241, routes/study_guide.py:137, routes/flashcards.py:153 and :163, routes/documents.py:1230 all pass "deleted_at": "is.null". This query does not. Before this PR that was invisible because the query 400'd and returned [] unconditionally; this PR is what makes it return rows, so it is this PR that first exposes deleted documents. Concretely: a student deletes a document from their Library, documents.deleted_at is stamped, the row stays, and the tutor keeps decrypting its summary + concept_notes into the LLM context on every search_course_materials call. Nothing in backend/agents/ or the tutor tests references deleted_at, so no test catches it.

[P1] The stale run_result.output read is unguarded on the JSON path — the very fallback this PR routes textless turns intobackend/routes/learn.py:636-654

result=record_agent_usage(
awaitagent.run(user_message, **run_kwargs),
feature="chat_tutor", task="chat_tutor", user_id=deps.user_id,
)
reply=result.output# str — chat_tutor agents return plain Markdown.ifnotreply.strip():

_prepare_chat_run puts "message_history": message_history into run_kwargs (routes/learn.py:577), so result.output here resolves out of the same history-bearing message list the PR indicts. The only guard is not reply.strip() — and the PR's whole point is that a replayed prior turn is "fully formed, non-blank, and therefore invisible to the blank-reply ladder". _chat_turn_json is both a first-class route (POST /api/learn/chat, routes/learn.py:745-751) and the streamed route's Rung-1 nonstream_fallback (routes/learn.py:823). So after this change a textless streamed turn correctly refuses the stale text, degrades to _chat_turn_json, and if that re-run is also textless — the PR body measures ~40% — it takes the prior assistant message from message_history, persists it via save_message (routes/learn.py:727) and returns it. The repeat is not eliminated, it is moved one rung down, onto a path this PR sends more traffic to.

[P1] Offering resolution is narrower than the writer's and than every sibling reader'sbackend/agents/tools/chat_context.py:151-153

offering_ids=user_offering_ids_for_course(user_id, course_id)
ifnotoffering_ids:
return []

user_offering_ids_for_course (services/academics.py:185-200) intersects the course's offerings with the user's enrollments rows. Documents are written with a different resolver — offering_id = resolve_offering(course_id, create=True) at routes/documents.py:572 and :710 — which picks the current-term offering (creating it if absent, else falling back to any offering of the course) and never consults enrollments. The two sibling readers use the writer's resolver too: routes/study_guide.py:136 and routes/flashcards.py:144 filter on resolve_offering(...). Divergence is reachable across a term boundary: a student enrolled via routes/onboarding.py:95-112 in Fall-26's offering who uploads in the next term gets documents.offering_id = the new term's offering, has no enrollment row for it, and the tutor silently returns [] — while the Library still lists the file (routes/documents.py:239-241 filters on user_id only). Note the enrollment intersection buys no safety: user_id is already the security boundary (your own #125 comment says so), so this filter can only remove the user's own documents.

P2

[P2] The empty-offering short-circuit is silent — the exact failure mode this PR exists to removebackend/agents/tools/chat_context.py:152-153

ifnotoffering_ids:
return []

The PR body's own indictment of the old bug is "The tutor answered from base knowledge alone, which presents as 'it's generic about my class' rather than as a failure. Nothing was logged at the user's level." This early return reproduces that precisely: no log, no metric, indistinguishable from "this course genuinely has no materials". The except below it does logger.exception; this branch gets nothing. Given the P1 above makes it reachable, a log line here is what turns the next occurrence of this class of bug into a five-minute diagnosis instead of a two-month one.

P3

[P3] Two extra uncached PostgREST round-trips per tool call, in the streaming hot pathbackend/services/academics.py:191-200 via chat_context.py:151

offs=table("course_offerings").select(
"id", filters={"course_id": f"eq.{course_id}"}
) or []
off_ids= {o["id"] foroinoffs}
ifnotoff_ids:
return []
enr=table("enrollments").select(
"offering_id", filters={"user_id": f"eq.{user_id}"}
) or []

user_offering_ids_for_course is deliberately uncached and the second select pulls all of the user's enrollments across every course. search_course_materials is an LLM-callable tool that can fire more than once per turn, on the latency-critical SSE path — so this is +2 sequential round-trips per call. The documents select that follows is also unbounded (no limit, order="created_at.desc" only) and every returned row is AES-decrypted before the list is truncated to limit (5) in Python at chat_context.py:213. Harmless today; both become real once a course accumulates documents.

[P3] No test pins the newly-reachable textless-turn-with-writes branchbackend/tests/test_chat_stream.py:710-750

agent=FakeAgent([
FunctionToolCallEvent("read_graph_neighborhood"),
FunctionToolResultEvent(), # no writes landedAgentRunResultEvent(PRIOR), # stale: from message_history
])

The new test only covers the no-writes path (Rung-1 fallback). But the trigger it describes is "the model ended its turn after tool calls", and every tutor agent registers apply_graph_update_tool and update_mastery_tool (agents/chat_tutor.py:161-162), which populate deps.graph_updates / deps.mastery_changes (agents/tools/graph.py:122, :174). So the likely shape of a textless turn is textless-with-writes, which lands on the terminal retryable: False error at chat_stream.py:362-368 — a user-visible dead end, and the branch with no new coverage. Worth one more FakeAgent case with an on_fire write.

What's good

  • The root-cause work is real and checkable: 0025_study_integrity.sql:15-31 confirms documents has offering_id NOT NULL and no course_id, exactly as claimed.
  • The new TestSearchCourseMaterialsOfferingScope fake raises on a course_id filter instead of accepting any filter — the right response to "the existing mocks accept any filter, which is precisely how bug 2 survived them".
  • The chat_stream.py guard is correctly scoped: chat_tutor is output_type=str (agents/chat_tutor.py:174), so there is no output-tool channel whose reply the joined.strip() condition could throw away. Per the Sapling Engineering Style Guide the new sync user_offering_ids_for_course call also stays off the event loop — it sits inside _fetch, which runs under asyncio.to_thread (chat_context.py:174).

Verdict: request changes — the soft-delete regression and the unguarded result.output on the JSON fallback path both need fixing before merge; the CI failure is pre-existing on main and should be unblocked there.


Review-only pass — no code changed and nothing fixed. Conventions checked against the Canopy live docs (Engineering Style Guide, Architecture, Infrastructure, Backend & AI Agents). Every finding cites a snippet re-read at this PR's head SHA; severity: P0 blocker · P1 major · P2 minor · P3 nit.

…le degrade
Three findings on the course-materials read, all made reachable by the
offering fix in b8aa904 — before it the query 400'd and returned [] on
every call, so none of them could be observed.
1. `documents` is soft-deleted. routes/documents.py stamps `deleted_at`
and every other reader filters on it (study_guide.py, flashcards.py);
this query did not, so a file the student deleted from their Library
kept getting its `summary` + `concept_notes` decrypted into LLM
context forever. Adds `deleted_at is.null`, which also makes the
filter set identical to PR #534's fix of the same bug — the eventual
merge conflict is now trivial.
2. `user_offering_ids_for_course` is narrower than the WRITER.
Documents are written with `resolve_offering(course_id, create=True)`
— current term, `enrollments` never consulted — and the sibling
readers use the writer's resolver too. Across a term boundary a
student enrolled in Fall-26 who uploads next term gets
`documents.offering_id` = the new offering, has no enrollment row for
it, and the tutor silently returned [] while the Library still listed
the file. The intersection bought no security either: `user_id` is
the access boundary on `documents` (#125), so dropping offerings can
only hide the student's OWN uploads. Widened to the union of both
resolvers, order-stable for the `in.(...)` list.
3. The empty-offering short-circuit was silent — no log, no metric,
indistinguishable from "this course has no materials", which is
exactly the failure mode the offering fix exists to remove. It logs
now, without a raw student id.
Also bounds the read. The select was unbounded while every returned row
gets AES-decrypted before Python truncates to `limit`, on the
latency-critical SSE path. The bound is a multiple of `limit`, not
`limit` itself: ranking happens after the fetch, so limiting to exactly
`limit` would silently turn "most relevant" into "most recent".
The new tests use a schema-faithful `table()` fake that rejects filter
columns `documents` does not have. The older mocks in that file accept
any filter and return a canned list, which is precisely how a query
against a non-existent column survived review.
… path
5092a83 narrowed `run_result.output` inside `stream_agent_turn`, but
routes/learn.py reads it in three more places and two of them run with
`message_history` in `run_kwargs` — so `.output` resolves out of the same
history-bearing message list, and a textless turn hands back the
PREVIOUS turn's assistant message: fully formed, non-blank, and
therefore invisible to the `if not reply.strip()` guard sitting right
below it.
That relocated the repeat rather than removing it, onto a path taking
MORE traffic: `_chat_turn_json` is both POST /api/learn/chat and the
streamed route's Rung-1 `nonstream_fallback`, which is exactly where the
streaming fix now sends textless turns. `_action_turn` is the third
reader and persists with `save_message` directly.
`new_messages()` excludes the history that was passed in, so joining the
TextParts of its model responses is exactly "what this turn said" — the
non-streaming twin of the `joined.strip()` check in chat_stream.py.
`_start_session_agent` passes `message_history=[]`, so it has no prior
message to resolve back to and keeps reading `.output` directly; the
comment there names why.
Also adds the missing coverage for the newly-reachable streamed branch.
Every tutor agent registers `apply_graph_update_tool` and
`update_mastery_tool`, so a model that ends its turn after tool calls
usually arrives with `deps.graph_updates` / `deps.mastery_changes`
already populated — the textless-WITH-writes shape, which lands on the
terminal `retryable: False` rung rather than Rung 1. That was untested.
The agent-run fakes had to become shape-faithful (tests/agent_run_fakes.py):
`SimpleNamespace(output=...)` has no `new_messages`, and a bare MagicMock
iterates EMPTY on it — which would have silently turned every mocked
turn into a "textless" one while the assertions still went green through
the 502 catch-all. That is the same class of blind spot as the mock that
accepted a filter on a column PostgREST does not have.
@Jose-Gael-Cruz-Lopez

Copy link
Copy Markdown
Member

Review fixes applied

Every outstanding finding on this PR (human review + CodeRabbit) has been addressed and pushed.

Major

  • Soft-deleted documents reached the tutor. Added deleted_at is.null to the documents select. This PR is what first exposed them (the query used to 400 into the silent degrade), so a deleted file's decrypted summary/concept_notes would have kept flowing into LLM context. Filter set is now byte-identical to fix(tutor): repair course-material retrieval, silence course-scope commentary, make quizzes practical #534's, making that overlap a trivial pick-either.
  • The stale run_result.output read was only narrowed on the streamed path._chat_turn_json is both a first-class route and the streamed route's Rung-1 fallback — i.e. exactly where this PR now sends textless turns — so the repeat was relocated, not removed. Added _new_run_text() (the non-streaming twin of the joined.strip() narrowing) and applied it to both history-bearing .output sites; the message_history=[] site is left alone with a comment saying why.
  • Offering resolution was narrower than the writer's. Now the union of user_offering_ids_for_course and resolve_offering, so a document uploaded across a term boundary is still found. Test covers the cross-term case.

Minor

  • The empty-offering short-circuit logs a warning instead of returning [] silently — the exact failure mode this PR exists to remove.
  • limit pushed into the query so rows are no longer AES-decrypted and then discarded.
  • Added the missing textless-turn-with-writes case (the likely shape, landing on the terminal retryable: False path).

Not fixed here

The red Backend (pytest) is the otel _IncludedRouter failure that is pre-existing on main; #563 carries the shim that fixes it.

Verificationruff check . clean · 1973 passed, 56 skipped

Fixes applied and verified locally against this branch head; each figure above is a command I ran, not an estimate.

@Jose-Gael-Cruz-Lopez

Copy link
Copy Markdown
Member

Verify against the real database before merging

I could not reach a live database while working on this — there are no credentials on this machine (only .env.example files) and the local Supabase stack needs a container runtime that wouldn't start. So everything below was verified statically, by replaying every migration in ledger order to build a schema model and checking this PR's DB access against it. That model found 0 schema mismatches here, and it is trustworthy enough to have independently reproduced the documents.course_id absence that caused #562/#534 — but it cannot see data, and it cannot see whether staging's ledger actually matches the repo.

These are the checks that need a real connection.

No schema change — but the behaviour change is data-dependent

Two fixes here only show their value against real rows, so they are worth confirming on staging.

1. How much was actually leaking

The documents read had no deleted_at filter. Before this PR the query 400'd and returned [], so nothing leaked; this PR is what makes it return rows, which is why the filter had to be added in the same change.

SELECTcount(*) FROM documents WHERE deleted_at IS NOT NULL;

That count is exactly how many soft-deleted documents the tutor would otherwise have decrypted into LLM context. If it is non-zero, this fix is load-bearing rather than theoretical.

2. The cross-term offering gap is real or it isn't

The offering set is now the union of user_offering_ids_for_course (enrollment-derived) and resolve_offering (the writer's resolver), because documents are written with the latter and read with the former.

-- documents sitting on an offering the owner has no enrollment row for:-- these are exactly the files the tutor could not see before this changeSELECTcount(*) FROM documents d
WHEREd.deleted_at IS NULLAND NOT EXISTS (
SELECT1FROM enrollments e
WHEREe.user_id=d.user_idANDe.offering_id=d.offering_id);

Non-zero means the divergence is already live, not just reachable at the next term boundary.

3. Sanity-check the filter set on the wire

The final query filters on exactly offering_id in (...), user_id eq, deleted_at is.null. Worth eyeballing one real request in logs to confirm no fourth filter crept in — this is the query whose column set was wrong for months without anyone noticing.

Static verification only — no live database was reachable from this environment. Schema model built by replaying backend/db/migrations/ in ledger order.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@Darkest-Teddy@Jose-Gael-Cruz-Lopez
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix(tutor): stop the tutor repeating itself, and let it actually read course materials by Darkest-Teddy · Pull Request #562 · SaplingLearn/Sapling · GitHub
Skip to content

fix(tutor): stop the tutor repeating itself, and let it actually read course materials - #562

Open
Darkest-Teddy wants to merge 5 commits into
mainfrom
fix/tutor-repeat-and-course-materials
Open

fix(tutor): stop the tutor repeating itself, and let it actually read course materials#562
Darkest-Teddy wants to merge 5 commits into
mainfrom
fix/tutor-repeat-and-course-materials

Conversation

@Darkest-Teddy

@Darkest-TeddyDarkest-Teddy commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator

Two independent bugs in the tutor chat, found while debugging "why does the AI repeat the message instead of responding to it". One commit each.

1. The tutor replayed its previous reply verbatim

stream_agent_turn preferred run_result.output over the streamed chunks:

reply=final_outputiffinal_outputisnotNoneelsejoined

.output resolves out of the run's message list, and that list includes message_history. So a turn whose model response carries no text part — the model ended its turn after tool calls — handed back the previous turn's assistant message. Fully formed and non-blank, so the blank-reply ladder immediately below never saw it. The route persisted it, and the student got a byte-identical copy of the last answer.

Confirmed in two live sessions two days apart: same sha256, same length, including a 1757-character reply reproduced exactly against a completely different question.

Fix: trust final_output only when this turn actually streamed text. Text always arrives as PartStart/PartDelta events, so "nothing streamed" means "this turn produced no text" — degrade through the existing blank-reply ladder rather than replaying history.

Evidence. Replaying a real session turn against gemini-2.5-flash-lite, the correlation is exact:

streamed token/replyoutcomecount
0repeated the prior answer2/8
>0answered correctly6/8

After the fix: 0 repeats in 10 runs.

What this does not fix: the model still ends ~40% of these turns without text. Those now take the Rung-1 JSON fallback (a real answer), or — if graph writes already landed — show the honest "interrupted, please retry". A silent wrong answer becomes a correct one or a visible error. Why the model does this at all is unexplored and worth a follow-up.

2. The tutor never read any course document

search_course_materials filtered documents on course_id. That table keys on offering_id (0025) and has no course_id column, so PostgREST 400s on every call — and the tool's deliberate degrade-silently except turned that into [].

The tutor answered from base knowledge alone, which presents as "it's generic about my class" rather than as a failure. Nothing was logged at the user's level.

Fix: resolve the abstract course to the user's offerings via academics.user_offering_ids_for_course, matching the convention that the API boundary keeps the abstract course while documents key on the offering. The #125 user_id scope is unchanged — documents stay user-scoped within a shared offering.

Tests

Both regression tests were written first and watched fail for the right reason:

  • test_textless_turn_never_replays_the_previous_turns_reply — failed with on_complete receiving the stale prior-turn text.
  • test_documents_are_fetched_by_offering_not_course_id — failed with column documents.course_id does not exist.

The second one needed a schema-faithful fake. The existing mocks accept any filter, which is precisely how bug 2 survived them. The two older search_course_materials test classes now stub the offering lookup, which became a real dependency of that function.

Full backend suite: 1999 passed, 56 skipped, exit 0. ruff check clean on all four files.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes

    • Course material searches now return documents associated with the user’s course offerings while maintaining access restrictions.
    • Prevented tool-only chat turns from displaying or saving stale assistant responses from earlier messages.
    • Ensured empty or non-text streaming responses use a fresh fallback reply.
  • Tests

    • Added coverage for offering-based material searches, access scoping, and stale-response prevention.

Darkest-Teddyand others added 2 commits August 13, 2026 22:21
`stream_agent_turn` preferred `run_result.output` over the streamed
chunks. That output resolves out of the run's message list, and that
list includes `message_history` — so a turn whose model response
carries no text part (the model ended its turn after tool calls) handed
back the PREVIOUS turn's assistant message: fully formed, non-blank, and
therefore invisible to the blank-reply ladder below it.
The route then persisted it, so the tutor answered a follow-up with a
byte-identical copy of its own last answer — same sha256, same length,
including a 1757-char reply reproduced verbatim against a completely
different question.
Trust `final_output` only when this turn actually streamed text. Text
always arrives as PartStart/PartDelta events, so "nothing streamed"
means "this turn produced no text": degrade through the existing
blank-reply ladder instead of replaying history.
Measured on gemini-2.5-flash-lite by replaying a real session turn: the
correlation is exact — `token/reply == 0` repeated (2/8), any streamed
text answered correctly (6/8). After the fix, 0 repeats in 10 runs.
Note this does not stop the model from ending a turn without text; those
turns now take the Rung-1 fallback or a visible interrupted-error rather
than a silent duplicate.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…n't exist
`search_course_materials` filtered `documents` on `course_id`. That
table keys on `offering_id` (0025) and has no `course_id` column, so
PostgREST returned 400 on every call — and the tool's deliberate
degrade-silently `except` turned that into `[]`.
Net effect: the tutor never read a single course document, with no error
surfaced anywhere. It answered from base knowledge alone, which reads as
"the tutor is generic about my class" rather than as a bug.
Resolve the abstract course to the user's offerings via
`academics.user_offering_ids_for_course`, per the convention that the
API boundary keeps the abstract course while documents key on the
offering. The #125 user_id scope is unchanged: documents stay
user-scoped WITHIN a shared offering.
The existing unit tests missed this because they mock `table` loosely
enough to accept any filter; the new test uses a schema-faithful fake
that rejects a column the table does not have, exactly as PostgREST
does. The two older test classes now stub the offering lookup, which is
a real dependency of this function for the first time.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@supabase

supabaseBot commented Aug 14, 2026

Copy link
Copy Markdown

This pull request has been ignored for the connected project ybgqdonkoqftwrmweuyv because there are no changes detected in supabase directory. You can change this behaviour in Project Integrations Settings ↗︎.


Preview Branches by Supabase.
Learn more about Supabase Branching ↗︎.

@coderabbitai

coderabbitaiBot commented Aug 14, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@Jose-Gael-Cruz-Lopez, you've reached your PR review limit, so we couldn't start this review.

Next review available in:8 minutes

Limit details: You’ve used the included review currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

Wait for the limit to reset, then comment @coderabbitai review or push new commits to the PR.

An organization admin can change what happens after included review limits in Billing.

How do review limits work?

CodeRabbit enforces per-developer PR review limits within each organization.

For paid Pro and Pro+ reviews, CodeRabbit uses a developer's included PR review attempts over the past 7 days to set the current hourly allowance. At typical activity levels, the full plan allowance applies. Higher sustained activity can lower the allowance until earlier attempts leave the 7-day window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 0bb76370-0c3c-4d13-a541-292b94564424

📥 Commits

Reviewing files that changed from the base of the PR and between b8aa904 and 4e2949d.

📒 Files selected for processing (8)
  • backend/agents/tools/chat_context.py
  • backend/routes/learn.py
  • backend/tests/agent_run_fakes.py
  • backend/tests/test_chat_context_tools.py
  • backend/tests/test_chat_stream.py
  • backend/tests/test_event_capture_seams.py
  • backend/tests/test_graph_tools_bugs.py
  • backend/tests/test_learn_routes.py

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 06d0ac13-1c32-40b4-a002-18d7e154c3c1

📥 Commits

Reviewing files that changed from the base of the PR and between 9b77819 and b8aa904.

📒 Files selected for processing (4)
  • backend/agents/tools/chat_context.py
  • backend/services/chat_stream.py
  • backend/tests/test_chat_context_tools.py
  • backend/tests/test_chat_stream.py

📝 Walkthrough

Walkthrough

The change scopes course-material searches to the user’s offerings and updates streamed-turn reply selection to avoid stale history output. Tests cover offering resolution, user scoping, document lookup, and tool-only turns.

Changes

Chat behavior corrections

Layer / File(s)Summary
Offering-scoped course material search
backend/agents/tools/chat_context.py, backend/tests/test_chat_context_tools.py
Course searches resolve user-specific offering IDs and filter documents by offering_id while retaining user_id scoping. Tests cover matching offerings and schema-valid document queries.
Textless streamed turn handling
backend/services/chat_stream.py, backend/tests/test_chat_stream.py
Textless turns use joined current-turn chunks instead of stale message-history output. Regression coverage checks streaming, persistence, callbacks, and nonstream fallback behavior.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Mergeability Score:⚪ Minimal · up to b8aa9

The changes address two localized tutor-chat defects, with regression coverage and passing checks reported; no actionable merge-blocking risk remains beyond normal review.

Possibly related PRs

Suggested reviewers:andresl230, jose-gael-cruz-lopez

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 37.50% which is insufficient. The required threshold is 80.00%.Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check nameStatusExplanation
Title check✅ PassedThe title clearly summarizes both primary tutor fixes: preventing repeated replies and enabling course-material retrieval.
Description check✅ PassedThe description clearly explains both bugs, their fixes, regression tests, test results, and reviewer-relevant limitations.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/tutor-repeat-and-course-materials

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Aug 14, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-staging4e2949dCommit Preview URL

Branch Preview URL
Aug 19 2026, 09:02 PM

@Jose-Gael-Cruz-Lopez

Copy link
Copy Markdown
Member

Code review — tutor repetition + course-material retrieval

Two genuinely independent bugs, both diagnosed properly, both with regression tests written first. The documents.course_id finding is correct — migration 0025_study_integrity.sql recreates documents with offering_id TEXT NOT NULL REFERENCES course_offerings(id) and no course_id column, so the old filter really did 400 into the tool's degrade-silently except. The chat_stream guard is a one-line narrowing that is safe for this agent (chat_tutor is output_type=str, agents/chat_tutor.py:174, so its reply always arrives as text parts — there is no output-tool channel the guard could discard). Three things need attention before merge: the now-working documents query no longer honours the soft-delete convention every other reader follows, the offering resolution it picked can only shrink the user's own document set relative to the writer, and the identical stale-run_result.output read on the JSON path — which is exactly the fallback this PR now routes textless turns into — was left unguarded, so the repeat symptom can still be produced.

Findings

P0

[P0] CI is red — Backend (pytest) fails (pre-existing on main, not a regression from this PR) — run 31763538674

FAILED tests/test_quiz_preflight_a.py::TestQuizErrorEnvelope::test_method_not_allowed_gets_generic_code - AttributeError: '_IncludedRouter' object has no attribute 'path'
1 failed, 1965 passed, 56 skipped, 13 warnings in 42.25s
 if match == Match.PARTIAL:
> route = starlette_route.path
^^^^^^^^^^^^^^^^^^^^
E AttributeError: '_IncludedRouter' object has no attribute 'path'
/opt/hostedtoolcache/Python/3.13.14/x64/lib/python3.13/site-packages/opentelemetry/instrumentation/fastapi/__init__.py:495: AttributeError

Not this PR's fault. The identical failure is on main — run 31975087459 (4f5a7f90, "Merge pull request #524 from SaplingLearn/feat/landing-v5-port"): same test, same AttributeError, 1 failed, 1963 passed, 56 skipped. It is dependency drift in opentelemetry-instrumentation-fastapi._get_route_details, which assumes every Match.PARTIAL route object has .path; FastAPI's _IncludedRouter does not. None of this PR's four files touch routing or quiz. It still blocks the merge queue, so it needs a pin/fix on main first — worth noting because the PR body claims "Full backend suite: 1999 passed, 56 skipped, exit 0", which does not match CI.

P1

[P1] Soft-deleted documents now reach the tutorbackend/agents/tools/chat_context.py:154-164

return (
table("documents").select(
"id,file_name,summary,concept_notes",
filters={
# #125 user scope is preserved: documents are# user-scoped WITHIN a shared offering."offering_id": f"in.({','.join(offering_ids)})",
"user_id": f"eq.{user_id}",
},
order="created_at.desc",
)

documents is soft-deleted, not hard-deleted — routes/documents.py:288-293: "Soft delete (0025): stamp deleted_at; reads filter it out." Every other reader honours that: routes/documents.py:241, routes/study_guide.py:137, routes/flashcards.py:153 and :163, routes/documents.py:1230 all pass "deleted_at": "is.null". This query does not. Before this PR that was invisible because the query 400'd and returned [] unconditionally; this PR is what makes it return rows, so it is this PR that first exposes deleted documents. Concretely: a student deletes a document from their Library, documents.deleted_at is stamped, the row stays, and the tutor keeps decrypting its summary + concept_notes into the LLM context on every search_course_materials call. Nothing in backend/agents/ or the tutor tests references deleted_at, so no test catches it.

[P1] The stale run_result.output read is unguarded on the JSON path — the very fallback this PR routes textless turns intobackend/routes/learn.py:636-654

result=record_agent_usage(
awaitagent.run(user_message, **run_kwargs),
feature="chat_tutor", task="chat_tutor", user_id=deps.user_id,
)
reply=result.output# str — chat_tutor agents return plain Markdown.ifnotreply.strip():

_prepare_chat_run puts "message_history": message_history into run_kwargs (routes/learn.py:577), so result.output here resolves out of the same history-bearing message list the PR indicts. The only guard is not reply.strip() — and the PR's whole point is that a replayed prior turn is "fully formed, non-blank, and therefore invisible to the blank-reply ladder". _chat_turn_json is both a first-class route (POST /api/learn/chat, routes/learn.py:745-751) and the streamed route's Rung-1 nonstream_fallback (routes/learn.py:823). So after this change a textless streamed turn correctly refuses the stale text, degrades to _chat_turn_json, and if that re-run is also textless — the PR body measures ~40% — it takes the prior assistant message from message_history, persists it via save_message (routes/learn.py:727) and returns it. The repeat is not eliminated, it is moved one rung down, onto a path this PR sends more traffic to.

[P1] Offering resolution is narrower than the writer's and than every sibling reader'sbackend/agents/tools/chat_context.py:151-153

offering_ids=user_offering_ids_for_course(user_id, course_id)
ifnotoffering_ids:
return []

user_offering_ids_for_course (services/academics.py:185-200) intersects the course's offerings with the user's enrollments rows. Documents are written with a different resolver — offering_id = resolve_offering(course_id, create=True) at routes/documents.py:572 and :710 — which picks the current-term offering (creating it if absent, else falling back to any offering of the course) and never consults enrollments. The two sibling readers use the writer's resolver too: routes/study_guide.py:136 and routes/flashcards.py:144 filter on resolve_offering(...). Divergence is reachable across a term boundary: a student enrolled via routes/onboarding.py:95-112 in Fall-26's offering who uploads in the next term gets documents.offering_id = the new term's offering, has no enrollment row for it, and the tutor silently returns [] — while the Library still lists the file (routes/documents.py:239-241 filters on user_id only). Note the enrollment intersection buys no safety: user_id is already the security boundary (your own #125 comment says so), so this filter can only remove the user's own documents.

P2

[P2] The empty-offering short-circuit is silent — the exact failure mode this PR exists to removebackend/agents/tools/chat_context.py:152-153

ifnotoffering_ids:
return []

The PR body's own indictment of the old bug is "The tutor answered from base knowledge alone, which presents as 'it's generic about my class' rather than as a failure. Nothing was logged at the user's level." This early return reproduces that precisely: no log, no metric, indistinguishable from "this course genuinely has no materials". The except below it does logger.exception; this branch gets nothing. Given the P1 above makes it reachable, a log line here is what turns the next occurrence of this class of bug into a five-minute diagnosis instead of a two-month one.

P3

[P3] Two extra uncached PostgREST round-trips per tool call, in the streaming hot pathbackend/services/academics.py:191-200 via chat_context.py:151

offs=table("course_offerings").select(
"id", filters={"course_id": f"eq.{course_id}"}
) or []
off_ids= {o["id"] foroinoffs}
ifnotoff_ids:
return []
enr=table("enrollments").select(
"offering_id", filters={"user_id": f"eq.{user_id}"}
) or []

user_offering_ids_for_course is deliberately uncached and the second select pulls all of the user's enrollments across every course. search_course_materials is an LLM-callable tool that can fire more than once per turn, on the latency-critical SSE path — so this is +2 sequential round-trips per call. The documents select that follows is also unbounded (no limit, order="created_at.desc" only) and every returned row is AES-decrypted before the list is truncated to limit (5) in Python at chat_context.py:213. Harmless today; both become real once a course accumulates documents.

[P3] No test pins the newly-reachable textless-turn-with-writes branchbackend/tests/test_chat_stream.py:710-750

agent=FakeAgent([
FunctionToolCallEvent("read_graph_neighborhood"),
FunctionToolResultEvent(), # no writes landedAgentRunResultEvent(PRIOR), # stale: from message_history
])

The new test only covers the no-writes path (Rung-1 fallback). But the trigger it describes is "the model ended its turn after tool calls", and every tutor agent registers apply_graph_update_tool and update_mastery_tool (agents/chat_tutor.py:161-162), which populate deps.graph_updates / deps.mastery_changes (agents/tools/graph.py:122, :174). So the likely shape of a textless turn is textless-with-writes, which lands on the terminal retryable: False error at chat_stream.py:362-368 — a user-visible dead end, and the branch with no new coverage. Worth one more FakeAgent case with an on_fire write.

What's good

  • The root-cause work is real and checkable: 0025_study_integrity.sql:15-31 confirms documents has offering_id NOT NULL and no course_id, exactly as claimed.
  • The new TestSearchCourseMaterialsOfferingScope fake raises on a course_id filter instead of accepting any filter — the right response to "the existing mocks accept any filter, which is precisely how bug 2 survived them".
  • The chat_stream.py guard is correctly scoped: chat_tutor is output_type=str (agents/chat_tutor.py:174), so there is no output-tool channel whose reply the joined.strip() condition could throw away. Per the Sapling Engineering Style Guide the new sync user_offering_ids_for_course call also stays off the event loop — it sits inside _fetch, which runs under asyncio.to_thread (chat_context.py:174).

Verdict: request changes — the soft-delete regression and the unguarded result.output on the JSON fallback path both need fixing before merge; the CI failure is pre-existing on main and should be unblocked there.


Review-only pass — no code changed and nothing fixed. Conventions checked against the Canopy live docs (Engineering Style Guide, Architecture, Infrastructure, Backend & AI Agents). Every finding cites a snippet re-read at this PR's head SHA; severity: P0 blocker · P1 major · P2 minor · P3 nit.

…le degrade
Three findings on the course-materials read, all made reachable by the
offering fix in b8aa904 — before it the query 400'd and returned [] on
every call, so none of them could be observed.
1. `documents` is soft-deleted. routes/documents.py stamps `deleted_at`
and every other reader filters on it (study_guide.py, flashcards.py);
this query did not, so a file the student deleted from their Library
kept getting its `summary` + `concept_notes` decrypted into LLM
context forever. Adds `deleted_at is.null`, which also makes the
filter set identical to PR #534's fix of the same bug — the eventual
merge conflict is now trivial.
2. `user_offering_ids_for_course` is narrower than the WRITER.
Documents are written with `resolve_offering(course_id, create=True)`
— current term, `enrollments` never consulted — and the sibling
readers use the writer's resolver too. Across a term boundary a
student enrolled in Fall-26 who uploads next term gets
`documents.offering_id` = the new offering, has no enrollment row for
it, and the tutor silently returned [] while the Library still listed
the file. The intersection bought no security either: `user_id` is
the access boundary on `documents` (#125), so dropping offerings can
only hide the student's OWN uploads. Widened to the union of both
resolvers, order-stable for the `in.(...)` list.
3. The empty-offering short-circuit was silent — no log, no metric,
indistinguishable from "this course has no materials", which is
exactly the failure mode the offering fix exists to remove. It logs
now, without a raw student id.
Also bounds the read. The select was unbounded while every returned row
gets AES-decrypted before Python truncates to `limit`, on the
latency-critical SSE path. The bound is a multiple of `limit`, not
`limit` itself: ranking happens after the fetch, so limiting to exactly
`limit` would silently turn "most relevant" into "most recent".
The new tests use a schema-faithful `table()` fake that rejects filter
columns `documents` does not have. The older mocks in that file accept
any filter and return a canned list, which is precisely how a query
against a non-existent column survived review.
… path
5092a83 narrowed `run_result.output` inside `stream_agent_turn`, but
routes/learn.py reads it in three more places and two of them run with
`message_history` in `run_kwargs` — so `.output` resolves out of the same
history-bearing message list, and a textless turn hands back the
PREVIOUS turn's assistant message: fully formed, non-blank, and
therefore invisible to the `if not reply.strip()` guard sitting right
below it.
That relocated the repeat rather than removing it, onto a path taking
MORE traffic: `_chat_turn_json` is both POST /api/learn/chat and the
streamed route's Rung-1 `nonstream_fallback`, which is exactly where the
streaming fix now sends textless turns. `_action_turn` is the third
reader and persists with `save_message` directly.
`new_messages()` excludes the history that was passed in, so joining the
TextParts of its model responses is exactly "what this turn said" — the
non-streaming twin of the `joined.strip()` check in chat_stream.py.
`_start_session_agent` passes `message_history=[]`, so it has no prior
message to resolve back to and keeps reading `.output` directly; the
comment there names why.
Also adds the missing coverage for the newly-reachable streamed branch.
Every tutor agent registers `apply_graph_update_tool` and
`update_mastery_tool`, so a model that ends its turn after tool calls
usually arrives with `deps.graph_updates` / `deps.mastery_changes`
already populated — the textless-WITH-writes shape, which lands on the
terminal `retryable: False` rung rather than Rung 1. That was untested.
The agent-run fakes had to become shape-faithful (tests/agent_run_fakes.py):
`SimpleNamespace(output=...)` has no `new_messages`, and a bare MagicMock
iterates EMPTY on it — which would have silently turned every mocked
turn into a "textless" one while the assertions still went green through
the 502 catch-all. That is the same class of blind spot as the mock that
accepted a filter on a column PostgREST does not have.
@Jose-Gael-Cruz-Lopez

Copy link
Copy Markdown
Member

Review fixes applied

Every outstanding finding on this PR (human review + CodeRabbit) has been addressed and pushed.

Major

  • Soft-deleted documents reached the tutor. Added deleted_at is.null to the documents select. This PR is what first exposed them (the query used to 400 into the silent degrade), so a deleted file's decrypted summary/concept_notes would have kept flowing into LLM context. Filter set is now byte-identical to fix(tutor): repair course-material retrieval, silence course-scope commentary, make quizzes practical #534's, making that overlap a trivial pick-either.
  • The stale run_result.output read was only narrowed on the streamed path._chat_turn_json is both a first-class route and the streamed route's Rung-1 fallback — i.e. exactly where this PR now sends textless turns — so the repeat was relocated, not removed. Added _new_run_text() (the non-streaming twin of the joined.strip() narrowing) and applied it to both history-bearing .output sites; the message_history=[] site is left alone with a comment saying why.
  • Offering resolution was narrower than the writer's. Now the union of user_offering_ids_for_course and resolve_offering, so a document uploaded across a term boundary is still found. Test covers the cross-term case.

Minor

  • The empty-offering short-circuit logs a warning instead of returning [] silently — the exact failure mode this PR exists to remove.
  • limit pushed into the query so rows are no longer AES-decrypted and then discarded.
  • Added the missing textless-turn-with-writes case (the likely shape, landing on the terminal retryable: False path).

Not fixed here

The red Backend (pytest) is the otel _IncludedRouter failure that is pre-existing on main; #563 carries the shim that fixes it.

Verificationruff check . clean · 1973 passed, 56 skipped

Fixes applied and verified locally against this branch head; each figure above is a command I ran, not an estimate.

@Jose-Gael-Cruz-Lopez

Copy link
Copy Markdown
Member

Verify against the real database before merging

I could not reach a live database while working on this — there are no credentials on this machine (only .env.example files) and the local Supabase stack needs a container runtime that wouldn't start. So everything below was verified statically, by replaying every migration in ledger order to build a schema model and checking this PR's DB access against it. That model found 0 schema mismatches here, and it is trustworthy enough to have independently reproduced the documents.course_id absence that caused #562/#534 — but it cannot see data, and it cannot see whether staging's ledger actually matches the repo.

These are the checks that need a real connection.

No schema change — but the behaviour change is data-dependent

Two fixes here only show their value against real rows, so they are worth confirming on staging.

1. How much was actually leaking

The documents read had no deleted_at filter. Before this PR the query 400'd and returned [], so nothing leaked; this PR is what makes it return rows, which is why the filter had to be added in the same change.

SELECTcount(*) FROM documents WHERE deleted_at IS NOT NULL;

That count is exactly how many soft-deleted documents the tutor would otherwise have decrypted into LLM context. If it is non-zero, this fix is load-bearing rather than theoretical.

2. The cross-term offering gap is real or it isn't

The offering set is now the union of user_offering_ids_for_course (enrollment-derived) and resolve_offering (the writer's resolver), because documents are written with the latter and read with the former.

-- documents sitting on an offering the owner has no enrollment row for:-- these are exactly the files the tutor could not see before this changeSELECTcount(*) FROM documents d
WHEREd.deleted_at IS NULLAND NOT EXISTS (
SELECT1FROM enrollments e
WHEREe.user_id=d.user_idANDe.offering_id=d.offering_id);

Non-zero means the divergence is already live, not just reachable at the next term boundary.

3. Sanity-check the filter set on the wire

The final query filters on exactly offering_id in (...), user_id eq, deleted_at is.null. Worth eyeballing one real request in logs to confirm no fourth filter crept in — this is the query whose column set was wrong for months without anyone noticing.

Static verification only — no live database was reachable from this environment. Schema model built by replaying backend/db/migrations/ in ledger order.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@Darkest-Teddy@Jose-Gael-Cruz-Lopez
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })(); fix(tutor): stop the tutor repeating itself, and let it actually read course materials by Darkest-Teddy · Pull Request #562 · SaplingLearn/Sapling · GitHub
Skip to content

fix(tutor): stop the tutor repeating itself, and let it actually read course materials - #562

Open
Darkest-Teddy wants to merge 5 commits into
mainfrom
fix/tutor-repeat-and-course-materials
Open

fix(tutor): stop the tutor repeating itself, and let it actually read course materials#562
Darkest-Teddy wants to merge 5 commits into
mainfrom
fix/tutor-repeat-and-course-materials

Conversation

@Darkest-Teddy

@Darkest-TeddyDarkest-Teddy commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator

Two independent bugs in the tutor chat, found while debugging "why does the AI repeat the message instead of responding to it". One commit each.

1. The tutor replayed its previous reply verbatim

stream_agent_turn preferred run_result.output over the streamed chunks:

reply=final_outputiffinal_outputisnotNoneelsejoined

.output resolves out of the run's message list, and that list includes message_history. So a turn whose model response carries no text part — the model ended its turn after tool calls — handed back the previous turn's assistant message. Fully formed and non-blank, so the blank-reply ladder immediately below never saw it. The route persisted it, and the student got a byte-identical copy of the last answer.

Confirmed in two live sessions two days apart: same sha256, same length, including a 1757-character reply reproduced exactly against a completely different question.

Fix: trust final_output only when this turn actually streamed text. Text always arrives as PartStart/PartDelta events, so "nothing streamed" means "this turn produced no text" — degrade through the existing blank-reply ladder rather than replaying history.

Evidence. Replaying a real session turn against gemini-2.5-flash-lite, the correlation is exact:

streamed token/replyoutcomecount
0repeated the prior answer2/8
>0answered correctly6/8

After the fix: 0 repeats in 10 runs.

What this does not fix: the model still ends ~40% of these turns without text. Those now take the Rung-1 JSON fallback (a real answer), or — if graph writes already landed — show the honest "interrupted, please retry". A silent wrong answer becomes a correct one or a visible error. Why the model does this at all is unexplored and worth a follow-up.

2. The tutor never read any course document

search_course_materials filtered documents on course_id. That table keys on offering_id (0025) and has no course_id column, so PostgREST 400s on every call — and the tool's deliberate degrade-silently except turned that into [].

The tutor answered from base knowledge alone, which presents as "it's generic about my class" rather than as a failure. Nothing was logged at the user's level.

Fix: resolve the abstract course to the user's offerings via academics.user_offering_ids_for_course, matching the convention that the API boundary keeps the abstract course while documents key on the offering. The #125 user_id scope is unchanged — documents stay user-scoped within a shared offering.

Tests

Both regression tests were written first and watched fail for the right reason:

  • test_textless_turn_never_replays_the_previous_turns_reply — failed with on_complete receiving the stale prior-turn text.
  • test_documents_are_fetched_by_offering_not_course_id — failed with column documents.course_id does not exist.

The second one needed a schema-faithful fake. The existing mocks accept any filter, which is precisely how bug 2 survived them. The two older search_course_materials test classes now stub the offering lookup, which became a real dependency of that function.

Full backend suite: 1999 passed, 56 skipped, exit 0. ruff check clean on all four files.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes

    • Course material searches now return documents associated with the user’s course offerings while maintaining access restrictions.
    • Prevented tool-only chat turns from displaying or saving stale assistant responses from earlier messages.
    • Ensured empty or non-text streaming responses use a fresh fallback reply.
  • Tests

    • Added coverage for offering-based material searches, access scoping, and stale-response prevention.

Darkest-Teddyand others added 2 commits August 13, 2026 22:21
`stream_agent_turn` preferred `run_result.output` over the streamed
chunks. That output resolves out of the run's message list, and that
list includes `message_history` — so a turn whose model response
carries no text part (the model ended its turn after tool calls) handed
back the PREVIOUS turn's assistant message: fully formed, non-blank, and
therefore invisible to the blank-reply ladder below it.
The route then persisted it, so the tutor answered a follow-up with a
byte-identical copy of its own last answer — same sha256, same length,
including a 1757-char reply reproduced verbatim against a completely
different question.
Trust `final_output` only when this turn actually streamed text. Text
always arrives as PartStart/PartDelta events, so "nothing streamed"
means "this turn produced no text": degrade through the existing
blank-reply ladder instead of replaying history.
Measured on gemini-2.5-flash-lite by replaying a real session turn: the
correlation is exact — `token/reply == 0` repeated (2/8), any streamed
text answered correctly (6/8). After the fix, 0 repeats in 10 runs.
Note this does not stop the model from ending a turn without text; those
turns now take the Rung-1 fallback or a visible interrupted-error rather
than a silent duplicate.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…n't exist
`search_course_materials` filtered `documents` on `course_id`. That
table keys on `offering_id` (0025) and has no `course_id` column, so
PostgREST returned 400 on every call — and the tool's deliberate
degrade-silently `except` turned that into `[]`.
Net effect: the tutor never read a single course document, with no error
surfaced anywhere. It answered from base knowledge alone, which reads as
"the tutor is generic about my class" rather than as a bug.
Resolve the abstract course to the user's offerings via
`academics.user_offering_ids_for_course`, per the convention that the
API boundary keeps the abstract course while documents key on the
offering. The #125 user_id scope is unchanged: documents stay
user-scoped WITHIN a shared offering.
The existing unit tests missed this because they mock `table` loosely
enough to accept any filter; the new test uses a schema-faithful fake
that rejects a column the table does not have, exactly as PostgREST
does. The two older test classes now stub the offering lookup, which is
a real dependency of this function for the first time.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@supabase

supabaseBot commented Aug 14, 2026

Copy link
Copy Markdown

This pull request has been ignored for the connected project ybgqdonkoqftwrmweuyv because there are no changes detected in supabase directory. You can change this behaviour in Project Integrations Settings ↗︎.


Preview Branches by Supabase.
Learn more about Supabase Branching ↗︎.

@coderabbitai

coderabbitaiBot commented Aug 14, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@Jose-Gael-Cruz-Lopez, you've reached your PR review limit, so we couldn't start this review.

Next review available in:8 minutes

Limit details: You’ve used the included review currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

Wait for the limit to reset, then comment @coderabbitai review or push new commits to the PR.

An organization admin can change what happens after included review limits in Billing.

How do review limits work?

CodeRabbit enforces per-developer PR review limits within each organization.

For paid Pro and Pro+ reviews, CodeRabbit uses a developer's included PR review attempts over the past 7 days to set the current hourly allowance. At typical activity levels, the full plan allowance applies. Higher sustained activity can lower the allowance until earlier attempts leave the 7-day window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 0bb76370-0c3c-4d13-a541-292b94564424

📥 Commits

Reviewing files that changed from the base of the PR and between b8aa904 and 4e2949d.

📒 Files selected for processing (8)
  • backend/agents/tools/chat_context.py
  • backend/routes/learn.py
  • backend/tests/agent_run_fakes.py
  • backend/tests/test_chat_context_tools.py
  • backend/tests/test_chat_stream.py
  • backend/tests/test_event_capture_seams.py
  • backend/tests/test_graph_tools_bugs.py
  • backend/tests/test_learn_routes.py

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 06d0ac13-1c32-40b4-a002-18d7e154c3c1

📥 Commits

Reviewing files that changed from the base of the PR and between 9b77819 and b8aa904.

📒 Files selected for processing (4)
  • backend/agents/tools/chat_context.py
  • backend/services/chat_stream.py
  • backend/tests/test_chat_context_tools.py
  • backend/tests/test_chat_stream.py

📝 Walkthrough

Walkthrough

The change scopes course-material searches to the user’s offerings and updates streamed-turn reply selection to avoid stale history output. Tests cover offering resolution, user scoping, document lookup, and tool-only turns.

Changes

Chat behavior corrections

Layer / File(s)Summary
Offering-scoped course material search
backend/agents/tools/chat_context.py, backend/tests/test_chat_context_tools.py
Course searches resolve user-specific offering IDs and filter documents by offering_id while retaining user_id scoping. Tests cover matching offerings and schema-valid document queries.
Textless streamed turn handling
backend/services/chat_stream.py, backend/tests/test_chat_stream.py
Textless turns use joined current-turn chunks instead of stale message-history output. Regression coverage checks streaming, persistence, callbacks, and nonstream fallback behavior.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Mergeability Score:⚪ Minimal · up to b8aa9

The changes address two localized tutor-chat defects, with regression coverage and passing checks reported; no actionable merge-blocking risk remains beyond normal review.

Possibly related PRs

Suggested reviewers:andresl230, jose-gael-cruz-lopez

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 37.50% which is insufficient. The required threshold is 80.00%.Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check nameStatusExplanation
Title check✅ PassedThe title clearly summarizes both primary tutor fixes: preventing repeated replies and enabling course-material retrieval.
Description check✅ PassedThe description clearly explains both bugs, their fixes, regression tests, test results, and reviewer-relevant limitations.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/tutor-repeat-and-course-materials

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Aug 14, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-staging4e2949dCommit Preview URL

Branch Preview URL
Aug 19 2026, 09:02 PM

@Jose-Gael-Cruz-Lopez

Copy link
Copy Markdown
Member

Code review — tutor repetition + course-material retrieval

Two genuinely independent bugs, both diagnosed properly, both with regression tests written first. The documents.course_id finding is correct — migration 0025_study_integrity.sql recreates documents with offering_id TEXT NOT NULL REFERENCES course_offerings(id) and no course_id column, so the old filter really did 400 into the tool's degrade-silently except. The chat_stream guard is a one-line narrowing that is safe for this agent (chat_tutor is output_type=str, agents/chat_tutor.py:174, so its reply always arrives as text parts — there is no output-tool channel the guard could discard). Three things need attention before merge: the now-working documents query no longer honours the soft-delete convention every other reader follows, the offering resolution it picked can only shrink the user's own document set relative to the writer, and the identical stale-run_result.output read on the JSON path — which is exactly the fallback this PR now routes textless turns into — was left unguarded, so the repeat symptom can still be produced.

Findings

P0

[P0] CI is red — Backend (pytest) fails (pre-existing on main, not a regression from this PR) — run 31763538674

FAILED tests/test_quiz_preflight_a.py::TestQuizErrorEnvelope::test_method_not_allowed_gets_generic_code - AttributeError: '_IncludedRouter' object has no attribute 'path'
1 failed, 1965 passed, 56 skipped, 13 warnings in 42.25s
 if match == Match.PARTIAL:
> route = starlette_route.path
^^^^^^^^^^^^^^^^^^^^
E AttributeError: '_IncludedRouter' object has no attribute 'path'
/opt/hostedtoolcache/Python/3.13.14/x64/lib/python3.13/site-packages/opentelemetry/instrumentation/fastapi/__init__.py:495: AttributeError

Not this PR's fault. The identical failure is on main — run 31975087459 (4f5a7f90, "Merge pull request #524 from SaplingLearn/feat/landing-v5-port"): same test, same AttributeError, 1 failed, 1963 passed, 56 skipped. It is dependency drift in opentelemetry-instrumentation-fastapi._get_route_details, which assumes every Match.PARTIAL route object has .path; FastAPI's _IncludedRouter does not. None of this PR's four files touch routing or quiz. It still blocks the merge queue, so it needs a pin/fix on main first — worth noting because the PR body claims "Full backend suite: 1999 passed, 56 skipped, exit 0", which does not match CI.

P1

[P1] Soft-deleted documents now reach the tutorbackend/agents/tools/chat_context.py:154-164

return (
table("documents").select(
"id,file_name,summary,concept_notes",
filters={
# #125 user scope is preserved: documents are# user-scoped WITHIN a shared offering."offering_id": f"in.({','.join(offering_ids)})",
"user_id": f"eq.{user_id}",
},
order="created_at.desc",
)

documents is soft-deleted, not hard-deleted — routes/documents.py:288-293: "Soft delete (0025): stamp deleted_at; reads filter it out." Every other reader honours that: routes/documents.py:241, routes/study_guide.py:137, routes/flashcards.py:153 and :163, routes/documents.py:1230 all pass "deleted_at": "is.null". This query does not. Before this PR that was invisible because the query 400'd and returned [] unconditionally; this PR is what makes it return rows, so it is this PR that first exposes deleted documents. Concretely: a student deletes a document from their Library, documents.deleted_at is stamped, the row stays, and the tutor keeps decrypting its summary + concept_notes into the LLM context on every search_course_materials call. Nothing in backend/agents/ or the tutor tests references deleted_at, so no test catches it.

[P1] The stale run_result.output read is unguarded on the JSON path — the very fallback this PR routes textless turns intobackend/routes/learn.py:636-654

result=record_agent_usage(
awaitagent.run(user_message, **run_kwargs),
feature="chat_tutor", task="chat_tutor", user_id=deps.user_id,
)
reply=result.output# str — chat_tutor agents return plain Markdown.ifnotreply.strip():

_prepare_chat_run puts "message_history": message_history into run_kwargs (routes/learn.py:577), so result.output here resolves out of the same history-bearing message list the PR indicts. The only guard is not reply.strip() — and the PR's whole point is that a replayed prior turn is "fully formed, non-blank, and therefore invisible to the blank-reply ladder". _chat_turn_json is both a first-class route (POST /api/learn/chat, routes/learn.py:745-751) and the streamed route's Rung-1 nonstream_fallback (routes/learn.py:823). So after this change a textless streamed turn correctly refuses the stale text, degrades to _chat_turn_json, and if that re-run is also textless — the PR body measures ~40% — it takes the prior assistant message from message_history, persists it via save_message (routes/learn.py:727) and returns it. The repeat is not eliminated, it is moved one rung down, onto a path this PR sends more traffic to.

[P1] Offering resolution is narrower than the writer's and than every sibling reader'sbackend/agents/tools/chat_context.py:151-153

offering_ids=user_offering_ids_for_course(user_id, course_id)
ifnotoffering_ids:
return []

user_offering_ids_for_course (services/academics.py:185-200) intersects the course's offerings with the user's enrollments rows. Documents are written with a different resolver — offering_id = resolve_offering(course_id, create=True) at routes/documents.py:572 and :710 — which picks the current-term offering (creating it if absent, else falling back to any offering of the course) and never consults enrollments. The two sibling readers use the writer's resolver too: routes/study_guide.py:136 and routes/flashcards.py:144 filter on resolve_offering(...). Divergence is reachable across a term boundary: a student enrolled via routes/onboarding.py:95-112 in Fall-26's offering who uploads in the next term gets documents.offering_id = the new term's offering, has no enrollment row for it, and the tutor silently returns [] — while the Library still lists the file (routes/documents.py:239-241 filters on user_id only). Note the enrollment intersection buys no safety: user_id is already the security boundary (your own #125 comment says so), so this filter can only remove the user's own documents.

P2

[P2] The empty-offering short-circuit is silent — the exact failure mode this PR exists to removebackend/agents/tools/chat_context.py:152-153

ifnotoffering_ids:
return []

The PR body's own indictment of the old bug is "The tutor answered from base knowledge alone, which presents as 'it's generic about my class' rather than as a failure. Nothing was logged at the user's level." This early return reproduces that precisely: no log, no metric, indistinguishable from "this course genuinely has no materials". The except below it does logger.exception; this branch gets nothing. Given the P1 above makes it reachable, a log line here is what turns the next occurrence of this class of bug into a five-minute diagnosis instead of a two-month one.

P3

[P3] Two extra uncached PostgREST round-trips per tool call, in the streaming hot pathbackend/services/academics.py:191-200 via chat_context.py:151

offs=table("course_offerings").select(
"id", filters={"course_id": f"eq.{course_id}"}
) or []
off_ids= {o["id"] foroinoffs}
ifnotoff_ids:
return []
enr=table("enrollments").select(
"offering_id", filters={"user_id": f"eq.{user_id}"}
) or []

user_offering_ids_for_course is deliberately uncached and the second select pulls all of the user's enrollments across every course. search_course_materials is an LLM-callable tool that can fire more than once per turn, on the latency-critical SSE path — so this is +2 sequential round-trips per call. The documents select that follows is also unbounded (no limit, order="created_at.desc" only) and every returned row is AES-decrypted before the list is truncated to limit (5) in Python at chat_context.py:213. Harmless today; both become real once a course accumulates documents.

[P3] No test pins the newly-reachable textless-turn-with-writes branchbackend/tests/test_chat_stream.py:710-750

agent=FakeAgent([
FunctionToolCallEvent("read_graph_neighborhood"),
FunctionToolResultEvent(), # no writes landedAgentRunResultEvent(PRIOR), # stale: from message_history
])

The new test only covers the no-writes path (Rung-1 fallback). But the trigger it describes is "the model ended its turn after tool calls", and every tutor agent registers apply_graph_update_tool and update_mastery_tool (agents/chat_tutor.py:161-162), which populate deps.graph_updates / deps.mastery_changes (agents/tools/graph.py:122, :174). So the likely shape of a textless turn is textless-with-writes, which lands on the terminal retryable: False error at chat_stream.py:362-368 — a user-visible dead end, and the branch with no new coverage. Worth one more FakeAgent case with an on_fire write.

What's good

  • The root-cause work is real and checkable: 0025_study_integrity.sql:15-31 confirms documents has offering_id NOT NULL and no course_id, exactly as claimed.
  • The new TestSearchCourseMaterialsOfferingScope fake raises on a course_id filter instead of accepting any filter — the right response to "the existing mocks accept any filter, which is precisely how bug 2 survived them".
  • The chat_stream.py guard is correctly scoped: chat_tutor is output_type=str (agents/chat_tutor.py:174), so there is no output-tool channel whose reply the joined.strip() condition could throw away. Per the Sapling Engineering Style Guide the new sync user_offering_ids_for_course call also stays off the event loop — it sits inside _fetch, which runs under asyncio.to_thread (chat_context.py:174).

Verdict: request changes — the soft-delete regression and the unguarded result.output on the JSON fallback path both need fixing before merge; the CI failure is pre-existing on main and should be unblocked there.


Review-only pass — no code changed and nothing fixed. Conventions checked against the Canopy live docs (Engineering Style Guide, Architecture, Infrastructure, Backend & AI Agents). Every finding cites a snippet re-read at this PR's head SHA; severity: P0 blocker · P1 major · P2 minor · P3 nit.

…le degrade
Three findings on the course-materials read, all made reachable by the
offering fix in b8aa904 — before it the query 400'd and returned [] on
every call, so none of them could be observed.
1. `documents` is soft-deleted. routes/documents.py stamps `deleted_at`
and every other reader filters on it (study_guide.py, flashcards.py);
this query did not, so a file the student deleted from their Library
kept getting its `summary` + `concept_notes` decrypted into LLM
context forever. Adds `deleted_at is.null`, which also makes the
filter set identical to PR #534's fix of the same bug — the eventual
merge conflict is now trivial.
2. `user_offering_ids_for_course` is narrower than the WRITER.
Documents are written with `resolve_offering(course_id, create=True)`
— current term, `enrollments` never consulted — and the sibling
readers use the writer's resolver too. Across a term boundary a
student enrolled in Fall-26 who uploads next term gets
`documents.offering_id` = the new offering, has no enrollment row for
it, and the tutor silently returned [] while the Library still listed
the file. The intersection bought no security either: `user_id` is
the access boundary on `documents` (#125), so dropping offerings can
only hide the student's OWN uploads. Widened to the union of both
resolvers, order-stable for the `in.(...)` list.
3. The empty-offering short-circuit was silent — no log, no metric,
indistinguishable from "this course has no materials", which is
exactly the failure mode the offering fix exists to remove. It logs
now, without a raw student id.
Also bounds the read. The select was unbounded while every returned row
gets AES-decrypted before Python truncates to `limit`, on the
latency-critical SSE path. The bound is a multiple of `limit`, not
`limit` itself: ranking happens after the fetch, so limiting to exactly
`limit` would silently turn "most relevant" into "most recent".
The new tests use a schema-faithful `table()` fake that rejects filter
columns `documents` does not have. The older mocks in that file accept
any filter and return a canned list, which is precisely how a query
against a non-existent column survived review.
… path
5092a83 narrowed `run_result.output` inside `stream_agent_turn`, but
routes/learn.py reads it in three more places and two of them run with
`message_history` in `run_kwargs` — so `.output` resolves out of the same
history-bearing message list, and a textless turn hands back the
PREVIOUS turn's assistant message: fully formed, non-blank, and
therefore invisible to the `if not reply.strip()` guard sitting right
below it.
That relocated the repeat rather than removing it, onto a path taking
MORE traffic: `_chat_turn_json` is both POST /api/learn/chat and the
streamed route's Rung-1 `nonstream_fallback`, which is exactly where the
streaming fix now sends textless turns. `_action_turn` is the third
reader and persists with `save_message` directly.
`new_messages()` excludes the history that was passed in, so joining the
TextParts of its model responses is exactly "what this turn said" — the
non-streaming twin of the `joined.strip()` check in chat_stream.py.
`_start_session_agent` passes `message_history=[]`, so it has no prior
message to resolve back to and keeps reading `.output` directly; the
comment there names why.
Also adds the missing coverage for the newly-reachable streamed branch.
Every tutor agent registers `apply_graph_update_tool` and
`update_mastery_tool`, so a model that ends its turn after tool calls
usually arrives with `deps.graph_updates` / `deps.mastery_changes`
already populated — the textless-WITH-writes shape, which lands on the
terminal `retryable: False` rung rather than Rung 1. That was untested.
The agent-run fakes had to become shape-faithful (tests/agent_run_fakes.py):
`SimpleNamespace(output=...)` has no `new_messages`, and a bare MagicMock
iterates EMPTY on it — which would have silently turned every mocked
turn into a "textless" one while the assertions still went green through
the 502 catch-all. That is the same class of blind spot as the mock that
accepted a filter on a column PostgREST does not have.
@Jose-Gael-Cruz-Lopez

Copy link
Copy Markdown
Member

Review fixes applied

Every outstanding finding on this PR (human review + CodeRabbit) has been addressed and pushed.

Major

  • Soft-deleted documents reached the tutor. Added deleted_at is.null to the documents select. This PR is what first exposed them (the query used to 400 into the silent degrade), so a deleted file's decrypted summary/concept_notes would have kept flowing into LLM context. Filter set is now byte-identical to fix(tutor): repair course-material retrieval, silence course-scope commentary, make quizzes practical #534's, making that overlap a trivial pick-either.
  • The stale run_result.output read was only narrowed on the streamed path._chat_turn_json is both a first-class route and the streamed route's Rung-1 fallback — i.e. exactly where this PR now sends textless turns — so the repeat was relocated, not removed. Added _new_run_text() (the non-streaming twin of the joined.strip() narrowing) and applied it to both history-bearing .output sites; the message_history=[] site is left alone with a comment saying why.
  • Offering resolution was narrower than the writer's. Now the union of user_offering_ids_for_course and resolve_offering, so a document uploaded across a term boundary is still found. Test covers the cross-term case.

Minor

  • The empty-offering short-circuit logs a warning instead of returning [] silently — the exact failure mode this PR exists to remove.
  • limit pushed into the query so rows are no longer AES-decrypted and then discarded.
  • Added the missing textless-turn-with-writes case (the likely shape, landing on the terminal retryable: False path).

Not fixed here

The red Backend (pytest) is the otel _IncludedRouter failure that is pre-existing on main; #563 carries the shim that fixes it.

Verificationruff check . clean · 1973 passed, 56 skipped

Fixes applied and verified locally against this branch head; each figure above is a command I ran, not an estimate.

@Jose-Gael-Cruz-Lopez

Copy link
Copy Markdown
Member

Verify against the real database before merging

I could not reach a live database while working on this — there are no credentials on this machine (only .env.example files) and the local Supabase stack needs a container runtime that wouldn't start. So everything below was verified statically, by replaying every migration in ledger order to build a schema model and checking this PR's DB access against it. That model found 0 schema mismatches here, and it is trustworthy enough to have independently reproduced the documents.course_id absence that caused #562/#534 — but it cannot see data, and it cannot see whether staging's ledger actually matches the repo.

These are the checks that need a real connection.

No schema change — but the behaviour change is data-dependent

Two fixes here only show their value against real rows, so they are worth confirming on staging.

1. How much was actually leaking

The documents read had no deleted_at filter. Before this PR the query 400'd and returned [], so nothing leaked; this PR is what makes it return rows, which is why the filter had to be added in the same change.

SELECTcount(*) FROM documents WHERE deleted_at IS NOT NULL;

That count is exactly how many soft-deleted documents the tutor would otherwise have decrypted into LLM context. If it is non-zero, this fix is load-bearing rather than theoretical.

2. The cross-term offering gap is real or it isn't

The offering set is now the union of user_offering_ids_for_course (enrollment-derived) and resolve_offering (the writer's resolver), because documents are written with the latter and read with the former.

-- documents sitting on an offering the owner has no enrollment row for:-- these are exactly the files the tutor could not see before this changeSELECTcount(*) FROM documents d
WHEREd.deleted_at IS NULLAND NOT EXISTS (
SELECT1FROM enrollments e
WHEREe.user_id=d.user_idANDe.offering_id=d.offering_id);

Non-zero means the divergence is already live, not just reachable at the next term boundary.

3. Sanity-check the filter set on the wire

The final query filters on exactly offering_id in (...), user_id eq, deleted_at is.null. Worth eyeballing one real request in logs to confirm no fourth filter crept in — this is the query whose column set was wrong for months without anyone noticing.

Static verification only — no live database was reachable from this environment. Schema model built by replaying backend/db/migrations/ in ledger order.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@Darkest-Teddy@Jose-Gael-Cruz-Lopez