Skip to content

feat(tutor): graph-grounded retrieval + tool-use loop (#149) - #469

Merged
AndresL230 merged 3 commits into
mainfrom
feat/b7-149-graph-grounded-tutor
Jul 30, 2026
Merged

feat(tutor): graph-grounded retrieval + tool-use loop (#149)#469
AndresL230 merged 3 commits into
mainfrom
feat/b7-149-graph-grounded-tutor

Conversation

@AndresL230

@AndresL230AndresL230 commented Jul 30, 2026

Copy link
Copy Markdown
Collaborator

What

First link of the B7 seam-endgame chain (#149#153#150#151#154). The scoping pass corrected the issue's premise: the agent path inlined no graph context at all (the full-graph dump was legacy-only — and user-wide, leaking other courses' concepts), and the mid-turn write half already shipped with #127. Full detail in the commit message; the shape:

  • Read surface: read_graph_neighborhood (course-scoped, name-seeded, depth-1, ids never leave the tool) + read_concepts_for_user, registered on the tutor (5 → 7 tools) with a hard two-reads-per-turn prompt cap. The misconceptions reader is deliberately deferred until the shared-context opt-out is enforced (ADR 0023).
  • Deterministic seed block: every agent turn carries a ≤1.5k-char GRAPH CONTEXT block (overlap-first/weakest-fill + depth-1 edges) — turn-1 graph awareness without a tool round-trip on the streaming UI.
  • Legacy de-dump: the three call_gemini* prompt sites use the same compact serializer (kills the raw JSON dump and its cross-course leak) — de-risking the rung-1 fallback ahead of [P1] Agent migration: retire call_gemini* + gemini_service.py (final cutover) #151.
  • TUTOR_LIMITS (12/12/100k): a legitimate multi-tool turn can no longer trip UsageLimitExceeded into a silent legacy fallback.
  • The ADR-0021 retrieval seam: TutorRetrieval protocol on deps (production None → byte-identical), fixture retrieval + synthetic course, cassettes now record tool-call traces, and chat_tutor joins the offline eval harness (16 cases). The first commit on this branch holds the pre-change cassettes so the delta is auditable.

Eval deltas (before → after): ExpositoryHasStructure 0.812→1.000 · GraphToolUsed 0.938→1.000 · GroundedConcept 0.812→0.875 · MasteryUpdateEmitted 0.688→1.000 · the four legacy evaluators hold 1.000.

Verification

  • Backend 1440 passed + ruff clean; seam/stream/tool files re-run green under lock-pinned pydantic-ai 1.107; evals replay green across all six datasets; frontend untouched (tsc clean).
  • 40+ new tests incl. red-first tool-registration, id-leak/scoping/truncation coverage, the read-only-tools-never-emit-graph_update stream invariant, a scripted-tool seam test, and an opt-in live test proving the real model chooses the readers (run green once).
  • Known follow-up recorded in ADR 0023: gemini-2.5-pro occasionally emits a bare-newline final reply after an end-of-turn tool call — [P2] Agent platform: structured-output retry + validation hardening #153 (structured-output hardening) territory.
  • Full local e2e cycle pre-merge; results below.

Closes#149.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Chat tutoring now provides compact, course-scoped graph context for more relevant responses.
    • Added read-only graph tools for exploring related concepts and reviewing concept mastery.
    • Tutor responses can use course materials, progress, and session history through consistent retrieval.
    • Added offline chat-tutor evaluation coverage with recorded scenarios and tool-behavior checks.
  • Bug Fixes

    • Improved course isolation and prevented unrelated graph data from appearing in tutor context.
    • Read-only graph interactions no longer create unintended updates.
  • Documentation

    • Updated evaluation coverage and architecture decision records.

@supabase

supabaseBot commented Jul 30, 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 Jul 30, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@AndresL230, you've reached your PR review limit, so we couldn't start this review.

Next review available in:35 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 6c9c74c7-f890-4387-84d5-108dd11d7884

📥 Commits

Reviewing files that changed from the base of the PR and between 7eb0ce2 and 220d6a8.

📒 Files selected for processing (44)
  • .github/workflows/evals.yml
  • backend/agents/__init__.py
  • backend/agents/chat_tutor.py
  • backend/agents/deps.py
  • backend/agents/tools/chat_context.py
  • backend/agents/tools/graph_read.py
  • backend/agents/tools/retrieval.py
  • backend/routes/learn.py
  • backend/services/graph_context.py
  • backend/services/token_overlap.py
  • backend/tests/evals/README.md
  • backend/tests/evals/_retrieval_fixture.py
  • backend/tests/evals/baselines.json
  • backend/tests/evals/cassettes/chat_tutor/expository_explain_big_o.json
  • backend/tests/evals/cassettes/chat_tutor/expository_explain_dependency_injection.json
  • backend/tests/evals/cassettes/chat_tutor/expository_explain_kantian_ethics.json
  • backend/tests/evals/cassettes/chat_tutor/expository_explain_photosynthesis.json
  • backend/tests/evals/cassettes/chat_tutor/expository_explain_supply_demand.json
  • backend/tests/evals/cassettes/chat_tutor/socratic_chemistry_balancing.json
  • backend/tests/evals/cassettes/chat_tutor/socratic_history_themes.json
  • backend/tests/evals/cassettes/chat_tutor/socratic_intro_calculus.json
  • backend/tests/evals/cassettes/chat_tutor/socratic_open_followup.json
  • backend/tests/evals/cassettes/chat_tutor/socratic_python_recursion.json
  • backend/tests/evals/cassettes/chat_tutor/socratic_stale_concept_review.json
  • backend/tests/evals/cassettes/chat_tutor/teachback_advanced.json
  • backend/tests/evals/cassettes/chat_tutor/teachback_correct_concept.json
  • backend/tests/evals/cassettes/chat_tutor/teachback_minimal.json
  • backend/tests/evals/cassettes/chat_tutor/teachback_misconception.json
  • backend/tests/evals/cassettes/chat_tutor/teachback_partial_correct.json
  • backend/tests/evals/chat_tutor.py
  • backend/tests/evals/fixtures/tutor_course.json
  • backend/tests/evals/run_all.py
  • backend/tests/test_chat_stream.py
  • backend/tests/test_chat_tutor_imports.py
  • backend/tests/test_e2e_function_handlers.py
  • backend/tests/test_graph_context_block.py
  • backend/tests/test_graph_neighborhood_tool.py
  • backend/tests/test_graph_tools_bugs.py
  • backend/tests/test_live_tutor_tools.py
  • backend/tests/test_model_mode_seam.py
  • backend/tests/test_tutor_retrieval.py
  • docs/decisions/0015-refactor-3-chat-tutor-shipped.md
  • docs/decisions/0021-agent-eval-harness-baselines.md
  • docs/decisions/0023-tutor-graph-retrieval-seam.md
📝 Walkthrough

Walkthrough

The chat tutor now uses compact course-scoped graph context, exposes bounded read-only graph tools, supports injectable retrieval implementations, and runs offline evaluations against fixture data with structured tool-call cassettes and new behavioral evaluators.

Changes

Tutor graph grounding

Layer / File(s)Summary
Compact graph context and route wiring
backend/services/graph_context.py, backend/services/token_overlap.py, backend/routes/learn.py, backend/tests/test_graph_context_block.py
Graph context is selected, scoped, rendered within a character budget, and used by both agent and legacy tutor prompt paths.
Retrieval seam and graph read tools
backend/agents/tools/*, backend/agents/chat_tutor.py, backend/agents/deps.py, backend/tests/test_graph_neighborhood_tool.py, backend/tests/test_tutor_retrieval.py
Tutor reads resolve injected or Supabase retrieval, and graph readers provide bounded, course/user-scoped neighborhoods without graph writes.
Offline evaluation and regression coverage
backend/tests/evals/*, backend/tests/test_*.py, .github/workflows/evals.yml
Fixture retrieval, structured cassettes, graph/tool evaluators, baselines, CI gating, and integration tests cover the new tutor flow.

Estimated code review effort: 4 (Complex) | ~60 minutes

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 27.86% 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 the main change: graph-grounded tutor retrieval with tool use.
Description check✅ PassedThe description is substantial and covers the change, issues, and verification, though some template sections are omitted.
Linked Issues check✅ PassedThe PR implements course-scoped graph retrieval, prompt graph context, and mid-turn graph/mastery updates, matching #149.
Out of Scope Changes check✅ PassedMost changes support the tutor graph-grounding/eval harness work, with no clearly unrelated code changes evident.
✨ 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 feat/b7-149-graph-grounded-tutor

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.


async def course_materials(
self, course_id: str | None, query: str, limit: int, *, user_id: str
) -> list["CourseMaterial"]: ...
concepts: list[str],
*,
limit: int = 20,
) -> "GraphNeighborhood": ...

async def concept_mastery(
self, user_id: str, course_id: str | None
) -> list["ConceptMastery"]: ...

async def progress(
self, user_id: str, course_id: str | None
) -> "CourseProgress": ...

async def session_history(
self, session_id: str, last_n: int
) -> list["SessionMessage"]: ...
@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Jul 30, 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-staging220d6a8Commit Preview URL

Branch Preview URL
Jul 30 2026, 10:23 AM

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (6)
backend/tests/evals/chat_tutor.py (1)

459-478: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Prefer isinstance over string-based class-name matching for tool-call parts.

type(part).__name__ != "ToolCallPart" only matches parts of the exact ToolCallPart class. pydantic_ai's message types also include a distinct NativeToolCallPart for native/provider tool-calling paths; if any of the tutor's tools were ever surfaced that way, this check would silently drop them from the cassette, undercounting tool usage that GraphToolUsedEvaluator/MasteryUpdateEmittedEvaluator rely on.

♻️ Suggested fix
+from pydantic_ai.messages import ToolCallPart+
def _extract_tool_calls(result) -> list[ToolCall]:
...
for message in messages:
for part in getattr(message, "parts", []) or []:
- if type(part).__name__ != "ToolCallPart":+ if not isinstance(part, ToolCallPart):
continue
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/tests/evals/chat_tutor.py` around lines 459 - 478, Update
_extract_tool_calls to use isinstance-based recognition of pydantic_ai tool-call
part types, including both ToolCallPart and NativeToolCallPart, instead of
comparing type(part).__name__. Preserve the existing argument extraction
fallback and ToolCall construction for every recognized tool-call part.
backend/agents/tools/graph_read.py (2)

181-189: 🎯 Functional Correctness | 🔵 Trivial | 💤 Low value

Duplicate normalized concept names silently collapse.

by_norm[_normalize_concept(name)] = n is last-write-wins, so if the graph holds two rows normalizing to the same key (e.g. pre-dedup legacy rows), a seed resolves to whichever row came back last from PostgREST — non-deterministic across reads. Preferring the first occurrence (setdefault) at least makes it stable given a stable row order.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/agents/tools/graph_read.py` around lines 181 - 189, Update the
by_norm construction in the node-processing loop to preserve the first node for
each normalized concept name instead of overwriting it with later duplicates.
Replace the last-write-wins assignment associated with _normalize_concept(name)
while leaving by_id behavior unchanged.

104-105: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Clamp the LLM-chosen limit on the upper end too.

Both sites only floor at 0. A model that passes limit=5000 gets every tracked concept (and, for the neighborhood, every kept edge) back into the context window — the whole point of the cap. A module-level max (e.g. 50) keeps the tool's prompt cost bounded regardless of what the model asks for.

♻️ Suggested clamp
+_MAX_LIMIT = 50+
...
- n = max(0, int(limit))+ n = min(_MAX_LIMIT, max(0, int(limit)))
return rows[:n]
...
- cap = max(0, int(limit))+ cap = min(_MAX_LIMIT, max(0, int(limit)))

Also applies to: 244-247

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/agents/tools/graph_read.py` around lines 104 - 105, Clamp the
LLM-provided limit to a defined module-level maximum in both row-slicing sites,
including the neighborhood path referenced by the comment. Update the limit
normalization around `n = max(0, int(limit))` so values above the cap are
reduced while negative values still produce zero, keeping prompt results
bounded.
backend/agents/chat_tutor.py (1)

70-73: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

"Hard cap" is prompt-advisory only.

Nothing enforces two graph reads per turn; the real ceiling is TUTOR_LIMITS. Either soften the wording or enforce it in the tool wrapper (e.g. a per-run counter on SaplingDeps that returns an empty neighborhood past the second call) so cost is bounded even when the model ignores the instruction.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/agents/chat_tutor.py` around lines 70 - 73, Update the graph-read
enforcement around SaplingDeps and its tool wrapper so each tutor run permits no
more than two graph reads, returning an empty neighborhood after the second
call; otherwise soften the prompt’s “Hard cap” wording to match the actual
TUTOR_LIMITS behavior.
backend/tests/test_model_mode_seam.py (1)

304-307: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Registered function handler is never unregistered.

register_function_handler mutates a module-level registry that monkeypatch won't restore, so this handler leaks into any later test that runs chat_tutor in function mode. If the file doesn't already have an autouse reset fixture, worth adding one.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/tests/test_model_mode_seam.py` around lines 304 - 307, Add cleanup
for the module-level handler registry after tests using
register_function_handler, including the test around socratic_agent.run_sync and
the "chat_tutor" handler. Prefer an autouse fixture if the file lacks one,
ensuring the registry is reset after each test so handlers do not leak into
later function-mode tests.
backend/services/graph_context.py (1)

105-111: 🚀 Performance & Scalability | 🔵 Trivial | 💤 Low value

Budget trim rejoins the whole block each iteration.

Minor: with ≤12 lines this is irrelevant, but a running length counter (or trimming from a precomputed cumulative sum) avoids the O(n²) rejoin and reads clearer.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/services/graph_context.py` around lines 105 - 111, Update the
budget-trimming logic in the graph-context builder around _HEADER, lines, and
GRAPH_CONTEXT_CHAR_BUDGET to track the assembled character length incrementally
instead of rejoining the entire block on every loop iteration. Preserve the
existing behavior of removing trailing lines until the budget fits and returning
an empty string when no lines remain.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@backend/routes/learn.py`:
- Around line 520-523: Update the graph-context construction at the referenced
legacy-turn paths, including the flows around lines 520, 763, and 1328, so
course-less sessions retain an appropriate user-wide compact graph fallback
instead of receiving an empty block. Avoid calling get_graph when no course_id
is available unless the fallback requires it, and ensure the serializer’s
selection remains bounded and weakest-first.
In `@backend/tests/test_live_tutor_tools.py`:
- Around line 36-38: Update
test_real_model_chooses_a_graph_reader_for_a_graph_question to accept pytest’s
monkeypatch fixture and replace the direct sys.path.insert mutation with
monkeypatch.syspath_prepend(...), ensuring the evals path is restored after the
test.
In `@docs/decisions/0021-agent-eval-harness-baselines.md`:
- Around line 46-48: The ADRs retain obsolete pre-change descriptions of
chat_tutor. In docs/decisions/0021-agent-eval-harness-baselines.md lines 46-48,
update the five-dataset wording, baseline table, and “unmeasured” consequence to
include chat_tutor; in docs/decisions/0015-refactor-3-chat-tutor-shipped.md
lines 28-33, replace the remaining four-tool and four-registration descriptions
or explicitly mark them as historical.
---
Nitpick comments:
In `@backend/agents/chat_tutor.py`:
- Around line 70-73: Update the graph-read enforcement around SaplingDeps and
its tool wrapper so each tutor run permits no more than two graph reads,
returning an empty neighborhood after the second call; otherwise soften the
prompt’s “Hard cap” wording to match the actual TUTOR_LIMITS behavior.
In `@backend/agents/tools/graph_read.py`:
- Around line 181-189: Update the by_norm construction in the node-processing
loop to preserve the first node for each normalized concept name instead of
overwriting it with later duplicates. Replace the last-write-wins assignment
associated with _normalize_concept(name) while leaving by_id behavior unchanged.
- Around line 104-105: Clamp the LLM-provided limit to a defined module-level
maximum in both row-slicing sites, including the neighborhood path referenced by
the comment. Update the limit normalization around `n = max(0, int(limit))` so
values above the cap are reduced while negative values still produce zero,
keeping prompt results bounded.
In `@backend/services/graph_context.py`:
- Around line 105-111: Update the budget-trimming logic in the graph-context
builder around _HEADER, lines, and GRAPH_CONTEXT_CHAR_BUDGET to track the
assembled character length incrementally instead of rejoining the entire block
on every loop iteration. Preserve the existing behavior of removing trailing
lines until the budget fits and returning an empty string when no lines remain.
In `@backend/tests/evals/chat_tutor.py`:
- Around line 459-478: Update _extract_tool_calls to use isinstance-based
recognition of pydantic_ai tool-call part types, including both ToolCallPart and
NativeToolCallPart, instead of comparing type(part).__name__. Preserve the
existing argument extraction fallback and ToolCall construction for every
recognized tool-call part.
In `@backend/tests/test_model_mode_seam.py`:
- Around line 304-307: Add cleanup for the module-level handler registry after
tests using register_function_handler, including the test around
socratic_agent.run_sync and the "chat_tutor" handler. Prefer an autouse fixture
if the file lacks one, ensuring the registry is reset after each test so
handlers do not leak into later function-mode tests.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 10240725-b7f7-4f3d-8294-03d828114d0c

📥 Commits

Reviewing files that changed from the base of the PR and between ec0e2af and 7eb0ce2.

📒 Files selected for processing (44)
  • .github/workflows/evals.yml
  • backend/agents/__init__.py
  • backend/agents/chat_tutor.py
  • backend/agents/deps.py
  • backend/agents/tools/chat_context.py
  • backend/agents/tools/graph_read.py
  • backend/agents/tools/retrieval.py
  • backend/routes/learn.py
  • backend/services/graph_context.py
  • backend/services/token_overlap.py
  • backend/tests/evals/README.md
  • backend/tests/evals/_retrieval_fixture.py
  • backend/tests/evals/baselines.json
  • backend/tests/evals/cassettes/chat_tutor/expository_explain_big_o.json
  • backend/tests/evals/cassettes/chat_tutor/expository_explain_dependency_injection.json
  • backend/tests/evals/cassettes/chat_tutor/expository_explain_kantian_ethics.json
  • backend/tests/evals/cassettes/chat_tutor/expository_explain_photosynthesis.json
  • backend/tests/evals/cassettes/chat_tutor/expository_explain_supply_demand.json
  • backend/tests/evals/cassettes/chat_tutor/socratic_chemistry_balancing.json
  • backend/tests/evals/cassettes/chat_tutor/socratic_history_themes.json
  • backend/tests/evals/cassettes/chat_tutor/socratic_intro_calculus.json
  • backend/tests/evals/cassettes/chat_tutor/socratic_open_followup.json
  • backend/tests/evals/cassettes/chat_tutor/socratic_python_recursion.json
  • backend/tests/evals/cassettes/chat_tutor/socratic_stale_concept_review.json
  • backend/tests/evals/cassettes/chat_tutor/teachback_advanced.json
  • backend/tests/evals/cassettes/chat_tutor/teachback_correct_concept.json
  • backend/tests/evals/cassettes/chat_tutor/teachback_minimal.json
  • backend/tests/evals/cassettes/chat_tutor/teachback_misconception.json
  • backend/tests/evals/cassettes/chat_tutor/teachback_partial_correct.json
  • backend/tests/evals/chat_tutor.py
  • backend/tests/evals/fixtures/tutor_course.json
  • backend/tests/evals/run_all.py
  • backend/tests/test_chat_stream.py
  • backend/tests/test_chat_tutor_imports.py
  • backend/tests/test_e2e_function_handlers.py
  • backend/tests/test_graph_context_block.py
  • backend/tests/test_graph_neighborhood_tool.py
  • backend/tests/test_graph_tools_bugs.py
  • backend/tests/test_live_tutor_tools.py
  • backend/tests/test_model_mode_seam.py
  • backend/tests/test_tutor_retrieval.py
  • docs/decisions/0015-refactor-3-chat-tutor-shipped.md
  • docs/decisions/0021-agent-eval-harness-baselines.md
  • docs/decisions/0023-tutor-graph-retrieval-seam.md

Comment threadbackend/routes/learn.py
Comment on lines +36 to +38
def test_real_model_chooses_a_graph_reader_for_a_graph_question():
sys.path.insert(0, str(Path(__file__).parent / "evals"))
from _retrieval_fixture import FixtureRetrieval

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bashset -euo pipefail
echo"Files matching test name:"
fd -a 'test_live_tutor_tools\.py$'.||true
test_file="$(fd 'test_live_tutor_tools\.py$'.| head -n1 || true)"if [ -n"${test_file:-}" ];thenechoecho"Relevant test file:"echo"$test_file"echoecho"Line count:"
wc -l "$test_file"echoecho"First 120 lines:"
sed -n '1,120p'"$test_file"| cat -n
fiechoecho"Search for sys.path usage in file:"if [ -n"${test_file:-}" ];then
rg -n "sys\.path|syspath_prepend|Path\(__file__\).parent""$test_file"||truefiechoecho"Search for global sys.path mutations in backend/tests:"
rg -n "sys\.path\.insert|sys\.path\.append|sys\.path\.extend|sys\.path\.remove|syspath_prepend" backend/tests ||trueechoecho"pytest availability:"
python3 - <<'PY'try: import pytest print("pytest:", pytest.__version__)except Exception as e: print("pytest import failed:", repr(e))try: import _retrieval_fixture print("module _retrieval_fixture found")except Exception as e: print("_retrieval_fixture import failed:", repr(e))PY

Repository: SaplingLearn/Sapling

Length of output: 6296


Scope the import-path mutation to this test.

sys.path.insert(...) mutates the pytest process state for any following tests. Use pytest’s scoped monkeypatch.syspath_prepend(...) so the path is restored automatically after this test.

Proposed fix
-def test_real_model_chooses_a_graph_reader_for_a_graph_question():- sys.path.insert(0, str(Path(__file__).parent / "evals"))+def test_real_model_chooses_a_graph_reader_for_a_graph_question(monkeypatch):+ monkeypatch.syspath_prepend(str(Path(__file__).parent / "evals"))
📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
deftest_real_model_chooses_a_graph_reader_for_a_graph_question():
sys.path.insert(0, str(Path(__file__).parent/"evals"))
from_retrieval_fixtureimportFixtureRetrieval
deftest_real_model_chooses_a_graph_reader_for_a_graph_question(monkeypatch):
monkeypatch.syspath_prepend(str(Path(__file__).parent/"evals"))
from_retrieval_fixtureimportFixtureRetrieval
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/tests/test_live_tutor_tools.py` around lines 36 - 38, Update
test_real_model_chooses_a_graph_reader_for_a_graph_question to accept pytest’s
monkeypatch fixture and replace the direct sys.path.insert mutation with
monkeypatch.syspath_prepend(...), ensuring the evals path is restored after the
test.

Comment threaddocs/decisions/0021-agent-eval-harness-baselines.md
AndresL230 added a commit that referenced this pull request Jul 30, 2026
…ion test, tool-count comment, ADR 0015 prose
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Review pass complete: four reviewers, two fully clean (one empirically re-verified the before/after eval baselines by resurrecting the deleted pre-change cassettes and replaying them — exact match). Three sub-80 findings, all fixed: a /chat/stream twin of the raw-body.message persistence regression test (the invariant was correct but only the JSON route was pinned), the stale 'all five tools' comment, and the ADR 0015 blockquote that had swallowed a sentence. Backend 1441 + ruff green. e2e cycle next.

AndresL230and others added 3 commits July 30, 2026 03:21
…n the next commit; kept in PR history so the before→after delta is auditable)
The agent path inlined NO graph context (the full-graph JSON dump was
legacy-only — and user-wide, leaking other courses' concepts); the tutor
had write tools (#127) but no graph read surface. Now:
- Tools 5 → 7: read_graph_neighborhood (course-scoped, seed-matched via
_normalize_concept, depth-1 edges via two merged in.() reads with a
both-endpoints-in-course rule, ids never leave the tool, truncated flag,
degrade-to-empty) + read_concepts_for_user (existing reader, capped
wrapper). Preamble gains the graph-tools paragraph with a hard
two-reads-per-turn cap. read_misconceptions_for_course deliberately
deferred (shared-context opt-out isn't enforced yet — ADR 0023).
- Deterministic GRAPH CONTEXT seed block on every agent turn
(services/graph_context.py: overlap-first/weakest-fill selection,
depth-1 edges, ≤1.5k chars, no ids) — turn-1 graph awareness without a
tool round-trip on the streaming UI; raw body.message persistence
unchanged.
- Legacy prompt de-dumped: all three call sites now use the same compact
course-scoped serializer (also fixes the cross-course concept leak).
- TUTOR_LIMITS (12/12/100k) so a legitimate multi-tool turn can't trip
UsageLimitExceeded into a silent rung-1 legacy fallback.
- The ADR-0021 retrieval seam: TutorRetrieval protocol on SaplingDeps
(production None → byte-identical Supabase path), FixtureRetrieval +
committed synthetic course, cassettes now carry tool-call traces, three
new evaluators, chat_tutor joins the offline harness (16 cases; the
previous commit holds the pre-change cassettes).
Eval deltas (before → after): ExpositoryHasStructure 0.812→1.000,
GraphToolUsed 0.938→1.000, GroundedConcept 0.812→0.875,
MasteryUpdateEmitted 0.688→1.000; legacy four stay 1.000.
Suites: backend 1440 passed + ruff clean (seam/stream files green under
lock-pinned pydantic-ai 1.107); evals replay green across all 6 datasets;
frontend untouched (tsc clean). ADR 0023; ADR 0021/0015 updated.
Closes#149.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ion test, tool-count comment, ADR 0015 prose
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@AndresL230
AndresL230force-pushed the feat/b7-149-graph-grounded-tutor branch from 6e1cf19 to 220d6a8CompareJuly 30, 2026 10:21
@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Pre-merge gate at the rebased head (over B6's #468): full lane 28/28 passed + oracles clean. Merging.

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.

[P1] Agent migration: graph-grounded tutor retrieval + tool-use loop

1 participant

@AndresL230
, '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" + '
feat(tutor): graph-grounded retrieval + tool-use loop (#149) by AndresL230 · Pull Request #469 · SaplingLearn/Sapling · GitHub
Skip to content

feat(tutor): graph-grounded retrieval + tool-use loop (#149) - #469

Merged
AndresL230 merged 3 commits into
mainfrom
feat/b7-149-graph-grounded-tutor
Jul 30, 2026
Merged

feat(tutor): graph-grounded retrieval + tool-use loop (#149)#469
AndresL230 merged 3 commits into
mainfrom
feat/b7-149-graph-grounded-tutor

Conversation

@AndresL230

@AndresL230AndresL230 commented Jul 30, 2026

Copy link
Copy Markdown
Collaborator

What

First link of the B7 seam-endgame chain (#149#153#150#151#154). The scoping pass corrected the issue's premise: the agent path inlined no graph context at all (the full-graph dump was legacy-only — and user-wide, leaking other courses' concepts), and the mid-turn write half already shipped with #127. Full detail in the commit message; the shape:

  • Read surface: read_graph_neighborhood (course-scoped, name-seeded, depth-1, ids never leave the tool) + read_concepts_for_user, registered on the tutor (5 → 7 tools) with a hard two-reads-per-turn prompt cap. The misconceptions reader is deliberately deferred until the shared-context opt-out is enforced (ADR 0023).
  • Deterministic seed block: every agent turn carries a ≤1.5k-char GRAPH CONTEXT block (overlap-first/weakest-fill + depth-1 edges) — turn-1 graph awareness without a tool round-trip on the streaming UI.
  • Legacy de-dump: the three call_gemini* prompt sites use the same compact serializer (kills the raw JSON dump and its cross-course leak) — de-risking the rung-1 fallback ahead of [P1] Agent migration: retire call_gemini* + gemini_service.py (final cutover) #151.
  • TUTOR_LIMITS (12/12/100k): a legitimate multi-tool turn can no longer trip UsageLimitExceeded into a silent legacy fallback.
  • The ADR-0021 retrieval seam: TutorRetrieval protocol on deps (production None → byte-identical), fixture retrieval + synthetic course, cassettes now record tool-call traces, and chat_tutor joins the offline eval harness (16 cases). The first commit on this branch holds the pre-change cassettes so the delta is auditable.

Eval deltas (before → after): ExpositoryHasStructure 0.812→1.000 · GraphToolUsed 0.938→1.000 · GroundedConcept 0.812→0.875 · MasteryUpdateEmitted 0.688→1.000 · the four legacy evaluators hold 1.000.

Verification

  • Backend 1440 passed + ruff clean; seam/stream/tool files re-run green under lock-pinned pydantic-ai 1.107; evals replay green across all six datasets; frontend untouched (tsc clean).
  • 40+ new tests incl. red-first tool-registration, id-leak/scoping/truncation coverage, the read-only-tools-never-emit-graph_update stream invariant, a scripted-tool seam test, and an opt-in live test proving the real model chooses the readers (run green once).
  • Known follow-up recorded in ADR 0023: gemini-2.5-pro occasionally emits a bare-newline final reply after an end-of-turn tool call — [P2] Agent platform: structured-output retry + validation hardening #153 (structured-output hardening) territory.
  • Full local e2e cycle pre-merge; results below.

Closes#149.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Chat tutoring now provides compact, course-scoped graph context for more relevant responses.
    • Added read-only graph tools for exploring related concepts and reviewing concept mastery.
    • Tutor responses can use course materials, progress, and session history through consistent retrieval.
    • Added offline chat-tutor evaluation coverage with recorded scenarios and tool-behavior checks.
  • Bug Fixes

    • Improved course isolation and prevented unrelated graph data from appearing in tutor context.
    • Read-only graph interactions no longer create unintended updates.
  • Documentation

    • Updated evaluation coverage and architecture decision records.

@supabase

supabaseBot commented Jul 30, 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 Jul 30, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@AndresL230, you've reached your PR review limit, so we couldn't start this review.

Next review available in:35 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 6c9c74c7-f890-4387-84d5-108dd11d7884

📥 Commits

Reviewing files that changed from the base of the PR and between 7eb0ce2 and 220d6a8.

📒 Files selected for processing (44)
  • .github/workflows/evals.yml
  • backend/agents/__init__.py
  • backend/agents/chat_tutor.py
  • backend/agents/deps.py
  • backend/agents/tools/chat_context.py
  • backend/agents/tools/graph_read.py
  • backend/agents/tools/retrieval.py
  • backend/routes/learn.py
  • backend/services/graph_context.py
  • backend/services/token_overlap.py
  • backend/tests/evals/README.md
  • backend/tests/evals/_retrieval_fixture.py
  • backend/tests/evals/baselines.json
  • backend/tests/evals/cassettes/chat_tutor/expository_explain_big_o.json
  • backend/tests/evals/cassettes/chat_tutor/expository_explain_dependency_injection.json
  • backend/tests/evals/cassettes/chat_tutor/expository_explain_kantian_ethics.json
  • backend/tests/evals/cassettes/chat_tutor/expository_explain_photosynthesis.json
  • backend/tests/evals/cassettes/chat_tutor/expository_explain_supply_demand.json
  • backend/tests/evals/cassettes/chat_tutor/socratic_chemistry_balancing.json
  • backend/tests/evals/cassettes/chat_tutor/socratic_history_themes.json
  • backend/tests/evals/cassettes/chat_tutor/socratic_intro_calculus.json
  • backend/tests/evals/cassettes/chat_tutor/socratic_open_followup.json
  • backend/tests/evals/cassettes/chat_tutor/socratic_python_recursion.json
  • backend/tests/evals/cassettes/chat_tutor/socratic_stale_concept_review.json
  • backend/tests/evals/cassettes/chat_tutor/teachback_advanced.json
  • backend/tests/evals/cassettes/chat_tutor/teachback_correct_concept.json
  • backend/tests/evals/cassettes/chat_tutor/teachback_minimal.json
  • backend/tests/evals/cassettes/chat_tutor/teachback_misconception.json
  • backend/tests/evals/cassettes/chat_tutor/teachback_partial_correct.json
  • backend/tests/evals/chat_tutor.py
  • backend/tests/evals/fixtures/tutor_course.json
  • backend/tests/evals/run_all.py
  • backend/tests/test_chat_stream.py
  • backend/tests/test_chat_tutor_imports.py
  • backend/tests/test_e2e_function_handlers.py
  • backend/tests/test_graph_context_block.py
  • backend/tests/test_graph_neighborhood_tool.py
  • backend/tests/test_graph_tools_bugs.py
  • backend/tests/test_live_tutor_tools.py
  • backend/tests/test_model_mode_seam.py
  • backend/tests/test_tutor_retrieval.py
  • docs/decisions/0015-refactor-3-chat-tutor-shipped.md
  • docs/decisions/0021-agent-eval-harness-baselines.md
  • docs/decisions/0023-tutor-graph-retrieval-seam.md
📝 Walkthrough

Walkthrough

The chat tutor now uses compact course-scoped graph context, exposes bounded read-only graph tools, supports injectable retrieval implementations, and runs offline evaluations against fixture data with structured tool-call cassettes and new behavioral evaluators.

Changes

Tutor graph grounding

Layer / File(s)Summary
Compact graph context and route wiring
backend/services/graph_context.py, backend/services/token_overlap.py, backend/routes/learn.py, backend/tests/test_graph_context_block.py
Graph context is selected, scoped, rendered within a character budget, and used by both agent and legacy tutor prompt paths.
Retrieval seam and graph read tools
backend/agents/tools/*, backend/agents/chat_tutor.py, backend/agents/deps.py, backend/tests/test_graph_neighborhood_tool.py, backend/tests/test_tutor_retrieval.py
Tutor reads resolve injected or Supabase retrieval, and graph readers provide bounded, course/user-scoped neighborhoods without graph writes.
Offline evaluation and regression coverage
backend/tests/evals/*, backend/tests/test_*.py, .github/workflows/evals.yml
Fixture retrieval, structured cassettes, graph/tool evaluators, baselines, CI gating, and integration tests cover the new tutor flow.

Estimated code review effort: 4 (Complex) | ~60 minutes

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 27.86% 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 the main change: graph-grounded tutor retrieval with tool use.
Description check✅ PassedThe description is substantial and covers the change, issues, and verification, though some template sections are omitted.
Linked Issues check✅ PassedThe PR implements course-scoped graph retrieval, prompt graph context, and mid-turn graph/mastery updates, matching #149.
Out of Scope Changes check✅ PassedMost changes support the tutor graph-grounding/eval harness work, with no clearly unrelated code changes evident.
✨ 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 feat/b7-149-graph-grounded-tutor

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.


async def course_materials(
self, course_id: str | None, query: str, limit: int, *, user_id: str
) -> list["CourseMaterial"]: ...
concepts: list[str],
*,
limit: int = 20,
) -> "GraphNeighborhood": ...

async def concept_mastery(
self, user_id: str, course_id: str | None
) -> list["ConceptMastery"]: ...

async def progress(
self, user_id: str, course_id: str | None
) -> "CourseProgress": ...

async def session_history(
self, session_id: str, last_n: int
) -> list["SessionMessage"]: ...
@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Jul 30, 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-staging220d6a8Commit Preview URL

Branch Preview URL
Jul 30 2026, 10:23 AM

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (6)
backend/tests/evals/chat_tutor.py (1)

459-478: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Prefer isinstance over string-based class-name matching for tool-call parts.

type(part).__name__ != "ToolCallPart" only matches parts of the exact ToolCallPart class. pydantic_ai's message types also include a distinct NativeToolCallPart for native/provider tool-calling paths; if any of the tutor's tools were ever surfaced that way, this check would silently drop them from the cassette, undercounting tool usage that GraphToolUsedEvaluator/MasteryUpdateEmittedEvaluator rely on.

♻️ Suggested fix
+from pydantic_ai.messages import ToolCallPart+
def _extract_tool_calls(result) -> list[ToolCall]:
...
for message in messages:
for part in getattr(message, "parts", []) or []:
- if type(part).__name__ != "ToolCallPart":+ if not isinstance(part, ToolCallPart):
continue
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/tests/evals/chat_tutor.py` around lines 459 - 478, Update
_extract_tool_calls to use isinstance-based recognition of pydantic_ai tool-call
part types, including both ToolCallPart and NativeToolCallPart, instead of
comparing type(part).__name__. Preserve the existing argument extraction
fallback and ToolCall construction for every recognized tool-call part.
backend/agents/tools/graph_read.py (2)

181-189: 🎯 Functional Correctness | 🔵 Trivial | 💤 Low value

Duplicate normalized concept names silently collapse.

by_norm[_normalize_concept(name)] = n is last-write-wins, so if the graph holds two rows normalizing to the same key (e.g. pre-dedup legacy rows), a seed resolves to whichever row came back last from PostgREST — non-deterministic across reads. Preferring the first occurrence (setdefault) at least makes it stable given a stable row order.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/agents/tools/graph_read.py` around lines 181 - 189, Update the
by_norm construction in the node-processing loop to preserve the first node for
each normalized concept name instead of overwriting it with later duplicates.
Replace the last-write-wins assignment associated with _normalize_concept(name)
while leaving by_id behavior unchanged.

104-105: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Clamp the LLM-chosen limit on the upper end too.

Both sites only floor at 0. A model that passes limit=5000 gets every tracked concept (and, for the neighborhood, every kept edge) back into the context window — the whole point of the cap. A module-level max (e.g. 50) keeps the tool's prompt cost bounded regardless of what the model asks for.

♻️ Suggested clamp
+_MAX_LIMIT = 50+
...
- n = max(0, int(limit))+ n = min(_MAX_LIMIT, max(0, int(limit)))
return rows[:n]
...
- cap = max(0, int(limit))+ cap = min(_MAX_LIMIT, max(0, int(limit)))

Also applies to: 244-247

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/agents/tools/graph_read.py` around lines 104 - 105, Clamp the
LLM-provided limit to a defined module-level maximum in both row-slicing sites,
including the neighborhood path referenced by the comment. Update the limit
normalization around `n = max(0, int(limit))` so values above the cap are
reduced while negative values still produce zero, keeping prompt results
bounded.
backend/agents/chat_tutor.py (1)

70-73: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

"Hard cap" is prompt-advisory only.

Nothing enforces two graph reads per turn; the real ceiling is TUTOR_LIMITS. Either soften the wording or enforce it in the tool wrapper (e.g. a per-run counter on SaplingDeps that returns an empty neighborhood past the second call) so cost is bounded even when the model ignores the instruction.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/agents/chat_tutor.py` around lines 70 - 73, Update the graph-read
enforcement around SaplingDeps and its tool wrapper so each tutor run permits no
more than two graph reads, returning an empty neighborhood after the second
call; otherwise soften the prompt’s “Hard cap” wording to match the actual
TUTOR_LIMITS behavior.
backend/tests/test_model_mode_seam.py (1)

304-307: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Registered function handler is never unregistered.

register_function_handler mutates a module-level registry that monkeypatch won't restore, so this handler leaks into any later test that runs chat_tutor in function mode. If the file doesn't already have an autouse reset fixture, worth adding one.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/tests/test_model_mode_seam.py` around lines 304 - 307, Add cleanup
for the module-level handler registry after tests using
register_function_handler, including the test around socratic_agent.run_sync and
the "chat_tutor" handler. Prefer an autouse fixture if the file lacks one,
ensuring the registry is reset after each test so handlers do not leak into
later function-mode tests.
backend/services/graph_context.py (1)

105-111: 🚀 Performance & Scalability | 🔵 Trivial | 💤 Low value

Budget trim rejoins the whole block each iteration.

Minor: with ≤12 lines this is irrelevant, but a running length counter (or trimming from a precomputed cumulative sum) avoids the O(n²) rejoin and reads clearer.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/services/graph_context.py` around lines 105 - 111, Update the
budget-trimming logic in the graph-context builder around _HEADER, lines, and
GRAPH_CONTEXT_CHAR_BUDGET to track the assembled character length incrementally
instead of rejoining the entire block on every loop iteration. Preserve the
existing behavior of removing trailing lines until the budget fits and returning
an empty string when no lines remain.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@backend/routes/learn.py`:
- Around line 520-523: Update the graph-context construction at the referenced
legacy-turn paths, including the flows around lines 520, 763, and 1328, so
course-less sessions retain an appropriate user-wide compact graph fallback
instead of receiving an empty block. Avoid calling get_graph when no course_id
is available unless the fallback requires it, and ensure the serializer’s
selection remains bounded and weakest-first.
In `@backend/tests/test_live_tutor_tools.py`:
- Around line 36-38: Update
test_real_model_chooses_a_graph_reader_for_a_graph_question to accept pytest’s
monkeypatch fixture and replace the direct sys.path.insert mutation with
monkeypatch.syspath_prepend(...), ensuring the evals path is restored after the
test.
In `@docs/decisions/0021-agent-eval-harness-baselines.md`:
- Around line 46-48: The ADRs retain obsolete pre-change descriptions of
chat_tutor. In docs/decisions/0021-agent-eval-harness-baselines.md lines 46-48,
update the five-dataset wording, baseline table, and “unmeasured” consequence to
include chat_tutor; in docs/decisions/0015-refactor-3-chat-tutor-shipped.md
lines 28-33, replace the remaining four-tool and four-registration descriptions
or explicitly mark them as historical.
---
Nitpick comments:
In `@backend/agents/chat_tutor.py`:
- Around line 70-73: Update the graph-read enforcement around SaplingDeps and
its tool wrapper so each tutor run permits no more than two graph reads,
returning an empty neighborhood after the second call; otherwise soften the
prompt’s “Hard cap” wording to match the actual TUTOR_LIMITS behavior.
In `@backend/agents/tools/graph_read.py`:
- Around line 181-189: Update the by_norm construction in the node-processing
loop to preserve the first node for each normalized concept name instead of
overwriting it with later duplicates. Replace the last-write-wins assignment
associated with _normalize_concept(name) while leaving by_id behavior unchanged.
- Around line 104-105: Clamp the LLM-provided limit to a defined module-level
maximum in both row-slicing sites, including the neighborhood path referenced by
the comment. Update the limit normalization around `n = max(0, int(limit))` so
values above the cap are reduced while negative values still produce zero,
keeping prompt results bounded.
In `@backend/services/graph_context.py`:
- Around line 105-111: Update the budget-trimming logic in the graph-context
builder around _HEADER, lines, and GRAPH_CONTEXT_CHAR_BUDGET to track the
assembled character length incrementally instead of rejoining the entire block
on every loop iteration. Preserve the existing behavior of removing trailing
lines until the budget fits and returning an empty string when no lines remain.
In `@backend/tests/evals/chat_tutor.py`:
- Around line 459-478: Update _extract_tool_calls to use isinstance-based
recognition of pydantic_ai tool-call part types, including both ToolCallPart and
NativeToolCallPart, instead of comparing type(part).__name__. Preserve the
existing argument extraction fallback and ToolCall construction for every
recognized tool-call part.
In `@backend/tests/test_model_mode_seam.py`:
- Around line 304-307: Add cleanup for the module-level handler registry after
tests using register_function_handler, including the test around
socratic_agent.run_sync and the "chat_tutor" handler. Prefer an autouse fixture
if the file lacks one, ensuring the registry is reset after each test so
handlers do not leak into later function-mode tests.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 10240725-b7f7-4f3d-8294-03d828114d0c

📥 Commits

Reviewing files that changed from the base of the PR and between ec0e2af and 7eb0ce2.

📒 Files selected for processing (44)
  • .github/workflows/evals.yml
  • backend/agents/__init__.py
  • backend/agents/chat_tutor.py
  • backend/agents/deps.py
  • backend/agents/tools/chat_context.py
  • backend/agents/tools/graph_read.py
  • backend/agents/tools/retrieval.py
  • backend/routes/learn.py
  • backend/services/graph_context.py
  • backend/services/token_overlap.py
  • backend/tests/evals/README.md
  • backend/tests/evals/_retrieval_fixture.py
  • backend/tests/evals/baselines.json
  • backend/tests/evals/cassettes/chat_tutor/expository_explain_big_o.json
  • backend/tests/evals/cassettes/chat_tutor/expository_explain_dependency_injection.json
  • backend/tests/evals/cassettes/chat_tutor/expository_explain_kantian_ethics.json
  • backend/tests/evals/cassettes/chat_tutor/expository_explain_photosynthesis.json
  • backend/tests/evals/cassettes/chat_tutor/expository_explain_supply_demand.json
  • backend/tests/evals/cassettes/chat_tutor/socratic_chemistry_balancing.json
  • backend/tests/evals/cassettes/chat_tutor/socratic_history_themes.json
  • backend/tests/evals/cassettes/chat_tutor/socratic_intro_calculus.json
  • backend/tests/evals/cassettes/chat_tutor/socratic_open_followup.json
  • backend/tests/evals/cassettes/chat_tutor/socratic_python_recursion.json
  • backend/tests/evals/cassettes/chat_tutor/socratic_stale_concept_review.json
  • backend/tests/evals/cassettes/chat_tutor/teachback_advanced.json
  • backend/tests/evals/cassettes/chat_tutor/teachback_correct_concept.json
  • backend/tests/evals/cassettes/chat_tutor/teachback_minimal.json
  • backend/tests/evals/cassettes/chat_tutor/teachback_misconception.json
  • backend/tests/evals/cassettes/chat_tutor/teachback_partial_correct.json
  • backend/tests/evals/chat_tutor.py
  • backend/tests/evals/fixtures/tutor_course.json
  • backend/tests/evals/run_all.py
  • backend/tests/test_chat_stream.py
  • backend/tests/test_chat_tutor_imports.py
  • backend/tests/test_e2e_function_handlers.py
  • backend/tests/test_graph_context_block.py
  • backend/tests/test_graph_neighborhood_tool.py
  • backend/tests/test_graph_tools_bugs.py
  • backend/tests/test_live_tutor_tools.py
  • backend/tests/test_model_mode_seam.py
  • backend/tests/test_tutor_retrieval.py
  • docs/decisions/0015-refactor-3-chat-tutor-shipped.md
  • docs/decisions/0021-agent-eval-harness-baselines.md
  • docs/decisions/0023-tutor-graph-retrieval-seam.md

Comment threadbackend/routes/learn.py
Comment on lines +36 to +38
def test_real_model_chooses_a_graph_reader_for_a_graph_question():
sys.path.insert(0, str(Path(__file__).parent / "evals"))
from _retrieval_fixture import FixtureRetrieval

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bashset -euo pipefail
echo"Files matching test name:"
fd -a 'test_live_tutor_tools\.py$'.||true
test_file="$(fd 'test_live_tutor_tools\.py$'.| head -n1 || true)"if [ -n"${test_file:-}" ];thenechoecho"Relevant test file:"echo"$test_file"echoecho"Line count:"
wc -l "$test_file"echoecho"First 120 lines:"
sed -n '1,120p'"$test_file"| cat -n
fiechoecho"Search for sys.path usage in file:"if [ -n"${test_file:-}" ];then
rg -n "sys\.path|syspath_prepend|Path\(__file__\).parent""$test_file"||truefiechoecho"Search for global sys.path mutations in backend/tests:"
rg -n "sys\.path\.insert|sys\.path\.append|sys\.path\.extend|sys\.path\.remove|syspath_prepend" backend/tests ||trueechoecho"pytest availability:"
python3 - <<'PY'try: import pytest print("pytest:", pytest.__version__)except Exception as e: print("pytest import failed:", repr(e))try: import _retrieval_fixture print("module _retrieval_fixture found")except Exception as e: print("_retrieval_fixture import failed:", repr(e))PY

Repository: SaplingLearn/Sapling

Length of output: 6296


Scope the import-path mutation to this test.

sys.path.insert(...) mutates the pytest process state for any following tests. Use pytest’s scoped monkeypatch.syspath_prepend(...) so the path is restored automatically after this test.

Proposed fix
-def test_real_model_chooses_a_graph_reader_for_a_graph_question():- sys.path.insert(0, str(Path(__file__).parent / "evals"))+def test_real_model_chooses_a_graph_reader_for_a_graph_question(monkeypatch):+ monkeypatch.syspath_prepend(str(Path(__file__).parent / "evals"))
📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
deftest_real_model_chooses_a_graph_reader_for_a_graph_question():
sys.path.insert(0, str(Path(__file__).parent/"evals"))
from_retrieval_fixtureimportFixtureRetrieval
deftest_real_model_chooses_a_graph_reader_for_a_graph_question(monkeypatch):
monkeypatch.syspath_prepend(str(Path(__file__).parent/"evals"))
from_retrieval_fixtureimportFixtureRetrieval
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/tests/test_live_tutor_tools.py` around lines 36 - 38, Update
test_real_model_chooses_a_graph_reader_for_a_graph_question to accept pytest’s
monkeypatch fixture and replace the direct sys.path.insert mutation with
monkeypatch.syspath_prepend(...), ensuring the evals path is restored after the
test.

Comment threaddocs/decisions/0021-agent-eval-harness-baselines.md
AndresL230 added a commit that referenced this pull request Jul 30, 2026
…ion test, tool-count comment, ADR 0015 prose
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Review pass complete: four reviewers, two fully clean (one empirically re-verified the before/after eval baselines by resurrecting the deleted pre-change cassettes and replaying them — exact match). Three sub-80 findings, all fixed: a /chat/stream twin of the raw-body.message persistence regression test (the invariant was correct but only the JSON route was pinned), the stale 'all five tools' comment, and the ADR 0015 blockquote that had swallowed a sentence. Backend 1441 + ruff green. e2e cycle next.

AndresL230and others added 3 commits July 30, 2026 03:21
…n the next commit; kept in PR history so the before→after delta is auditable)
The agent path inlined NO graph context (the full-graph JSON dump was
legacy-only — and user-wide, leaking other courses' concepts); the tutor
had write tools (#127) but no graph read surface. Now:
- Tools 5 → 7: read_graph_neighborhood (course-scoped, seed-matched via
_normalize_concept, depth-1 edges via two merged in.() reads with a
both-endpoints-in-course rule, ids never leave the tool, truncated flag,
degrade-to-empty) + read_concepts_for_user (existing reader, capped
wrapper). Preamble gains the graph-tools paragraph with a hard
two-reads-per-turn cap. read_misconceptions_for_course deliberately
deferred (shared-context opt-out isn't enforced yet — ADR 0023).
- Deterministic GRAPH CONTEXT seed block on every agent turn
(services/graph_context.py: overlap-first/weakest-fill selection,
depth-1 edges, ≤1.5k chars, no ids) — turn-1 graph awareness without a
tool round-trip on the streaming UI; raw body.message persistence
unchanged.
- Legacy prompt de-dumped: all three call sites now use the same compact
course-scoped serializer (also fixes the cross-course concept leak).
- TUTOR_LIMITS (12/12/100k) so a legitimate multi-tool turn can't trip
UsageLimitExceeded into a silent rung-1 legacy fallback.
- The ADR-0021 retrieval seam: TutorRetrieval protocol on SaplingDeps
(production None → byte-identical Supabase path), FixtureRetrieval +
committed synthetic course, cassettes now carry tool-call traces, three
new evaluators, chat_tutor joins the offline harness (16 cases; the
previous commit holds the pre-change cassettes).
Eval deltas (before → after): ExpositoryHasStructure 0.812→1.000,
GraphToolUsed 0.938→1.000, GroundedConcept 0.812→0.875,
MasteryUpdateEmitted 0.688→1.000; legacy four stay 1.000.
Suites: backend 1440 passed + ruff clean (seam/stream files green under
lock-pinned pydantic-ai 1.107); evals replay green across all 6 datasets;
frontend untouched (tsc clean). ADR 0023; ADR 0021/0015 updated.
Closes#149.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ion test, tool-count comment, ADR 0015 prose
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@AndresL230
AndresL230force-pushed the feat/b7-149-graph-grounded-tutor branch from 6e1cf19 to 220d6a8CompareJuly 30, 2026 10:21
@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Pre-merge gate at the rebased head (over B6's #468): full lane 28/28 passed + oracles clean. Merging.

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.

[P1] Agent migration: graph-grounded tutor retrieval + tool-use loop

1 participant

@AndresL230
, '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('^' + ".*" + ' feat(tutor): graph-grounded retrieval + tool-use loop (#149) by AndresL230 · Pull Request #469 · SaplingLearn/Sapling · GitHub
Skip to content

feat(tutor): graph-grounded retrieval + tool-use loop (#149) - #469

Merged
AndresL230 merged 3 commits into
mainfrom
feat/b7-149-graph-grounded-tutor
Jul 30, 2026
Merged

feat(tutor): graph-grounded retrieval + tool-use loop (#149)#469
AndresL230 merged 3 commits into
mainfrom
feat/b7-149-graph-grounded-tutor

Conversation

@AndresL230

@AndresL230AndresL230 commented Jul 30, 2026

Copy link
Copy Markdown
Collaborator

What

First link of the B7 seam-endgame chain (#149#153#150#151#154). The scoping pass corrected the issue's premise: the agent path inlined no graph context at all (the full-graph dump was legacy-only — and user-wide, leaking other courses' concepts), and the mid-turn write half already shipped with #127. Full detail in the commit message; the shape:

  • Read surface: read_graph_neighborhood (course-scoped, name-seeded, depth-1, ids never leave the tool) + read_concepts_for_user, registered on the tutor (5 → 7 tools) with a hard two-reads-per-turn prompt cap. The misconceptions reader is deliberately deferred until the shared-context opt-out is enforced (ADR 0023).
  • Deterministic seed block: every agent turn carries a ≤1.5k-char GRAPH CONTEXT block (overlap-first/weakest-fill + depth-1 edges) — turn-1 graph awareness without a tool round-trip on the streaming UI.
  • Legacy de-dump: the three call_gemini* prompt sites use the same compact serializer (kills the raw JSON dump and its cross-course leak) — de-risking the rung-1 fallback ahead of [P1] Agent migration: retire call_gemini* + gemini_service.py (final cutover) #151.
  • TUTOR_LIMITS (12/12/100k): a legitimate multi-tool turn can no longer trip UsageLimitExceeded into a silent legacy fallback.
  • The ADR-0021 retrieval seam: TutorRetrieval protocol on deps (production None → byte-identical), fixture retrieval + synthetic course, cassettes now record tool-call traces, and chat_tutor joins the offline eval harness (16 cases). The first commit on this branch holds the pre-change cassettes so the delta is auditable.

Eval deltas (before → after): ExpositoryHasStructure 0.812→1.000 · GraphToolUsed 0.938→1.000 · GroundedConcept 0.812→0.875 · MasteryUpdateEmitted 0.688→1.000 · the four legacy evaluators hold 1.000.

Verification

  • Backend 1440 passed + ruff clean; seam/stream/tool files re-run green under lock-pinned pydantic-ai 1.107; evals replay green across all six datasets; frontend untouched (tsc clean).
  • 40+ new tests incl. red-first tool-registration, id-leak/scoping/truncation coverage, the read-only-tools-never-emit-graph_update stream invariant, a scripted-tool seam test, and an opt-in live test proving the real model chooses the readers (run green once).
  • Known follow-up recorded in ADR 0023: gemini-2.5-pro occasionally emits a bare-newline final reply after an end-of-turn tool call — [P2] Agent platform: structured-output retry + validation hardening #153 (structured-output hardening) territory.
  • Full local e2e cycle pre-merge; results below.

Closes#149.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Chat tutoring now provides compact, course-scoped graph context for more relevant responses.
    • Added read-only graph tools for exploring related concepts and reviewing concept mastery.
    • Tutor responses can use course materials, progress, and session history through consistent retrieval.
    • Added offline chat-tutor evaluation coverage with recorded scenarios and tool-behavior checks.
  • Bug Fixes

    • Improved course isolation and prevented unrelated graph data from appearing in tutor context.
    • Read-only graph interactions no longer create unintended updates.
  • Documentation

    • Updated evaluation coverage and architecture decision records.

@supabase

supabaseBot commented Jul 30, 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 Jul 30, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@AndresL230, you've reached your PR review limit, so we couldn't start this review.

Next review available in:35 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 6c9c74c7-f890-4387-84d5-108dd11d7884

📥 Commits

Reviewing files that changed from the base of the PR and between 7eb0ce2 and 220d6a8.

📒 Files selected for processing (44)
  • .github/workflows/evals.yml
  • backend/agents/__init__.py
  • backend/agents/chat_tutor.py
  • backend/agents/deps.py
  • backend/agents/tools/chat_context.py
  • backend/agents/tools/graph_read.py
  • backend/agents/tools/retrieval.py
  • backend/routes/learn.py
  • backend/services/graph_context.py
  • backend/services/token_overlap.py
  • backend/tests/evals/README.md
  • backend/tests/evals/_retrieval_fixture.py
  • backend/tests/evals/baselines.json
  • backend/tests/evals/cassettes/chat_tutor/expository_explain_big_o.json
  • backend/tests/evals/cassettes/chat_tutor/expository_explain_dependency_injection.json
  • backend/tests/evals/cassettes/chat_tutor/expository_explain_kantian_ethics.json
  • backend/tests/evals/cassettes/chat_tutor/expository_explain_photosynthesis.json
  • backend/tests/evals/cassettes/chat_tutor/expository_explain_supply_demand.json
  • backend/tests/evals/cassettes/chat_tutor/socratic_chemistry_balancing.json
  • backend/tests/evals/cassettes/chat_tutor/socratic_history_themes.json
  • backend/tests/evals/cassettes/chat_tutor/socratic_intro_calculus.json
  • backend/tests/evals/cassettes/chat_tutor/socratic_open_followup.json
  • backend/tests/evals/cassettes/chat_tutor/socratic_python_recursion.json
  • backend/tests/evals/cassettes/chat_tutor/socratic_stale_concept_review.json
  • backend/tests/evals/cassettes/chat_tutor/teachback_advanced.json
  • backend/tests/evals/cassettes/chat_tutor/teachback_correct_concept.json
  • backend/tests/evals/cassettes/chat_tutor/teachback_minimal.json
  • backend/tests/evals/cassettes/chat_tutor/teachback_misconception.json
  • backend/tests/evals/cassettes/chat_tutor/teachback_partial_correct.json
  • backend/tests/evals/chat_tutor.py
  • backend/tests/evals/fixtures/tutor_course.json
  • backend/tests/evals/run_all.py
  • backend/tests/test_chat_stream.py
  • backend/tests/test_chat_tutor_imports.py
  • backend/tests/test_e2e_function_handlers.py
  • backend/tests/test_graph_context_block.py
  • backend/tests/test_graph_neighborhood_tool.py
  • backend/tests/test_graph_tools_bugs.py
  • backend/tests/test_live_tutor_tools.py
  • backend/tests/test_model_mode_seam.py
  • backend/tests/test_tutor_retrieval.py
  • docs/decisions/0015-refactor-3-chat-tutor-shipped.md
  • docs/decisions/0021-agent-eval-harness-baselines.md
  • docs/decisions/0023-tutor-graph-retrieval-seam.md
📝 Walkthrough

Walkthrough

The chat tutor now uses compact course-scoped graph context, exposes bounded read-only graph tools, supports injectable retrieval implementations, and runs offline evaluations against fixture data with structured tool-call cassettes and new behavioral evaluators.

Changes

Tutor graph grounding

Layer / File(s)Summary
Compact graph context and route wiring
backend/services/graph_context.py, backend/services/token_overlap.py, backend/routes/learn.py, backend/tests/test_graph_context_block.py
Graph context is selected, scoped, rendered within a character budget, and used by both agent and legacy tutor prompt paths.
Retrieval seam and graph read tools
backend/agents/tools/*, backend/agents/chat_tutor.py, backend/agents/deps.py, backend/tests/test_graph_neighborhood_tool.py, backend/tests/test_tutor_retrieval.py
Tutor reads resolve injected or Supabase retrieval, and graph readers provide bounded, course/user-scoped neighborhoods without graph writes.
Offline evaluation and regression coverage
backend/tests/evals/*, backend/tests/test_*.py, .github/workflows/evals.yml
Fixture retrieval, structured cassettes, graph/tool evaluators, baselines, CI gating, and integration tests cover the new tutor flow.

Estimated code review effort: 4 (Complex) | ~60 minutes

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 27.86% 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 the main change: graph-grounded tutor retrieval with tool use.
Description check✅ PassedThe description is substantial and covers the change, issues, and verification, though some template sections are omitted.
Linked Issues check✅ PassedThe PR implements course-scoped graph retrieval, prompt graph context, and mid-turn graph/mastery updates, matching #149.
Out of Scope Changes check✅ PassedMost changes support the tutor graph-grounding/eval harness work, with no clearly unrelated code changes evident.
✨ 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 feat/b7-149-graph-grounded-tutor

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.


async def course_materials(
self, course_id: str | None, query: str, limit: int, *, user_id: str
) -> list["CourseMaterial"]: ...
concepts: list[str],
*,
limit: int = 20,
) -> "GraphNeighborhood": ...

async def concept_mastery(
self, user_id: str, course_id: str | None
) -> list["ConceptMastery"]: ...

async def progress(
self, user_id: str, course_id: str | None
) -> "CourseProgress": ...

async def session_history(
self, session_id: str, last_n: int
) -> list["SessionMessage"]: ...
@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Jul 30, 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-staging220d6a8Commit Preview URL

Branch Preview URL
Jul 30 2026, 10:23 AM

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (6)
backend/tests/evals/chat_tutor.py (1)

459-478: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Prefer isinstance over string-based class-name matching for tool-call parts.

type(part).__name__ != "ToolCallPart" only matches parts of the exact ToolCallPart class. pydantic_ai's message types also include a distinct NativeToolCallPart for native/provider tool-calling paths; if any of the tutor's tools were ever surfaced that way, this check would silently drop them from the cassette, undercounting tool usage that GraphToolUsedEvaluator/MasteryUpdateEmittedEvaluator rely on.

♻️ Suggested fix
+from pydantic_ai.messages import ToolCallPart+
def _extract_tool_calls(result) -> list[ToolCall]:
...
for message in messages:
for part in getattr(message, "parts", []) or []:
- if type(part).__name__ != "ToolCallPart":+ if not isinstance(part, ToolCallPart):
continue
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/tests/evals/chat_tutor.py` around lines 459 - 478, Update
_extract_tool_calls to use isinstance-based recognition of pydantic_ai tool-call
part types, including both ToolCallPart and NativeToolCallPart, instead of
comparing type(part).__name__. Preserve the existing argument extraction
fallback and ToolCall construction for every recognized tool-call part.
backend/agents/tools/graph_read.py (2)

181-189: 🎯 Functional Correctness | 🔵 Trivial | 💤 Low value

Duplicate normalized concept names silently collapse.

by_norm[_normalize_concept(name)] = n is last-write-wins, so if the graph holds two rows normalizing to the same key (e.g. pre-dedup legacy rows), a seed resolves to whichever row came back last from PostgREST — non-deterministic across reads. Preferring the first occurrence (setdefault) at least makes it stable given a stable row order.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/agents/tools/graph_read.py` around lines 181 - 189, Update the
by_norm construction in the node-processing loop to preserve the first node for
each normalized concept name instead of overwriting it with later duplicates.
Replace the last-write-wins assignment associated with _normalize_concept(name)
while leaving by_id behavior unchanged.

104-105: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Clamp the LLM-chosen limit on the upper end too.

Both sites only floor at 0. A model that passes limit=5000 gets every tracked concept (and, for the neighborhood, every kept edge) back into the context window — the whole point of the cap. A module-level max (e.g. 50) keeps the tool's prompt cost bounded regardless of what the model asks for.

♻️ Suggested clamp
+_MAX_LIMIT = 50+
...
- n = max(0, int(limit))+ n = min(_MAX_LIMIT, max(0, int(limit)))
return rows[:n]
...
- cap = max(0, int(limit))+ cap = min(_MAX_LIMIT, max(0, int(limit)))

Also applies to: 244-247

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/agents/tools/graph_read.py` around lines 104 - 105, Clamp the
LLM-provided limit to a defined module-level maximum in both row-slicing sites,
including the neighborhood path referenced by the comment. Update the limit
normalization around `n = max(0, int(limit))` so values above the cap are
reduced while negative values still produce zero, keeping prompt results
bounded.
backend/agents/chat_tutor.py (1)

70-73: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

"Hard cap" is prompt-advisory only.

Nothing enforces two graph reads per turn; the real ceiling is TUTOR_LIMITS. Either soften the wording or enforce it in the tool wrapper (e.g. a per-run counter on SaplingDeps that returns an empty neighborhood past the second call) so cost is bounded even when the model ignores the instruction.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/agents/chat_tutor.py` around lines 70 - 73, Update the graph-read
enforcement around SaplingDeps and its tool wrapper so each tutor run permits no
more than two graph reads, returning an empty neighborhood after the second
call; otherwise soften the prompt’s “Hard cap” wording to match the actual
TUTOR_LIMITS behavior.
backend/tests/test_model_mode_seam.py (1)

304-307: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Registered function handler is never unregistered.

register_function_handler mutates a module-level registry that monkeypatch won't restore, so this handler leaks into any later test that runs chat_tutor in function mode. If the file doesn't already have an autouse reset fixture, worth adding one.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/tests/test_model_mode_seam.py` around lines 304 - 307, Add cleanup
for the module-level handler registry after tests using
register_function_handler, including the test around socratic_agent.run_sync and
the "chat_tutor" handler. Prefer an autouse fixture if the file lacks one,
ensuring the registry is reset after each test so handlers do not leak into
later function-mode tests.
backend/services/graph_context.py (1)

105-111: 🚀 Performance & Scalability | 🔵 Trivial | 💤 Low value

Budget trim rejoins the whole block each iteration.

Minor: with ≤12 lines this is irrelevant, but a running length counter (or trimming from a precomputed cumulative sum) avoids the O(n²) rejoin and reads clearer.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/services/graph_context.py` around lines 105 - 111, Update the
budget-trimming logic in the graph-context builder around _HEADER, lines, and
GRAPH_CONTEXT_CHAR_BUDGET to track the assembled character length incrementally
instead of rejoining the entire block on every loop iteration. Preserve the
existing behavior of removing trailing lines until the budget fits and returning
an empty string when no lines remain.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@backend/routes/learn.py`:
- Around line 520-523: Update the graph-context construction at the referenced
legacy-turn paths, including the flows around lines 520, 763, and 1328, so
course-less sessions retain an appropriate user-wide compact graph fallback
instead of receiving an empty block. Avoid calling get_graph when no course_id
is available unless the fallback requires it, and ensure the serializer’s
selection remains bounded and weakest-first.
In `@backend/tests/test_live_tutor_tools.py`:
- Around line 36-38: Update
test_real_model_chooses_a_graph_reader_for_a_graph_question to accept pytest’s
monkeypatch fixture and replace the direct sys.path.insert mutation with
monkeypatch.syspath_prepend(...), ensuring the evals path is restored after the
test.
In `@docs/decisions/0021-agent-eval-harness-baselines.md`:
- Around line 46-48: The ADRs retain obsolete pre-change descriptions of
chat_tutor. In docs/decisions/0021-agent-eval-harness-baselines.md lines 46-48,
update the five-dataset wording, baseline table, and “unmeasured” consequence to
include chat_tutor; in docs/decisions/0015-refactor-3-chat-tutor-shipped.md
lines 28-33, replace the remaining four-tool and four-registration descriptions
or explicitly mark them as historical.
---
Nitpick comments:
In `@backend/agents/chat_tutor.py`:
- Around line 70-73: Update the graph-read enforcement around SaplingDeps and
its tool wrapper so each tutor run permits no more than two graph reads,
returning an empty neighborhood after the second call; otherwise soften the
prompt’s “Hard cap” wording to match the actual TUTOR_LIMITS behavior.
In `@backend/agents/tools/graph_read.py`:
- Around line 181-189: Update the by_norm construction in the node-processing
loop to preserve the first node for each normalized concept name instead of
overwriting it with later duplicates. Replace the last-write-wins assignment
associated with _normalize_concept(name) while leaving by_id behavior unchanged.
- Around line 104-105: Clamp the LLM-provided limit to a defined module-level
maximum in both row-slicing sites, including the neighborhood path referenced by
the comment. Update the limit normalization around `n = max(0, int(limit))` so
values above the cap are reduced while negative values still produce zero,
keeping prompt results bounded.
In `@backend/services/graph_context.py`:
- Around line 105-111: Update the budget-trimming logic in the graph-context
builder around _HEADER, lines, and GRAPH_CONTEXT_CHAR_BUDGET to track the
assembled character length incrementally instead of rejoining the entire block
on every loop iteration. Preserve the existing behavior of removing trailing
lines until the budget fits and returning an empty string when no lines remain.
In `@backend/tests/evals/chat_tutor.py`:
- Around line 459-478: Update _extract_tool_calls to use isinstance-based
recognition of pydantic_ai tool-call part types, including both ToolCallPart and
NativeToolCallPart, instead of comparing type(part).__name__. Preserve the
existing argument extraction fallback and ToolCall construction for every
recognized tool-call part.
In `@backend/tests/test_model_mode_seam.py`:
- Around line 304-307: Add cleanup for the module-level handler registry after
tests using register_function_handler, including the test around
socratic_agent.run_sync and the "chat_tutor" handler. Prefer an autouse fixture
if the file lacks one, ensuring the registry is reset after each test so
handlers do not leak into later function-mode tests.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 10240725-b7f7-4f3d-8294-03d828114d0c

📥 Commits

Reviewing files that changed from the base of the PR and between ec0e2af and 7eb0ce2.

📒 Files selected for processing (44)
  • .github/workflows/evals.yml
  • backend/agents/__init__.py
  • backend/agents/chat_tutor.py
  • backend/agents/deps.py
  • backend/agents/tools/chat_context.py
  • backend/agents/tools/graph_read.py
  • backend/agents/tools/retrieval.py
  • backend/routes/learn.py
  • backend/services/graph_context.py
  • backend/services/token_overlap.py
  • backend/tests/evals/README.md
  • backend/tests/evals/_retrieval_fixture.py
  • backend/tests/evals/baselines.json
  • backend/tests/evals/cassettes/chat_tutor/expository_explain_big_o.json
  • backend/tests/evals/cassettes/chat_tutor/expository_explain_dependency_injection.json
  • backend/tests/evals/cassettes/chat_tutor/expository_explain_kantian_ethics.json
  • backend/tests/evals/cassettes/chat_tutor/expository_explain_photosynthesis.json
  • backend/tests/evals/cassettes/chat_tutor/expository_explain_supply_demand.json
  • backend/tests/evals/cassettes/chat_tutor/socratic_chemistry_balancing.json
  • backend/tests/evals/cassettes/chat_tutor/socratic_history_themes.json
  • backend/tests/evals/cassettes/chat_tutor/socratic_intro_calculus.json
  • backend/tests/evals/cassettes/chat_tutor/socratic_open_followup.json
  • backend/tests/evals/cassettes/chat_tutor/socratic_python_recursion.json
  • backend/tests/evals/cassettes/chat_tutor/socratic_stale_concept_review.json
  • backend/tests/evals/cassettes/chat_tutor/teachback_advanced.json
  • backend/tests/evals/cassettes/chat_tutor/teachback_correct_concept.json
  • backend/tests/evals/cassettes/chat_tutor/teachback_minimal.json
  • backend/tests/evals/cassettes/chat_tutor/teachback_misconception.json
  • backend/tests/evals/cassettes/chat_tutor/teachback_partial_correct.json
  • backend/tests/evals/chat_tutor.py
  • backend/tests/evals/fixtures/tutor_course.json
  • backend/tests/evals/run_all.py
  • backend/tests/test_chat_stream.py
  • backend/tests/test_chat_tutor_imports.py
  • backend/tests/test_e2e_function_handlers.py
  • backend/tests/test_graph_context_block.py
  • backend/tests/test_graph_neighborhood_tool.py
  • backend/tests/test_graph_tools_bugs.py
  • backend/tests/test_live_tutor_tools.py
  • backend/tests/test_model_mode_seam.py
  • backend/tests/test_tutor_retrieval.py
  • docs/decisions/0015-refactor-3-chat-tutor-shipped.md
  • docs/decisions/0021-agent-eval-harness-baselines.md
  • docs/decisions/0023-tutor-graph-retrieval-seam.md

Comment threadbackend/routes/learn.py
Comment on lines +36 to +38
def test_real_model_chooses_a_graph_reader_for_a_graph_question():
sys.path.insert(0, str(Path(__file__).parent / "evals"))
from _retrieval_fixture import FixtureRetrieval

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bashset -euo pipefail
echo"Files matching test name:"
fd -a 'test_live_tutor_tools\.py$'.||true
test_file="$(fd 'test_live_tutor_tools\.py$'.| head -n1 || true)"if [ -n"${test_file:-}" ];thenechoecho"Relevant test file:"echo"$test_file"echoecho"Line count:"
wc -l "$test_file"echoecho"First 120 lines:"
sed -n '1,120p'"$test_file"| cat -n
fiechoecho"Search for sys.path usage in file:"if [ -n"${test_file:-}" ];then
rg -n "sys\.path|syspath_prepend|Path\(__file__\).parent""$test_file"||truefiechoecho"Search for global sys.path mutations in backend/tests:"
rg -n "sys\.path\.insert|sys\.path\.append|sys\.path\.extend|sys\.path\.remove|syspath_prepend" backend/tests ||trueechoecho"pytest availability:"
python3 - <<'PY'try: import pytest print("pytest:", pytest.__version__)except Exception as e: print("pytest import failed:", repr(e))try: import _retrieval_fixture print("module _retrieval_fixture found")except Exception as e: print("_retrieval_fixture import failed:", repr(e))PY

Repository: SaplingLearn/Sapling

Length of output: 6296


Scope the import-path mutation to this test.

sys.path.insert(...) mutates the pytest process state for any following tests. Use pytest’s scoped monkeypatch.syspath_prepend(...) so the path is restored automatically after this test.

Proposed fix
-def test_real_model_chooses_a_graph_reader_for_a_graph_question():- sys.path.insert(0, str(Path(__file__).parent / "evals"))+def test_real_model_chooses_a_graph_reader_for_a_graph_question(monkeypatch):+ monkeypatch.syspath_prepend(str(Path(__file__).parent / "evals"))
📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
deftest_real_model_chooses_a_graph_reader_for_a_graph_question():
sys.path.insert(0, str(Path(__file__).parent/"evals"))
from_retrieval_fixtureimportFixtureRetrieval
deftest_real_model_chooses_a_graph_reader_for_a_graph_question(monkeypatch):
monkeypatch.syspath_prepend(str(Path(__file__).parent/"evals"))
from_retrieval_fixtureimportFixtureRetrieval
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/tests/test_live_tutor_tools.py` around lines 36 - 38, Update
test_real_model_chooses_a_graph_reader_for_a_graph_question to accept pytest’s
monkeypatch fixture and replace the direct sys.path.insert mutation with
monkeypatch.syspath_prepend(...), ensuring the evals path is restored after the
test.

Comment threaddocs/decisions/0021-agent-eval-harness-baselines.md
AndresL230 added a commit that referenced this pull request Jul 30, 2026
…ion test, tool-count comment, ADR 0015 prose
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Review pass complete: four reviewers, two fully clean (one empirically re-verified the before/after eval baselines by resurrecting the deleted pre-change cassettes and replaying them — exact match). Three sub-80 findings, all fixed: a /chat/stream twin of the raw-body.message persistence regression test (the invariant was correct but only the JSON route was pinned), the stale 'all five tools' comment, and the ADR 0015 blockquote that had swallowed a sentence. Backend 1441 + ruff green. e2e cycle next.

AndresL230and others added 3 commits July 30, 2026 03:21
…n the next commit; kept in PR history so the before→after delta is auditable)
The agent path inlined NO graph context (the full-graph JSON dump was
legacy-only — and user-wide, leaking other courses' concepts); the tutor
had write tools (#127) but no graph read surface. Now:
- Tools 5 → 7: read_graph_neighborhood (course-scoped, seed-matched via
_normalize_concept, depth-1 edges via two merged in.() reads with a
both-endpoints-in-course rule, ids never leave the tool, truncated flag,
degrade-to-empty) + read_concepts_for_user (existing reader, capped
wrapper). Preamble gains the graph-tools paragraph with a hard
two-reads-per-turn cap. read_misconceptions_for_course deliberately
deferred (shared-context opt-out isn't enforced yet — ADR 0023).
- Deterministic GRAPH CONTEXT seed block on every agent turn
(services/graph_context.py: overlap-first/weakest-fill selection,
depth-1 edges, ≤1.5k chars, no ids) — turn-1 graph awareness without a
tool round-trip on the streaming UI; raw body.message persistence
unchanged.
- Legacy prompt de-dumped: all three call sites now use the same compact
course-scoped serializer (also fixes the cross-course concept leak).
- TUTOR_LIMITS (12/12/100k) so a legitimate multi-tool turn can't trip
UsageLimitExceeded into a silent rung-1 legacy fallback.
- The ADR-0021 retrieval seam: TutorRetrieval protocol on SaplingDeps
(production None → byte-identical Supabase path), FixtureRetrieval +
committed synthetic course, cassettes now carry tool-call traces, three
new evaluators, chat_tutor joins the offline harness (16 cases; the
previous commit holds the pre-change cassettes).
Eval deltas (before → after): ExpositoryHasStructure 0.812→1.000,
GraphToolUsed 0.938→1.000, GroundedConcept 0.812→0.875,
MasteryUpdateEmitted 0.688→1.000; legacy four stay 1.000.
Suites: backend 1440 passed + ruff clean (seam/stream files green under
lock-pinned pydantic-ai 1.107); evals replay green across all 6 datasets;
frontend untouched (tsc clean). ADR 0023; ADR 0021/0015 updated.
Closes#149.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ion test, tool-count comment, ADR 0015 prose
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@AndresL230
AndresL230force-pushed the feat/b7-149-graph-grounded-tutor branch from 6e1cf19 to 220d6a8CompareJuly 30, 2026 10:21
@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Pre-merge gate at the rebased head (over B6's #468): full lane 28/28 passed + oracles clean. Merging.

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.

[P1] Agent migration: graph-grounded tutor retrieval + tool-use loop

1 participant

@AndresL230
, '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('^' + ".*" + ' feat(tutor): graph-grounded retrieval + tool-use loop (#149) by AndresL230 · Pull Request #469 · SaplingLearn/Sapling · GitHub
Skip to content

feat(tutor): graph-grounded retrieval + tool-use loop (#149) - #469

Merged
AndresL230 merged 3 commits into
mainfrom
feat/b7-149-graph-grounded-tutor
Jul 30, 2026
Merged

feat(tutor): graph-grounded retrieval + tool-use loop (#149)#469
AndresL230 merged 3 commits into
mainfrom
feat/b7-149-graph-grounded-tutor

Conversation

@AndresL230

@AndresL230AndresL230 commented Jul 30, 2026

Copy link
Copy Markdown
Collaborator

What

First link of the B7 seam-endgame chain (#149#153#150#151#154). The scoping pass corrected the issue's premise: the agent path inlined no graph context at all (the full-graph dump was legacy-only — and user-wide, leaking other courses' concepts), and the mid-turn write half already shipped with #127. Full detail in the commit message; the shape:

  • Read surface: read_graph_neighborhood (course-scoped, name-seeded, depth-1, ids never leave the tool) + read_concepts_for_user, registered on the tutor (5 → 7 tools) with a hard two-reads-per-turn prompt cap. The misconceptions reader is deliberately deferred until the shared-context opt-out is enforced (ADR 0023).
  • Deterministic seed block: every agent turn carries a ≤1.5k-char GRAPH CONTEXT block (overlap-first/weakest-fill + depth-1 edges) — turn-1 graph awareness without a tool round-trip on the streaming UI.
  • Legacy de-dump: the three call_gemini* prompt sites use the same compact serializer (kills the raw JSON dump and its cross-course leak) — de-risking the rung-1 fallback ahead of [P1] Agent migration: retire call_gemini* + gemini_service.py (final cutover) #151.
  • TUTOR_LIMITS (12/12/100k): a legitimate multi-tool turn can no longer trip UsageLimitExceeded into a silent legacy fallback.
  • The ADR-0021 retrieval seam: TutorRetrieval protocol on deps (production None → byte-identical), fixture retrieval + synthetic course, cassettes now record tool-call traces, and chat_tutor joins the offline eval harness (16 cases). The first commit on this branch holds the pre-change cassettes so the delta is auditable.

Eval deltas (before → after): ExpositoryHasStructure 0.812→1.000 · GraphToolUsed 0.938→1.000 · GroundedConcept 0.812→0.875 · MasteryUpdateEmitted 0.688→1.000 · the four legacy evaluators hold 1.000.

Verification

  • Backend 1440 passed + ruff clean; seam/stream/tool files re-run green under lock-pinned pydantic-ai 1.107; evals replay green across all six datasets; frontend untouched (tsc clean).
  • 40+ new tests incl. red-first tool-registration, id-leak/scoping/truncation coverage, the read-only-tools-never-emit-graph_update stream invariant, a scripted-tool seam test, and an opt-in live test proving the real model chooses the readers (run green once).
  • Known follow-up recorded in ADR 0023: gemini-2.5-pro occasionally emits a bare-newline final reply after an end-of-turn tool call — [P2] Agent platform: structured-output retry + validation hardening #153 (structured-output hardening) territory.
  • Full local e2e cycle pre-merge; results below.

Closes#149.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Chat tutoring now provides compact, course-scoped graph context for more relevant responses.
    • Added read-only graph tools for exploring related concepts and reviewing concept mastery.
    • Tutor responses can use course materials, progress, and session history through consistent retrieval.
    • Added offline chat-tutor evaluation coverage with recorded scenarios and tool-behavior checks.
  • Bug Fixes

    • Improved course isolation and prevented unrelated graph data from appearing in tutor context.
    • Read-only graph interactions no longer create unintended updates.
  • Documentation

    • Updated evaluation coverage and architecture decision records.

@supabase

supabaseBot commented Jul 30, 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 Jul 30, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@AndresL230, you've reached your PR review limit, so we couldn't start this review.

Next review available in:35 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 6c9c74c7-f890-4387-84d5-108dd11d7884

📥 Commits

Reviewing files that changed from the base of the PR and between 7eb0ce2 and 220d6a8.

📒 Files selected for processing (44)
  • .github/workflows/evals.yml
  • backend/agents/__init__.py
  • backend/agents/chat_tutor.py
  • backend/agents/deps.py
  • backend/agents/tools/chat_context.py
  • backend/agents/tools/graph_read.py
  • backend/agents/tools/retrieval.py
  • backend/routes/learn.py
  • backend/services/graph_context.py
  • backend/services/token_overlap.py
  • backend/tests/evals/README.md
  • backend/tests/evals/_retrieval_fixture.py
  • backend/tests/evals/baselines.json
  • backend/tests/evals/cassettes/chat_tutor/expository_explain_big_o.json
  • backend/tests/evals/cassettes/chat_tutor/expository_explain_dependency_injection.json
  • backend/tests/evals/cassettes/chat_tutor/expository_explain_kantian_ethics.json
  • backend/tests/evals/cassettes/chat_tutor/expository_explain_photosynthesis.json
  • backend/tests/evals/cassettes/chat_tutor/expository_explain_supply_demand.json
  • backend/tests/evals/cassettes/chat_tutor/socratic_chemistry_balancing.json
  • backend/tests/evals/cassettes/chat_tutor/socratic_history_themes.json
  • backend/tests/evals/cassettes/chat_tutor/socratic_intro_calculus.json
  • backend/tests/evals/cassettes/chat_tutor/socratic_open_followup.json
  • backend/tests/evals/cassettes/chat_tutor/socratic_python_recursion.json
  • backend/tests/evals/cassettes/chat_tutor/socratic_stale_concept_review.json
  • backend/tests/evals/cassettes/chat_tutor/teachback_advanced.json
  • backend/tests/evals/cassettes/chat_tutor/teachback_correct_concept.json
  • backend/tests/evals/cassettes/chat_tutor/teachback_minimal.json
  • backend/tests/evals/cassettes/chat_tutor/teachback_misconception.json
  • backend/tests/evals/cassettes/chat_tutor/teachback_partial_correct.json
  • backend/tests/evals/chat_tutor.py
  • backend/tests/evals/fixtures/tutor_course.json
  • backend/tests/evals/run_all.py
  • backend/tests/test_chat_stream.py
  • backend/tests/test_chat_tutor_imports.py
  • backend/tests/test_e2e_function_handlers.py
  • backend/tests/test_graph_context_block.py
  • backend/tests/test_graph_neighborhood_tool.py
  • backend/tests/test_graph_tools_bugs.py
  • backend/tests/test_live_tutor_tools.py
  • backend/tests/test_model_mode_seam.py
  • backend/tests/test_tutor_retrieval.py
  • docs/decisions/0015-refactor-3-chat-tutor-shipped.md
  • docs/decisions/0021-agent-eval-harness-baselines.md
  • docs/decisions/0023-tutor-graph-retrieval-seam.md
📝 Walkthrough

Walkthrough

The chat tutor now uses compact course-scoped graph context, exposes bounded read-only graph tools, supports injectable retrieval implementations, and runs offline evaluations against fixture data with structured tool-call cassettes and new behavioral evaluators.

Changes

Tutor graph grounding

Layer / File(s)Summary
Compact graph context and route wiring
backend/services/graph_context.py, backend/services/token_overlap.py, backend/routes/learn.py, backend/tests/test_graph_context_block.py
Graph context is selected, scoped, rendered within a character budget, and used by both agent and legacy tutor prompt paths.
Retrieval seam and graph read tools
backend/agents/tools/*, backend/agents/chat_tutor.py, backend/agents/deps.py, backend/tests/test_graph_neighborhood_tool.py, backend/tests/test_tutor_retrieval.py
Tutor reads resolve injected or Supabase retrieval, and graph readers provide bounded, course/user-scoped neighborhoods without graph writes.
Offline evaluation and regression coverage
backend/tests/evals/*, backend/tests/test_*.py, .github/workflows/evals.yml
Fixture retrieval, structured cassettes, graph/tool evaluators, baselines, CI gating, and integration tests cover the new tutor flow.

Estimated code review effort: 4 (Complex) | ~60 minutes

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 27.86% 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 the main change: graph-grounded tutor retrieval with tool use.
Description check✅ PassedThe description is substantial and covers the change, issues, and verification, though some template sections are omitted.
Linked Issues check✅ PassedThe PR implements course-scoped graph retrieval, prompt graph context, and mid-turn graph/mastery updates, matching #149.
Out of Scope Changes check✅ PassedMost changes support the tutor graph-grounding/eval harness work, with no clearly unrelated code changes evident.
✨ 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 feat/b7-149-graph-grounded-tutor

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.


async def course_materials(
self, course_id: str | None, query: str, limit: int, *, user_id: str
) -> list["CourseMaterial"]: ...
concepts: list[str],
*,
limit: int = 20,
) -> "GraphNeighborhood": ...

async def concept_mastery(
self, user_id: str, course_id: str | None
) -> list["ConceptMastery"]: ...

async def progress(
self, user_id: str, course_id: str | None
) -> "CourseProgress": ...

async def session_history(
self, session_id: str, last_n: int
) -> list["SessionMessage"]: ...
@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Jul 30, 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-staging220d6a8Commit Preview URL

Branch Preview URL
Jul 30 2026, 10:23 AM

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (6)
backend/tests/evals/chat_tutor.py (1)

459-478: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Prefer isinstance over string-based class-name matching for tool-call parts.

type(part).__name__ != "ToolCallPart" only matches parts of the exact ToolCallPart class. pydantic_ai's message types also include a distinct NativeToolCallPart for native/provider tool-calling paths; if any of the tutor's tools were ever surfaced that way, this check would silently drop them from the cassette, undercounting tool usage that GraphToolUsedEvaluator/MasteryUpdateEmittedEvaluator rely on.

♻️ Suggested fix
+from pydantic_ai.messages import ToolCallPart+
def _extract_tool_calls(result) -> list[ToolCall]:
...
for message in messages:
for part in getattr(message, "parts", []) or []:
- if type(part).__name__ != "ToolCallPart":+ if not isinstance(part, ToolCallPart):
continue
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/tests/evals/chat_tutor.py` around lines 459 - 478, Update
_extract_tool_calls to use isinstance-based recognition of pydantic_ai tool-call
part types, including both ToolCallPart and NativeToolCallPart, instead of
comparing type(part).__name__. Preserve the existing argument extraction
fallback and ToolCall construction for every recognized tool-call part.
backend/agents/tools/graph_read.py (2)

181-189: 🎯 Functional Correctness | 🔵 Trivial | 💤 Low value

Duplicate normalized concept names silently collapse.

by_norm[_normalize_concept(name)] = n is last-write-wins, so if the graph holds two rows normalizing to the same key (e.g. pre-dedup legacy rows), a seed resolves to whichever row came back last from PostgREST — non-deterministic across reads. Preferring the first occurrence (setdefault) at least makes it stable given a stable row order.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/agents/tools/graph_read.py` around lines 181 - 189, Update the
by_norm construction in the node-processing loop to preserve the first node for
each normalized concept name instead of overwriting it with later duplicates.
Replace the last-write-wins assignment associated with _normalize_concept(name)
while leaving by_id behavior unchanged.

104-105: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Clamp the LLM-chosen limit on the upper end too.

Both sites only floor at 0. A model that passes limit=5000 gets every tracked concept (and, for the neighborhood, every kept edge) back into the context window — the whole point of the cap. A module-level max (e.g. 50) keeps the tool's prompt cost bounded regardless of what the model asks for.

♻️ Suggested clamp
+_MAX_LIMIT = 50+
...
- n = max(0, int(limit))+ n = min(_MAX_LIMIT, max(0, int(limit)))
return rows[:n]
...
- cap = max(0, int(limit))+ cap = min(_MAX_LIMIT, max(0, int(limit)))

Also applies to: 244-247

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/agents/tools/graph_read.py` around lines 104 - 105, Clamp the
LLM-provided limit to a defined module-level maximum in both row-slicing sites,
including the neighborhood path referenced by the comment. Update the limit
normalization around `n = max(0, int(limit))` so values above the cap are
reduced while negative values still produce zero, keeping prompt results
bounded.
backend/agents/chat_tutor.py (1)

70-73: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

"Hard cap" is prompt-advisory only.

Nothing enforces two graph reads per turn; the real ceiling is TUTOR_LIMITS. Either soften the wording or enforce it in the tool wrapper (e.g. a per-run counter on SaplingDeps that returns an empty neighborhood past the second call) so cost is bounded even when the model ignores the instruction.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/agents/chat_tutor.py` around lines 70 - 73, Update the graph-read
enforcement around SaplingDeps and its tool wrapper so each tutor run permits no
more than two graph reads, returning an empty neighborhood after the second
call; otherwise soften the prompt’s “Hard cap” wording to match the actual
TUTOR_LIMITS behavior.
backend/tests/test_model_mode_seam.py (1)

304-307: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Registered function handler is never unregistered.

register_function_handler mutates a module-level registry that monkeypatch won't restore, so this handler leaks into any later test that runs chat_tutor in function mode. If the file doesn't already have an autouse reset fixture, worth adding one.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/tests/test_model_mode_seam.py` around lines 304 - 307, Add cleanup
for the module-level handler registry after tests using
register_function_handler, including the test around socratic_agent.run_sync and
the "chat_tutor" handler. Prefer an autouse fixture if the file lacks one,
ensuring the registry is reset after each test so handlers do not leak into
later function-mode tests.
backend/services/graph_context.py (1)

105-111: 🚀 Performance & Scalability | 🔵 Trivial | 💤 Low value

Budget trim rejoins the whole block each iteration.

Minor: with ≤12 lines this is irrelevant, but a running length counter (or trimming from a precomputed cumulative sum) avoids the O(n²) rejoin and reads clearer.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/services/graph_context.py` around lines 105 - 111, Update the
budget-trimming logic in the graph-context builder around _HEADER, lines, and
GRAPH_CONTEXT_CHAR_BUDGET to track the assembled character length incrementally
instead of rejoining the entire block on every loop iteration. Preserve the
existing behavior of removing trailing lines until the budget fits and returning
an empty string when no lines remain.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@backend/routes/learn.py`:
- Around line 520-523: Update the graph-context construction at the referenced
legacy-turn paths, including the flows around lines 520, 763, and 1328, so
course-less sessions retain an appropriate user-wide compact graph fallback
instead of receiving an empty block. Avoid calling get_graph when no course_id
is available unless the fallback requires it, and ensure the serializer’s
selection remains bounded and weakest-first.
In `@backend/tests/test_live_tutor_tools.py`:
- Around line 36-38: Update
test_real_model_chooses_a_graph_reader_for_a_graph_question to accept pytest’s
monkeypatch fixture and replace the direct sys.path.insert mutation with
monkeypatch.syspath_prepend(...), ensuring the evals path is restored after the
test.
In `@docs/decisions/0021-agent-eval-harness-baselines.md`:
- Around line 46-48: The ADRs retain obsolete pre-change descriptions of
chat_tutor. In docs/decisions/0021-agent-eval-harness-baselines.md lines 46-48,
update the five-dataset wording, baseline table, and “unmeasured” consequence to
include chat_tutor; in docs/decisions/0015-refactor-3-chat-tutor-shipped.md
lines 28-33, replace the remaining four-tool and four-registration descriptions
or explicitly mark them as historical.
---
Nitpick comments:
In `@backend/agents/chat_tutor.py`:
- Around line 70-73: Update the graph-read enforcement around SaplingDeps and
its tool wrapper so each tutor run permits no more than two graph reads,
returning an empty neighborhood after the second call; otherwise soften the
prompt’s “Hard cap” wording to match the actual TUTOR_LIMITS behavior.
In `@backend/agents/tools/graph_read.py`:
- Around line 181-189: Update the by_norm construction in the node-processing
loop to preserve the first node for each normalized concept name instead of
overwriting it with later duplicates. Replace the last-write-wins assignment
associated with _normalize_concept(name) while leaving by_id behavior unchanged.
- Around line 104-105: Clamp the LLM-provided limit to a defined module-level
maximum in both row-slicing sites, including the neighborhood path referenced by
the comment. Update the limit normalization around `n = max(0, int(limit))` so
values above the cap are reduced while negative values still produce zero,
keeping prompt results bounded.
In `@backend/services/graph_context.py`:
- Around line 105-111: Update the budget-trimming logic in the graph-context
builder around _HEADER, lines, and GRAPH_CONTEXT_CHAR_BUDGET to track the
assembled character length incrementally instead of rejoining the entire block
on every loop iteration. Preserve the existing behavior of removing trailing
lines until the budget fits and returning an empty string when no lines remain.
In `@backend/tests/evals/chat_tutor.py`:
- Around line 459-478: Update _extract_tool_calls to use isinstance-based
recognition of pydantic_ai tool-call part types, including both ToolCallPart and
NativeToolCallPart, instead of comparing type(part).__name__. Preserve the
existing argument extraction fallback and ToolCall construction for every
recognized tool-call part.
In `@backend/tests/test_model_mode_seam.py`:
- Around line 304-307: Add cleanup for the module-level handler registry after
tests using register_function_handler, including the test around
socratic_agent.run_sync and the "chat_tutor" handler. Prefer an autouse fixture
if the file lacks one, ensuring the registry is reset after each test so
handlers do not leak into later function-mode tests.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 10240725-b7f7-4f3d-8294-03d828114d0c

📥 Commits

Reviewing files that changed from the base of the PR and between ec0e2af and 7eb0ce2.

📒 Files selected for processing (44)
  • .github/workflows/evals.yml
  • backend/agents/__init__.py
  • backend/agents/chat_tutor.py
  • backend/agents/deps.py
  • backend/agents/tools/chat_context.py
  • backend/agents/tools/graph_read.py
  • backend/agents/tools/retrieval.py
  • backend/routes/learn.py
  • backend/services/graph_context.py
  • backend/services/token_overlap.py
  • backend/tests/evals/README.md
  • backend/tests/evals/_retrieval_fixture.py
  • backend/tests/evals/baselines.json
  • backend/tests/evals/cassettes/chat_tutor/expository_explain_big_o.json
  • backend/tests/evals/cassettes/chat_tutor/expository_explain_dependency_injection.json
  • backend/tests/evals/cassettes/chat_tutor/expository_explain_kantian_ethics.json
  • backend/tests/evals/cassettes/chat_tutor/expository_explain_photosynthesis.json
  • backend/tests/evals/cassettes/chat_tutor/expository_explain_supply_demand.json
  • backend/tests/evals/cassettes/chat_tutor/socratic_chemistry_balancing.json
  • backend/tests/evals/cassettes/chat_tutor/socratic_history_themes.json
  • backend/tests/evals/cassettes/chat_tutor/socratic_intro_calculus.json
  • backend/tests/evals/cassettes/chat_tutor/socratic_open_followup.json
  • backend/tests/evals/cassettes/chat_tutor/socratic_python_recursion.json
  • backend/tests/evals/cassettes/chat_tutor/socratic_stale_concept_review.json
  • backend/tests/evals/cassettes/chat_tutor/teachback_advanced.json
  • backend/tests/evals/cassettes/chat_tutor/teachback_correct_concept.json
  • backend/tests/evals/cassettes/chat_tutor/teachback_minimal.json
  • backend/tests/evals/cassettes/chat_tutor/teachback_misconception.json
  • backend/tests/evals/cassettes/chat_tutor/teachback_partial_correct.json
  • backend/tests/evals/chat_tutor.py
  • backend/tests/evals/fixtures/tutor_course.json
  • backend/tests/evals/run_all.py
  • backend/tests/test_chat_stream.py
  • backend/tests/test_chat_tutor_imports.py
  • backend/tests/test_e2e_function_handlers.py
  • backend/tests/test_graph_context_block.py
  • backend/tests/test_graph_neighborhood_tool.py
  • backend/tests/test_graph_tools_bugs.py
  • backend/tests/test_live_tutor_tools.py
  • backend/tests/test_model_mode_seam.py
  • backend/tests/test_tutor_retrieval.py
  • docs/decisions/0015-refactor-3-chat-tutor-shipped.md
  • docs/decisions/0021-agent-eval-harness-baselines.md
  • docs/decisions/0023-tutor-graph-retrieval-seam.md

Comment threadbackend/routes/learn.py
Comment on lines +36 to +38
def test_real_model_chooses_a_graph_reader_for_a_graph_question():
sys.path.insert(0, str(Path(__file__).parent / "evals"))
from _retrieval_fixture import FixtureRetrieval

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bashset -euo pipefail
echo"Files matching test name:"
fd -a 'test_live_tutor_tools\.py$'.||true
test_file="$(fd 'test_live_tutor_tools\.py$'.| head -n1 || true)"if [ -n"${test_file:-}" ];thenechoecho"Relevant test file:"echo"$test_file"echoecho"Line count:"
wc -l "$test_file"echoecho"First 120 lines:"
sed -n '1,120p'"$test_file"| cat -n
fiechoecho"Search for sys.path usage in file:"if [ -n"${test_file:-}" ];then
rg -n "sys\.path|syspath_prepend|Path\(__file__\).parent""$test_file"||truefiechoecho"Search for global sys.path mutations in backend/tests:"
rg -n "sys\.path\.insert|sys\.path\.append|sys\.path\.extend|sys\.path\.remove|syspath_prepend" backend/tests ||trueechoecho"pytest availability:"
python3 - <<'PY'try: import pytest print("pytest:", pytest.__version__)except Exception as e: print("pytest import failed:", repr(e))try: import _retrieval_fixture print("module _retrieval_fixture found")except Exception as e: print("_retrieval_fixture import failed:", repr(e))PY

Repository: SaplingLearn/Sapling

Length of output: 6296


Scope the import-path mutation to this test.

sys.path.insert(...) mutates the pytest process state for any following tests. Use pytest’s scoped monkeypatch.syspath_prepend(...) so the path is restored automatically after this test.

Proposed fix
-def test_real_model_chooses_a_graph_reader_for_a_graph_question():- sys.path.insert(0, str(Path(__file__).parent / "evals"))+def test_real_model_chooses_a_graph_reader_for_a_graph_question(monkeypatch):+ monkeypatch.syspath_prepend(str(Path(__file__).parent / "evals"))
📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
deftest_real_model_chooses_a_graph_reader_for_a_graph_question():
sys.path.insert(0, str(Path(__file__).parent/"evals"))
from_retrieval_fixtureimportFixtureRetrieval
deftest_real_model_chooses_a_graph_reader_for_a_graph_question(monkeypatch):
monkeypatch.syspath_prepend(str(Path(__file__).parent/"evals"))
from_retrieval_fixtureimportFixtureRetrieval
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/tests/test_live_tutor_tools.py` around lines 36 - 38, Update
test_real_model_chooses_a_graph_reader_for_a_graph_question to accept pytest’s
monkeypatch fixture and replace the direct sys.path.insert mutation with
monkeypatch.syspath_prepend(...), ensuring the evals path is restored after the
test.

Comment threaddocs/decisions/0021-agent-eval-harness-baselines.md
AndresL230 added a commit that referenced this pull request Jul 30, 2026
…ion test, tool-count comment, ADR 0015 prose
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Review pass complete: four reviewers, two fully clean (one empirically re-verified the before/after eval baselines by resurrecting the deleted pre-change cassettes and replaying them — exact match). Three sub-80 findings, all fixed: a /chat/stream twin of the raw-body.message persistence regression test (the invariant was correct but only the JSON route was pinned), the stale 'all five tools' comment, and the ADR 0015 blockquote that had swallowed a sentence. Backend 1441 + ruff green. e2e cycle next.

AndresL230and others added 3 commits July 30, 2026 03:21
…n the next commit; kept in PR history so the before→after delta is auditable)
The agent path inlined NO graph context (the full-graph JSON dump was
legacy-only — and user-wide, leaking other courses' concepts); the tutor
had write tools (#127) but no graph read surface. Now:
- Tools 5 → 7: read_graph_neighborhood (course-scoped, seed-matched via
_normalize_concept, depth-1 edges via two merged in.() reads with a
both-endpoints-in-course rule, ids never leave the tool, truncated flag,
degrade-to-empty) + read_concepts_for_user (existing reader, capped
wrapper). Preamble gains the graph-tools paragraph with a hard
two-reads-per-turn cap. read_misconceptions_for_course deliberately
deferred (shared-context opt-out isn't enforced yet — ADR 0023).
- Deterministic GRAPH CONTEXT seed block on every agent turn
(services/graph_context.py: overlap-first/weakest-fill selection,
depth-1 edges, ≤1.5k chars, no ids) — turn-1 graph awareness without a
tool round-trip on the streaming UI; raw body.message persistence
unchanged.
- Legacy prompt de-dumped: all three call sites now use the same compact
course-scoped serializer (also fixes the cross-course concept leak).
- TUTOR_LIMITS (12/12/100k) so a legitimate multi-tool turn can't trip
UsageLimitExceeded into a silent rung-1 legacy fallback.
- The ADR-0021 retrieval seam: TutorRetrieval protocol on SaplingDeps
(production None → byte-identical Supabase path), FixtureRetrieval +
committed synthetic course, cassettes now carry tool-call traces, three
new evaluators, chat_tutor joins the offline harness (16 cases; the
previous commit holds the pre-change cassettes).
Eval deltas (before → after): ExpositoryHasStructure 0.812→1.000,
GraphToolUsed 0.938→1.000, GroundedConcept 0.812→0.875,
MasteryUpdateEmitted 0.688→1.000; legacy four stay 1.000.
Suites: backend 1440 passed + ruff clean (seam/stream files green under
lock-pinned pydantic-ai 1.107); evals replay green across all 6 datasets;
frontend untouched (tsc clean). ADR 0023; ADR 0021/0015 updated.
Closes#149.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ion test, tool-count comment, ADR 0015 prose
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@AndresL230
AndresL230force-pushed the feat/b7-149-graph-grounded-tutor branch from 6e1cf19 to 220d6a8CompareJuly 30, 2026 10:21
@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Pre-merge gate at the rebased head (over B6's #468): full lane 28/28 passed + oracles clean. Merging.

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.

[P1] Agent migration: graph-grounded tutor retrieval + tool-use loop

1 participant

@AndresL230
, '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" + ' feat(tutor): graph-grounded retrieval + tool-use loop (#149) by AndresL230 · Pull Request #469 · SaplingLearn/Sapling · GitHub
Skip to content

feat(tutor): graph-grounded retrieval + tool-use loop (#149) - #469

Merged
AndresL230 merged 3 commits into
mainfrom
feat/b7-149-graph-grounded-tutor
Jul 30, 2026
Merged

feat(tutor): graph-grounded retrieval + tool-use loop (#149)#469
AndresL230 merged 3 commits into
mainfrom
feat/b7-149-graph-grounded-tutor

Conversation

@AndresL230

@AndresL230AndresL230 commented Jul 30, 2026

Copy link
Copy Markdown
Collaborator

What

First link of the B7 seam-endgame chain (#149#153#150#151#154). The scoping pass corrected the issue's premise: the agent path inlined no graph context at all (the full-graph dump was legacy-only — and user-wide, leaking other courses' concepts), and the mid-turn write half already shipped with #127. Full detail in the commit message; the shape:

  • Read surface: read_graph_neighborhood (course-scoped, name-seeded, depth-1, ids never leave the tool) + read_concepts_for_user, registered on the tutor (5 → 7 tools) with a hard two-reads-per-turn prompt cap. The misconceptions reader is deliberately deferred until the shared-context opt-out is enforced (ADR 0023).
  • Deterministic seed block: every agent turn carries a ≤1.5k-char GRAPH CONTEXT block (overlap-first/weakest-fill + depth-1 edges) — turn-1 graph awareness without a tool round-trip on the streaming UI.
  • Legacy de-dump: the three call_gemini* prompt sites use the same compact serializer (kills the raw JSON dump and its cross-course leak) — de-risking the rung-1 fallback ahead of [P1] Agent migration: retire call_gemini* + gemini_service.py (final cutover) #151.
  • TUTOR_LIMITS (12/12/100k): a legitimate multi-tool turn can no longer trip UsageLimitExceeded into a silent legacy fallback.
  • The ADR-0021 retrieval seam: TutorRetrieval protocol on deps (production None → byte-identical), fixture retrieval + synthetic course, cassettes now record tool-call traces, and chat_tutor joins the offline eval harness (16 cases). The first commit on this branch holds the pre-change cassettes so the delta is auditable.

Eval deltas (before → after): ExpositoryHasStructure 0.812→1.000 · GraphToolUsed 0.938→1.000 · GroundedConcept 0.812→0.875 · MasteryUpdateEmitted 0.688→1.000 · the four legacy evaluators hold 1.000.

Verification

  • Backend 1440 passed + ruff clean; seam/stream/tool files re-run green under lock-pinned pydantic-ai 1.107; evals replay green across all six datasets; frontend untouched (tsc clean).
  • 40+ new tests incl. red-first tool-registration, id-leak/scoping/truncation coverage, the read-only-tools-never-emit-graph_update stream invariant, a scripted-tool seam test, and an opt-in live test proving the real model chooses the readers (run green once).
  • Known follow-up recorded in ADR 0023: gemini-2.5-pro occasionally emits a bare-newline final reply after an end-of-turn tool call — [P2] Agent platform: structured-output retry + validation hardening #153 (structured-output hardening) territory.
  • Full local e2e cycle pre-merge; results below.

Closes#149.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Chat tutoring now provides compact, course-scoped graph context for more relevant responses.
    • Added read-only graph tools for exploring related concepts and reviewing concept mastery.
    • Tutor responses can use course materials, progress, and session history through consistent retrieval.
    • Added offline chat-tutor evaluation coverage with recorded scenarios and tool-behavior checks.
  • Bug Fixes

    • Improved course isolation and prevented unrelated graph data from appearing in tutor context.
    • Read-only graph interactions no longer create unintended updates.
  • Documentation

    • Updated evaluation coverage and architecture decision records.

@supabase

supabaseBot commented Jul 30, 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 Jul 30, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@AndresL230, you've reached your PR review limit, so we couldn't start this review.

Next review available in:35 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 6c9c74c7-f890-4387-84d5-108dd11d7884

📥 Commits

Reviewing files that changed from the base of the PR and between 7eb0ce2 and 220d6a8.

📒 Files selected for processing (44)
  • .github/workflows/evals.yml
  • backend/agents/__init__.py
  • backend/agents/chat_tutor.py
  • backend/agents/deps.py
  • backend/agents/tools/chat_context.py
  • backend/agents/tools/graph_read.py
  • backend/agents/tools/retrieval.py
  • backend/routes/learn.py
  • backend/services/graph_context.py
  • backend/services/token_overlap.py
  • backend/tests/evals/README.md
  • backend/tests/evals/_retrieval_fixture.py
  • backend/tests/evals/baselines.json
  • backend/tests/evals/cassettes/chat_tutor/expository_explain_big_o.json
  • backend/tests/evals/cassettes/chat_tutor/expository_explain_dependency_injection.json
  • backend/tests/evals/cassettes/chat_tutor/expository_explain_kantian_ethics.json
  • backend/tests/evals/cassettes/chat_tutor/expository_explain_photosynthesis.json
  • backend/tests/evals/cassettes/chat_tutor/expository_explain_supply_demand.json
  • backend/tests/evals/cassettes/chat_tutor/socratic_chemistry_balancing.json
  • backend/tests/evals/cassettes/chat_tutor/socratic_history_themes.json
  • backend/tests/evals/cassettes/chat_tutor/socratic_intro_calculus.json
  • backend/tests/evals/cassettes/chat_tutor/socratic_open_followup.json
  • backend/tests/evals/cassettes/chat_tutor/socratic_python_recursion.json
  • backend/tests/evals/cassettes/chat_tutor/socratic_stale_concept_review.json
  • backend/tests/evals/cassettes/chat_tutor/teachback_advanced.json
  • backend/tests/evals/cassettes/chat_tutor/teachback_correct_concept.json
  • backend/tests/evals/cassettes/chat_tutor/teachback_minimal.json
  • backend/tests/evals/cassettes/chat_tutor/teachback_misconception.json
  • backend/tests/evals/cassettes/chat_tutor/teachback_partial_correct.json
  • backend/tests/evals/chat_tutor.py
  • backend/tests/evals/fixtures/tutor_course.json
  • backend/tests/evals/run_all.py
  • backend/tests/test_chat_stream.py
  • backend/tests/test_chat_tutor_imports.py
  • backend/tests/test_e2e_function_handlers.py
  • backend/tests/test_graph_context_block.py
  • backend/tests/test_graph_neighborhood_tool.py
  • backend/tests/test_graph_tools_bugs.py
  • backend/tests/test_live_tutor_tools.py
  • backend/tests/test_model_mode_seam.py
  • backend/tests/test_tutor_retrieval.py
  • docs/decisions/0015-refactor-3-chat-tutor-shipped.md
  • docs/decisions/0021-agent-eval-harness-baselines.md
  • docs/decisions/0023-tutor-graph-retrieval-seam.md
📝 Walkthrough

Walkthrough

The chat tutor now uses compact course-scoped graph context, exposes bounded read-only graph tools, supports injectable retrieval implementations, and runs offline evaluations against fixture data with structured tool-call cassettes and new behavioral evaluators.

Changes

Tutor graph grounding

Layer / File(s)Summary
Compact graph context and route wiring
backend/services/graph_context.py, backend/services/token_overlap.py, backend/routes/learn.py, backend/tests/test_graph_context_block.py
Graph context is selected, scoped, rendered within a character budget, and used by both agent and legacy tutor prompt paths.
Retrieval seam and graph read tools
backend/agents/tools/*, backend/agents/chat_tutor.py, backend/agents/deps.py, backend/tests/test_graph_neighborhood_tool.py, backend/tests/test_tutor_retrieval.py
Tutor reads resolve injected or Supabase retrieval, and graph readers provide bounded, course/user-scoped neighborhoods without graph writes.
Offline evaluation and regression coverage
backend/tests/evals/*, backend/tests/test_*.py, .github/workflows/evals.yml
Fixture retrieval, structured cassettes, graph/tool evaluators, baselines, CI gating, and integration tests cover the new tutor flow.

Estimated code review effort: 4 (Complex) | ~60 minutes

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 27.86% 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 the main change: graph-grounded tutor retrieval with tool use.
Description check✅ PassedThe description is substantial and covers the change, issues, and verification, though some template sections are omitted.
Linked Issues check✅ PassedThe PR implements course-scoped graph retrieval, prompt graph context, and mid-turn graph/mastery updates, matching #149.
Out of Scope Changes check✅ PassedMost changes support the tutor graph-grounding/eval harness work, with no clearly unrelated code changes evident.
✨ 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 feat/b7-149-graph-grounded-tutor

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.


async def course_materials(
self, course_id: str | None, query: str, limit: int, *, user_id: str
) -> list["CourseMaterial"]: ...
concepts: list[str],
*,
limit: int = 20,
) -> "GraphNeighborhood": ...

async def concept_mastery(
self, user_id: str, course_id: str | None
) -> list["ConceptMastery"]: ...

async def progress(
self, user_id: str, course_id: str | None
) -> "CourseProgress": ...

async def session_history(
self, session_id: str, last_n: int
) -> list["SessionMessage"]: ...
@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Jul 30, 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-staging220d6a8Commit Preview URL

Branch Preview URL
Jul 30 2026, 10:23 AM

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (6)
backend/tests/evals/chat_tutor.py (1)

459-478: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Prefer isinstance over string-based class-name matching for tool-call parts.

type(part).__name__ != "ToolCallPart" only matches parts of the exact ToolCallPart class. pydantic_ai's message types also include a distinct NativeToolCallPart for native/provider tool-calling paths; if any of the tutor's tools were ever surfaced that way, this check would silently drop them from the cassette, undercounting tool usage that GraphToolUsedEvaluator/MasteryUpdateEmittedEvaluator rely on.

♻️ Suggested fix
+from pydantic_ai.messages import ToolCallPart+
def _extract_tool_calls(result) -> list[ToolCall]:
...
for message in messages:
for part in getattr(message, "parts", []) or []:
- if type(part).__name__ != "ToolCallPart":+ if not isinstance(part, ToolCallPart):
continue
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/tests/evals/chat_tutor.py` around lines 459 - 478, Update
_extract_tool_calls to use isinstance-based recognition of pydantic_ai tool-call
part types, including both ToolCallPart and NativeToolCallPart, instead of
comparing type(part).__name__. Preserve the existing argument extraction
fallback and ToolCall construction for every recognized tool-call part.
backend/agents/tools/graph_read.py (2)

181-189: 🎯 Functional Correctness | 🔵 Trivial | 💤 Low value

Duplicate normalized concept names silently collapse.

by_norm[_normalize_concept(name)] = n is last-write-wins, so if the graph holds two rows normalizing to the same key (e.g. pre-dedup legacy rows), a seed resolves to whichever row came back last from PostgREST — non-deterministic across reads. Preferring the first occurrence (setdefault) at least makes it stable given a stable row order.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/agents/tools/graph_read.py` around lines 181 - 189, Update the
by_norm construction in the node-processing loop to preserve the first node for
each normalized concept name instead of overwriting it with later duplicates.
Replace the last-write-wins assignment associated with _normalize_concept(name)
while leaving by_id behavior unchanged.

104-105: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Clamp the LLM-chosen limit on the upper end too.

Both sites only floor at 0. A model that passes limit=5000 gets every tracked concept (and, for the neighborhood, every kept edge) back into the context window — the whole point of the cap. A module-level max (e.g. 50) keeps the tool's prompt cost bounded regardless of what the model asks for.

♻️ Suggested clamp
+_MAX_LIMIT = 50+
...
- n = max(0, int(limit))+ n = min(_MAX_LIMIT, max(0, int(limit)))
return rows[:n]
...
- cap = max(0, int(limit))+ cap = min(_MAX_LIMIT, max(0, int(limit)))

Also applies to: 244-247

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/agents/tools/graph_read.py` around lines 104 - 105, Clamp the
LLM-provided limit to a defined module-level maximum in both row-slicing sites,
including the neighborhood path referenced by the comment. Update the limit
normalization around `n = max(0, int(limit))` so values above the cap are
reduced while negative values still produce zero, keeping prompt results
bounded.
backend/agents/chat_tutor.py (1)

70-73: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

"Hard cap" is prompt-advisory only.

Nothing enforces two graph reads per turn; the real ceiling is TUTOR_LIMITS. Either soften the wording or enforce it in the tool wrapper (e.g. a per-run counter on SaplingDeps that returns an empty neighborhood past the second call) so cost is bounded even when the model ignores the instruction.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/agents/chat_tutor.py` around lines 70 - 73, Update the graph-read
enforcement around SaplingDeps and its tool wrapper so each tutor run permits no
more than two graph reads, returning an empty neighborhood after the second
call; otherwise soften the prompt’s “Hard cap” wording to match the actual
TUTOR_LIMITS behavior.
backend/tests/test_model_mode_seam.py (1)

304-307: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Registered function handler is never unregistered.

register_function_handler mutates a module-level registry that monkeypatch won't restore, so this handler leaks into any later test that runs chat_tutor in function mode. If the file doesn't already have an autouse reset fixture, worth adding one.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/tests/test_model_mode_seam.py` around lines 304 - 307, Add cleanup
for the module-level handler registry after tests using
register_function_handler, including the test around socratic_agent.run_sync and
the "chat_tutor" handler. Prefer an autouse fixture if the file lacks one,
ensuring the registry is reset after each test so handlers do not leak into
later function-mode tests.
backend/services/graph_context.py (1)

105-111: 🚀 Performance & Scalability | 🔵 Trivial | 💤 Low value

Budget trim rejoins the whole block each iteration.

Minor: with ≤12 lines this is irrelevant, but a running length counter (or trimming from a precomputed cumulative sum) avoids the O(n²) rejoin and reads clearer.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/services/graph_context.py` around lines 105 - 111, Update the
budget-trimming logic in the graph-context builder around _HEADER, lines, and
GRAPH_CONTEXT_CHAR_BUDGET to track the assembled character length incrementally
instead of rejoining the entire block on every loop iteration. Preserve the
existing behavior of removing trailing lines until the budget fits and returning
an empty string when no lines remain.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@backend/routes/learn.py`:
- Around line 520-523: Update the graph-context construction at the referenced
legacy-turn paths, including the flows around lines 520, 763, and 1328, so
course-less sessions retain an appropriate user-wide compact graph fallback
instead of receiving an empty block. Avoid calling get_graph when no course_id
is available unless the fallback requires it, and ensure the serializer’s
selection remains bounded and weakest-first.
In `@backend/tests/test_live_tutor_tools.py`:
- Around line 36-38: Update
test_real_model_chooses_a_graph_reader_for_a_graph_question to accept pytest’s
monkeypatch fixture and replace the direct sys.path.insert mutation with
monkeypatch.syspath_prepend(...), ensuring the evals path is restored after the
test.
In `@docs/decisions/0021-agent-eval-harness-baselines.md`:
- Around line 46-48: The ADRs retain obsolete pre-change descriptions of
chat_tutor. In docs/decisions/0021-agent-eval-harness-baselines.md lines 46-48,
update the five-dataset wording, baseline table, and “unmeasured” consequence to
include chat_tutor; in docs/decisions/0015-refactor-3-chat-tutor-shipped.md
lines 28-33, replace the remaining four-tool and four-registration descriptions
or explicitly mark them as historical.
---
Nitpick comments:
In `@backend/agents/chat_tutor.py`:
- Around line 70-73: Update the graph-read enforcement around SaplingDeps and
its tool wrapper so each tutor run permits no more than two graph reads,
returning an empty neighborhood after the second call; otherwise soften the
prompt’s “Hard cap” wording to match the actual TUTOR_LIMITS behavior.
In `@backend/agents/tools/graph_read.py`:
- Around line 181-189: Update the by_norm construction in the node-processing
loop to preserve the first node for each normalized concept name instead of
overwriting it with later duplicates. Replace the last-write-wins assignment
associated with _normalize_concept(name) while leaving by_id behavior unchanged.
- Around line 104-105: Clamp the LLM-provided limit to a defined module-level
maximum in both row-slicing sites, including the neighborhood path referenced by
the comment. Update the limit normalization around `n = max(0, int(limit))` so
values above the cap are reduced while negative values still produce zero,
keeping prompt results bounded.
In `@backend/services/graph_context.py`:
- Around line 105-111: Update the budget-trimming logic in the graph-context
builder around _HEADER, lines, and GRAPH_CONTEXT_CHAR_BUDGET to track the
assembled character length incrementally instead of rejoining the entire block
on every loop iteration. Preserve the existing behavior of removing trailing
lines until the budget fits and returning an empty string when no lines remain.
In `@backend/tests/evals/chat_tutor.py`:
- Around line 459-478: Update _extract_tool_calls to use isinstance-based
recognition of pydantic_ai tool-call part types, including both ToolCallPart and
NativeToolCallPart, instead of comparing type(part).__name__. Preserve the
existing argument extraction fallback and ToolCall construction for every
recognized tool-call part.
In `@backend/tests/test_model_mode_seam.py`:
- Around line 304-307: Add cleanup for the module-level handler registry after
tests using register_function_handler, including the test around
socratic_agent.run_sync and the "chat_tutor" handler. Prefer an autouse fixture
if the file lacks one, ensuring the registry is reset after each test so
handlers do not leak into later function-mode tests.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 10240725-b7f7-4f3d-8294-03d828114d0c

📥 Commits

Reviewing files that changed from the base of the PR and between ec0e2af and 7eb0ce2.

📒 Files selected for processing (44)
  • .github/workflows/evals.yml
  • backend/agents/__init__.py
  • backend/agents/chat_tutor.py
  • backend/agents/deps.py
  • backend/agents/tools/chat_context.py
  • backend/agents/tools/graph_read.py
  • backend/agents/tools/retrieval.py
  • backend/routes/learn.py
  • backend/services/graph_context.py
  • backend/services/token_overlap.py
  • backend/tests/evals/README.md
  • backend/tests/evals/_retrieval_fixture.py
  • backend/tests/evals/baselines.json
  • backend/tests/evals/cassettes/chat_tutor/expository_explain_big_o.json
  • backend/tests/evals/cassettes/chat_tutor/expository_explain_dependency_injection.json
  • backend/tests/evals/cassettes/chat_tutor/expository_explain_kantian_ethics.json
  • backend/tests/evals/cassettes/chat_tutor/expository_explain_photosynthesis.json
  • backend/tests/evals/cassettes/chat_tutor/expository_explain_supply_demand.json
  • backend/tests/evals/cassettes/chat_tutor/socratic_chemistry_balancing.json
  • backend/tests/evals/cassettes/chat_tutor/socratic_history_themes.json
  • backend/tests/evals/cassettes/chat_tutor/socratic_intro_calculus.json
  • backend/tests/evals/cassettes/chat_tutor/socratic_open_followup.json
  • backend/tests/evals/cassettes/chat_tutor/socratic_python_recursion.json
  • backend/tests/evals/cassettes/chat_tutor/socratic_stale_concept_review.json
  • backend/tests/evals/cassettes/chat_tutor/teachback_advanced.json
  • backend/tests/evals/cassettes/chat_tutor/teachback_correct_concept.json
  • backend/tests/evals/cassettes/chat_tutor/teachback_minimal.json
  • backend/tests/evals/cassettes/chat_tutor/teachback_misconception.json
  • backend/tests/evals/cassettes/chat_tutor/teachback_partial_correct.json
  • backend/tests/evals/chat_tutor.py
  • backend/tests/evals/fixtures/tutor_course.json
  • backend/tests/evals/run_all.py
  • backend/tests/test_chat_stream.py
  • backend/tests/test_chat_tutor_imports.py
  • backend/tests/test_e2e_function_handlers.py
  • backend/tests/test_graph_context_block.py
  • backend/tests/test_graph_neighborhood_tool.py
  • backend/tests/test_graph_tools_bugs.py
  • backend/tests/test_live_tutor_tools.py
  • backend/tests/test_model_mode_seam.py
  • backend/tests/test_tutor_retrieval.py
  • docs/decisions/0015-refactor-3-chat-tutor-shipped.md
  • docs/decisions/0021-agent-eval-harness-baselines.md
  • docs/decisions/0023-tutor-graph-retrieval-seam.md

Comment threadbackend/routes/learn.py
Comment on lines +36 to +38
def test_real_model_chooses_a_graph_reader_for_a_graph_question():
sys.path.insert(0, str(Path(__file__).parent / "evals"))
from _retrieval_fixture import FixtureRetrieval

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bashset -euo pipefail
echo"Files matching test name:"
fd -a 'test_live_tutor_tools\.py$'.||true
test_file="$(fd 'test_live_tutor_tools\.py$'.| head -n1 || true)"if [ -n"${test_file:-}" ];thenechoecho"Relevant test file:"echo"$test_file"echoecho"Line count:"
wc -l "$test_file"echoecho"First 120 lines:"
sed -n '1,120p'"$test_file"| cat -n
fiechoecho"Search for sys.path usage in file:"if [ -n"${test_file:-}" ];then
rg -n "sys\.path|syspath_prepend|Path\(__file__\).parent""$test_file"||truefiechoecho"Search for global sys.path mutations in backend/tests:"
rg -n "sys\.path\.insert|sys\.path\.append|sys\.path\.extend|sys\.path\.remove|syspath_prepend" backend/tests ||trueechoecho"pytest availability:"
python3 - <<'PY'try: import pytest print("pytest:", pytest.__version__)except Exception as e: print("pytest import failed:", repr(e))try: import _retrieval_fixture print("module _retrieval_fixture found")except Exception as e: print("_retrieval_fixture import failed:", repr(e))PY

Repository: SaplingLearn/Sapling

Length of output: 6296


Scope the import-path mutation to this test.

sys.path.insert(...) mutates the pytest process state for any following tests. Use pytest’s scoped monkeypatch.syspath_prepend(...) so the path is restored automatically after this test.

Proposed fix
-def test_real_model_chooses_a_graph_reader_for_a_graph_question():- sys.path.insert(0, str(Path(__file__).parent / "evals"))+def test_real_model_chooses_a_graph_reader_for_a_graph_question(monkeypatch):+ monkeypatch.syspath_prepend(str(Path(__file__).parent / "evals"))
📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
deftest_real_model_chooses_a_graph_reader_for_a_graph_question():
sys.path.insert(0, str(Path(__file__).parent/"evals"))
from_retrieval_fixtureimportFixtureRetrieval
deftest_real_model_chooses_a_graph_reader_for_a_graph_question(monkeypatch):
monkeypatch.syspath_prepend(str(Path(__file__).parent/"evals"))
from_retrieval_fixtureimportFixtureRetrieval
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/tests/test_live_tutor_tools.py` around lines 36 - 38, Update
test_real_model_chooses_a_graph_reader_for_a_graph_question to accept pytest’s
monkeypatch fixture and replace the direct sys.path.insert mutation with
monkeypatch.syspath_prepend(...), ensuring the evals path is restored after the
test.

Comment threaddocs/decisions/0021-agent-eval-harness-baselines.md
AndresL230 added a commit that referenced this pull request Jul 30, 2026
…ion test, tool-count comment, ADR 0015 prose
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Review pass complete: four reviewers, two fully clean (one empirically re-verified the before/after eval baselines by resurrecting the deleted pre-change cassettes and replaying them — exact match). Three sub-80 findings, all fixed: a /chat/stream twin of the raw-body.message persistence regression test (the invariant was correct but only the JSON route was pinned), the stale 'all five tools' comment, and the ADR 0015 blockquote that had swallowed a sentence. Backend 1441 + ruff green. e2e cycle next.

AndresL230and others added 3 commits July 30, 2026 03:21
…n the next commit; kept in PR history so the before→after delta is auditable)
The agent path inlined NO graph context (the full-graph JSON dump was
legacy-only — and user-wide, leaking other courses' concepts); the tutor
had write tools (#127) but no graph read surface. Now:
- Tools 5 → 7: read_graph_neighborhood (course-scoped, seed-matched via
_normalize_concept, depth-1 edges via two merged in.() reads with a
both-endpoints-in-course rule, ids never leave the tool, truncated flag,
degrade-to-empty) + read_concepts_for_user (existing reader, capped
wrapper). Preamble gains the graph-tools paragraph with a hard
two-reads-per-turn cap. read_misconceptions_for_course deliberately
deferred (shared-context opt-out isn't enforced yet — ADR 0023).
- Deterministic GRAPH CONTEXT seed block on every agent turn
(services/graph_context.py: overlap-first/weakest-fill selection,
depth-1 edges, ≤1.5k chars, no ids) — turn-1 graph awareness without a
tool round-trip on the streaming UI; raw body.message persistence
unchanged.
- Legacy prompt de-dumped: all three call sites now use the same compact
course-scoped serializer (also fixes the cross-course concept leak).
- TUTOR_LIMITS (12/12/100k) so a legitimate multi-tool turn can't trip
UsageLimitExceeded into a silent rung-1 legacy fallback.
- The ADR-0021 retrieval seam: TutorRetrieval protocol on SaplingDeps
(production None → byte-identical Supabase path), FixtureRetrieval +
committed synthetic course, cassettes now carry tool-call traces, three
new evaluators, chat_tutor joins the offline harness (16 cases; the
previous commit holds the pre-change cassettes).
Eval deltas (before → after): ExpositoryHasStructure 0.812→1.000,
GraphToolUsed 0.938→1.000, GroundedConcept 0.812→0.875,
MasteryUpdateEmitted 0.688→1.000; legacy four stay 1.000.
Suites: backend 1440 passed + ruff clean (seam/stream files green under
lock-pinned pydantic-ai 1.107); evals replay green across all 6 datasets;
frontend untouched (tsc clean). ADR 0023; ADR 0021/0015 updated.
Closes#149.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ion test, tool-count comment, ADR 0015 prose
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@AndresL230
AndresL230force-pushed the feat/b7-149-graph-grounded-tutor branch from 6e1cf19 to 220d6a8CompareJuly 30, 2026 10:21
@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Pre-merge gate at the rebased head (over B6's #468): full lane 28/28 passed + oracles clean. Merging.

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.

[P1] Agent migration: graph-grounded tutor retrieval + tool-use loop

1 participant

@AndresL230
, '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('^' + ".*" + ' feat(tutor): graph-grounded retrieval + tool-use loop (#149) by AndresL230 · Pull Request #469 · SaplingLearn/Sapling · GitHub
Skip to content

feat(tutor): graph-grounded retrieval + tool-use loop (#149) - #469

Merged
AndresL230 merged 3 commits into
mainfrom
feat/b7-149-graph-grounded-tutor
Jul 30, 2026
Merged

feat(tutor): graph-grounded retrieval + tool-use loop (#149)#469
AndresL230 merged 3 commits into
mainfrom
feat/b7-149-graph-grounded-tutor

Conversation

@AndresL230

@AndresL230AndresL230 commented Jul 30, 2026

Copy link
Copy Markdown
Collaborator

What

First link of the B7 seam-endgame chain (#149#153#150#151#154). The scoping pass corrected the issue's premise: the agent path inlined no graph context at all (the full-graph dump was legacy-only — and user-wide, leaking other courses' concepts), and the mid-turn write half already shipped with #127. Full detail in the commit message; the shape:

  • Read surface: read_graph_neighborhood (course-scoped, name-seeded, depth-1, ids never leave the tool) + read_concepts_for_user, registered on the tutor (5 → 7 tools) with a hard two-reads-per-turn prompt cap. The misconceptions reader is deliberately deferred until the shared-context opt-out is enforced (ADR 0023).
  • Deterministic seed block: every agent turn carries a ≤1.5k-char GRAPH CONTEXT block (overlap-first/weakest-fill + depth-1 edges) — turn-1 graph awareness without a tool round-trip on the streaming UI.
  • Legacy de-dump: the three call_gemini* prompt sites use the same compact serializer (kills the raw JSON dump and its cross-course leak) — de-risking the rung-1 fallback ahead of [P1] Agent migration: retire call_gemini* + gemini_service.py (final cutover) #151.
  • TUTOR_LIMITS (12/12/100k): a legitimate multi-tool turn can no longer trip UsageLimitExceeded into a silent legacy fallback.
  • The ADR-0021 retrieval seam: TutorRetrieval protocol on deps (production None → byte-identical), fixture retrieval + synthetic course, cassettes now record tool-call traces, and chat_tutor joins the offline eval harness (16 cases). The first commit on this branch holds the pre-change cassettes so the delta is auditable.

Eval deltas (before → after): ExpositoryHasStructure 0.812→1.000 · GraphToolUsed 0.938→1.000 · GroundedConcept 0.812→0.875 · MasteryUpdateEmitted 0.688→1.000 · the four legacy evaluators hold 1.000.

Verification

  • Backend 1440 passed + ruff clean; seam/stream/tool files re-run green under lock-pinned pydantic-ai 1.107; evals replay green across all six datasets; frontend untouched (tsc clean).
  • 40+ new tests incl. red-first tool-registration, id-leak/scoping/truncation coverage, the read-only-tools-never-emit-graph_update stream invariant, a scripted-tool seam test, and an opt-in live test proving the real model chooses the readers (run green once).
  • Known follow-up recorded in ADR 0023: gemini-2.5-pro occasionally emits a bare-newline final reply after an end-of-turn tool call — [P2] Agent platform: structured-output retry + validation hardening #153 (structured-output hardening) territory.
  • Full local e2e cycle pre-merge; results below.

Closes#149.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Chat tutoring now provides compact, course-scoped graph context for more relevant responses.
    • Added read-only graph tools for exploring related concepts and reviewing concept mastery.
    • Tutor responses can use course materials, progress, and session history through consistent retrieval.
    • Added offline chat-tutor evaluation coverage with recorded scenarios and tool-behavior checks.
  • Bug Fixes

    • Improved course isolation and prevented unrelated graph data from appearing in tutor context.
    • Read-only graph interactions no longer create unintended updates.
  • Documentation

    • Updated evaluation coverage and architecture decision records.

@supabase

supabaseBot commented Jul 30, 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 Jul 30, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@AndresL230, you've reached your PR review limit, so we couldn't start this review.

Next review available in:35 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 6c9c74c7-f890-4387-84d5-108dd11d7884

📥 Commits

Reviewing files that changed from the base of the PR and between 7eb0ce2 and 220d6a8.

📒 Files selected for processing (44)
  • .github/workflows/evals.yml
  • backend/agents/__init__.py
  • backend/agents/chat_tutor.py
  • backend/agents/deps.py
  • backend/agents/tools/chat_context.py
  • backend/agents/tools/graph_read.py
  • backend/agents/tools/retrieval.py
  • backend/routes/learn.py
  • backend/services/graph_context.py
  • backend/services/token_overlap.py
  • backend/tests/evals/README.md
  • backend/tests/evals/_retrieval_fixture.py
  • backend/tests/evals/baselines.json
  • backend/tests/evals/cassettes/chat_tutor/expository_explain_big_o.json
  • backend/tests/evals/cassettes/chat_tutor/expository_explain_dependency_injection.json
  • backend/tests/evals/cassettes/chat_tutor/expository_explain_kantian_ethics.json
  • backend/tests/evals/cassettes/chat_tutor/expository_explain_photosynthesis.json
  • backend/tests/evals/cassettes/chat_tutor/expository_explain_supply_demand.json
  • backend/tests/evals/cassettes/chat_tutor/socratic_chemistry_balancing.json
  • backend/tests/evals/cassettes/chat_tutor/socratic_history_themes.json
  • backend/tests/evals/cassettes/chat_tutor/socratic_intro_calculus.json
  • backend/tests/evals/cassettes/chat_tutor/socratic_open_followup.json
  • backend/tests/evals/cassettes/chat_tutor/socratic_python_recursion.json
  • backend/tests/evals/cassettes/chat_tutor/socratic_stale_concept_review.json
  • backend/tests/evals/cassettes/chat_tutor/teachback_advanced.json
  • backend/tests/evals/cassettes/chat_tutor/teachback_correct_concept.json
  • backend/tests/evals/cassettes/chat_tutor/teachback_minimal.json
  • backend/tests/evals/cassettes/chat_tutor/teachback_misconception.json
  • backend/tests/evals/cassettes/chat_tutor/teachback_partial_correct.json
  • backend/tests/evals/chat_tutor.py
  • backend/tests/evals/fixtures/tutor_course.json
  • backend/tests/evals/run_all.py
  • backend/tests/test_chat_stream.py
  • backend/tests/test_chat_tutor_imports.py
  • backend/tests/test_e2e_function_handlers.py
  • backend/tests/test_graph_context_block.py
  • backend/tests/test_graph_neighborhood_tool.py
  • backend/tests/test_graph_tools_bugs.py
  • backend/tests/test_live_tutor_tools.py
  • backend/tests/test_model_mode_seam.py
  • backend/tests/test_tutor_retrieval.py
  • docs/decisions/0015-refactor-3-chat-tutor-shipped.md
  • docs/decisions/0021-agent-eval-harness-baselines.md
  • docs/decisions/0023-tutor-graph-retrieval-seam.md
📝 Walkthrough

Walkthrough

The chat tutor now uses compact course-scoped graph context, exposes bounded read-only graph tools, supports injectable retrieval implementations, and runs offline evaluations against fixture data with structured tool-call cassettes and new behavioral evaluators.

Changes

Tutor graph grounding

Layer / File(s)Summary
Compact graph context and route wiring
backend/services/graph_context.py, backend/services/token_overlap.py, backend/routes/learn.py, backend/tests/test_graph_context_block.py
Graph context is selected, scoped, rendered within a character budget, and used by both agent and legacy tutor prompt paths.
Retrieval seam and graph read tools
backend/agents/tools/*, backend/agents/chat_tutor.py, backend/agents/deps.py, backend/tests/test_graph_neighborhood_tool.py, backend/tests/test_tutor_retrieval.py
Tutor reads resolve injected or Supabase retrieval, and graph readers provide bounded, course/user-scoped neighborhoods without graph writes.
Offline evaluation and regression coverage
backend/tests/evals/*, backend/tests/test_*.py, .github/workflows/evals.yml
Fixture retrieval, structured cassettes, graph/tool evaluators, baselines, CI gating, and integration tests cover the new tutor flow.

Estimated code review effort: 4 (Complex) | ~60 minutes

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 27.86% 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 the main change: graph-grounded tutor retrieval with tool use.
Description check✅ PassedThe description is substantial and covers the change, issues, and verification, though some template sections are omitted.
Linked Issues check✅ PassedThe PR implements course-scoped graph retrieval, prompt graph context, and mid-turn graph/mastery updates, matching #149.
Out of Scope Changes check✅ PassedMost changes support the tutor graph-grounding/eval harness work, with no clearly unrelated code changes evident.
✨ 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 feat/b7-149-graph-grounded-tutor

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.


async def course_materials(
self, course_id: str | None, query: str, limit: int, *, user_id: str
) -> list["CourseMaterial"]: ...
concepts: list[str],
*,
limit: int = 20,
) -> "GraphNeighborhood": ...

async def concept_mastery(
self, user_id: str, course_id: str | None
) -> list["ConceptMastery"]: ...

async def progress(
self, user_id: str, course_id: str | None
) -> "CourseProgress": ...

async def session_history(
self, session_id: str, last_n: int
) -> list["SessionMessage"]: ...
@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Jul 30, 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-staging220d6a8Commit Preview URL

Branch Preview URL
Jul 30 2026, 10:23 AM

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (6)
backend/tests/evals/chat_tutor.py (1)

459-478: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Prefer isinstance over string-based class-name matching for tool-call parts.

type(part).__name__ != "ToolCallPart" only matches parts of the exact ToolCallPart class. pydantic_ai's message types also include a distinct NativeToolCallPart for native/provider tool-calling paths; if any of the tutor's tools were ever surfaced that way, this check would silently drop them from the cassette, undercounting tool usage that GraphToolUsedEvaluator/MasteryUpdateEmittedEvaluator rely on.

♻️ Suggested fix
+from pydantic_ai.messages import ToolCallPart+
def _extract_tool_calls(result) -> list[ToolCall]:
...
for message in messages:
for part in getattr(message, "parts", []) or []:
- if type(part).__name__ != "ToolCallPart":+ if not isinstance(part, ToolCallPart):
continue
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/tests/evals/chat_tutor.py` around lines 459 - 478, Update
_extract_tool_calls to use isinstance-based recognition of pydantic_ai tool-call
part types, including both ToolCallPart and NativeToolCallPart, instead of
comparing type(part).__name__. Preserve the existing argument extraction
fallback and ToolCall construction for every recognized tool-call part.
backend/agents/tools/graph_read.py (2)

181-189: 🎯 Functional Correctness | 🔵 Trivial | 💤 Low value

Duplicate normalized concept names silently collapse.

by_norm[_normalize_concept(name)] = n is last-write-wins, so if the graph holds two rows normalizing to the same key (e.g. pre-dedup legacy rows), a seed resolves to whichever row came back last from PostgREST — non-deterministic across reads. Preferring the first occurrence (setdefault) at least makes it stable given a stable row order.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/agents/tools/graph_read.py` around lines 181 - 189, Update the
by_norm construction in the node-processing loop to preserve the first node for
each normalized concept name instead of overwriting it with later duplicates.
Replace the last-write-wins assignment associated with _normalize_concept(name)
while leaving by_id behavior unchanged.

104-105: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Clamp the LLM-chosen limit on the upper end too.

Both sites only floor at 0. A model that passes limit=5000 gets every tracked concept (and, for the neighborhood, every kept edge) back into the context window — the whole point of the cap. A module-level max (e.g. 50) keeps the tool's prompt cost bounded regardless of what the model asks for.

♻️ Suggested clamp
+_MAX_LIMIT = 50+
...
- n = max(0, int(limit))+ n = min(_MAX_LIMIT, max(0, int(limit)))
return rows[:n]
...
- cap = max(0, int(limit))+ cap = min(_MAX_LIMIT, max(0, int(limit)))

Also applies to: 244-247

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/agents/tools/graph_read.py` around lines 104 - 105, Clamp the
LLM-provided limit to a defined module-level maximum in both row-slicing sites,
including the neighborhood path referenced by the comment. Update the limit
normalization around `n = max(0, int(limit))` so values above the cap are
reduced while negative values still produce zero, keeping prompt results
bounded.
backend/agents/chat_tutor.py (1)

70-73: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

"Hard cap" is prompt-advisory only.

Nothing enforces two graph reads per turn; the real ceiling is TUTOR_LIMITS. Either soften the wording or enforce it in the tool wrapper (e.g. a per-run counter on SaplingDeps that returns an empty neighborhood past the second call) so cost is bounded even when the model ignores the instruction.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/agents/chat_tutor.py` around lines 70 - 73, Update the graph-read
enforcement around SaplingDeps and its tool wrapper so each tutor run permits no
more than two graph reads, returning an empty neighborhood after the second
call; otherwise soften the prompt’s “Hard cap” wording to match the actual
TUTOR_LIMITS behavior.
backend/tests/test_model_mode_seam.py (1)

304-307: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Registered function handler is never unregistered.

register_function_handler mutates a module-level registry that monkeypatch won't restore, so this handler leaks into any later test that runs chat_tutor in function mode. If the file doesn't already have an autouse reset fixture, worth adding one.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/tests/test_model_mode_seam.py` around lines 304 - 307, Add cleanup
for the module-level handler registry after tests using
register_function_handler, including the test around socratic_agent.run_sync and
the "chat_tutor" handler. Prefer an autouse fixture if the file lacks one,
ensuring the registry is reset after each test so handlers do not leak into
later function-mode tests.
backend/services/graph_context.py (1)

105-111: 🚀 Performance & Scalability | 🔵 Trivial | 💤 Low value

Budget trim rejoins the whole block each iteration.

Minor: with ≤12 lines this is irrelevant, but a running length counter (or trimming from a precomputed cumulative sum) avoids the O(n²) rejoin and reads clearer.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/services/graph_context.py` around lines 105 - 111, Update the
budget-trimming logic in the graph-context builder around _HEADER, lines, and
GRAPH_CONTEXT_CHAR_BUDGET to track the assembled character length incrementally
instead of rejoining the entire block on every loop iteration. Preserve the
existing behavior of removing trailing lines until the budget fits and returning
an empty string when no lines remain.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@backend/routes/learn.py`:
- Around line 520-523: Update the graph-context construction at the referenced
legacy-turn paths, including the flows around lines 520, 763, and 1328, so
course-less sessions retain an appropriate user-wide compact graph fallback
instead of receiving an empty block. Avoid calling get_graph when no course_id
is available unless the fallback requires it, and ensure the serializer’s
selection remains bounded and weakest-first.
In `@backend/tests/test_live_tutor_tools.py`:
- Around line 36-38: Update
test_real_model_chooses_a_graph_reader_for_a_graph_question to accept pytest’s
monkeypatch fixture and replace the direct sys.path.insert mutation with
monkeypatch.syspath_prepend(...), ensuring the evals path is restored after the
test.
In `@docs/decisions/0021-agent-eval-harness-baselines.md`:
- Around line 46-48: The ADRs retain obsolete pre-change descriptions of
chat_tutor. In docs/decisions/0021-agent-eval-harness-baselines.md lines 46-48,
update the five-dataset wording, baseline table, and “unmeasured” consequence to
include chat_tutor; in docs/decisions/0015-refactor-3-chat-tutor-shipped.md
lines 28-33, replace the remaining four-tool and four-registration descriptions
or explicitly mark them as historical.
---
Nitpick comments:
In `@backend/agents/chat_tutor.py`:
- Around line 70-73: Update the graph-read enforcement around SaplingDeps and
its tool wrapper so each tutor run permits no more than two graph reads,
returning an empty neighborhood after the second call; otherwise soften the
prompt’s “Hard cap” wording to match the actual TUTOR_LIMITS behavior.
In `@backend/agents/tools/graph_read.py`:
- Around line 181-189: Update the by_norm construction in the node-processing
loop to preserve the first node for each normalized concept name instead of
overwriting it with later duplicates. Replace the last-write-wins assignment
associated with _normalize_concept(name) while leaving by_id behavior unchanged.
- Around line 104-105: Clamp the LLM-provided limit to a defined module-level
maximum in both row-slicing sites, including the neighborhood path referenced by
the comment. Update the limit normalization around `n = max(0, int(limit))` so
values above the cap are reduced while negative values still produce zero,
keeping prompt results bounded.
In `@backend/services/graph_context.py`:
- Around line 105-111: Update the budget-trimming logic in the graph-context
builder around _HEADER, lines, and GRAPH_CONTEXT_CHAR_BUDGET to track the
assembled character length incrementally instead of rejoining the entire block
on every loop iteration. Preserve the existing behavior of removing trailing
lines until the budget fits and returning an empty string when no lines remain.
In `@backend/tests/evals/chat_tutor.py`:
- Around line 459-478: Update _extract_tool_calls to use isinstance-based
recognition of pydantic_ai tool-call part types, including both ToolCallPart and
NativeToolCallPart, instead of comparing type(part).__name__. Preserve the
existing argument extraction fallback and ToolCall construction for every
recognized tool-call part.
In `@backend/tests/test_model_mode_seam.py`:
- Around line 304-307: Add cleanup for the module-level handler registry after
tests using register_function_handler, including the test around
socratic_agent.run_sync and the "chat_tutor" handler. Prefer an autouse fixture
if the file lacks one, ensuring the registry is reset after each test so
handlers do not leak into later function-mode tests.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 10240725-b7f7-4f3d-8294-03d828114d0c

📥 Commits

Reviewing files that changed from the base of the PR and between ec0e2af and 7eb0ce2.

📒 Files selected for processing (44)
  • .github/workflows/evals.yml
  • backend/agents/__init__.py
  • backend/agents/chat_tutor.py
  • backend/agents/deps.py
  • backend/agents/tools/chat_context.py
  • backend/agents/tools/graph_read.py
  • backend/agents/tools/retrieval.py
  • backend/routes/learn.py
  • backend/services/graph_context.py
  • backend/services/token_overlap.py
  • backend/tests/evals/README.md
  • backend/tests/evals/_retrieval_fixture.py
  • backend/tests/evals/baselines.json
  • backend/tests/evals/cassettes/chat_tutor/expository_explain_big_o.json
  • backend/tests/evals/cassettes/chat_tutor/expository_explain_dependency_injection.json
  • backend/tests/evals/cassettes/chat_tutor/expository_explain_kantian_ethics.json
  • backend/tests/evals/cassettes/chat_tutor/expository_explain_photosynthesis.json
  • backend/tests/evals/cassettes/chat_tutor/expository_explain_supply_demand.json
  • backend/tests/evals/cassettes/chat_tutor/socratic_chemistry_balancing.json
  • backend/tests/evals/cassettes/chat_tutor/socratic_history_themes.json
  • backend/tests/evals/cassettes/chat_tutor/socratic_intro_calculus.json
  • backend/tests/evals/cassettes/chat_tutor/socratic_open_followup.json
  • backend/tests/evals/cassettes/chat_tutor/socratic_python_recursion.json
  • backend/tests/evals/cassettes/chat_tutor/socratic_stale_concept_review.json
  • backend/tests/evals/cassettes/chat_tutor/teachback_advanced.json
  • backend/tests/evals/cassettes/chat_tutor/teachback_correct_concept.json
  • backend/tests/evals/cassettes/chat_tutor/teachback_minimal.json
  • backend/tests/evals/cassettes/chat_tutor/teachback_misconception.json
  • backend/tests/evals/cassettes/chat_tutor/teachback_partial_correct.json
  • backend/tests/evals/chat_tutor.py
  • backend/tests/evals/fixtures/tutor_course.json
  • backend/tests/evals/run_all.py
  • backend/tests/test_chat_stream.py
  • backend/tests/test_chat_tutor_imports.py
  • backend/tests/test_e2e_function_handlers.py
  • backend/tests/test_graph_context_block.py
  • backend/tests/test_graph_neighborhood_tool.py
  • backend/tests/test_graph_tools_bugs.py
  • backend/tests/test_live_tutor_tools.py
  • backend/tests/test_model_mode_seam.py
  • backend/tests/test_tutor_retrieval.py
  • docs/decisions/0015-refactor-3-chat-tutor-shipped.md
  • docs/decisions/0021-agent-eval-harness-baselines.md
  • docs/decisions/0023-tutor-graph-retrieval-seam.md

Comment threadbackend/routes/learn.py
Comment on lines +36 to +38
def test_real_model_chooses_a_graph_reader_for_a_graph_question():
sys.path.insert(0, str(Path(__file__).parent / "evals"))
from _retrieval_fixture import FixtureRetrieval

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bashset -euo pipefail
echo"Files matching test name:"
fd -a 'test_live_tutor_tools\.py$'.||true
test_file="$(fd 'test_live_tutor_tools\.py$'.| head -n1 || true)"if [ -n"${test_file:-}" ];thenechoecho"Relevant test file:"echo"$test_file"echoecho"Line count:"
wc -l "$test_file"echoecho"First 120 lines:"
sed -n '1,120p'"$test_file"| cat -n
fiechoecho"Search for sys.path usage in file:"if [ -n"${test_file:-}" ];then
rg -n "sys\.path|syspath_prepend|Path\(__file__\).parent""$test_file"||truefiechoecho"Search for global sys.path mutations in backend/tests:"
rg -n "sys\.path\.insert|sys\.path\.append|sys\.path\.extend|sys\.path\.remove|syspath_prepend" backend/tests ||trueechoecho"pytest availability:"
python3 - <<'PY'try: import pytest print("pytest:", pytest.__version__)except Exception as e: print("pytest import failed:", repr(e))try: import _retrieval_fixture print("module _retrieval_fixture found")except Exception as e: print("_retrieval_fixture import failed:", repr(e))PY

Repository: SaplingLearn/Sapling

Length of output: 6296


Scope the import-path mutation to this test.

sys.path.insert(...) mutates the pytest process state for any following tests. Use pytest’s scoped monkeypatch.syspath_prepend(...) so the path is restored automatically after this test.

Proposed fix
-def test_real_model_chooses_a_graph_reader_for_a_graph_question():- sys.path.insert(0, str(Path(__file__).parent / "evals"))+def test_real_model_chooses_a_graph_reader_for_a_graph_question(monkeypatch):+ monkeypatch.syspath_prepend(str(Path(__file__).parent / "evals"))
📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
deftest_real_model_chooses_a_graph_reader_for_a_graph_question():
sys.path.insert(0, str(Path(__file__).parent/"evals"))
from_retrieval_fixtureimportFixtureRetrieval
deftest_real_model_chooses_a_graph_reader_for_a_graph_question(monkeypatch):
monkeypatch.syspath_prepend(str(Path(__file__).parent/"evals"))
from_retrieval_fixtureimportFixtureRetrieval
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/tests/test_live_tutor_tools.py` around lines 36 - 38, Update
test_real_model_chooses_a_graph_reader_for_a_graph_question to accept pytest’s
monkeypatch fixture and replace the direct sys.path.insert mutation with
monkeypatch.syspath_prepend(...), ensuring the evals path is restored after the
test.

Comment threaddocs/decisions/0021-agent-eval-harness-baselines.md
AndresL230 added a commit that referenced this pull request Jul 30, 2026
…ion test, tool-count comment, ADR 0015 prose
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Review pass complete: four reviewers, two fully clean (one empirically re-verified the before/after eval baselines by resurrecting the deleted pre-change cassettes and replaying them — exact match). Three sub-80 findings, all fixed: a /chat/stream twin of the raw-body.message persistence regression test (the invariant was correct but only the JSON route was pinned), the stale 'all five tools' comment, and the ADR 0015 blockquote that had swallowed a sentence. Backend 1441 + ruff green. e2e cycle next.

AndresL230and others added 3 commits July 30, 2026 03:21
…n the next commit; kept in PR history so the before→after delta is auditable)
The agent path inlined NO graph context (the full-graph JSON dump was
legacy-only — and user-wide, leaking other courses' concepts); the tutor
had write tools (#127) but no graph read surface. Now:
- Tools 5 → 7: read_graph_neighborhood (course-scoped, seed-matched via
_normalize_concept, depth-1 edges via two merged in.() reads with a
both-endpoints-in-course rule, ids never leave the tool, truncated flag,
degrade-to-empty) + read_concepts_for_user (existing reader, capped
wrapper). Preamble gains the graph-tools paragraph with a hard
two-reads-per-turn cap. read_misconceptions_for_course deliberately
deferred (shared-context opt-out isn't enforced yet — ADR 0023).
- Deterministic GRAPH CONTEXT seed block on every agent turn
(services/graph_context.py: overlap-first/weakest-fill selection,
depth-1 edges, ≤1.5k chars, no ids) — turn-1 graph awareness without a
tool round-trip on the streaming UI; raw body.message persistence
unchanged.
- Legacy prompt de-dumped: all three call sites now use the same compact
course-scoped serializer (also fixes the cross-course concept leak).
- TUTOR_LIMITS (12/12/100k) so a legitimate multi-tool turn can't trip
UsageLimitExceeded into a silent rung-1 legacy fallback.
- The ADR-0021 retrieval seam: TutorRetrieval protocol on SaplingDeps
(production None → byte-identical Supabase path), FixtureRetrieval +
committed synthetic course, cassettes now carry tool-call traces, three
new evaluators, chat_tutor joins the offline harness (16 cases; the
previous commit holds the pre-change cassettes).
Eval deltas (before → after): ExpositoryHasStructure 0.812→1.000,
GraphToolUsed 0.938→1.000, GroundedConcept 0.812→0.875,
MasteryUpdateEmitted 0.688→1.000; legacy four stay 1.000.
Suites: backend 1440 passed + ruff clean (seam/stream files green under
lock-pinned pydantic-ai 1.107); evals replay green across all 6 datasets;
frontend untouched (tsc clean). ADR 0023; ADR 0021/0015 updated.
Closes#149.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ion test, tool-count comment, ADR 0015 prose
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@AndresL230
AndresL230force-pushed the feat/b7-149-graph-grounded-tutor branch from 6e1cf19 to 220d6a8CompareJuly 30, 2026 10:21
@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Pre-merge gate at the rebased head (over B6's #468): full lane 28/28 passed + oracles clean. Merging.

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.

[P1] Agent migration: graph-grounded tutor retrieval + tool-use loop

1 participant

@AndresL230
, '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('^' + ".*" + ' feat(tutor): graph-grounded retrieval + tool-use loop (#149) by AndresL230 · Pull Request #469 · SaplingLearn/Sapling · GitHub
Skip to content

feat(tutor): graph-grounded retrieval + tool-use loop (#149) - #469

Merged
AndresL230 merged 3 commits into
mainfrom
feat/b7-149-graph-grounded-tutor
Jul 30, 2026
Merged

feat(tutor): graph-grounded retrieval + tool-use loop (#149)#469
AndresL230 merged 3 commits into
mainfrom
feat/b7-149-graph-grounded-tutor

Conversation

@AndresL230

@AndresL230AndresL230 commented Jul 30, 2026

Copy link
Copy Markdown
Collaborator

What

First link of the B7 seam-endgame chain (#149#153#150#151#154). The scoping pass corrected the issue's premise: the agent path inlined no graph context at all (the full-graph dump was legacy-only — and user-wide, leaking other courses' concepts), and the mid-turn write half already shipped with #127. Full detail in the commit message; the shape:

  • Read surface: read_graph_neighborhood (course-scoped, name-seeded, depth-1, ids never leave the tool) + read_concepts_for_user, registered on the tutor (5 → 7 tools) with a hard two-reads-per-turn prompt cap. The misconceptions reader is deliberately deferred until the shared-context opt-out is enforced (ADR 0023).
  • Deterministic seed block: every agent turn carries a ≤1.5k-char GRAPH CONTEXT block (overlap-first/weakest-fill + depth-1 edges) — turn-1 graph awareness without a tool round-trip on the streaming UI.
  • Legacy de-dump: the three call_gemini* prompt sites use the same compact serializer (kills the raw JSON dump and its cross-course leak) — de-risking the rung-1 fallback ahead of [P1] Agent migration: retire call_gemini* + gemini_service.py (final cutover) #151.
  • TUTOR_LIMITS (12/12/100k): a legitimate multi-tool turn can no longer trip UsageLimitExceeded into a silent legacy fallback.
  • The ADR-0021 retrieval seam: TutorRetrieval protocol on deps (production None → byte-identical), fixture retrieval + synthetic course, cassettes now record tool-call traces, and chat_tutor joins the offline eval harness (16 cases). The first commit on this branch holds the pre-change cassettes so the delta is auditable.

Eval deltas (before → after): ExpositoryHasStructure 0.812→1.000 · GraphToolUsed 0.938→1.000 · GroundedConcept 0.812→0.875 · MasteryUpdateEmitted 0.688→1.000 · the four legacy evaluators hold 1.000.

Verification

  • Backend 1440 passed + ruff clean; seam/stream/tool files re-run green under lock-pinned pydantic-ai 1.107; evals replay green across all six datasets; frontend untouched (tsc clean).
  • 40+ new tests incl. red-first tool-registration, id-leak/scoping/truncation coverage, the read-only-tools-never-emit-graph_update stream invariant, a scripted-tool seam test, and an opt-in live test proving the real model chooses the readers (run green once).
  • Known follow-up recorded in ADR 0023: gemini-2.5-pro occasionally emits a bare-newline final reply after an end-of-turn tool call — [P2] Agent platform: structured-output retry + validation hardening #153 (structured-output hardening) territory.
  • Full local e2e cycle pre-merge; results below.

Closes#149.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Chat tutoring now provides compact, course-scoped graph context for more relevant responses.
    • Added read-only graph tools for exploring related concepts and reviewing concept mastery.
    • Tutor responses can use course materials, progress, and session history through consistent retrieval.
    • Added offline chat-tutor evaluation coverage with recorded scenarios and tool-behavior checks.
  • Bug Fixes

    • Improved course isolation and prevented unrelated graph data from appearing in tutor context.
    • Read-only graph interactions no longer create unintended updates.
  • Documentation

    • Updated evaluation coverage and architecture decision records.

@supabase

supabaseBot commented Jul 30, 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 Jul 30, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@AndresL230, you've reached your PR review limit, so we couldn't start this review.

Next review available in:35 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 6c9c74c7-f890-4387-84d5-108dd11d7884

📥 Commits

Reviewing files that changed from the base of the PR and between 7eb0ce2 and 220d6a8.

📒 Files selected for processing (44)
  • .github/workflows/evals.yml
  • backend/agents/__init__.py
  • backend/agents/chat_tutor.py
  • backend/agents/deps.py
  • backend/agents/tools/chat_context.py
  • backend/agents/tools/graph_read.py
  • backend/agents/tools/retrieval.py
  • backend/routes/learn.py
  • backend/services/graph_context.py
  • backend/services/token_overlap.py
  • backend/tests/evals/README.md
  • backend/tests/evals/_retrieval_fixture.py
  • backend/tests/evals/baselines.json
  • backend/tests/evals/cassettes/chat_tutor/expository_explain_big_o.json
  • backend/tests/evals/cassettes/chat_tutor/expository_explain_dependency_injection.json
  • backend/tests/evals/cassettes/chat_tutor/expository_explain_kantian_ethics.json
  • backend/tests/evals/cassettes/chat_tutor/expository_explain_photosynthesis.json
  • backend/tests/evals/cassettes/chat_tutor/expository_explain_supply_demand.json
  • backend/tests/evals/cassettes/chat_tutor/socratic_chemistry_balancing.json
  • backend/tests/evals/cassettes/chat_tutor/socratic_history_themes.json
  • backend/tests/evals/cassettes/chat_tutor/socratic_intro_calculus.json
  • backend/tests/evals/cassettes/chat_tutor/socratic_open_followup.json
  • backend/tests/evals/cassettes/chat_tutor/socratic_python_recursion.json
  • backend/tests/evals/cassettes/chat_tutor/socratic_stale_concept_review.json
  • backend/tests/evals/cassettes/chat_tutor/teachback_advanced.json
  • backend/tests/evals/cassettes/chat_tutor/teachback_correct_concept.json
  • backend/tests/evals/cassettes/chat_tutor/teachback_minimal.json
  • backend/tests/evals/cassettes/chat_tutor/teachback_misconception.json
  • backend/tests/evals/cassettes/chat_tutor/teachback_partial_correct.json
  • backend/tests/evals/chat_tutor.py
  • backend/tests/evals/fixtures/tutor_course.json
  • backend/tests/evals/run_all.py
  • backend/tests/test_chat_stream.py
  • backend/tests/test_chat_tutor_imports.py
  • backend/tests/test_e2e_function_handlers.py
  • backend/tests/test_graph_context_block.py
  • backend/tests/test_graph_neighborhood_tool.py
  • backend/tests/test_graph_tools_bugs.py
  • backend/tests/test_live_tutor_tools.py
  • backend/tests/test_model_mode_seam.py
  • backend/tests/test_tutor_retrieval.py
  • docs/decisions/0015-refactor-3-chat-tutor-shipped.md
  • docs/decisions/0021-agent-eval-harness-baselines.md
  • docs/decisions/0023-tutor-graph-retrieval-seam.md
📝 Walkthrough

Walkthrough

The chat tutor now uses compact course-scoped graph context, exposes bounded read-only graph tools, supports injectable retrieval implementations, and runs offline evaluations against fixture data with structured tool-call cassettes and new behavioral evaluators.

Changes

Tutor graph grounding

Layer / File(s)Summary
Compact graph context and route wiring
backend/services/graph_context.py, backend/services/token_overlap.py, backend/routes/learn.py, backend/tests/test_graph_context_block.py
Graph context is selected, scoped, rendered within a character budget, and used by both agent and legacy tutor prompt paths.
Retrieval seam and graph read tools
backend/agents/tools/*, backend/agents/chat_tutor.py, backend/agents/deps.py, backend/tests/test_graph_neighborhood_tool.py, backend/tests/test_tutor_retrieval.py
Tutor reads resolve injected or Supabase retrieval, and graph readers provide bounded, course/user-scoped neighborhoods without graph writes.
Offline evaluation and regression coverage
backend/tests/evals/*, backend/tests/test_*.py, .github/workflows/evals.yml
Fixture retrieval, structured cassettes, graph/tool evaluators, baselines, CI gating, and integration tests cover the new tutor flow.

Estimated code review effort: 4 (Complex) | ~60 minutes

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 27.86% 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 the main change: graph-grounded tutor retrieval with tool use.
Description check✅ PassedThe description is substantial and covers the change, issues, and verification, though some template sections are omitted.
Linked Issues check✅ PassedThe PR implements course-scoped graph retrieval, prompt graph context, and mid-turn graph/mastery updates, matching #149.
Out of Scope Changes check✅ PassedMost changes support the tutor graph-grounding/eval harness work, with no clearly unrelated code changes evident.
✨ 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 feat/b7-149-graph-grounded-tutor

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.


async def course_materials(
self, course_id: str | None, query: str, limit: int, *, user_id: str
) -> list["CourseMaterial"]: ...
concepts: list[str],
*,
limit: int = 20,
) -> "GraphNeighborhood": ...

async def concept_mastery(
self, user_id: str, course_id: str | None
) -> list["ConceptMastery"]: ...

async def progress(
self, user_id: str, course_id: str | None
) -> "CourseProgress": ...

async def session_history(
self, session_id: str, last_n: int
) -> list["SessionMessage"]: ...
@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Jul 30, 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-staging220d6a8Commit Preview URL

Branch Preview URL
Jul 30 2026, 10:23 AM

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (6)
backend/tests/evals/chat_tutor.py (1)

459-478: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Prefer isinstance over string-based class-name matching for tool-call parts.

type(part).__name__ != "ToolCallPart" only matches parts of the exact ToolCallPart class. pydantic_ai's message types also include a distinct NativeToolCallPart for native/provider tool-calling paths; if any of the tutor's tools were ever surfaced that way, this check would silently drop them from the cassette, undercounting tool usage that GraphToolUsedEvaluator/MasteryUpdateEmittedEvaluator rely on.

♻️ Suggested fix
+from pydantic_ai.messages import ToolCallPart+
def _extract_tool_calls(result) -> list[ToolCall]:
...
for message in messages:
for part in getattr(message, "parts", []) or []:
- if type(part).__name__ != "ToolCallPart":+ if not isinstance(part, ToolCallPart):
continue
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/tests/evals/chat_tutor.py` around lines 459 - 478, Update
_extract_tool_calls to use isinstance-based recognition of pydantic_ai tool-call
part types, including both ToolCallPart and NativeToolCallPart, instead of
comparing type(part).__name__. Preserve the existing argument extraction
fallback and ToolCall construction for every recognized tool-call part.
backend/agents/tools/graph_read.py (2)

181-189: 🎯 Functional Correctness | 🔵 Trivial | 💤 Low value

Duplicate normalized concept names silently collapse.

by_norm[_normalize_concept(name)] = n is last-write-wins, so if the graph holds two rows normalizing to the same key (e.g. pre-dedup legacy rows), a seed resolves to whichever row came back last from PostgREST — non-deterministic across reads. Preferring the first occurrence (setdefault) at least makes it stable given a stable row order.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/agents/tools/graph_read.py` around lines 181 - 189, Update the
by_norm construction in the node-processing loop to preserve the first node for
each normalized concept name instead of overwriting it with later duplicates.
Replace the last-write-wins assignment associated with _normalize_concept(name)
while leaving by_id behavior unchanged.

104-105: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Clamp the LLM-chosen limit on the upper end too.

Both sites only floor at 0. A model that passes limit=5000 gets every tracked concept (and, for the neighborhood, every kept edge) back into the context window — the whole point of the cap. A module-level max (e.g. 50) keeps the tool's prompt cost bounded regardless of what the model asks for.

♻️ Suggested clamp
+_MAX_LIMIT = 50+
...
- n = max(0, int(limit))+ n = min(_MAX_LIMIT, max(0, int(limit)))
return rows[:n]
...
- cap = max(0, int(limit))+ cap = min(_MAX_LIMIT, max(0, int(limit)))

Also applies to: 244-247

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/agents/tools/graph_read.py` around lines 104 - 105, Clamp the
LLM-provided limit to a defined module-level maximum in both row-slicing sites,
including the neighborhood path referenced by the comment. Update the limit
normalization around `n = max(0, int(limit))` so values above the cap are
reduced while negative values still produce zero, keeping prompt results
bounded.
backend/agents/chat_tutor.py (1)

70-73: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

"Hard cap" is prompt-advisory only.

Nothing enforces two graph reads per turn; the real ceiling is TUTOR_LIMITS. Either soften the wording or enforce it in the tool wrapper (e.g. a per-run counter on SaplingDeps that returns an empty neighborhood past the second call) so cost is bounded even when the model ignores the instruction.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/agents/chat_tutor.py` around lines 70 - 73, Update the graph-read
enforcement around SaplingDeps and its tool wrapper so each tutor run permits no
more than two graph reads, returning an empty neighborhood after the second
call; otherwise soften the prompt’s “Hard cap” wording to match the actual
TUTOR_LIMITS behavior.
backend/tests/test_model_mode_seam.py (1)

304-307: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Registered function handler is never unregistered.

register_function_handler mutates a module-level registry that monkeypatch won't restore, so this handler leaks into any later test that runs chat_tutor in function mode. If the file doesn't already have an autouse reset fixture, worth adding one.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/tests/test_model_mode_seam.py` around lines 304 - 307, Add cleanup
for the module-level handler registry after tests using
register_function_handler, including the test around socratic_agent.run_sync and
the "chat_tutor" handler. Prefer an autouse fixture if the file lacks one,
ensuring the registry is reset after each test so handlers do not leak into
later function-mode tests.
backend/services/graph_context.py (1)

105-111: 🚀 Performance & Scalability | 🔵 Trivial | 💤 Low value

Budget trim rejoins the whole block each iteration.

Minor: with ≤12 lines this is irrelevant, but a running length counter (or trimming from a precomputed cumulative sum) avoids the O(n²) rejoin and reads clearer.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/services/graph_context.py` around lines 105 - 111, Update the
budget-trimming logic in the graph-context builder around _HEADER, lines, and
GRAPH_CONTEXT_CHAR_BUDGET to track the assembled character length incrementally
instead of rejoining the entire block on every loop iteration. Preserve the
existing behavior of removing trailing lines until the budget fits and returning
an empty string when no lines remain.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@backend/routes/learn.py`:
- Around line 520-523: Update the graph-context construction at the referenced
legacy-turn paths, including the flows around lines 520, 763, and 1328, so
course-less sessions retain an appropriate user-wide compact graph fallback
instead of receiving an empty block. Avoid calling get_graph when no course_id
is available unless the fallback requires it, and ensure the serializer’s
selection remains bounded and weakest-first.
In `@backend/tests/test_live_tutor_tools.py`:
- Around line 36-38: Update
test_real_model_chooses_a_graph_reader_for_a_graph_question to accept pytest’s
monkeypatch fixture and replace the direct sys.path.insert mutation with
monkeypatch.syspath_prepend(...), ensuring the evals path is restored after the
test.
In `@docs/decisions/0021-agent-eval-harness-baselines.md`:
- Around line 46-48: The ADRs retain obsolete pre-change descriptions of
chat_tutor. In docs/decisions/0021-agent-eval-harness-baselines.md lines 46-48,
update the five-dataset wording, baseline table, and “unmeasured” consequence to
include chat_tutor; in docs/decisions/0015-refactor-3-chat-tutor-shipped.md
lines 28-33, replace the remaining four-tool and four-registration descriptions
or explicitly mark them as historical.
---
Nitpick comments:
In `@backend/agents/chat_tutor.py`:
- Around line 70-73: Update the graph-read enforcement around SaplingDeps and
its tool wrapper so each tutor run permits no more than two graph reads,
returning an empty neighborhood after the second call; otherwise soften the
prompt’s “Hard cap” wording to match the actual TUTOR_LIMITS behavior.
In `@backend/agents/tools/graph_read.py`:
- Around line 181-189: Update the by_norm construction in the node-processing
loop to preserve the first node for each normalized concept name instead of
overwriting it with later duplicates. Replace the last-write-wins assignment
associated with _normalize_concept(name) while leaving by_id behavior unchanged.
- Around line 104-105: Clamp the LLM-provided limit to a defined module-level
maximum in both row-slicing sites, including the neighborhood path referenced by
the comment. Update the limit normalization around `n = max(0, int(limit))` so
values above the cap are reduced while negative values still produce zero,
keeping prompt results bounded.
In `@backend/services/graph_context.py`:
- Around line 105-111: Update the budget-trimming logic in the graph-context
builder around _HEADER, lines, and GRAPH_CONTEXT_CHAR_BUDGET to track the
assembled character length incrementally instead of rejoining the entire block
on every loop iteration. Preserve the existing behavior of removing trailing
lines until the budget fits and returning an empty string when no lines remain.
In `@backend/tests/evals/chat_tutor.py`:
- Around line 459-478: Update _extract_tool_calls to use isinstance-based
recognition of pydantic_ai tool-call part types, including both ToolCallPart and
NativeToolCallPart, instead of comparing type(part).__name__. Preserve the
existing argument extraction fallback and ToolCall construction for every
recognized tool-call part.
In `@backend/tests/test_model_mode_seam.py`:
- Around line 304-307: Add cleanup for the module-level handler registry after
tests using register_function_handler, including the test around
socratic_agent.run_sync and the "chat_tutor" handler. Prefer an autouse fixture
if the file lacks one, ensuring the registry is reset after each test so
handlers do not leak into later function-mode tests.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 10240725-b7f7-4f3d-8294-03d828114d0c

📥 Commits

Reviewing files that changed from the base of the PR and between ec0e2af and 7eb0ce2.

📒 Files selected for processing (44)
  • .github/workflows/evals.yml
  • backend/agents/__init__.py
  • backend/agents/chat_tutor.py
  • backend/agents/deps.py
  • backend/agents/tools/chat_context.py
  • backend/agents/tools/graph_read.py
  • backend/agents/tools/retrieval.py
  • backend/routes/learn.py
  • backend/services/graph_context.py
  • backend/services/token_overlap.py
  • backend/tests/evals/README.md
  • backend/tests/evals/_retrieval_fixture.py
  • backend/tests/evals/baselines.json
  • backend/tests/evals/cassettes/chat_tutor/expository_explain_big_o.json
  • backend/tests/evals/cassettes/chat_tutor/expository_explain_dependency_injection.json
  • backend/tests/evals/cassettes/chat_tutor/expository_explain_kantian_ethics.json
  • backend/tests/evals/cassettes/chat_tutor/expository_explain_photosynthesis.json
  • backend/tests/evals/cassettes/chat_tutor/expository_explain_supply_demand.json
  • backend/tests/evals/cassettes/chat_tutor/socratic_chemistry_balancing.json
  • backend/tests/evals/cassettes/chat_tutor/socratic_history_themes.json
  • backend/tests/evals/cassettes/chat_tutor/socratic_intro_calculus.json
  • backend/tests/evals/cassettes/chat_tutor/socratic_open_followup.json
  • backend/tests/evals/cassettes/chat_tutor/socratic_python_recursion.json
  • backend/tests/evals/cassettes/chat_tutor/socratic_stale_concept_review.json
  • backend/tests/evals/cassettes/chat_tutor/teachback_advanced.json
  • backend/tests/evals/cassettes/chat_tutor/teachback_correct_concept.json
  • backend/tests/evals/cassettes/chat_tutor/teachback_minimal.json
  • backend/tests/evals/cassettes/chat_tutor/teachback_misconception.json
  • backend/tests/evals/cassettes/chat_tutor/teachback_partial_correct.json
  • backend/tests/evals/chat_tutor.py
  • backend/tests/evals/fixtures/tutor_course.json
  • backend/tests/evals/run_all.py
  • backend/tests/test_chat_stream.py
  • backend/tests/test_chat_tutor_imports.py
  • backend/tests/test_e2e_function_handlers.py
  • backend/tests/test_graph_context_block.py
  • backend/tests/test_graph_neighborhood_tool.py
  • backend/tests/test_graph_tools_bugs.py
  • backend/tests/test_live_tutor_tools.py
  • backend/tests/test_model_mode_seam.py
  • backend/tests/test_tutor_retrieval.py
  • docs/decisions/0015-refactor-3-chat-tutor-shipped.md
  • docs/decisions/0021-agent-eval-harness-baselines.md
  • docs/decisions/0023-tutor-graph-retrieval-seam.md

Comment threadbackend/routes/learn.py
Comment on lines +36 to +38
def test_real_model_chooses_a_graph_reader_for_a_graph_question():
sys.path.insert(0, str(Path(__file__).parent / "evals"))
from _retrieval_fixture import FixtureRetrieval

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bashset -euo pipefail
echo"Files matching test name:"
fd -a 'test_live_tutor_tools\.py$'.||true
test_file="$(fd 'test_live_tutor_tools\.py$'.| head -n1 || true)"if [ -n"${test_file:-}" ];thenechoecho"Relevant test file:"echo"$test_file"echoecho"Line count:"
wc -l "$test_file"echoecho"First 120 lines:"
sed -n '1,120p'"$test_file"| cat -n
fiechoecho"Search for sys.path usage in file:"if [ -n"${test_file:-}" ];then
rg -n "sys\.path|syspath_prepend|Path\(__file__\).parent""$test_file"||truefiechoecho"Search for global sys.path mutations in backend/tests:"
rg -n "sys\.path\.insert|sys\.path\.append|sys\.path\.extend|sys\.path\.remove|syspath_prepend" backend/tests ||trueechoecho"pytest availability:"
python3 - <<'PY'try: import pytest print("pytest:", pytest.__version__)except Exception as e: print("pytest import failed:", repr(e))try: import _retrieval_fixture print("module _retrieval_fixture found")except Exception as e: print("_retrieval_fixture import failed:", repr(e))PY

Repository: SaplingLearn/Sapling

Length of output: 6296


Scope the import-path mutation to this test.

sys.path.insert(...) mutates the pytest process state for any following tests. Use pytest’s scoped monkeypatch.syspath_prepend(...) so the path is restored automatically after this test.

Proposed fix
-def test_real_model_chooses_a_graph_reader_for_a_graph_question():- sys.path.insert(0, str(Path(__file__).parent / "evals"))+def test_real_model_chooses_a_graph_reader_for_a_graph_question(monkeypatch):+ monkeypatch.syspath_prepend(str(Path(__file__).parent / "evals"))
📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
deftest_real_model_chooses_a_graph_reader_for_a_graph_question():
sys.path.insert(0, str(Path(__file__).parent/"evals"))
from_retrieval_fixtureimportFixtureRetrieval
deftest_real_model_chooses_a_graph_reader_for_a_graph_question(monkeypatch):
monkeypatch.syspath_prepend(str(Path(__file__).parent/"evals"))
from_retrieval_fixtureimportFixtureRetrieval
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/tests/test_live_tutor_tools.py` around lines 36 - 38, Update
test_real_model_chooses_a_graph_reader_for_a_graph_question to accept pytest’s
monkeypatch fixture and replace the direct sys.path.insert mutation with
monkeypatch.syspath_prepend(...), ensuring the evals path is restored after the
test.

Comment threaddocs/decisions/0021-agent-eval-harness-baselines.md
AndresL230 added a commit that referenced this pull request Jul 30, 2026
…ion test, tool-count comment, ADR 0015 prose
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Review pass complete: four reviewers, two fully clean (one empirically re-verified the before/after eval baselines by resurrecting the deleted pre-change cassettes and replaying them — exact match). Three sub-80 findings, all fixed: a /chat/stream twin of the raw-body.message persistence regression test (the invariant was correct but only the JSON route was pinned), the stale 'all five tools' comment, and the ADR 0015 blockquote that had swallowed a sentence. Backend 1441 + ruff green. e2e cycle next.

AndresL230and others added 3 commits July 30, 2026 03:21
…n the next commit; kept in PR history so the before→after delta is auditable)
The agent path inlined NO graph context (the full-graph JSON dump was
legacy-only — and user-wide, leaking other courses' concepts); the tutor
had write tools (#127) but no graph read surface. Now:
- Tools 5 → 7: read_graph_neighborhood (course-scoped, seed-matched via
_normalize_concept, depth-1 edges via two merged in.() reads with a
both-endpoints-in-course rule, ids never leave the tool, truncated flag,
degrade-to-empty) + read_concepts_for_user (existing reader, capped
wrapper). Preamble gains the graph-tools paragraph with a hard
two-reads-per-turn cap. read_misconceptions_for_course deliberately
deferred (shared-context opt-out isn't enforced yet — ADR 0023).
- Deterministic GRAPH CONTEXT seed block on every agent turn
(services/graph_context.py: overlap-first/weakest-fill selection,
depth-1 edges, ≤1.5k chars, no ids) — turn-1 graph awareness without a
tool round-trip on the streaming UI; raw body.message persistence
unchanged.
- Legacy prompt de-dumped: all three call sites now use the same compact
course-scoped serializer (also fixes the cross-course concept leak).
- TUTOR_LIMITS (12/12/100k) so a legitimate multi-tool turn can't trip
UsageLimitExceeded into a silent rung-1 legacy fallback.
- The ADR-0021 retrieval seam: TutorRetrieval protocol on SaplingDeps
(production None → byte-identical Supabase path), FixtureRetrieval +
committed synthetic course, cassettes now carry tool-call traces, three
new evaluators, chat_tutor joins the offline harness (16 cases; the
previous commit holds the pre-change cassettes).
Eval deltas (before → after): ExpositoryHasStructure 0.812→1.000,
GraphToolUsed 0.938→1.000, GroundedConcept 0.812→0.875,
MasteryUpdateEmitted 0.688→1.000; legacy four stay 1.000.
Suites: backend 1440 passed + ruff clean (seam/stream files green under
lock-pinned pydantic-ai 1.107); evals replay green across all 6 datasets;
frontend untouched (tsc clean). ADR 0023; ADR 0021/0015 updated.
Closes#149.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ion test, tool-count comment, ADR 0015 prose
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@AndresL230
AndresL230force-pushed the feat/b7-149-graph-grounded-tutor branch from 6e1cf19 to 220d6a8CompareJuly 30, 2026 10:21
@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Pre-merge gate at the rebased head (over B6's #468): full lane 28/28 passed + oracles clean. Merging.

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.

[P1] Agent migration: graph-grounded tutor retrieval + tool-use loop

1 participant

@AndresL230
, '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); } })(); })(); feat(tutor): graph-grounded retrieval + tool-use loop (#149) by AndresL230 · Pull Request #469 · SaplingLearn/Sapling · GitHub
Skip to content

feat(tutor): graph-grounded retrieval + tool-use loop (#149) - #469

Merged
AndresL230 merged 3 commits into
mainfrom
feat/b7-149-graph-grounded-tutor
Jul 30, 2026
Merged

feat(tutor): graph-grounded retrieval + tool-use loop (#149)#469
AndresL230 merged 3 commits into
mainfrom
feat/b7-149-graph-grounded-tutor

Conversation

@AndresL230

@AndresL230AndresL230 commented Jul 30, 2026

Copy link
Copy Markdown
Collaborator

What

First link of the B7 seam-endgame chain (#149#153#150#151#154). The scoping pass corrected the issue's premise: the agent path inlined no graph context at all (the full-graph dump was legacy-only — and user-wide, leaking other courses' concepts), and the mid-turn write half already shipped with #127. Full detail in the commit message; the shape:

  • Read surface: read_graph_neighborhood (course-scoped, name-seeded, depth-1, ids never leave the tool) + read_concepts_for_user, registered on the tutor (5 → 7 tools) with a hard two-reads-per-turn prompt cap. The misconceptions reader is deliberately deferred until the shared-context opt-out is enforced (ADR 0023).
  • Deterministic seed block: every agent turn carries a ≤1.5k-char GRAPH CONTEXT block (overlap-first/weakest-fill + depth-1 edges) — turn-1 graph awareness without a tool round-trip on the streaming UI.
  • Legacy de-dump: the three call_gemini* prompt sites use the same compact serializer (kills the raw JSON dump and its cross-course leak) — de-risking the rung-1 fallback ahead of [P1] Agent migration: retire call_gemini* + gemini_service.py (final cutover) #151.
  • TUTOR_LIMITS (12/12/100k): a legitimate multi-tool turn can no longer trip UsageLimitExceeded into a silent legacy fallback.
  • The ADR-0021 retrieval seam: TutorRetrieval protocol on deps (production None → byte-identical), fixture retrieval + synthetic course, cassettes now record tool-call traces, and chat_tutor joins the offline eval harness (16 cases). The first commit on this branch holds the pre-change cassettes so the delta is auditable.

Eval deltas (before → after): ExpositoryHasStructure 0.812→1.000 · GraphToolUsed 0.938→1.000 · GroundedConcept 0.812→0.875 · MasteryUpdateEmitted 0.688→1.000 · the four legacy evaluators hold 1.000.

Verification

  • Backend 1440 passed + ruff clean; seam/stream/tool files re-run green under lock-pinned pydantic-ai 1.107; evals replay green across all six datasets; frontend untouched (tsc clean).
  • 40+ new tests incl. red-first tool-registration, id-leak/scoping/truncation coverage, the read-only-tools-never-emit-graph_update stream invariant, a scripted-tool seam test, and an opt-in live test proving the real model chooses the readers (run green once).
  • Known follow-up recorded in ADR 0023: gemini-2.5-pro occasionally emits a bare-newline final reply after an end-of-turn tool call — [P2] Agent platform: structured-output retry + validation hardening #153 (structured-output hardening) territory.
  • Full local e2e cycle pre-merge; results below.

Closes#149.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Chat tutoring now provides compact, course-scoped graph context for more relevant responses.
    • Added read-only graph tools for exploring related concepts and reviewing concept mastery.
    • Tutor responses can use course materials, progress, and session history through consistent retrieval.
    • Added offline chat-tutor evaluation coverage with recorded scenarios and tool-behavior checks.
  • Bug Fixes

    • Improved course isolation and prevented unrelated graph data from appearing in tutor context.
    • Read-only graph interactions no longer create unintended updates.
  • Documentation

    • Updated evaluation coverage and architecture decision records.

@supabase

supabaseBot commented Jul 30, 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 Jul 30, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@AndresL230, you've reached your PR review limit, so we couldn't start this review.

Next review available in:35 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 6c9c74c7-f890-4387-84d5-108dd11d7884

📥 Commits

Reviewing files that changed from the base of the PR and between 7eb0ce2 and 220d6a8.

📒 Files selected for processing (44)
  • .github/workflows/evals.yml
  • backend/agents/__init__.py
  • backend/agents/chat_tutor.py
  • backend/agents/deps.py
  • backend/agents/tools/chat_context.py
  • backend/agents/tools/graph_read.py
  • backend/agents/tools/retrieval.py
  • backend/routes/learn.py
  • backend/services/graph_context.py
  • backend/services/token_overlap.py
  • backend/tests/evals/README.md
  • backend/tests/evals/_retrieval_fixture.py
  • backend/tests/evals/baselines.json
  • backend/tests/evals/cassettes/chat_tutor/expository_explain_big_o.json
  • backend/tests/evals/cassettes/chat_tutor/expository_explain_dependency_injection.json
  • backend/tests/evals/cassettes/chat_tutor/expository_explain_kantian_ethics.json
  • backend/tests/evals/cassettes/chat_tutor/expository_explain_photosynthesis.json
  • backend/tests/evals/cassettes/chat_tutor/expository_explain_supply_demand.json
  • backend/tests/evals/cassettes/chat_tutor/socratic_chemistry_balancing.json
  • backend/tests/evals/cassettes/chat_tutor/socratic_history_themes.json
  • backend/tests/evals/cassettes/chat_tutor/socratic_intro_calculus.json
  • backend/tests/evals/cassettes/chat_tutor/socratic_open_followup.json
  • backend/tests/evals/cassettes/chat_tutor/socratic_python_recursion.json
  • backend/tests/evals/cassettes/chat_tutor/socratic_stale_concept_review.json
  • backend/tests/evals/cassettes/chat_tutor/teachback_advanced.json
  • backend/tests/evals/cassettes/chat_tutor/teachback_correct_concept.json
  • backend/tests/evals/cassettes/chat_tutor/teachback_minimal.json
  • backend/tests/evals/cassettes/chat_tutor/teachback_misconception.json
  • backend/tests/evals/cassettes/chat_tutor/teachback_partial_correct.json
  • backend/tests/evals/chat_tutor.py
  • backend/tests/evals/fixtures/tutor_course.json
  • backend/tests/evals/run_all.py
  • backend/tests/test_chat_stream.py
  • backend/tests/test_chat_tutor_imports.py
  • backend/tests/test_e2e_function_handlers.py
  • backend/tests/test_graph_context_block.py
  • backend/tests/test_graph_neighborhood_tool.py
  • backend/tests/test_graph_tools_bugs.py
  • backend/tests/test_live_tutor_tools.py
  • backend/tests/test_model_mode_seam.py
  • backend/tests/test_tutor_retrieval.py
  • docs/decisions/0015-refactor-3-chat-tutor-shipped.md
  • docs/decisions/0021-agent-eval-harness-baselines.md
  • docs/decisions/0023-tutor-graph-retrieval-seam.md
📝 Walkthrough

Walkthrough

The chat tutor now uses compact course-scoped graph context, exposes bounded read-only graph tools, supports injectable retrieval implementations, and runs offline evaluations against fixture data with structured tool-call cassettes and new behavioral evaluators.

Changes

Tutor graph grounding

Layer / File(s)Summary
Compact graph context and route wiring
backend/services/graph_context.py, backend/services/token_overlap.py, backend/routes/learn.py, backend/tests/test_graph_context_block.py
Graph context is selected, scoped, rendered within a character budget, and used by both agent and legacy tutor prompt paths.
Retrieval seam and graph read tools
backend/agents/tools/*, backend/agents/chat_tutor.py, backend/agents/deps.py, backend/tests/test_graph_neighborhood_tool.py, backend/tests/test_tutor_retrieval.py
Tutor reads resolve injected or Supabase retrieval, and graph readers provide bounded, course/user-scoped neighborhoods without graph writes.
Offline evaluation and regression coverage
backend/tests/evals/*, backend/tests/test_*.py, .github/workflows/evals.yml
Fixture retrieval, structured cassettes, graph/tool evaluators, baselines, CI gating, and integration tests cover the new tutor flow.

Estimated code review effort: 4 (Complex) | ~60 minutes

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 27.86% 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 the main change: graph-grounded tutor retrieval with tool use.
Description check✅ PassedThe description is substantial and covers the change, issues, and verification, though some template sections are omitted.
Linked Issues check✅ PassedThe PR implements course-scoped graph retrieval, prompt graph context, and mid-turn graph/mastery updates, matching #149.
Out of Scope Changes check✅ PassedMost changes support the tutor graph-grounding/eval harness work, with no clearly unrelated code changes evident.
✨ 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 feat/b7-149-graph-grounded-tutor

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.


async def course_materials(
self, course_id: str | None, query: str, limit: int, *, user_id: str
) -> list["CourseMaterial"]: ...
concepts: list[str],
*,
limit: int = 20,
) -> "GraphNeighborhood": ...

async def concept_mastery(
self, user_id: str, course_id: str | None
) -> list["ConceptMastery"]: ...

async def progress(
self, user_id: str, course_id: str | None
) -> "CourseProgress": ...

async def session_history(
self, session_id: str, last_n: int
) -> list["SessionMessage"]: ...
@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Jul 30, 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-staging220d6a8Commit Preview URL

Branch Preview URL
Jul 30 2026, 10:23 AM

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (6)
backend/tests/evals/chat_tutor.py (1)

459-478: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Prefer isinstance over string-based class-name matching for tool-call parts.

type(part).__name__ != "ToolCallPart" only matches parts of the exact ToolCallPart class. pydantic_ai's message types also include a distinct NativeToolCallPart for native/provider tool-calling paths; if any of the tutor's tools were ever surfaced that way, this check would silently drop them from the cassette, undercounting tool usage that GraphToolUsedEvaluator/MasteryUpdateEmittedEvaluator rely on.

♻️ Suggested fix
+from pydantic_ai.messages import ToolCallPart+
def _extract_tool_calls(result) -> list[ToolCall]:
...
for message in messages:
for part in getattr(message, "parts", []) or []:
- if type(part).__name__ != "ToolCallPart":+ if not isinstance(part, ToolCallPart):
continue
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/tests/evals/chat_tutor.py` around lines 459 - 478, Update
_extract_tool_calls to use isinstance-based recognition of pydantic_ai tool-call
part types, including both ToolCallPart and NativeToolCallPart, instead of
comparing type(part).__name__. Preserve the existing argument extraction
fallback and ToolCall construction for every recognized tool-call part.
backend/agents/tools/graph_read.py (2)

181-189: 🎯 Functional Correctness | 🔵 Trivial | 💤 Low value

Duplicate normalized concept names silently collapse.

by_norm[_normalize_concept(name)] = n is last-write-wins, so if the graph holds two rows normalizing to the same key (e.g. pre-dedup legacy rows), a seed resolves to whichever row came back last from PostgREST — non-deterministic across reads. Preferring the first occurrence (setdefault) at least makes it stable given a stable row order.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/agents/tools/graph_read.py` around lines 181 - 189, Update the
by_norm construction in the node-processing loop to preserve the first node for
each normalized concept name instead of overwriting it with later duplicates.
Replace the last-write-wins assignment associated with _normalize_concept(name)
while leaving by_id behavior unchanged.

104-105: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Clamp the LLM-chosen limit on the upper end too.

Both sites only floor at 0. A model that passes limit=5000 gets every tracked concept (and, for the neighborhood, every kept edge) back into the context window — the whole point of the cap. A module-level max (e.g. 50) keeps the tool's prompt cost bounded regardless of what the model asks for.

♻️ Suggested clamp
+_MAX_LIMIT = 50+
...
- n = max(0, int(limit))+ n = min(_MAX_LIMIT, max(0, int(limit)))
return rows[:n]
...
- cap = max(0, int(limit))+ cap = min(_MAX_LIMIT, max(0, int(limit)))

Also applies to: 244-247

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/agents/tools/graph_read.py` around lines 104 - 105, Clamp the
LLM-provided limit to a defined module-level maximum in both row-slicing sites,
including the neighborhood path referenced by the comment. Update the limit
normalization around `n = max(0, int(limit))` so values above the cap are
reduced while negative values still produce zero, keeping prompt results
bounded.
backend/agents/chat_tutor.py (1)

70-73: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

"Hard cap" is prompt-advisory only.

Nothing enforces two graph reads per turn; the real ceiling is TUTOR_LIMITS. Either soften the wording or enforce it in the tool wrapper (e.g. a per-run counter on SaplingDeps that returns an empty neighborhood past the second call) so cost is bounded even when the model ignores the instruction.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/agents/chat_tutor.py` around lines 70 - 73, Update the graph-read
enforcement around SaplingDeps and its tool wrapper so each tutor run permits no
more than two graph reads, returning an empty neighborhood after the second
call; otherwise soften the prompt’s “Hard cap” wording to match the actual
TUTOR_LIMITS behavior.
backend/tests/test_model_mode_seam.py (1)

304-307: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Registered function handler is never unregistered.

register_function_handler mutates a module-level registry that monkeypatch won't restore, so this handler leaks into any later test that runs chat_tutor in function mode. If the file doesn't already have an autouse reset fixture, worth adding one.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/tests/test_model_mode_seam.py` around lines 304 - 307, Add cleanup
for the module-level handler registry after tests using
register_function_handler, including the test around socratic_agent.run_sync and
the "chat_tutor" handler. Prefer an autouse fixture if the file lacks one,
ensuring the registry is reset after each test so handlers do not leak into
later function-mode tests.
backend/services/graph_context.py (1)

105-111: 🚀 Performance & Scalability | 🔵 Trivial | 💤 Low value

Budget trim rejoins the whole block each iteration.

Minor: with ≤12 lines this is irrelevant, but a running length counter (or trimming from a precomputed cumulative sum) avoids the O(n²) rejoin and reads clearer.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/services/graph_context.py` around lines 105 - 111, Update the
budget-trimming logic in the graph-context builder around _HEADER, lines, and
GRAPH_CONTEXT_CHAR_BUDGET to track the assembled character length incrementally
instead of rejoining the entire block on every loop iteration. Preserve the
existing behavior of removing trailing lines until the budget fits and returning
an empty string when no lines remain.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@backend/routes/learn.py`:
- Around line 520-523: Update the graph-context construction at the referenced
legacy-turn paths, including the flows around lines 520, 763, and 1328, so
course-less sessions retain an appropriate user-wide compact graph fallback
instead of receiving an empty block. Avoid calling get_graph when no course_id
is available unless the fallback requires it, and ensure the serializer’s
selection remains bounded and weakest-first.
In `@backend/tests/test_live_tutor_tools.py`:
- Around line 36-38: Update
test_real_model_chooses_a_graph_reader_for_a_graph_question to accept pytest’s
monkeypatch fixture and replace the direct sys.path.insert mutation with
monkeypatch.syspath_prepend(...), ensuring the evals path is restored after the
test.
In `@docs/decisions/0021-agent-eval-harness-baselines.md`:
- Around line 46-48: The ADRs retain obsolete pre-change descriptions of
chat_tutor. In docs/decisions/0021-agent-eval-harness-baselines.md lines 46-48,
update the five-dataset wording, baseline table, and “unmeasured” consequence to
include chat_tutor; in docs/decisions/0015-refactor-3-chat-tutor-shipped.md
lines 28-33, replace the remaining four-tool and four-registration descriptions
or explicitly mark them as historical.
---
Nitpick comments:
In `@backend/agents/chat_tutor.py`:
- Around line 70-73: Update the graph-read enforcement around SaplingDeps and
its tool wrapper so each tutor run permits no more than two graph reads,
returning an empty neighborhood after the second call; otherwise soften the
prompt’s “Hard cap” wording to match the actual TUTOR_LIMITS behavior.
In `@backend/agents/tools/graph_read.py`:
- Around line 181-189: Update the by_norm construction in the node-processing
loop to preserve the first node for each normalized concept name instead of
overwriting it with later duplicates. Replace the last-write-wins assignment
associated with _normalize_concept(name) while leaving by_id behavior unchanged.
- Around line 104-105: Clamp the LLM-provided limit to a defined module-level
maximum in both row-slicing sites, including the neighborhood path referenced by
the comment. Update the limit normalization around `n = max(0, int(limit))` so
values above the cap are reduced while negative values still produce zero,
keeping prompt results bounded.
In `@backend/services/graph_context.py`:
- Around line 105-111: Update the budget-trimming logic in the graph-context
builder around _HEADER, lines, and GRAPH_CONTEXT_CHAR_BUDGET to track the
assembled character length incrementally instead of rejoining the entire block
on every loop iteration. Preserve the existing behavior of removing trailing
lines until the budget fits and returning an empty string when no lines remain.
In `@backend/tests/evals/chat_tutor.py`:
- Around line 459-478: Update _extract_tool_calls to use isinstance-based
recognition of pydantic_ai tool-call part types, including both ToolCallPart and
NativeToolCallPart, instead of comparing type(part).__name__. Preserve the
existing argument extraction fallback and ToolCall construction for every
recognized tool-call part.
In `@backend/tests/test_model_mode_seam.py`:
- Around line 304-307: Add cleanup for the module-level handler registry after
tests using register_function_handler, including the test around
socratic_agent.run_sync and the "chat_tutor" handler. Prefer an autouse fixture
if the file lacks one, ensuring the registry is reset after each test so
handlers do not leak into later function-mode tests.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 10240725-b7f7-4f3d-8294-03d828114d0c

📥 Commits

Reviewing files that changed from the base of the PR and between ec0e2af and 7eb0ce2.

📒 Files selected for processing (44)
  • .github/workflows/evals.yml
  • backend/agents/__init__.py
  • backend/agents/chat_tutor.py
  • backend/agents/deps.py
  • backend/agents/tools/chat_context.py
  • backend/agents/tools/graph_read.py
  • backend/agents/tools/retrieval.py
  • backend/routes/learn.py
  • backend/services/graph_context.py
  • backend/services/token_overlap.py
  • backend/tests/evals/README.md
  • backend/tests/evals/_retrieval_fixture.py
  • backend/tests/evals/baselines.json
  • backend/tests/evals/cassettes/chat_tutor/expository_explain_big_o.json
  • backend/tests/evals/cassettes/chat_tutor/expository_explain_dependency_injection.json
  • backend/tests/evals/cassettes/chat_tutor/expository_explain_kantian_ethics.json
  • backend/tests/evals/cassettes/chat_tutor/expository_explain_photosynthesis.json
  • backend/tests/evals/cassettes/chat_tutor/expository_explain_supply_demand.json
  • backend/tests/evals/cassettes/chat_tutor/socratic_chemistry_balancing.json
  • backend/tests/evals/cassettes/chat_tutor/socratic_history_themes.json
  • backend/tests/evals/cassettes/chat_tutor/socratic_intro_calculus.json
  • backend/tests/evals/cassettes/chat_tutor/socratic_open_followup.json
  • backend/tests/evals/cassettes/chat_tutor/socratic_python_recursion.json
  • backend/tests/evals/cassettes/chat_tutor/socratic_stale_concept_review.json
  • backend/tests/evals/cassettes/chat_tutor/teachback_advanced.json
  • backend/tests/evals/cassettes/chat_tutor/teachback_correct_concept.json
  • backend/tests/evals/cassettes/chat_tutor/teachback_minimal.json
  • backend/tests/evals/cassettes/chat_tutor/teachback_misconception.json
  • backend/tests/evals/cassettes/chat_tutor/teachback_partial_correct.json
  • backend/tests/evals/chat_tutor.py
  • backend/tests/evals/fixtures/tutor_course.json
  • backend/tests/evals/run_all.py
  • backend/tests/test_chat_stream.py
  • backend/tests/test_chat_tutor_imports.py
  • backend/tests/test_e2e_function_handlers.py
  • backend/tests/test_graph_context_block.py
  • backend/tests/test_graph_neighborhood_tool.py
  • backend/tests/test_graph_tools_bugs.py
  • backend/tests/test_live_tutor_tools.py
  • backend/tests/test_model_mode_seam.py
  • backend/tests/test_tutor_retrieval.py
  • docs/decisions/0015-refactor-3-chat-tutor-shipped.md
  • docs/decisions/0021-agent-eval-harness-baselines.md
  • docs/decisions/0023-tutor-graph-retrieval-seam.md

Comment threadbackend/routes/learn.py
Comment on lines +36 to +38
def test_real_model_chooses_a_graph_reader_for_a_graph_question():
sys.path.insert(0, str(Path(__file__).parent / "evals"))
from _retrieval_fixture import FixtureRetrieval

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bashset -euo pipefail
echo"Files matching test name:"
fd -a 'test_live_tutor_tools\.py$'.||true
test_file="$(fd 'test_live_tutor_tools\.py$'.| head -n1 || true)"if [ -n"${test_file:-}" ];thenechoecho"Relevant test file:"echo"$test_file"echoecho"Line count:"
wc -l "$test_file"echoecho"First 120 lines:"
sed -n '1,120p'"$test_file"| cat -n
fiechoecho"Search for sys.path usage in file:"if [ -n"${test_file:-}" ];then
rg -n "sys\.path|syspath_prepend|Path\(__file__\).parent""$test_file"||truefiechoecho"Search for global sys.path mutations in backend/tests:"
rg -n "sys\.path\.insert|sys\.path\.append|sys\.path\.extend|sys\.path\.remove|syspath_prepend" backend/tests ||trueechoecho"pytest availability:"
python3 - <<'PY'try: import pytest print("pytest:", pytest.__version__)except Exception as e: print("pytest import failed:", repr(e))try: import _retrieval_fixture print("module _retrieval_fixture found")except Exception as e: print("_retrieval_fixture import failed:", repr(e))PY

Repository: SaplingLearn/Sapling

Length of output: 6296


Scope the import-path mutation to this test.

sys.path.insert(...) mutates the pytest process state for any following tests. Use pytest’s scoped monkeypatch.syspath_prepend(...) so the path is restored automatically after this test.

Proposed fix
-def test_real_model_chooses_a_graph_reader_for_a_graph_question():- sys.path.insert(0, str(Path(__file__).parent / "evals"))+def test_real_model_chooses_a_graph_reader_for_a_graph_question(monkeypatch):+ monkeypatch.syspath_prepend(str(Path(__file__).parent / "evals"))
📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
deftest_real_model_chooses_a_graph_reader_for_a_graph_question():
sys.path.insert(0, str(Path(__file__).parent/"evals"))
from_retrieval_fixtureimportFixtureRetrieval
deftest_real_model_chooses_a_graph_reader_for_a_graph_question(monkeypatch):
monkeypatch.syspath_prepend(str(Path(__file__).parent/"evals"))
from_retrieval_fixtureimportFixtureRetrieval
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/tests/test_live_tutor_tools.py` around lines 36 - 38, Update
test_real_model_chooses_a_graph_reader_for_a_graph_question to accept pytest’s
monkeypatch fixture and replace the direct sys.path.insert mutation with
monkeypatch.syspath_prepend(...), ensuring the evals path is restored after the
test.

Comment threaddocs/decisions/0021-agent-eval-harness-baselines.md
AndresL230 added a commit that referenced this pull request Jul 30, 2026
…ion test, tool-count comment, ADR 0015 prose
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Review pass complete: four reviewers, two fully clean (one empirically re-verified the before/after eval baselines by resurrecting the deleted pre-change cassettes and replaying them — exact match). Three sub-80 findings, all fixed: a /chat/stream twin of the raw-body.message persistence regression test (the invariant was correct but only the JSON route was pinned), the stale 'all five tools' comment, and the ADR 0015 blockquote that had swallowed a sentence. Backend 1441 + ruff green. e2e cycle next.

AndresL230and others added 3 commits July 30, 2026 03:21
…n the next commit; kept in PR history so the before→after delta is auditable)
The agent path inlined NO graph context (the full-graph JSON dump was
legacy-only — and user-wide, leaking other courses' concepts); the tutor
had write tools (#127) but no graph read surface. Now:
- Tools 5 → 7: read_graph_neighborhood (course-scoped, seed-matched via
_normalize_concept, depth-1 edges via two merged in.() reads with a
both-endpoints-in-course rule, ids never leave the tool, truncated flag,
degrade-to-empty) + read_concepts_for_user (existing reader, capped
wrapper). Preamble gains the graph-tools paragraph with a hard
two-reads-per-turn cap. read_misconceptions_for_course deliberately
deferred (shared-context opt-out isn't enforced yet — ADR 0023).
- Deterministic GRAPH CONTEXT seed block on every agent turn
(services/graph_context.py: overlap-first/weakest-fill selection,
depth-1 edges, ≤1.5k chars, no ids) — turn-1 graph awareness without a
tool round-trip on the streaming UI; raw body.message persistence
unchanged.
- Legacy prompt de-dumped: all three call sites now use the same compact
course-scoped serializer (also fixes the cross-course concept leak).
- TUTOR_LIMITS (12/12/100k) so a legitimate multi-tool turn can't trip
UsageLimitExceeded into a silent rung-1 legacy fallback.
- The ADR-0021 retrieval seam: TutorRetrieval protocol on SaplingDeps
(production None → byte-identical Supabase path), FixtureRetrieval +
committed synthetic course, cassettes now carry tool-call traces, three
new evaluators, chat_tutor joins the offline harness (16 cases; the
previous commit holds the pre-change cassettes).
Eval deltas (before → after): ExpositoryHasStructure 0.812→1.000,
GraphToolUsed 0.938→1.000, GroundedConcept 0.812→0.875,
MasteryUpdateEmitted 0.688→1.000; legacy four stay 1.000.
Suites: backend 1440 passed + ruff clean (seam/stream files green under
lock-pinned pydantic-ai 1.107); evals replay green across all 6 datasets;
frontend untouched (tsc clean). ADR 0023; ADR 0021/0015 updated.
Closes#149.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ion test, tool-count comment, ADR 0015 prose
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@AndresL230
AndresL230force-pushed the feat/b7-149-graph-grounded-tutor branch from 6e1cf19 to 220d6a8CompareJuly 30, 2026 10:21
@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Pre-merge gate at the rebased head (over B6's #468): full lane 28/28 passed + oracles clean. Merging.

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.

[P1] Agent migration: graph-grounded tutor retrieval + tool-use loop

1 participant

@AndresL230