Skip to content

refactor(learn): convert chat tutor to chat_tutor_agent (refactor #3) - #78

Merged
Jose-Gael-Cruz-Lopez merged 2 commits into
mainfrom
refactor/3-chat-tutor
May 5, 2026
Merged

refactor(learn): convert chat tutor to chat_tutor_agent (refactor #3)#78
Jose-Gael-Cruz-Lopez merged 2 commits into
mainfrom
refactor/3-chat-tutor

Conversation

@Jose-Gael-Cruz-Lopez

@Jose-Gael-Cruz-LopezJose-Gael-Cruz-Lopez commented May 5, 2026

Copy link
Copy Markdown
Member

Summary

Third and final refactor named in the ADR 0001 migration plan: convert routes/learn.py::chat from build_system_prompt + call_gemini_multiturn to a typed Pydantic AI agent with three mode-specific instances and a four-tool surface. Same orchestrator-vs-legacy fallback pattern PR #67 (documents) and PR #71 (quiz) established. Wire format unchanged, encryption boundary preserved, legacy path intact per ADR 0001.

What shipped

  • backend/agents/chat_tutor.py — three Agent[SaplingDeps, str] instances built from a shared preamble + per-mode body. Prompt versions:

    ModeHash
    Socratic57f278a01d2d
    Expository8c840f43b6e2
    TeachBack70a34fb09224
  • backend/agents/tools/chat_context.py — three new context tools, all decryption-aware:

    • search_course_materials — keyword overlap on documents.summary + concept_notes
    • read_session_history — last N messages, messages.content decrypted at the boundary
    • read_user_progress — aggregates graph_nodes to mastered / weak / in-progress counts
  • backend/agents/_providers.py — added chat_tutor task slot, default gemini-2.5-pro (matches main's chat behavior post-PR Restore Google sign-in popup + add tutor Fast/Smart model toggle #73), env-var override SAPLING_MODEL_CHAT_TUTOR.

  • backend/routes/learn.py_chat_via_agent (new), _legacy_chat (preserved per ADR 0001), _load_message_history (Pydantic-AI ModelMessage adapter with decryption), _resolve_model_pref (mirrors quiz). chat migrated agent-first; start_session and action carry TODO(refactor-3 follow-up) comments and remain on the legacy path for this PR.

  • Teststest_chat_tutor_imports.py (5), test_chat_context_tools.py (15), test_learn_routes.py +10 new in TestChatViaAgent. 577 pass on this branch; 3 pre-existing live-Supabase failures (test_skips_self_edges, test_save_to_db, test_full_pipeline) unchanged from PR re-architecture: agentic document upload + AES-256-GCM column encryption + dev-context vault #67/refactor(quiz): convert generate_quiz to quiz_agent (refactor #2) #71. No regressions.

  • backend/tests/evals/chat_tutor.py — 15 cases (5 per mode) and 5 evaluators: NonEmpty, SocraticEndsWithQuestion, ExpositoryHasStructure, TeachBackProbes, NoToolMisuse.

  • docs/decisions/0015-refactor-3-chat-tutor-shipped.md — full ADR with surprises, consequences, rollback. Numbered 0015 because PR feat(quiz): adaptive iteration — spaced repetition + history + difficulty #77 (adaptive-quiz iteration) claimed 0014 between refactor Refine LLM Model selection for each function #2 ship and this work.

Scope split — flagged for review

This PR migrates only chat, not start_session or action. Both share enough plumbing with chat (same prompt assembly, same legacy call_gemini_multiturn call) that migrating all three would have doubled the route diff and bundled three independent rollback decisions. They carry TODO(refactor-3 follow-up) comments and remain on the legacy path. A follow-up PR will migrate them after the chat agent path proves stable in production.

What's NOT in this PR (per ADR 0001 migration contract)

  • services/gemini_service.py is NOT deleted. Still alive as the chat fallback target (and the quiz fallback). A separate small PR removes it after the agent path proves stable in production AND start_session / action get migrated.
  • Frontend Learn.tsx SSE wiring is NOT in this PR. That's sub-agent E and ships separately. The agent path uses non-streaming agent.run for now; streaming via run_stream_events is a follow-up.
  • Eval cassettes are NOT yet recorded. Cassettes get written on the next SAPLING_EVAL_MODE=record run; replay-mode CI continues to fail loudly when a cassette is missing, so neither the new nor existing cases silently no-op.

References

Test plan

  • pytest tests/test_chat_tutor_imports.py -q → 5 passed
  • pytest tests/test_chat_context_tools.py -q → 15 passed
  • pytest tests/test_learn_routes.py -q → 34 passed (24 prior + 10 new)
  • pytest tests/ -q --ignore=tests/evals → 577 passed, 3 pre-existing failures (unchanged)
  • Live-mode eval recording (SAPLING_EVAL_MODE=record python tests/evals/chat_tutor.py) — recommended before merge
  • Manual smoke: chat in each mode (Socratic / Expository / TeachBack) and confirm tool calls show up in Logfire
  • Latency check after ~50 chats — agent path may have higher round-trip count vs the legacy single multi-turn call; will measure in Logfire

Rollback

The legacy path is intact. Single revert of the merge commit drops the three new agent modules (chat_tutor.py, chat_context.py are pure-leaf), removes the chat_tutor task slot, reverts routes/learn.py::chat to the legacy path, and the messages table schema is unchanged so no migration to undo.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Introduced three distinct tutoring modes for the chat tutor: Socratic questioning, Expository teaching, and TeachBack learning techniques
    • Enhanced chat tutor with improved context awareness by integrating course materials, session history, and learner progress tracking for more personalized guidance

Closes the third refactor in the migration plan from ADR 0001 (and the
last one named in ADR 0005). The legacy `routes/learn.py::chat` path —
hand-built `build_system_prompt` + `services/gemini_service.py::call_gemini_multiturn`
— is replaced with three mode-specific Pydantic AI agents
(Socratic / Expository / TeachBack), each sharing the same four-tool
surface, with the established orchestrator-vs-legacy fallback contract.
What shipped:
- agents/chat_tutor.py — three Agent[SaplingDeps, str] instances built
from a shared preamble + per-mode body. Prompt versions:
Socratic 57f278a01d2d
Expository 8c840f43b6e2
TeachBack 70a34fb09224
- agents/tools/chat_context.py — three new tools:
search_course_materials, read_session_history, read_user_progress.
All decryption-aware (messages.content, documents.summary,
documents.concept_notes are encrypted at rest per CLAUDE.md).
- agents/_providers.py — chat_tutor task slot, default gemini-2.5-pro
(matches main's chat behavior post-PR #73), env override
SAPLING_MODEL_CHAT_TUTOR.
- routes/learn.py — _chat_via_agent (new), _legacy_chat (preserved per
ADR 0001), _load_message_history (Pydantic-AI ModelMessage adapter
with decryption), _resolve_model_pref (mirrors quiz). chat migrated
agent-first; start_session and action carry TODO(refactor-3 follow-up)
comments and remain on the legacy path for this PR.
- tests/test_chat_tutor_imports.py (5), test_chat_context_tools.py (15),
test_learn_routes.py +10 new in TestChatViaAgent class. 577 pass on
this branch (3 pre-existing failures: test_skips_self_edges,
test_save_to_db, test_full_pipeline — unchanged from PR #67/#71).
- tests/evals/chat_tutor.py — 15 cases (5 per mode) and 5 evaluators
(NonEmpty, SocraticEndsWithQuestion, ExpositoryHasStructure,
TeachBackProbes, NoToolMisuse). Cassettes recorded out-of-band.
Decisions:
- Scope split: only `chat` migrated this PR. start_session and action
share enough plumbing that bundling would have doubled the diff and
bundled three independent rollbacks. Follow-up PR.
- gemini_service.py NOT deleted — still alive as the chat fallback
target (and the quiz fallback). Separate PR removes it after the
agent path proves stable in production AND start_session/action get
migrated.
- Wire format unchanged: response still returns reply / graph_update /
mastery_changes; messages table schema untouched.
ADR 0015 captures the full rollback path and what we'd carry forward.
Note: original template numbered this 0014 but PR #77's adaptive-quiz
iteration claimed that slot, so this is 0015.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitaiBot commented May 5, 2026

Copy link
Copy Markdown

Caution

Review failed

Pull request was closed or merged during review

📝 Walkthrough

Walkthrough

This PR implements a comprehensive refactor of the chat tutor system, replacing a legacy Gemini-based multiturn chat with a typed Pydantic AI agent framework. The change introduces three mode-specific agents (socratic, expository, teachback) backed by on-demand data-access tools, integrates the new agent into the /chat route with graceful fallback to legacy behavior, and includes extensive test coverage and refactor documentation.

Changes

Chat Tutor Agent Refactor

Layer / File(s)Summary
Task & Provider Registration
backend/agents/_providers.py
chat_tutor is added to AgentTask Literal and mapped to gemini-2.5-pro in _DEFAULTS, with environment-variable override support via SAPLING_MODEL_CHAT_TUTOR.
Dependency Wiring
backend/agents/deps.py
SaplingDeps dataclass gains optional session_id: str | None = None field to scope tools to active chat sessions.
Agent Implementation
backend/agents/chat_tutor.py
Three mode-specific agents (socratic, expository, teachback) are constructed with distinct system prompts, per-mode SHA-256 prompt hashes for versioning, and a shared tool surface. agent_for_mode(mode) selects the agent instance, normalizing input and falling back to socratic for unknown/missing modes.
Data-Access Tools
backend/agents/tools/chat_context.py
Implements three async tools: search_course_materials_tool (keyword-ranked document search), read_session_history_tool (decrypted message retrieval), and read_user_progress_tool (mastery aggregation). Tool wrappers extract user_id/course_id/session_id from ctx.deps to enforce access control, while underlying functions handle decryption at the boundary, gracefully degrade failures to empty results, and filter out unusable rows.
Route & Request Handling
backend/routes/learn.py
The POST /api/learn/chat endpoint becomes async and agent-first: loads decrypted message history via _load_message_history(), attempts the new agent path, falls back to _legacy_chat() on guardrail/exception failures, and persists encrypted user/model messages. Model selection is unified via _resolve_model_pref() for the agent and _resolve_legacy_model() for fallback. Legacy routes (start_session, action) continue to use the legacy model resolver.
Unit & Integration Tests
backend/tests/test_chat_context_tools.py, backend/tests/test_chat_tutor_imports.py, backend/tests/test_learn_routes.py
Tool-level tests verify decryption/filtering/ranking, import smoke tests validate agent instantiation and tool registration, and route tests cover agent success, fallback paths, message encryption/decryption, and model-preference override behavior.
Evaluation Framework
backend/tests/evals/chat_tutor.py
Defines 15 fixed test cases (5 per mode) with mode-conditional evaluators enforcing reply length, ending-punctuation rules, and prohibition of raw tool names; integrates with the replay/record cassette system.
Documentation & Planning
backend/prompts/refactor-3-chat-tutor/..., docs/decisions/0015-refactor-3-chat-tutor-shipped.md
Comprehensive refactor orchestration plan, sub-agent task specs, ADR documenting shipped scope/decisions/consequences, and implementation guide for optional frontend SSE event wiring.

Sequence Diagram

sequenceDiagram
participant Client
participant Learn as learn.py<br/>POST /chat
participant Agent as chat_tutor_agent
participant Tools as chat_context tools
participant DB as Supabase
participant Legacy as _legacy_chat<br/>(fallback)
Client->>Learn: User message + mode
Learn->>DB: Load message history
DB-->>Learn: Encrypted messages
Learn->>Learn: Decrypt message history
Learn->>Agent: agent_for_mode(mode).run()
Agent->>Tools: search_course_materials_tool(query)
Tools->>DB: Fetch documents
DB-->>Tools: Encrypted results
Tools->>Tools: Decrypt, score, filter
Tools-->>Agent: Ranked materials
Agent->>Tools: read_session_history_tool()
Tools->>DB: Fetch messages
DB-->>Tools: Encrypted messages
Tools->>Tools: Decrypt, map roles, filter
Tools-->>Agent: Session history
Agent->>Tools: read_user_progress_tool()
Tools->>DB: Fetch mastery scores
DB-->>Tools: Raw scores
Tools->>Tools: Aggregate, clamp, bin
Tools-->>Agent: Progress summary
alt Agent succeeds
Agent-->>Learn: Reply string
Learn->>DB: Save encrypted messages
Learn-->>Client: Reply + empty graph_update
else Guardrail exception
Learn->>Legacy: _legacy_chat()
Legacy->>DB: Call legacy Gemini
DB-->>Legacy: Response
Legacy-->>Learn: Reply
Learn->>DB: Save messages
Learn-->>Client: Reply + empty graph_update
end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

This PR spans multiple interdependent subsystems (provider registration, agent implementation, tool layer, route integration) with diverse logic density: cryptographic boundary enforcement in tools, prompt-versioning via SHA-256 hashing, keyword-ranking algorithms, mode-based dispatch logic, message-role mapping with legacy fallback, and comprehensive fallback control flow in the route handler. The changes affect 15+ files across distinct concerns (configuration, agent/tool implementations, routes, tests, and documentation), and while individual tool implementations follow similar patterns (decrypt at boundary, filter, aggregate), each has distinct domain logic (scoring vs. decryption vs. aggregation). The test suite is substantial and heterogeneous, covering unit-level tool behavior, import assertions, route integration, and evaluation harness setup.

Possibly related PRs

  • SaplingLearn/Sapling#65: Implements message encryption/decryption primitives (encrypt_if_present, decrypt_if_present, decrypt_json) that this PR directly depends on for loading/decrypting message history and tool results.
  • SaplingLearn/Sapling#71: Extends the agent task framework in backend/agents/_providers.py for the quiz agent, establishing the same pattern (new AgentTask Literal value, _DEFAULTS mapping, environment-variable override) that this PR reuses for chat_tutor.
  • SaplingLearn/Sapling#67: Introduces the foundational Pydantic AI agent framework (task registration, SaplingDeps deps class, tool-wrapper patterns, event mapping) that this PR builds upon to create the chat tutor agents.

Poem

🐰 Hoppy hops with prompts so neat,
Three modes tutor—Socratic sweet,
Tools that search and track progress well,
Agent-first with fallback spell.
Legacy lanes become side streets,
Type-safe chat makes the heart beat! 💙

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 36.14% 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✅ PassedTitle clearly summarizes the main change: converting chat tutor to a Pydantic AI agent as part of refactor #3, with scope indicator.
Description check✅ PassedDescription comprehensively covers all required sections: Summary, What Shipped, Scope, What's NOT Included, References, Test Plan, and Rollback instructions.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch refactor/3-chat-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 and usage tips.


import asyncio
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented May 5, 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
frontendbe5b842Commit Preview URL

Branch Preview URL
May 05 2026, 03:21 AM

…ssion_id
Two correctness fixes from the self-review on PR #78.
1. Symmetric model defaults across agent + legacy paths.
When body.model_pref is None (the default), the legacy fallback used
to return MODEL_DEFAULT (gemini-2.5-flash) while the agent path
returned the agent's task-default (gemini-2.5-pro). A user with no
explicit pref silently downgraded from Pro -> Flash on fallback.
This is the same bug PR #71's commit a2fd5cd fixed for quiz; chat
now matches.
- routes/learn.py: _resolve_tutor_model -> _resolve_legacy_model.
Default fallback flipped from MODEL_DEFAULT to MODEL_SMART.
Comment block + three call sites (start_session, _legacy_chat,
action) updated.
- tests/test_learn_routes.py: TestResolveTutorModel ->
TestResolveLegacyModel. Four tests flipped their expected return
from MODEL_DEFAULT to MODEL_SMART to pin the new symmetric
contract. One new test (test_default_matches_agent_default_for_no_pref)
pins agent/legacy parity explicitly.
2. session_id is a declared field on SaplingDeps, not an attribute attach.
Before: routes/learn.py constructed SaplingDeps without session_id
and then did `deps.session_id = session_id # type: ignore`. The
tool wrapper read it via `getattr(..., None)`. Worked at runtime
(unfrozen dataclass), but type checkers couldn't validate it,
frozen=True would silently break it, and future SaplingDeps
consumers had no way to discover the seam.
- agents/deps.py: added `session_id: str | None = None` with a
docstring entry covering the eval/batch case (legitimately None).
- routes/learn.py: pass session_id via the constructor; removed
the imperative attach + the obsolete comment + the # type: ignore.
- agents/tools/chat_context.py: read_session_history_tool now does
a direct `ctx.deps.session_id` read; getattr ceremony gone.
- tests/test_chat_context_tools.py: missing-session test sets
session_id=None explicitly to mirror the declared default.
55 tests pass across the three touched test files. No regressions.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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.

1 participant

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

refactor(learn): convert chat tutor to chat_tutor_agent (refactor #3) - #78

Merged
Jose-Gael-Cruz-Lopez merged 2 commits into
mainfrom
refactor/3-chat-tutor
May 5, 2026
Merged

refactor(learn): convert chat tutor to chat_tutor_agent (refactor #3)#78
Jose-Gael-Cruz-Lopez merged 2 commits into
mainfrom
refactor/3-chat-tutor

Conversation

@Jose-Gael-Cruz-Lopez

@Jose-Gael-Cruz-LopezJose-Gael-Cruz-Lopez commented May 5, 2026

Copy link
Copy Markdown
Member

Summary

Third and final refactor named in the ADR 0001 migration plan: convert routes/learn.py::chat from build_system_prompt + call_gemini_multiturn to a typed Pydantic AI agent with three mode-specific instances and a four-tool surface. Same orchestrator-vs-legacy fallback pattern PR #67 (documents) and PR #71 (quiz) established. Wire format unchanged, encryption boundary preserved, legacy path intact per ADR 0001.

What shipped

  • backend/agents/chat_tutor.py — three Agent[SaplingDeps, str] instances built from a shared preamble + per-mode body. Prompt versions:

    ModeHash
    Socratic57f278a01d2d
    Expository8c840f43b6e2
    TeachBack70a34fb09224
  • backend/agents/tools/chat_context.py — three new context tools, all decryption-aware:

    • search_course_materials — keyword overlap on documents.summary + concept_notes
    • read_session_history — last N messages, messages.content decrypted at the boundary
    • read_user_progress — aggregates graph_nodes to mastered / weak / in-progress counts
  • backend/agents/_providers.py — added chat_tutor task slot, default gemini-2.5-pro (matches main's chat behavior post-PR Restore Google sign-in popup + add tutor Fast/Smart model toggle #73), env-var override SAPLING_MODEL_CHAT_TUTOR.

  • backend/routes/learn.py_chat_via_agent (new), _legacy_chat (preserved per ADR 0001), _load_message_history (Pydantic-AI ModelMessage adapter with decryption), _resolve_model_pref (mirrors quiz). chat migrated agent-first; start_session and action carry TODO(refactor-3 follow-up) comments and remain on the legacy path for this PR.

  • Teststest_chat_tutor_imports.py (5), test_chat_context_tools.py (15), test_learn_routes.py +10 new in TestChatViaAgent. 577 pass on this branch; 3 pre-existing live-Supabase failures (test_skips_self_edges, test_save_to_db, test_full_pipeline) unchanged from PR re-architecture: agentic document upload + AES-256-GCM column encryption + dev-context vault #67/refactor(quiz): convert generate_quiz to quiz_agent (refactor #2) #71. No regressions.

  • backend/tests/evals/chat_tutor.py — 15 cases (5 per mode) and 5 evaluators: NonEmpty, SocraticEndsWithQuestion, ExpositoryHasStructure, TeachBackProbes, NoToolMisuse.

  • docs/decisions/0015-refactor-3-chat-tutor-shipped.md — full ADR with surprises, consequences, rollback. Numbered 0015 because PR feat(quiz): adaptive iteration — spaced repetition + history + difficulty #77 (adaptive-quiz iteration) claimed 0014 between refactor Refine LLM Model selection for each function #2 ship and this work.

Scope split — flagged for review

This PR migrates only chat, not start_session or action. Both share enough plumbing with chat (same prompt assembly, same legacy call_gemini_multiturn call) that migrating all three would have doubled the route diff and bundled three independent rollback decisions. They carry TODO(refactor-3 follow-up) comments and remain on the legacy path. A follow-up PR will migrate them after the chat agent path proves stable in production.

What's NOT in this PR (per ADR 0001 migration contract)

  • services/gemini_service.py is NOT deleted. Still alive as the chat fallback target (and the quiz fallback). A separate small PR removes it after the agent path proves stable in production AND start_session / action get migrated.
  • Frontend Learn.tsx SSE wiring is NOT in this PR. That's sub-agent E and ships separately. The agent path uses non-streaming agent.run for now; streaming via run_stream_events is a follow-up.
  • Eval cassettes are NOT yet recorded. Cassettes get written on the next SAPLING_EVAL_MODE=record run; replay-mode CI continues to fail loudly when a cassette is missing, so neither the new nor existing cases silently no-op.

References

Test plan

  • pytest tests/test_chat_tutor_imports.py -q → 5 passed
  • pytest tests/test_chat_context_tools.py -q → 15 passed
  • pytest tests/test_learn_routes.py -q → 34 passed (24 prior + 10 new)
  • pytest tests/ -q --ignore=tests/evals → 577 passed, 3 pre-existing failures (unchanged)
  • Live-mode eval recording (SAPLING_EVAL_MODE=record python tests/evals/chat_tutor.py) — recommended before merge
  • Manual smoke: chat in each mode (Socratic / Expository / TeachBack) and confirm tool calls show up in Logfire
  • Latency check after ~50 chats — agent path may have higher round-trip count vs the legacy single multi-turn call; will measure in Logfire

Rollback

The legacy path is intact. Single revert of the merge commit drops the three new agent modules (chat_tutor.py, chat_context.py are pure-leaf), removes the chat_tutor task slot, reverts routes/learn.py::chat to the legacy path, and the messages table schema is unchanged so no migration to undo.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Introduced three distinct tutoring modes for the chat tutor: Socratic questioning, Expository teaching, and TeachBack learning techniques
    • Enhanced chat tutor with improved context awareness by integrating course materials, session history, and learner progress tracking for more personalized guidance

Closes the third refactor in the migration plan from ADR 0001 (and the
last one named in ADR 0005). The legacy `routes/learn.py::chat` path —
hand-built `build_system_prompt` + `services/gemini_service.py::call_gemini_multiturn`
— is replaced with three mode-specific Pydantic AI agents
(Socratic / Expository / TeachBack), each sharing the same four-tool
surface, with the established orchestrator-vs-legacy fallback contract.
What shipped:
- agents/chat_tutor.py — three Agent[SaplingDeps, str] instances built
from a shared preamble + per-mode body. Prompt versions:
Socratic 57f278a01d2d
Expository 8c840f43b6e2
TeachBack 70a34fb09224
- agents/tools/chat_context.py — three new tools:
search_course_materials, read_session_history, read_user_progress.
All decryption-aware (messages.content, documents.summary,
documents.concept_notes are encrypted at rest per CLAUDE.md).
- agents/_providers.py — chat_tutor task slot, default gemini-2.5-pro
(matches main's chat behavior post-PR #73), env override
SAPLING_MODEL_CHAT_TUTOR.
- routes/learn.py — _chat_via_agent (new), _legacy_chat (preserved per
ADR 0001), _load_message_history (Pydantic-AI ModelMessage adapter
with decryption), _resolve_model_pref (mirrors quiz). chat migrated
agent-first; start_session and action carry TODO(refactor-3 follow-up)
comments and remain on the legacy path for this PR.
- tests/test_chat_tutor_imports.py (5), test_chat_context_tools.py (15),
test_learn_routes.py +10 new in TestChatViaAgent class. 577 pass on
this branch (3 pre-existing failures: test_skips_self_edges,
test_save_to_db, test_full_pipeline — unchanged from PR #67/#71).
- tests/evals/chat_tutor.py — 15 cases (5 per mode) and 5 evaluators
(NonEmpty, SocraticEndsWithQuestion, ExpositoryHasStructure,
TeachBackProbes, NoToolMisuse). Cassettes recorded out-of-band.
Decisions:
- Scope split: only `chat` migrated this PR. start_session and action
share enough plumbing that bundling would have doubled the diff and
bundled three independent rollbacks. Follow-up PR.
- gemini_service.py NOT deleted — still alive as the chat fallback
target (and the quiz fallback). Separate PR removes it after the
agent path proves stable in production AND start_session/action get
migrated.
- Wire format unchanged: response still returns reply / graph_update /
mastery_changes; messages table schema untouched.
ADR 0015 captures the full rollback path and what we'd carry forward.
Note: original template numbered this 0014 but PR #77's adaptive-quiz
iteration claimed that slot, so this is 0015.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitaiBot commented May 5, 2026

Copy link
Copy Markdown

Caution

Review failed

Pull request was closed or merged during review

📝 Walkthrough

Walkthrough

This PR implements a comprehensive refactor of the chat tutor system, replacing a legacy Gemini-based multiturn chat with a typed Pydantic AI agent framework. The change introduces three mode-specific agents (socratic, expository, teachback) backed by on-demand data-access tools, integrates the new agent into the /chat route with graceful fallback to legacy behavior, and includes extensive test coverage and refactor documentation.

Changes

Chat Tutor Agent Refactor

Layer / File(s)Summary
Task & Provider Registration
backend/agents/_providers.py
chat_tutor is added to AgentTask Literal and mapped to gemini-2.5-pro in _DEFAULTS, with environment-variable override support via SAPLING_MODEL_CHAT_TUTOR.
Dependency Wiring
backend/agents/deps.py
SaplingDeps dataclass gains optional session_id: str | None = None field to scope tools to active chat sessions.
Agent Implementation
backend/agents/chat_tutor.py
Three mode-specific agents (socratic, expository, teachback) are constructed with distinct system prompts, per-mode SHA-256 prompt hashes for versioning, and a shared tool surface. agent_for_mode(mode) selects the agent instance, normalizing input and falling back to socratic for unknown/missing modes.
Data-Access Tools
backend/agents/tools/chat_context.py
Implements three async tools: search_course_materials_tool (keyword-ranked document search), read_session_history_tool (decrypted message retrieval), and read_user_progress_tool (mastery aggregation). Tool wrappers extract user_id/course_id/session_id from ctx.deps to enforce access control, while underlying functions handle decryption at the boundary, gracefully degrade failures to empty results, and filter out unusable rows.
Route & Request Handling
backend/routes/learn.py
The POST /api/learn/chat endpoint becomes async and agent-first: loads decrypted message history via _load_message_history(), attempts the new agent path, falls back to _legacy_chat() on guardrail/exception failures, and persists encrypted user/model messages. Model selection is unified via _resolve_model_pref() for the agent and _resolve_legacy_model() for fallback. Legacy routes (start_session, action) continue to use the legacy model resolver.
Unit & Integration Tests
backend/tests/test_chat_context_tools.py, backend/tests/test_chat_tutor_imports.py, backend/tests/test_learn_routes.py
Tool-level tests verify decryption/filtering/ranking, import smoke tests validate agent instantiation and tool registration, and route tests cover agent success, fallback paths, message encryption/decryption, and model-preference override behavior.
Evaluation Framework
backend/tests/evals/chat_tutor.py
Defines 15 fixed test cases (5 per mode) with mode-conditional evaluators enforcing reply length, ending-punctuation rules, and prohibition of raw tool names; integrates with the replay/record cassette system.
Documentation & Planning
backend/prompts/refactor-3-chat-tutor/..., docs/decisions/0015-refactor-3-chat-tutor-shipped.md
Comprehensive refactor orchestration plan, sub-agent task specs, ADR documenting shipped scope/decisions/consequences, and implementation guide for optional frontend SSE event wiring.

Sequence Diagram

sequenceDiagram
participant Client
participant Learn as learn.py<br/>POST /chat
participant Agent as chat_tutor_agent
participant Tools as chat_context tools
participant DB as Supabase
participant Legacy as _legacy_chat<br/>(fallback)
Client->>Learn: User message + mode
Learn->>DB: Load message history
DB-->>Learn: Encrypted messages
Learn->>Learn: Decrypt message history
Learn->>Agent: agent_for_mode(mode).run()
Agent->>Tools: search_course_materials_tool(query)
Tools->>DB: Fetch documents
DB-->>Tools: Encrypted results
Tools->>Tools: Decrypt, score, filter
Tools-->>Agent: Ranked materials
Agent->>Tools: read_session_history_tool()
Tools->>DB: Fetch messages
DB-->>Tools: Encrypted messages
Tools->>Tools: Decrypt, map roles, filter
Tools-->>Agent: Session history
Agent->>Tools: read_user_progress_tool()
Tools->>DB: Fetch mastery scores
DB-->>Tools: Raw scores
Tools->>Tools: Aggregate, clamp, bin
Tools-->>Agent: Progress summary
alt Agent succeeds
Agent-->>Learn: Reply string
Learn->>DB: Save encrypted messages
Learn-->>Client: Reply + empty graph_update
else Guardrail exception
Learn->>Legacy: _legacy_chat()
Legacy->>DB: Call legacy Gemini
DB-->>Legacy: Response
Legacy-->>Learn: Reply
Learn->>DB: Save messages
Learn-->>Client: Reply + empty graph_update
end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

This PR spans multiple interdependent subsystems (provider registration, agent implementation, tool layer, route integration) with diverse logic density: cryptographic boundary enforcement in tools, prompt-versioning via SHA-256 hashing, keyword-ranking algorithms, mode-based dispatch logic, message-role mapping with legacy fallback, and comprehensive fallback control flow in the route handler. The changes affect 15+ files across distinct concerns (configuration, agent/tool implementations, routes, tests, and documentation), and while individual tool implementations follow similar patterns (decrypt at boundary, filter, aggregate), each has distinct domain logic (scoring vs. decryption vs. aggregation). The test suite is substantial and heterogeneous, covering unit-level tool behavior, import assertions, route integration, and evaluation harness setup.

Possibly related PRs

  • SaplingLearn/Sapling#65: Implements message encryption/decryption primitives (encrypt_if_present, decrypt_if_present, decrypt_json) that this PR directly depends on for loading/decrypting message history and tool results.
  • SaplingLearn/Sapling#71: Extends the agent task framework in backend/agents/_providers.py for the quiz agent, establishing the same pattern (new AgentTask Literal value, _DEFAULTS mapping, environment-variable override) that this PR reuses for chat_tutor.
  • SaplingLearn/Sapling#67: Introduces the foundational Pydantic AI agent framework (task registration, SaplingDeps deps class, tool-wrapper patterns, event mapping) that this PR builds upon to create the chat tutor agents.

Poem

🐰 Hoppy hops with prompts so neat,
Three modes tutor—Socratic sweet,
Tools that search and track progress well,
Agent-first with fallback spell.
Legacy lanes become side streets,
Type-safe chat makes the heart beat! 💙

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 36.14% 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✅ PassedTitle clearly summarizes the main change: converting chat tutor to a Pydantic AI agent as part of refactor #3, with scope indicator.
Description check✅ PassedDescription comprehensively covers all required sections: Summary, What Shipped, Scope, What's NOT Included, References, Test Plan, and Rollback instructions.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch refactor/3-chat-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 and usage tips.


import asyncio
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented May 5, 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
frontendbe5b842Commit Preview URL

Branch Preview URL
May 05 2026, 03:21 AM

…ssion_id
Two correctness fixes from the self-review on PR #78.
1. Symmetric model defaults across agent + legacy paths.
When body.model_pref is None (the default), the legacy fallback used
to return MODEL_DEFAULT (gemini-2.5-flash) while the agent path
returned the agent's task-default (gemini-2.5-pro). A user with no
explicit pref silently downgraded from Pro -> Flash on fallback.
This is the same bug PR #71's commit a2fd5cd fixed for quiz; chat
now matches.
- routes/learn.py: _resolve_tutor_model -> _resolve_legacy_model.
Default fallback flipped from MODEL_DEFAULT to MODEL_SMART.
Comment block + three call sites (start_session, _legacy_chat,
action) updated.
- tests/test_learn_routes.py: TestResolveTutorModel ->
TestResolveLegacyModel. Four tests flipped their expected return
from MODEL_DEFAULT to MODEL_SMART to pin the new symmetric
contract. One new test (test_default_matches_agent_default_for_no_pref)
pins agent/legacy parity explicitly.
2. session_id is a declared field on SaplingDeps, not an attribute attach.
Before: routes/learn.py constructed SaplingDeps without session_id
and then did `deps.session_id = session_id # type: ignore`. The
tool wrapper read it via `getattr(..., None)`. Worked at runtime
(unfrozen dataclass), but type checkers couldn't validate it,
frozen=True would silently break it, and future SaplingDeps
consumers had no way to discover the seam.
- agents/deps.py: added `session_id: str | None = None` with a
docstring entry covering the eval/batch case (legitimately None).
- routes/learn.py: pass session_id via the constructor; removed
the imperative attach + the obsolete comment + the # type: ignore.
- agents/tools/chat_context.py: read_session_history_tool now does
a direct `ctx.deps.session_id` read; getattr ceremony gone.
- tests/test_chat_context_tools.py: missing-session test sets
session_id=None explicitly to mirror the declared default.
55 tests pass across the three touched test files. No regressions.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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.

1 participant

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

refactor(learn): convert chat tutor to chat_tutor_agent (refactor #3) - #78

Merged
Jose-Gael-Cruz-Lopez merged 2 commits into
mainfrom
refactor/3-chat-tutor
May 5, 2026
Merged

refactor(learn): convert chat tutor to chat_tutor_agent (refactor #3)#78
Jose-Gael-Cruz-Lopez merged 2 commits into
mainfrom
refactor/3-chat-tutor

Conversation

@Jose-Gael-Cruz-Lopez

@Jose-Gael-Cruz-LopezJose-Gael-Cruz-Lopez commented May 5, 2026

Copy link
Copy Markdown
Member

Summary

Third and final refactor named in the ADR 0001 migration plan: convert routes/learn.py::chat from build_system_prompt + call_gemini_multiturn to a typed Pydantic AI agent with three mode-specific instances and a four-tool surface. Same orchestrator-vs-legacy fallback pattern PR #67 (documents) and PR #71 (quiz) established. Wire format unchanged, encryption boundary preserved, legacy path intact per ADR 0001.

What shipped

  • backend/agents/chat_tutor.py — three Agent[SaplingDeps, str] instances built from a shared preamble + per-mode body. Prompt versions:

    ModeHash
    Socratic57f278a01d2d
    Expository8c840f43b6e2
    TeachBack70a34fb09224
  • backend/agents/tools/chat_context.py — three new context tools, all decryption-aware:

    • search_course_materials — keyword overlap on documents.summary + concept_notes
    • read_session_history — last N messages, messages.content decrypted at the boundary
    • read_user_progress — aggregates graph_nodes to mastered / weak / in-progress counts
  • backend/agents/_providers.py — added chat_tutor task slot, default gemini-2.5-pro (matches main's chat behavior post-PR Restore Google sign-in popup + add tutor Fast/Smart model toggle #73), env-var override SAPLING_MODEL_CHAT_TUTOR.

  • backend/routes/learn.py_chat_via_agent (new), _legacy_chat (preserved per ADR 0001), _load_message_history (Pydantic-AI ModelMessage adapter with decryption), _resolve_model_pref (mirrors quiz). chat migrated agent-first; start_session and action carry TODO(refactor-3 follow-up) comments and remain on the legacy path for this PR.

  • Teststest_chat_tutor_imports.py (5), test_chat_context_tools.py (15), test_learn_routes.py +10 new in TestChatViaAgent. 577 pass on this branch; 3 pre-existing live-Supabase failures (test_skips_self_edges, test_save_to_db, test_full_pipeline) unchanged from PR re-architecture: agentic document upload + AES-256-GCM column encryption + dev-context vault #67/refactor(quiz): convert generate_quiz to quiz_agent (refactor #2) #71. No regressions.

  • backend/tests/evals/chat_tutor.py — 15 cases (5 per mode) and 5 evaluators: NonEmpty, SocraticEndsWithQuestion, ExpositoryHasStructure, TeachBackProbes, NoToolMisuse.

  • docs/decisions/0015-refactor-3-chat-tutor-shipped.md — full ADR with surprises, consequences, rollback. Numbered 0015 because PR feat(quiz): adaptive iteration — spaced repetition + history + difficulty #77 (adaptive-quiz iteration) claimed 0014 between refactor Refine LLM Model selection for each function #2 ship and this work.

Scope split — flagged for review

This PR migrates only chat, not start_session or action. Both share enough plumbing with chat (same prompt assembly, same legacy call_gemini_multiturn call) that migrating all three would have doubled the route diff and bundled three independent rollback decisions. They carry TODO(refactor-3 follow-up) comments and remain on the legacy path. A follow-up PR will migrate them after the chat agent path proves stable in production.

What's NOT in this PR (per ADR 0001 migration contract)

  • services/gemini_service.py is NOT deleted. Still alive as the chat fallback target (and the quiz fallback). A separate small PR removes it after the agent path proves stable in production AND start_session / action get migrated.
  • Frontend Learn.tsx SSE wiring is NOT in this PR. That's sub-agent E and ships separately. The agent path uses non-streaming agent.run for now; streaming via run_stream_events is a follow-up.
  • Eval cassettes are NOT yet recorded. Cassettes get written on the next SAPLING_EVAL_MODE=record run; replay-mode CI continues to fail loudly when a cassette is missing, so neither the new nor existing cases silently no-op.

References

Test plan

  • pytest tests/test_chat_tutor_imports.py -q → 5 passed
  • pytest tests/test_chat_context_tools.py -q → 15 passed
  • pytest tests/test_learn_routes.py -q → 34 passed (24 prior + 10 new)
  • pytest tests/ -q --ignore=tests/evals → 577 passed, 3 pre-existing failures (unchanged)
  • Live-mode eval recording (SAPLING_EVAL_MODE=record python tests/evals/chat_tutor.py) — recommended before merge
  • Manual smoke: chat in each mode (Socratic / Expository / TeachBack) and confirm tool calls show up in Logfire
  • Latency check after ~50 chats — agent path may have higher round-trip count vs the legacy single multi-turn call; will measure in Logfire

Rollback

The legacy path is intact. Single revert of the merge commit drops the three new agent modules (chat_tutor.py, chat_context.py are pure-leaf), removes the chat_tutor task slot, reverts routes/learn.py::chat to the legacy path, and the messages table schema is unchanged so no migration to undo.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Introduced three distinct tutoring modes for the chat tutor: Socratic questioning, Expository teaching, and TeachBack learning techniques
    • Enhanced chat tutor with improved context awareness by integrating course materials, session history, and learner progress tracking for more personalized guidance

Closes the third refactor in the migration plan from ADR 0001 (and the
last one named in ADR 0005). The legacy `routes/learn.py::chat` path —
hand-built `build_system_prompt` + `services/gemini_service.py::call_gemini_multiturn`
— is replaced with three mode-specific Pydantic AI agents
(Socratic / Expository / TeachBack), each sharing the same four-tool
surface, with the established orchestrator-vs-legacy fallback contract.
What shipped:
- agents/chat_tutor.py — three Agent[SaplingDeps, str] instances built
from a shared preamble + per-mode body. Prompt versions:
Socratic 57f278a01d2d
Expository 8c840f43b6e2
TeachBack 70a34fb09224
- agents/tools/chat_context.py — three new tools:
search_course_materials, read_session_history, read_user_progress.
All decryption-aware (messages.content, documents.summary,
documents.concept_notes are encrypted at rest per CLAUDE.md).
- agents/_providers.py — chat_tutor task slot, default gemini-2.5-pro
(matches main's chat behavior post-PR #73), env override
SAPLING_MODEL_CHAT_TUTOR.
- routes/learn.py — _chat_via_agent (new), _legacy_chat (preserved per
ADR 0001), _load_message_history (Pydantic-AI ModelMessage adapter
with decryption), _resolve_model_pref (mirrors quiz). chat migrated
agent-first; start_session and action carry TODO(refactor-3 follow-up)
comments and remain on the legacy path for this PR.
- tests/test_chat_tutor_imports.py (5), test_chat_context_tools.py (15),
test_learn_routes.py +10 new in TestChatViaAgent class. 577 pass on
this branch (3 pre-existing failures: test_skips_self_edges,
test_save_to_db, test_full_pipeline — unchanged from PR #67/#71).
- tests/evals/chat_tutor.py — 15 cases (5 per mode) and 5 evaluators
(NonEmpty, SocraticEndsWithQuestion, ExpositoryHasStructure,
TeachBackProbes, NoToolMisuse). Cassettes recorded out-of-band.
Decisions:
- Scope split: only `chat` migrated this PR. start_session and action
share enough plumbing that bundling would have doubled the diff and
bundled three independent rollbacks. Follow-up PR.
- gemini_service.py NOT deleted — still alive as the chat fallback
target (and the quiz fallback). Separate PR removes it after the
agent path proves stable in production AND start_session/action get
migrated.
- Wire format unchanged: response still returns reply / graph_update /
mastery_changes; messages table schema untouched.
ADR 0015 captures the full rollback path and what we'd carry forward.
Note: original template numbered this 0014 but PR #77's adaptive-quiz
iteration claimed that slot, so this is 0015.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitaiBot commented May 5, 2026

Copy link
Copy Markdown

Caution

Review failed

Pull request was closed or merged during review

📝 Walkthrough

Walkthrough

This PR implements a comprehensive refactor of the chat tutor system, replacing a legacy Gemini-based multiturn chat with a typed Pydantic AI agent framework. The change introduces three mode-specific agents (socratic, expository, teachback) backed by on-demand data-access tools, integrates the new agent into the /chat route with graceful fallback to legacy behavior, and includes extensive test coverage and refactor documentation.

Changes

Chat Tutor Agent Refactor

Layer / File(s)Summary
Task & Provider Registration
backend/agents/_providers.py
chat_tutor is added to AgentTask Literal and mapped to gemini-2.5-pro in _DEFAULTS, with environment-variable override support via SAPLING_MODEL_CHAT_TUTOR.
Dependency Wiring
backend/agents/deps.py
SaplingDeps dataclass gains optional session_id: str | None = None field to scope tools to active chat sessions.
Agent Implementation
backend/agents/chat_tutor.py
Three mode-specific agents (socratic, expository, teachback) are constructed with distinct system prompts, per-mode SHA-256 prompt hashes for versioning, and a shared tool surface. agent_for_mode(mode) selects the agent instance, normalizing input and falling back to socratic for unknown/missing modes.
Data-Access Tools
backend/agents/tools/chat_context.py
Implements three async tools: search_course_materials_tool (keyword-ranked document search), read_session_history_tool (decrypted message retrieval), and read_user_progress_tool (mastery aggregation). Tool wrappers extract user_id/course_id/session_id from ctx.deps to enforce access control, while underlying functions handle decryption at the boundary, gracefully degrade failures to empty results, and filter out unusable rows.
Route & Request Handling
backend/routes/learn.py
The POST /api/learn/chat endpoint becomes async and agent-first: loads decrypted message history via _load_message_history(), attempts the new agent path, falls back to _legacy_chat() on guardrail/exception failures, and persists encrypted user/model messages. Model selection is unified via _resolve_model_pref() for the agent and _resolve_legacy_model() for fallback. Legacy routes (start_session, action) continue to use the legacy model resolver.
Unit & Integration Tests
backend/tests/test_chat_context_tools.py, backend/tests/test_chat_tutor_imports.py, backend/tests/test_learn_routes.py
Tool-level tests verify decryption/filtering/ranking, import smoke tests validate agent instantiation and tool registration, and route tests cover agent success, fallback paths, message encryption/decryption, and model-preference override behavior.
Evaluation Framework
backend/tests/evals/chat_tutor.py
Defines 15 fixed test cases (5 per mode) with mode-conditional evaluators enforcing reply length, ending-punctuation rules, and prohibition of raw tool names; integrates with the replay/record cassette system.
Documentation & Planning
backend/prompts/refactor-3-chat-tutor/..., docs/decisions/0015-refactor-3-chat-tutor-shipped.md
Comprehensive refactor orchestration plan, sub-agent task specs, ADR documenting shipped scope/decisions/consequences, and implementation guide for optional frontend SSE event wiring.

Sequence Diagram

sequenceDiagram
participant Client
participant Learn as learn.py<br/>POST /chat
participant Agent as chat_tutor_agent
participant Tools as chat_context tools
participant DB as Supabase
participant Legacy as _legacy_chat<br/>(fallback)
Client->>Learn: User message + mode
Learn->>DB: Load message history
DB-->>Learn: Encrypted messages
Learn->>Learn: Decrypt message history
Learn->>Agent: agent_for_mode(mode).run()
Agent->>Tools: search_course_materials_tool(query)
Tools->>DB: Fetch documents
DB-->>Tools: Encrypted results
Tools->>Tools: Decrypt, score, filter
Tools-->>Agent: Ranked materials
Agent->>Tools: read_session_history_tool()
Tools->>DB: Fetch messages
DB-->>Tools: Encrypted messages
Tools->>Tools: Decrypt, map roles, filter
Tools-->>Agent: Session history
Agent->>Tools: read_user_progress_tool()
Tools->>DB: Fetch mastery scores
DB-->>Tools: Raw scores
Tools->>Tools: Aggregate, clamp, bin
Tools-->>Agent: Progress summary
alt Agent succeeds
Agent-->>Learn: Reply string
Learn->>DB: Save encrypted messages
Learn-->>Client: Reply + empty graph_update
else Guardrail exception
Learn->>Legacy: _legacy_chat()
Legacy->>DB: Call legacy Gemini
DB-->>Legacy: Response
Legacy-->>Learn: Reply
Learn->>DB: Save messages
Learn-->>Client: Reply + empty graph_update
end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

This PR spans multiple interdependent subsystems (provider registration, agent implementation, tool layer, route integration) with diverse logic density: cryptographic boundary enforcement in tools, prompt-versioning via SHA-256 hashing, keyword-ranking algorithms, mode-based dispatch logic, message-role mapping with legacy fallback, and comprehensive fallback control flow in the route handler. The changes affect 15+ files across distinct concerns (configuration, agent/tool implementations, routes, tests, and documentation), and while individual tool implementations follow similar patterns (decrypt at boundary, filter, aggregate), each has distinct domain logic (scoring vs. decryption vs. aggregation). The test suite is substantial and heterogeneous, covering unit-level tool behavior, import assertions, route integration, and evaluation harness setup.

Possibly related PRs

  • SaplingLearn/Sapling#65: Implements message encryption/decryption primitives (encrypt_if_present, decrypt_if_present, decrypt_json) that this PR directly depends on for loading/decrypting message history and tool results.
  • SaplingLearn/Sapling#71: Extends the agent task framework in backend/agents/_providers.py for the quiz agent, establishing the same pattern (new AgentTask Literal value, _DEFAULTS mapping, environment-variable override) that this PR reuses for chat_tutor.
  • SaplingLearn/Sapling#67: Introduces the foundational Pydantic AI agent framework (task registration, SaplingDeps deps class, tool-wrapper patterns, event mapping) that this PR builds upon to create the chat tutor agents.

Poem

🐰 Hoppy hops with prompts so neat,
Three modes tutor—Socratic sweet,
Tools that search and track progress well,
Agent-first with fallback spell.
Legacy lanes become side streets,
Type-safe chat makes the heart beat! 💙

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 36.14% 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✅ PassedTitle clearly summarizes the main change: converting chat tutor to a Pydantic AI agent as part of refactor #3, with scope indicator.
Description check✅ PassedDescription comprehensively covers all required sections: Summary, What Shipped, Scope, What's NOT Included, References, Test Plan, and Rollback instructions.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch refactor/3-chat-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 and usage tips.


import asyncio
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented May 5, 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
frontendbe5b842Commit Preview URL

Branch Preview URL
May 05 2026, 03:21 AM

…ssion_id
Two correctness fixes from the self-review on PR #78.
1. Symmetric model defaults across agent + legacy paths.
When body.model_pref is None (the default), the legacy fallback used
to return MODEL_DEFAULT (gemini-2.5-flash) while the agent path
returned the agent's task-default (gemini-2.5-pro). A user with no
explicit pref silently downgraded from Pro -> Flash on fallback.
This is the same bug PR #71's commit a2fd5cd fixed for quiz; chat
now matches.
- routes/learn.py: _resolve_tutor_model -> _resolve_legacy_model.
Default fallback flipped from MODEL_DEFAULT to MODEL_SMART.
Comment block + three call sites (start_session, _legacy_chat,
action) updated.
- tests/test_learn_routes.py: TestResolveTutorModel ->
TestResolveLegacyModel. Four tests flipped their expected return
from MODEL_DEFAULT to MODEL_SMART to pin the new symmetric
contract. One new test (test_default_matches_agent_default_for_no_pref)
pins agent/legacy parity explicitly.
2. session_id is a declared field on SaplingDeps, not an attribute attach.
Before: routes/learn.py constructed SaplingDeps without session_id
and then did `deps.session_id = session_id # type: ignore`. The
tool wrapper read it via `getattr(..., None)`. Worked at runtime
(unfrozen dataclass), but type checkers couldn't validate it,
frozen=True would silently break it, and future SaplingDeps
consumers had no way to discover the seam.
- agents/deps.py: added `session_id: str | None = None` with a
docstring entry covering the eval/batch case (legitimately None).
- routes/learn.py: pass session_id via the constructor; removed
the imperative attach + the obsolete comment + the # type: ignore.
- agents/tools/chat_context.py: read_session_history_tool now does
a direct `ctx.deps.session_id` read; getattr ceremony gone.
- tests/test_chat_context_tools.py: missing-session test sets
session_id=None explicitly to mirror the declared default.
55 tests pass across the three touched test files. No regressions.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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.

1 participant

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

refactor(learn): convert chat tutor to chat_tutor_agent (refactor #3) - #78

Merged
Jose-Gael-Cruz-Lopez merged 2 commits into
mainfrom
refactor/3-chat-tutor
May 5, 2026
Merged

refactor(learn): convert chat tutor to chat_tutor_agent (refactor #3)#78
Jose-Gael-Cruz-Lopez merged 2 commits into
mainfrom
refactor/3-chat-tutor

Conversation

@Jose-Gael-Cruz-Lopez

@Jose-Gael-Cruz-LopezJose-Gael-Cruz-Lopez commented May 5, 2026

Copy link
Copy Markdown
Member

Summary

Third and final refactor named in the ADR 0001 migration plan: convert routes/learn.py::chat from build_system_prompt + call_gemini_multiturn to a typed Pydantic AI agent with three mode-specific instances and a four-tool surface. Same orchestrator-vs-legacy fallback pattern PR #67 (documents) and PR #71 (quiz) established. Wire format unchanged, encryption boundary preserved, legacy path intact per ADR 0001.

What shipped

  • backend/agents/chat_tutor.py — three Agent[SaplingDeps, str] instances built from a shared preamble + per-mode body. Prompt versions:

    ModeHash
    Socratic57f278a01d2d
    Expository8c840f43b6e2
    TeachBack70a34fb09224
  • backend/agents/tools/chat_context.py — three new context tools, all decryption-aware:

    • search_course_materials — keyword overlap on documents.summary + concept_notes
    • read_session_history — last N messages, messages.content decrypted at the boundary
    • read_user_progress — aggregates graph_nodes to mastered / weak / in-progress counts
  • backend/agents/_providers.py — added chat_tutor task slot, default gemini-2.5-pro (matches main's chat behavior post-PR Restore Google sign-in popup + add tutor Fast/Smart model toggle #73), env-var override SAPLING_MODEL_CHAT_TUTOR.

  • backend/routes/learn.py_chat_via_agent (new), _legacy_chat (preserved per ADR 0001), _load_message_history (Pydantic-AI ModelMessage adapter with decryption), _resolve_model_pref (mirrors quiz). chat migrated agent-first; start_session and action carry TODO(refactor-3 follow-up) comments and remain on the legacy path for this PR.

  • Teststest_chat_tutor_imports.py (5), test_chat_context_tools.py (15), test_learn_routes.py +10 new in TestChatViaAgent. 577 pass on this branch; 3 pre-existing live-Supabase failures (test_skips_self_edges, test_save_to_db, test_full_pipeline) unchanged from PR re-architecture: agentic document upload + AES-256-GCM column encryption + dev-context vault #67/refactor(quiz): convert generate_quiz to quiz_agent (refactor #2) #71. No regressions.

  • backend/tests/evals/chat_tutor.py — 15 cases (5 per mode) and 5 evaluators: NonEmpty, SocraticEndsWithQuestion, ExpositoryHasStructure, TeachBackProbes, NoToolMisuse.

  • docs/decisions/0015-refactor-3-chat-tutor-shipped.md — full ADR with surprises, consequences, rollback. Numbered 0015 because PR feat(quiz): adaptive iteration — spaced repetition + history + difficulty #77 (adaptive-quiz iteration) claimed 0014 between refactor Refine LLM Model selection for each function #2 ship and this work.

Scope split — flagged for review

This PR migrates only chat, not start_session or action. Both share enough plumbing with chat (same prompt assembly, same legacy call_gemini_multiturn call) that migrating all three would have doubled the route diff and bundled three independent rollback decisions. They carry TODO(refactor-3 follow-up) comments and remain on the legacy path. A follow-up PR will migrate them after the chat agent path proves stable in production.

What's NOT in this PR (per ADR 0001 migration contract)

  • services/gemini_service.py is NOT deleted. Still alive as the chat fallback target (and the quiz fallback). A separate small PR removes it after the agent path proves stable in production AND start_session / action get migrated.
  • Frontend Learn.tsx SSE wiring is NOT in this PR. That's sub-agent E and ships separately. The agent path uses non-streaming agent.run for now; streaming via run_stream_events is a follow-up.
  • Eval cassettes are NOT yet recorded. Cassettes get written on the next SAPLING_EVAL_MODE=record run; replay-mode CI continues to fail loudly when a cassette is missing, so neither the new nor existing cases silently no-op.

References

Test plan

  • pytest tests/test_chat_tutor_imports.py -q → 5 passed
  • pytest tests/test_chat_context_tools.py -q → 15 passed
  • pytest tests/test_learn_routes.py -q → 34 passed (24 prior + 10 new)
  • pytest tests/ -q --ignore=tests/evals → 577 passed, 3 pre-existing failures (unchanged)
  • Live-mode eval recording (SAPLING_EVAL_MODE=record python tests/evals/chat_tutor.py) — recommended before merge
  • Manual smoke: chat in each mode (Socratic / Expository / TeachBack) and confirm tool calls show up in Logfire
  • Latency check after ~50 chats — agent path may have higher round-trip count vs the legacy single multi-turn call; will measure in Logfire

Rollback

The legacy path is intact. Single revert of the merge commit drops the three new agent modules (chat_tutor.py, chat_context.py are pure-leaf), removes the chat_tutor task slot, reverts routes/learn.py::chat to the legacy path, and the messages table schema is unchanged so no migration to undo.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Introduced three distinct tutoring modes for the chat tutor: Socratic questioning, Expository teaching, and TeachBack learning techniques
    • Enhanced chat tutor with improved context awareness by integrating course materials, session history, and learner progress tracking for more personalized guidance

Closes the third refactor in the migration plan from ADR 0001 (and the
last one named in ADR 0005). The legacy `routes/learn.py::chat` path —
hand-built `build_system_prompt` + `services/gemini_service.py::call_gemini_multiturn`
— is replaced with three mode-specific Pydantic AI agents
(Socratic / Expository / TeachBack), each sharing the same four-tool
surface, with the established orchestrator-vs-legacy fallback contract.
What shipped:
- agents/chat_tutor.py — three Agent[SaplingDeps, str] instances built
from a shared preamble + per-mode body. Prompt versions:
Socratic 57f278a01d2d
Expository 8c840f43b6e2
TeachBack 70a34fb09224
- agents/tools/chat_context.py — three new tools:
search_course_materials, read_session_history, read_user_progress.
All decryption-aware (messages.content, documents.summary,
documents.concept_notes are encrypted at rest per CLAUDE.md).
- agents/_providers.py — chat_tutor task slot, default gemini-2.5-pro
(matches main's chat behavior post-PR #73), env override
SAPLING_MODEL_CHAT_TUTOR.
- routes/learn.py — _chat_via_agent (new), _legacy_chat (preserved per
ADR 0001), _load_message_history (Pydantic-AI ModelMessage adapter
with decryption), _resolve_model_pref (mirrors quiz). chat migrated
agent-first; start_session and action carry TODO(refactor-3 follow-up)
comments and remain on the legacy path for this PR.
- tests/test_chat_tutor_imports.py (5), test_chat_context_tools.py (15),
test_learn_routes.py +10 new in TestChatViaAgent class. 577 pass on
this branch (3 pre-existing failures: test_skips_self_edges,
test_save_to_db, test_full_pipeline — unchanged from PR #67/#71).
- tests/evals/chat_tutor.py — 15 cases (5 per mode) and 5 evaluators
(NonEmpty, SocraticEndsWithQuestion, ExpositoryHasStructure,
TeachBackProbes, NoToolMisuse). Cassettes recorded out-of-band.
Decisions:
- Scope split: only `chat` migrated this PR. start_session and action
share enough plumbing that bundling would have doubled the diff and
bundled three independent rollbacks. Follow-up PR.
- gemini_service.py NOT deleted — still alive as the chat fallback
target (and the quiz fallback). Separate PR removes it after the
agent path proves stable in production AND start_session/action get
migrated.
- Wire format unchanged: response still returns reply / graph_update /
mastery_changes; messages table schema untouched.
ADR 0015 captures the full rollback path and what we'd carry forward.
Note: original template numbered this 0014 but PR #77's adaptive-quiz
iteration claimed that slot, so this is 0015.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitaiBot commented May 5, 2026

Copy link
Copy Markdown

Caution

Review failed

Pull request was closed or merged during review

📝 Walkthrough

Walkthrough

This PR implements a comprehensive refactor of the chat tutor system, replacing a legacy Gemini-based multiturn chat with a typed Pydantic AI agent framework. The change introduces three mode-specific agents (socratic, expository, teachback) backed by on-demand data-access tools, integrates the new agent into the /chat route with graceful fallback to legacy behavior, and includes extensive test coverage and refactor documentation.

Changes

Chat Tutor Agent Refactor

Layer / File(s)Summary
Task & Provider Registration
backend/agents/_providers.py
chat_tutor is added to AgentTask Literal and mapped to gemini-2.5-pro in _DEFAULTS, with environment-variable override support via SAPLING_MODEL_CHAT_TUTOR.
Dependency Wiring
backend/agents/deps.py
SaplingDeps dataclass gains optional session_id: str | None = None field to scope tools to active chat sessions.
Agent Implementation
backend/agents/chat_tutor.py
Three mode-specific agents (socratic, expository, teachback) are constructed with distinct system prompts, per-mode SHA-256 prompt hashes for versioning, and a shared tool surface. agent_for_mode(mode) selects the agent instance, normalizing input and falling back to socratic for unknown/missing modes.
Data-Access Tools
backend/agents/tools/chat_context.py
Implements three async tools: search_course_materials_tool (keyword-ranked document search), read_session_history_tool (decrypted message retrieval), and read_user_progress_tool (mastery aggregation). Tool wrappers extract user_id/course_id/session_id from ctx.deps to enforce access control, while underlying functions handle decryption at the boundary, gracefully degrade failures to empty results, and filter out unusable rows.
Route & Request Handling
backend/routes/learn.py
The POST /api/learn/chat endpoint becomes async and agent-first: loads decrypted message history via _load_message_history(), attempts the new agent path, falls back to _legacy_chat() on guardrail/exception failures, and persists encrypted user/model messages. Model selection is unified via _resolve_model_pref() for the agent and _resolve_legacy_model() for fallback. Legacy routes (start_session, action) continue to use the legacy model resolver.
Unit & Integration Tests
backend/tests/test_chat_context_tools.py, backend/tests/test_chat_tutor_imports.py, backend/tests/test_learn_routes.py
Tool-level tests verify decryption/filtering/ranking, import smoke tests validate agent instantiation and tool registration, and route tests cover agent success, fallback paths, message encryption/decryption, and model-preference override behavior.
Evaluation Framework
backend/tests/evals/chat_tutor.py
Defines 15 fixed test cases (5 per mode) with mode-conditional evaluators enforcing reply length, ending-punctuation rules, and prohibition of raw tool names; integrates with the replay/record cassette system.
Documentation & Planning
backend/prompts/refactor-3-chat-tutor/..., docs/decisions/0015-refactor-3-chat-tutor-shipped.md
Comprehensive refactor orchestration plan, sub-agent task specs, ADR documenting shipped scope/decisions/consequences, and implementation guide for optional frontend SSE event wiring.

Sequence Diagram

sequenceDiagram
participant Client
participant Learn as learn.py<br/>POST /chat
participant Agent as chat_tutor_agent
participant Tools as chat_context tools
participant DB as Supabase
participant Legacy as _legacy_chat<br/>(fallback)
Client->>Learn: User message + mode
Learn->>DB: Load message history
DB-->>Learn: Encrypted messages
Learn->>Learn: Decrypt message history
Learn->>Agent: agent_for_mode(mode).run()
Agent->>Tools: search_course_materials_tool(query)
Tools->>DB: Fetch documents
DB-->>Tools: Encrypted results
Tools->>Tools: Decrypt, score, filter
Tools-->>Agent: Ranked materials
Agent->>Tools: read_session_history_tool()
Tools->>DB: Fetch messages
DB-->>Tools: Encrypted messages
Tools->>Tools: Decrypt, map roles, filter
Tools-->>Agent: Session history
Agent->>Tools: read_user_progress_tool()
Tools->>DB: Fetch mastery scores
DB-->>Tools: Raw scores
Tools->>Tools: Aggregate, clamp, bin
Tools-->>Agent: Progress summary
alt Agent succeeds
Agent-->>Learn: Reply string
Learn->>DB: Save encrypted messages
Learn-->>Client: Reply + empty graph_update
else Guardrail exception
Learn->>Legacy: _legacy_chat()
Legacy->>DB: Call legacy Gemini
DB-->>Legacy: Response
Legacy-->>Learn: Reply
Learn->>DB: Save messages
Learn-->>Client: Reply + empty graph_update
end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

This PR spans multiple interdependent subsystems (provider registration, agent implementation, tool layer, route integration) with diverse logic density: cryptographic boundary enforcement in tools, prompt-versioning via SHA-256 hashing, keyword-ranking algorithms, mode-based dispatch logic, message-role mapping with legacy fallback, and comprehensive fallback control flow in the route handler. The changes affect 15+ files across distinct concerns (configuration, agent/tool implementations, routes, tests, and documentation), and while individual tool implementations follow similar patterns (decrypt at boundary, filter, aggregate), each has distinct domain logic (scoring vs. decryption vs. aggregation). The test suite is substantial and heterogeneous, covering unit-level tool behavior, import assertions, route integration, and evaluation harness setup.

Possibly related PRs

  • SaplingLearn/Sapling#65: Implements message encryption/decryption primitives (encrypt_if_present, decrypt_if_present, decrypt_json) that this PR directly depends on for loading/decrypting message history and tool results.
  • SaplingLearn/Sapling#71: Extends the agent task framework in backend/agents/_providers.py for the quiz agent, establishing the same pattern (new AgentTask Literal value, _DEFAULTS mapping, environment-variable override) that this PR reuses for chat_tutor.
  • SaplingLearn/Sapling#67: Introduces the foundational Pydantic AI agent framework (task registration, SaplingDeps deps class, tool-wrapper patterns, event mapping) that this PR builds upon to create the chat tutor agents.

Poem

🐰 Hoppy hops with prompts so neat,
Three modes tutor—Socratic sweet,
Tools that search and track progress well,
Agent-first with fallback spell.
Legacy lanes become side streets,
Type-safe chat makes the heart beat! 💙

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 36.14% 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✅ PassedTitle clearly summarizes the main change: converting chat tutor to a Pydantic AI agent as part of refactor #3, with scope indicator.
Description check✅ PassedDescription comprehensively covers all required sections: Summary, What Shipped, Scope, What's NOT Included, References, Test Plan, and Rollback instructions.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch refactor/3-chat-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 and usage tips.


import asyncio
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented May 5, 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
frontendbe5b842Commit Preview URL

Branch Preview URL
May 05 2026, 03:21 AM

…ssion_id
Two correctness fixes from the self-review on PR #78.
1. Symmetric model defaults across agent + legacy paths.
When body.model_pref is None (the default), the legacy fallback used
to return MODEL_DEFAULT (gemini-2.5-flash) while the agent path
returned the agent's task-default (gemini-2.5-pro). A user with no
explicit pref silently downgraded from Pro -> Flash on fallback.
This is the same bug PR #71's commit a2fd5cd fixed for quiz; chat
now matches.
- routes/learn.py: _resolve_tutor_model -> _resolve_legacy_model.
Default fallback flipped from MODEL_DEFAULT to MODEL_SMART.
Comment block + three call sites (start_session, _legacy_chat,
action) updated.
- tests/test_learn_routes.py: TestResolveTutorModel ->
TestResolveLegacyModel. Four tests flipped their expected return
from MODEL_DEFAULT to MODEL_SMART to pin the new symmetric
contract. One new test (test_default_matches_agent_default_for_no_pref)
pins agent/legacy parity explicitly.
2. session_id is a declared field on SaplingDeps, not an attribute attach.
Before: routes/learn.py constructed SaplingDeps without session_id
and then did `deps.session_id = session_id # type: ignore`. The
tool wrapper read it via `getattr(..., None)`. Worked at runtime
(unfrozen dataclass), but type checkers couldn't validate it,
frozen=True would silently break it, and future SaplingDeps
consumers had no way to discover the seam.
- agents/deps.py: added `session_id: str | None = None` with a
docstring entry covering the eval/batch case (legitimately None).
- routes/learn.py: pass session_id via the constructor; removed
the imperative attach + the obsolete comment + the # type: ignore.
- agents/tools/chat_context.py: read_session_history_tool now does
a direct `ctx.deps.session_id` read; getattr ceremony gone.
- tests/test_chat_context_tools.py: missing-session test sets
session_id=None explicitly to mirror the declared default.
55 tests pass across the three touched test files. No regressions.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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.

1 participant

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

refactor(learn): convert chat tutor to chat_tutor_agent (refactor #3) - #78

Merged
Jose-Gael-Cruz-Lopez merged 2 commits into
mainfrom
refactor/3-chat-tutor
May 5, 2026
Merged

refactor(learn): convert chat tutor to chat_tutor_agent (refactor #3)#78
Jose-Gael-Cruz-Lopez merged 2 commits into
mainfrom
refactor/3-chat-tutor

Conversation

@Jose-Gael-Cruz-Lopez

@Jose-Gael-Cruz-LopezJose-Gael-Cruz-Lopez commented May 5, 2026

Copy link
Copy Markdown
Member

Summary

Third and final refactor named in the ADR 0001 migration plan: convert routes/learn.py::chat from build_system_prompt + call_gemini_multiturn to a typed Pydantic AI agent with three mode-specific instances and a four-tool surface. Same orchestrator-vs-legacy fallback pattern PR #67 (documents) and PR #71 (quiz) established. Wire format unchanged, encryption boundary preserved, legacy path intact per ADR 0001.

What shipped

  • backend/agents/chat_tutor.py — three Agent[SaplingDeps, str] instances built from a shared preamble + per-mode body. Prompt versions:

    ModeHash
    Socratic57f278a01d2d
    Expository8c840f43b6e2
    TeachBack70a34fb09224
  • backend/agents/tools/chat_context.py — three new context tools, all decryption-aware:

    • search_course_materials — keyword overlap on documents.summary + concept_notes
    • read_session_history — last N messages, messages.content decrypted at the boundary
    • read_user_progress — aggregates graph_nodes to mastered / weak / in-progress counts
  • backend/agents/_providers.py — added chat_tutor task slot, default gemini-2.5-pro (matches main's chat behavior post-PR Restore Google sign-in popup + add tutor Fast/Smart model toggle #73), env-var override SAPLING_MODEL_CHAT_TUTOR.

  • backend/routes/learn.py_chat_via_agent (new), _legacy_chat (preserved per ADR 0001), _load_message_history (Pydantic-AI ModelMessage adapter with decryption), _resolve_model_pref (mirrors quiz). chat migrated agent-first; start_session and action carry TODO(refactor-3 follow-up) comments and remain on the legacy path for this PR.

  • Teststest_chat_tutor_imports.py (5), test_chat_context_tools.py (15), test_learn_routes.py +10 new in TestChatViaAgent. 577 pass on this branch; 3 pre-existing live-Supabase failures (test_skips_self_edges, test_save_to_db, test_full_pipeline) unchanged from PR re-architecture: agentic document upload + AES-256-GCM column encryption + dev-context vault #67/refactor(quiz): convert generate_quiz to quiz_agent (refactor #2) #71. No regressions.

  • backend/tests/evals/chat_tutor.py — 15 cases (5 per mode) and 5 evaluators: NonEmpty, SocraticEndsWithQuestion, ExpositoryHasStructure, TeachBackProbes, NoToolMisuse.

  • docs/decisions/0015-refactor-3-chat-tutor-shipped.md — full ADR with surprises, consequences, rollback. Numbered 0015 because PR feat(quiz): adaptive iteration — spaced repetition + history + difficulty #77 (adaptive-quiz iteration) claimed 0014 between refactor Refine LLM Model selection for each function #2 ship and this work.

Scope split — flagged for review

This PR migrates only chat, not start_session or action. Both share enough plumbing with chat (same prompt assembly, same legacy call_gemini_multiturn call) that migrating all three would have doubled the route diff and bundled three independent rollback decisions. They carry TODO(refactor-3 follow-up) comments and remain on the legacy path. A follow-up PR will migrate them after the chat agent path proves stable in production.

What's NOT in this PR (per ADR 0001 migration contract)

  • services/gemini_service.py is NOT deleted. Still alive as the chat fallback target (and the quiz fallback). A separate small PR removes it after the agent path proves stable in production AND start_session / action get migrated.
  • Frontend Learn.tsx SSE wiring is NOT in this PR. That's sub-agent E and ships separately. The agent path uses non-streaming agent.run for now; streaming via run_stream_events is a follow-up.
  • Eval cassettes are NOT yet recorded. Cassettes get written on the next SAPLING_EVAL_MODE=record run; replay-mode CI continues to fail loudly when a cassette is missing, so neither the new nor existing cases silently no-op.

References

Test plan

  • pytest tests/test_chat_tutor_imports.py -q → 5 passed
  • pytest tests/test_chat_context_tools.py -q → 15 passed
  • pytest tests/test_learn_routes.py -q → 34 passed (24 prior + 10 new)
  • pytest tests/ -q --ignore=tests/evals → 577 passed, 3 pre-existing failures (unchanged)
  • Live-mode eval recording (SAPLING_EVAL_MODE=record python tests/evals/chat_tutor.py) — recommended before merge
  • Manual smoke: chat in each mode (Socratic / Expository / TeachBack) and confirm tool calls show up in Logfire
  • Latency check after ~50 chats — agent path may have higher round-trip count vs the legacy single multi-turn call; will measure in Logfire

Rollback

The legacy path is intact. Single revert of the merge commit drops the three new agent modules (chat_tutor.py, chat_context.py are pure-leaf), removes the chat_tutor task slot, reverts routes/learn.py::chat to the legacy path, and the messages table schema is unchanged so no migration to undo.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Introduced three distinct tutoring modes for the chat tutor: Socratic questioning, Expository teaching, and TeachBack learning techniques
    • Enhanced chat tutor with improved context awareness by integrating course materials, session history, and learner progress tracking for more personalized guidance

Closes the third refactor in the migration plan from ADR 0001 (and the
last one named in ADR 0005). The legacy `routes/learn.py::chat` path —
hand-built `build_system_prompt` + `services/gemini_service.py::call_gemini_multiturn`
— is replaced with three mode-specific Pydantic AI agents
(Socratic / Expository / TeachBack), each sharing the same four-tool
surface, with the established orchestrator-vs-legacy fallback contract.
What shipped:
- agents/chat_tutor.py — three Agent[SaplingDeps, str] instances built
from a shared preamble + per-mode body. Prompt versions:
Socratic 57f278a01d2d
Expository 8c840f43b6e2
TeachBack 70a34fb09224
- agents/tools/chat_context.py — three new tools:
search_course_materials, read_session_history, read_user_progress.
All decryption-aware (messages.content, documents.summary,
documents.concept_notes are encrypted at rest per CLAUDE.md).
- agents/_providers.py — chat_tutor task slot, default gemini-2.5-pro
(matches main's chat behavior post-PR #73), env override
SAPLING_MODEL_CHAT_TUTOR.
- routes/learn.py — _chat_via_agent (new), _legacy_chat (preserved per
ADR 0001), _load_message_history (Pydantic-AI ModelMessage adapter
with decryption), _resolve_model_pref (mirrors quiz). chat migrated
agent-first; start_session and action carry TODO(refactor-3 follow-up)
comments and remain on the legacy path for this PR.
- tests/test_chat_tutor_imports.py (5), test_chat_context_tools.py (15),
test_learn_routes.py +10 new in TestChatViaAgent class. 577 pass on
this branch (3 pre-existing failures: test_skips_self_edges,
test_save_to_db, test_full_pipeline — unchanged from PR #67/#71).
- tests/evals/chat_tutor.py — 15 cases (5 per mode) and 5 evaluators
(NonEmpty, SocraticEndsWithQuestion, ExpositoryHasStructure,
TeachBackProbes, NoToolMisuse). Cassettes recorded out-of-band.
Decisions:
- Scope split: only `chat` migrated this PR. start_session and action
share enough plumbing that bundling would have doubled the diff and
bundled three independent rollbacks. Follow-up PR.
- gemini_service.py NOT deleted — still alive as the chat fallback
target (and the quiz fallback). Separate PR removes it after the
agent path proves stable in production AND start_session/action get
migrated.
- Wire format unchanged: response still returns reply / graph_update /
mastery_changes; messages table schema untouched.
ADR 0015 captures the full rollback path and what we'd carry forward.
Note: original template numbered this 0014 but PR #77's adaptive-quiz
iteration claimed that slot, so this is 0015.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitaiBot commented May 5, 2026

Copy link
Copy Markdown

Caution

Review failed

Pull request was closed or merged during review

📝 Walkthrough

Walkthrough

This PR implements a comprehensive refactor of the chat tutor system, replacing a legacy Gemini-based multiturn chat with a typed Pydantic AI agent framework. The change introduces three mode-specific agents (socratic, expository, teachback) backed by on-demand data-access tools, integrates the new agent into the /chat route with graceful fallback to legacy behavior, and includes extensive test coverage and refactor documentation.

Changes

Chat Tutor Agent Refactor

Layer / File(s)Summary
Task & Provider Registration
backend/agents/_providers.py
chat_tutor is added to AgentTask Literal and mapped to gemini-2.5-pro in _DEFAULTS, with environment-variable override support via SAPLING_MODEL_CHAT_TUTOR.
Dependency Wiring
backend/agents/deps.py
SaplingDeps dataclass gains optional session_id: str | None = None field to scope tools to active chat sessions.
Agent Implementation
backend/agents/chat_tutor.py
Three mode-specific agents (socratic, expository, teachback) are constructed with distinct system prompts, per-mode SHA-256 prompt hashes for versioning, and a shared tool surface. agent_for_mode(mode) selects the agent instance, normalizing input and falling back to socratic for unknown/missing modes.
Data-Access Tools
backend/agents/tools/chat_context.py
Implements three async tools: search_course_materials_tool (keyword-ranked document search), read_session_history_tool (decrypted message retrieval), and read_user_progress_tool (mastery aggregation). Tool wrappers extract user_id/course_id/session_id from ctx.deps to enforce access control, while underlying functions handle decryption at the boundary, gracefully degrade failures to empty results, and filter out unusable rows.
Route & Request Handling
backend/routes/learn.py
The POST /api/learn/chat endpoint becomes async and agent-first: loads decrypted message history via _load_message_history(), attempts the new agent path, falls back to _legacy_chat() on guardrail/exception failures, and persists encrypted user/model messages. Model selection is unified via _resolve_model_pref() for the agent and _resolve_legacy_model() for fallback. Legacy routes (start_session, action) continue to use the legacy model resolver.
Unit & Integration Tests
backend/tests/test_chat_context_tools.py, backend/tests/test_chat_tutor_imports.py, backend/tests/test_learn_routes.py
Tool-level tests verify decryption/filtering/ranking, import smoke tests validate agent instantiation and tool registration, and route tests cover agent success, fallback paths, message encryption/decryption, and model-preference override behavior.
Evaluation Framework
backend/tests/evals/chat_tutor.py
Defines 15 fixed test cases (5 per mode) with mode-conditional evaluators enforcing reply length, ending-punctuation rules, and prohibition of raw tool names; integrates with the replay/record cassette system.
Documentation & Planning
backend/prompts/refactor-3-chat-tutor/..., docs/decisions/0015-refactor-3-chat-tutor-shipped.md
Comprehensive refactor orchestration plan, sub-agent task specs, ADR documenting shipped scope/decisions/consequences, and implementation guide for optional frontend SSE event wiring.

Sequence Diagram

sequenceDiagram
participant Client
participant Learn as learn.py<br/>POST /chat
participant Agent as chat_tutor_agent
participant Tools as chat_context tools
participant DB as Supabase
participant Legacy as _legacy_chat<br/>(fallback)
Client->>Learn: User message + mode
Learn->>DB: Load message history
DB-->>Learn: Encrypted messages
Learn->>Learn: Decrypt message history
Learn->>Agent: agent_for_mode(mode).run()
Agent->>Tools: search_course_materials_tool(query)
Tools->>DB: Fetch documents
DB-->>Tools: Encrypted results
Tools->>Tools: Decrypt, score, filter
Tools-->>Agent: Ranked materials
Agent->>Tools: read_session_history_tool()
Tools->>DB: Fetch messages
DB-->>Tools: Encrypted messages
Tools->>Tools: Decrypt, map roles, filter
Tools-->>Agent: Session history
Agent->>Tools: read_user_progress_tool()
Tools->>DB: Fetch mastery scores
DB-->>Tools: Raw scores
Tools->>Tools: Aggregate, clamp, bin
Tools-->>Agent: Progress summary
alt Agent succeeds
Agent-->>Learn: Reply string
Learn->>DB: Save encrypted messages
Learn-->>Client: Reply + empty graph_update
else Guardrail exception
Learn->>Legacy: _legacy_chat()
Legacy->>DB: Call legacy Gemini
DB-->>Legacy: Response
Legacy-->>Learn: Reply
Learn->>DB: Save messages
Learn-->>Client: Reply + empty graph_update
end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

This PR spans multiple interdependent subsystems (provider registration, agent implementation, tool layer, route integration) with diverse logic density: cryptographic boundary enforcement in tools, prompt-versioning via SHA-256 hashing, keyword-ranking algorithms, mode-based dispatch logic, message-role mapping with legacy fallback, and comprehensive fallback control flow in the route handler. The changes affect 15+ files across distinct concerns (configuration, agent/tool implementations, routes, tests, and documentation), and while individual tool implementations follow similar patterns (decrypt at boundary, filter, aggregate), each has distinct domain logic (scoring vs. decryption vs. aggregation). The test suite is substantial and heterogeneous, covering unit-level tool behavior, import assertions, route integration, and evaluation harness setup.

Possibly related PRs

  • SaplingLearn/Sapling#65: Implements message encryption/decryption primitives (encrypt_if_present, decrypt_if_present, decrypt_json) that this PR directly depends on for loading/decrypting message history and tool results.
  • SaplingLearn/Sapling#71: Extends the agent task framework in backend/agents/_providers.py for the quiz agent, establishing the same pattern (new AgentTask Literal value, _DEFAULTS mapping, environment-variable override) that this PR reuses for chat_tutor.
  • SaplingLearn/Sapling#67: Introduces the foundational Pydantic AI agent framework (task registration, SaplingDeps deps class, tool-wrapper patterns, event mapping) that this PR builds upon to create the chat tutor agents.

Poem

🐰 Hoppy hops with prompts so neat,
Three modes tutor—Socratic sweet,
Tools that search and track progress well,
Agent-first with fallback spell.
Legacy lanes become side streets,
Type-safe chat makes the heart beat! 💙

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 36.14% 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✅ PassedTitle clearly summarizes the main change: converting chat tutor to a Pydantic AI agent as part of refactor #3, with scope indicator.
Description check✅ PassedDescription comprehensively covers all required sections: Summary, What Shipped, Scope, What's NOT Included, References, Test Plan, and Rollback instructions.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch refactor/3-chat-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 and usage tips.


import asyncio
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented May 5, 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
frontendbe5b842Commit Preview URL

Branch Preview URL
May 05 2026, 03:21 AM

…ssion_id
Two correctness fixes from the self-review on PR #78.
1. Symmetric model defaults across agent + legacy paths.
When body.model_pref is None (the default), the legacy fallback used
to return MODEL_DEFAULT (gemini-2.5-flash) while the agent path
returned the agent's task-default (gemini-2.5-pro). A user with no
explicit pref silently downgraded from Pro -> Flash on fallback.
This is the same bug PR #71's commit a2fd5cd fixed for quiz; chat
now matches.
- routes/learn.py: _resolve_tutor_model -> _resolve_legacy_model.
Default fallback flipped from MODEL_DEFAULT to MODEL_SMART.
Comment block + three call sites (start_session, _legacy_chat,
action) updated.
- tests/test_learn_routes.py: TestResolveTutorModel ->
TestResolveLegacyModel. Four tests flipped their expected return
from MODEL_DEFAULT to MODEL_SMART to pin the new symmetric
contract. One new test (test_default_matches_agent_default_for_no_pref)
pins agent/legacy parity explicitly.
2. session_id is a declared field on SaplingDeps, not an attribute attach.
Before: routes/learn.py constructed SaplingDeps without session_id
and then did `deps.session_id = session_id # type: ignore`. The
tool wrapper read it via `getattr(..., None)`. Worked at runtime
(unfrozen dataclass), but type checkers couldn't validate it,
frozen=True would silently break it, and future SaplingDeps
consumers had no way to discover the seam.
- agents/deps.py: added `session_id: str | None = None` with a
docstring entry covering the eval/batch case (legitimately None).
- routes/learn.py: pass session_id via the constructor; removed
the imperative attach + the obsolete comment + the # type: ignore.
- agents/tools/chat_context.py: read_session_history_tool now does
a direct `ctx.deps.session_id` read; getattr ceremony gone.
- tests/test_chat_context_tools.py: missing-session test sets
session_id=None explicitly to mirror the declared default.
55 tests pass across the three touched test files. No regressions.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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.

1 participant

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

refactor(learn): convert chat tutor to chat_tutor_agent (refactor #3) - #78

Merged
Jose-Gael-Cruz-Lopez merged 2 commits into
mainfrom
refactor/3-chat-tutor
May 5, 2026
Merged

refactor(learn): convert chat tutor to chat_tutor_agent (refactor #3)#78
Jose-Gael-Cruz-Lopez merged 2 commits into
mainfrom
refactor/3-chat-tutor

Conversation

@Jose-Gael-Cruz-Lopez

@Jose-Gael-Cruz-LopezJose-Gael-Cruz-Lopez commented May 5, 2026

Copy link
Copy Markdown
Member

Summary

Third and final refactor named in the ADR 0001 migration plan: convert routes/learn.py::chat from build_system_prompt + call_gemini_multiturn to a typed Pydantic AI agent with three mode-specific instances and a four-tool surface. Same orchestrator-vs-legacy fallback pattern PR #67 (documents) and PR #71 (quiz) established. Wire format unchanged, encryption boundary preserved, legacy path intact per ADR 0001.

What shipped

  • backend/agents/chat_tutor.py — three Agent[SaplingDeps, str] instances built from a shared preamble + per-mode body. Prompt versions:

    ModeHash
    Socratic57f278a01d2d
    Expository8c840f43b6e2
    TeachBack70a34fb09224
  • backend/agents/tools/chat_context.py — three new context tools, all decryption-aware:

    • search_course_materials — keyword overlap on documents.summary + concept_notes
    • read_session_history — last N messages, messages.content decrypted at the boundary
    • read_user_progress — aggregates graph_nodes to mastered / weak / in-progress counts
  • backend/agents/_providers.py — added chat_tutor task slot, default gemini-2.5-pro (matches main's chat behavior post-PR Restore Google sign-in popup + add tutor Fast/Smart model toggle #73), env-var override SAPLING_MODEL_CHAT_TUTOR.

  • backend/routes/learn.py_chat_via_agent (new), _legacy_chat (preserved per ADR 0001), _load_message_history (Pydantic-AI ModelMessage adapter with decryption), _resolve_model_pref (mirrors quiz). chat migrated agent-first; start_session and action carry TODO(refactor-3 follow-up) comments and remain on the legacy path for this PR.

  • Teststest_chat_tutor_imports.py (5), test_chat_context_tools.py (15), test_learn_routes.py +10 new in TestChatViaAgent. 577 pass on this branch; 3 pre-existing live-Supabase failures (test_skips_self_edges, test_save_to_db, test_full_pipeline) unchanged from PR re-architecture: agentic document upload + AES-256-GCM column encryption + dev-context vault #67/refactor(quiz): convert generate_quiz to quiz_agent (refactor #2) #71. No regressions.

  • backend/tests/evals/chat_tutor.py — 15 cases (5 per mode) and 5 evaluators: NonEmpty, SocraticEndsWithQuestion, ExpositoryHasStructure, TeachBackProbes, NoToolMisuse.

  • docs/decisions/0015-refactor-3-chat-tutor-shipped.md — full ADR with surprises, consequences, rollback. Numbered 0015 because PR feat(quiz): adaptive iteration — spaced repetition + history + difficulty #77 (adaptive-quiz iteration) claimed 0014 between refactor Refine LLM Model selection for each function #2 ship and this work.

Scope split — flagged for review

This PR migrates only chat, not start_session or action. Both share enough plumbing with chat (same prompt assembly, same legacy call_gemini_multiturn call) that migrating all three would have doubled the route diff and bundled three independent rollback decisions. They carry TODO(refactor-3 follow-up) comments and remain on the legacy path. A follow-up PR will migrate them after the chat agent path proves stable in production.

What's NOT in this PR (per ADR 0001 migration contract)

  • services/gemini_service.py is NOT deleted. Still alive as the chat fallback target (and the quiz fallback). A separate small PR removes it after the agent path proves stable in production AND start_session / action get migrated.
  • Frontend Learn.tsx SSE wiring is NOT in this PR. That's sub-agent E and ships separately. The agent path uses non-streaming agent.run for now; streaming via run_stream_events is a follow-up.
  • Eval cassettes are NOT yet recorded. Cassettes get written on the next SAPLING_EVAL_MODE=record run; replay-mode CI continues to fail loudly when a cassette is missing, so neither the new nor existing cases silently no-op.

References

Test plan

  • pytest tests/test_chat_tutor_imports.py -q → 5 passed
  • pytest tests/test_chat_context_tools.py -q → 15 passed
  • pytest tests/test_learn_routes.py -q → 34 passed (24 prior + 10 new)
  • pytest tests/ -q --ignore=tests/evals → 577 passed, 3 pre-existing failures (unchanged)
  • Live-mode eval recording (SAPLING_EVAL_MODE=record python tests/evals/chat_tutor.py) — recommended before merge
  • Manual smoke: chat in each mode (Socratic / Expository / TeachBack) and confirm tool calls show up in Logfire
  • Latency check after ~50 chats — agent path may have higher round-trip count vs the legacy single multi-turn call; will measure in Logfire

Rollback

The legacy path is intact. Single revert of the merge commit drops the three new agent modules (chat_tutor.py, chat_context.py are pure-leaf), removes the chat_tutor task slot, reverts routes/learn.py::chat to the legacy path, and the messages table schema is unchanged so no migration to undo.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Introduced three distinct tutoring modes for the chat tutor: Socratic questioning, Expository teaching, and TeachBack learning techniques
    • Enhanced chat tutor with improved context awareness by integrating course materials, session history, and learner progress tracking for more personalized guidance

Closes the third refactor in the migration plan from ADR 0001 (and the
last one named in ADR 0005). The legacy `routes/learn.py::chat` path —
hand-built `build_system_prompt` + `services/gemini_service.py::call_gemini_multiturn`
— is replaced with three mode-specific Pydantic AI agents
(Socratic / Expository / TeachBack), each sharing the same four-tool
surface, with the established orchestrator-vs-legacy fallback contract.
What shipped:
- agents/chat_tutor.py — three Agent[SaplingDeps, str] instances built
from a shared preamble + per-mode body. Prompt versions:
Socratic 57f278a01d2d
Expository 8c840f43b6e2
TeachBack 70a34fb09224
- agents/tools/chat_context.py — three new tools:
search_course_materials, read_session_history, read_user_progress.
All decryption-aware (messages.content, documents.summary,
documents.concept_notes are encrypted at rest per CLAUDE.md).
- agents/_providers.py — chat_tutor task slot, default gemini-2.5-pro
(matches main's chat behavior post-PR #73), env override
SAPLING_MODEL_CHAT_TUTOR.
- routes/learn.py — _chat_via_agent (new), _legacy_chat (preserved per
ADR 0001), _load_message_history (Pydantic-AI ModelMessage adapter
with decryption), _resolve_model_pref (mirrors quiz). chat migrated
agent-first; start_session and action carry TODO(refactor-3 follow-up)
comments and remain on the legacy path for this PR.
- tests/test_chat_tutor_imports.py (5), test_chat_context_tools.py (15),
test_learn_routes.py +10 new in TestChatViaAgent class. 577 pass on
this branch (3 pre-existing failures: test_skips_self_edges,
test_save_to_db, test_full_pipeline — unchanged from PR #67/#71).
- tests/evals/chat_tutor.py — 15 cases (5 per mode) and 5 evaluators
(NonEmpty, SocraticEndsWithQuestion, ExpositoryHasStructure,
TeachBackProbes, NoToolMisuse). Cassettes recorded out-of-band.
Decisions:
- Scope split: only `chat` migrated this PR. start_session and action
share enough plumbing that bundling would have doubled the diff and
bundled three independent rollbacks. Follow-up PR.
- gemini_service.py NOT deleted — still alive as the chat fallback
target (and the quiz fallback). Separate PR removes it after the
agent path proves stable in production AND start_session/action get
migrated.
- Wire format unchanged: response still returns reply / graph_update /
mastery_changes; messages table schema untouched.
ADR 0015 captures the full rollback path and what we'd carry forward.
Note: original template numbered this 0014 but PR #77's adaptive-quiz
iteration claimed that slot, so this is 0015.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitaiBot commented May 5, 2026

Copy link
Copy Markdown

Caution

Review failed

Pull request was closed or merged during review

📝 Walkthrough

Walkthrough

This PR implements a comprehensive refactor of the chat tutor system, replacing a legacy Gemini-based multiturn chat with a typed Pydantic AI agent framework. The change introduces three mode-specific agents (socratic, expository, teachback) backed by on-demand data-access tools, integrates the new agent into the /chat route with graceful fallback to legacy behavior, and includes extensive test coverage and refactor documentation.

Changes

Chat Tutor Agent Refactor

Layer / File(s)Summary
Task & Provider Registration
backend/agents/_providers.py
chat_tutor is added to AgentTask Literal and mapped to gemini-2.5-pro in _DEFAULTS, with environment-variable override support via SAPLING_MODEL_CHAT_TUTOR.
Dependency Wiring
backend/agents/deps.py
SaplingDeps dataclass gains optional session_id: str | None = None field to scope tools to active chat sessions.
Agent Implementation
backend/agents/chat_tutor.py
Three mode-specific agents (socratic, expository, teachback) are constructed with distinct system prompts, per-mode SHA-256 prompt hashes for versioning, and a shared tool surface. agent_for_mode(mode) selects the agent instance, normalizing input and falling back to socratic for unknown/missing modes.
Data-Access Tools
backend/agents/tools/chat_context.py
Implements three async tools: search_course_materials_tool (keyword-ranked document search), read_session_history_tool (decrypted message retrieval), and read_user_progress_tool (mastery aggregation). Tool wrappers extract user_id/course_id/session_id from ctx.deps to enforce access control, while underlying functions handle decryption at the boundary, gracefully degrade failures to empty results, and filter out unusable rows.
Route & Request Handling
backend/routes/learn.py
The POST /api/learn/chat endpoint becomes async and agent-first: loads decrypted message history via _load_message_history(), attempts the new agent path, falls back to _legacy_chat() on guardrail/exception failures, and persists encrypted user/model messages. Model selection is unified via _resolve_model_pref() for the agent and _resolve_legacy_model() for fallback. Legacy routes (start_session, action) continue to use the legacy model resolver.
Unit & Integration Tests
backend/tests/test_chat_context_tools.py, backend/tests/test_chat_tutor_imports.py, backend/tests/test_learn_routes.py
Tool-level tests verify decryption/filtering/ranking, import smoke tests validate agent instantiation and tool registration, and route tests cover agent success, fallback paths, message encryption/decryption, and model-preference override behavior.
Evaluation Framework
backend/tests/evals/chat_tutor.py
Defines 15 fixed test cases (5 per mode) with mode-conditional evaluators enforcing reply length, ending-punctuation rules, and prohibition of raw tool names; integrates with the replay/record cassette system.
Documentation & Planning
backend/prompts/refactor-3-chat-tutor/..., docs/decisions/0015-refactor-3-chat-tutor-shipped.md
Comprehensive refactor orchestration plan, sub-agent task specs, ADR documenting shipped scope/decisions/consequences, and implementation guide for optional frontend SSE event wiring.

Sequence Diagram

sequenceDiagram
participant Client
participant Learn as learn.py<br/>POST /chat
participant Agent as chat_tutor_agent
participant Tools as chat_context tools
participant DB as Supabase
participant Legacy as _legacy_chat<br/>(fallback)
Client->>Learn: User message + mode
Learn->>DB: Load message history
DB-->>Learn: Encrypted messages
Learn->>Learn: Decrypt message history
Learn->>Agent: agent_for_mode(mode).run()
Agent->>Tools: search_course_materials_tool(query)
Tools->>DB: Fetch documents
DB-->>Tools: Encrypted results
Tools->>Tools: Decrypt, score, filter
Tools-->>Agent: Ranked materials
Agent->>Tools: read_session_history_tool()
Tools->>DB: Fetch messages
DB-->>Tools: Encrypted messages
Tools->>Tools: Decrypt, map roles, filter
Tools-->>Agent: Session history
Agent->>Tools: read_user_progress_tool()
Tools->>DB: Fetch mastery scores
DB-->>Tools: Raw scores
Tools->>Tools: Aggregate, clamp, bin
Tools-->>Agent: Progress summary
alt Agent succeeds
Agent-->>Learn: Reply string
Learn->>DB: Save encrypted messages
Learn-->>Client: Reply + empty graph_update
else Guardrail exception
Learn->>Legacy: _legacy_chat()
Legacy->>DB: Call legacy Gemini
DB-->>Legacy: Response
Legacy-->>Learn: Reply
Learn->>DB: Save messages
Learn-->>Client: Reply + empty graph_update
end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

This PR spans multiple interdependent subsystems (provider registration, agent implementation, tool layer, route integration) with diverse logic density: cryptographic boundary enforcement in tools, prompt-versioning via SHA-256 hashing, keyword-ranking algorithms, mode-based dispatch logic, message-role mapping with legacy fallback, and comprehensive fallback control flow in the route handler. The changes affect 15+ files across distinct concerns (configuration, agent/tool implementations, routes, tests, and documentation), and while individual tool implementations follow similar patterns (decrypt at boundary, filter, aggregate), each has distinct domain logic (scoring vs. decryption vs. aggregation). The test suite is substantial and heterogeneous, covering unit-level tool behavior, import assertions, route integration, and evaluation harness setup.

Possibly related PRs

  • SaplingLearn/Sapling#65: Implements message encryption/decryption primitives (encrypt_if_present, decrypt_if_present, decrypt_json) that this PR directly depends on for loading/decrypting message history and tool results.
  • SaplingLearn/Sapling#71: Extends the agent task framework in backend/agents/_providers.py for the quiz agent, establishing the same pattern (new AgentTask Literal value, _DEFAULTS mapping, environment-variable override) that this PR reuses for chat_tutor.
  • SaplingLearn/Sapling#67: Introduces the foundational Pydantic AI agent framework (task registration, SaplingDeps deps class, tool-wrapper patterns, event mapping) that this PR builds upon to create the chat tutor agents.

Poem

🐰 Hoppy hops with prompts so neat,
Three modes tutor—Socratic sweet,
Tools that search and track progress well,
Agent-first with fallback spell.
Legacy lanes become side streets,
Type-safe chat makes the heart beat! 💙

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 36.14% 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✅ PassedTitle clearly summarizes the main change: converting chat tutor to a Pydantic AI agent as part of refactor #3, with scope indicator.
Description check✅ PassedDescription comprehensively covers all required sections: Summary, What Shipped, Scope, What's NOT Included, References, Test Plan, and Rollback instructions.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch refactor/3-chat-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 and usage tips.


import asyncio
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented May 5, 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
frontendbe5b842Commit Preview URL

Branch Preview URL
May 05 2026, 03:21 AM

…ssion_id
Two correctness fixes from the self-review on PR #78.
1. Symmetric model defaults across agent + legacy paths.
When body.model_pref is None (the default), the legacy fallback used
to return MODEL_DEFAULT (gemini-2.5-flash) while the agent path
returned the agent's task-default (gemini-2.5-pro). A user with no
explicit pref silently downgraded from Pro -> Flash on fallback.
This is the same bug PR #71's commit a2fd5cd fixed for quiz; chat
now matches.
- routes/learn.py: _resolve_tutor_model -> _resolve_legacy_model.
Default fallback flipped from MODEL_DEFAULT to MODEL_SMART.
Comment block + three call sites (start_session, _legacy_chat,
action) updated.
- tests/test_learn_routes.py: TestResolveTutorModel ->
TestResolveLegacyModel. Four tests flipped their expected return
from MODEL_DEFAULT to MODEL_SMART to pin the new symmetric
contract. One new test (test_default_matches_agent_default_for_no_pref)
pins agent/legacy parity explicitly.
2. session_id is a declared field on SaplingDeps, not an attribute attach.
Before: routes/learn.py constructed SaplingDeps without session_id
and then did `deps.session_id = session_id # type: ignore`. The
tool wrapper read it via `getattr(..., None)`. Worked at runtime
(unfrozen dataclass), but type checkers couldn't validate it,
frozen=True would silently break it, and future SaplingDeps
consumers had no way to discover the seam.
- agents/deps.py: added `session_id: str | None = None` with a
docstring entry covering the eval/batch case (legitimately None).
- routes/learn.py: pass session_id via the constructor; removed
the imperative attach + the obsolete comment + the # type: ignore.
- agents/tools/chat_context.py: read_session_history_tool now does
a direct `ctx.deps.session_id` read; getattr ceremony gone.
- tests/test_chat_context_tools.py: missing-session test sets
session_id=None explicitly to mirror the declared default.
55 tests pass across the three touched test files. No regressions.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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.

1 participant

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

refactor(learn): convert chat tutor to chat_tutor_agent (refactor #3) - #78

Merged
Jose-Gael-Cruz-Lopez merged 2 commits into
mainfrom
refactor/3-chat-tutor
May 5, 2026
Merged

refactor(learn): convert chat tutor to chat_tutor_agent (refactor #3)#78
Jose-Gael-Cruz-Lopez merged 2 commits into
mainfrom
refactor/3-chat-tutor

Conversation

@Jose-Gael-Cruz-Lopez

@Jose-Gael-Cruz-LopezJose-Gael-Cruz-Lopez commented May 5, 2026

Copy link
Copy Markdown
Member

Summary

Third and final refactor named in the ADR 0001 migration plan: convert routes/learn.py::chat from build_system_prompt + call_gemini_multiturn to a typed Pydantic AI agent with three mode-specific instances and a four-tool surface. Same orchestrator-vs-legacy fallback pattern PR #67 (documents) and PR #71 (quiz) established. Wire format unchanged, encryption boundary preserved, legacy path intact per ADR 0001.

What shipped

  • backend/agents/chat_tutor.py — three Agent[SaplingDeps, str] instances built from a shared preamble + per-mode body. Prompt versions:

    ModeHash
    Socratic57f278a01d2d
    Expository8c840f43b6e2
    TeachBack70a34fb09224
  • backend/agents/tools/chat_context.py — three new context tools, all decryption-aware:

    • search_course_materials — keyword overlap on documents.summary + concept_notes
    • read_session_history — last N messages, messages.content decrypted at the boundary
    • read_user_progress — aggregates graph_nodes to mastered / weak / in-progress counts
  • backend/agents/_providers.py — added chat_tutor task slot, default gemini-2.5-pro (matches main's chat behavior post-PR Restore Google sign-in popup + add tutor Fast/Smart model toggle #73), env-var override SAPLING_MODEL_CHAT_TUTOR.

  • backend/routes/learn.py_chat_via_agent (new), _legacy_chat (preserved per ADR 0001), _load_message_history (Pydantic-AI ModelMessage adapter with decryption), _resolve_model_pref (mirrors quiz). chat migrated agent-first; start_session and action carry TODO(refactor-3 follow-up) comments and remain on the legacy path for this PR.

  • Teststest_chat_tutor_imports.py (5), test_chat_context_tools.py (15), test_learn_routes.py +10 new in TestChatViaAgent. 577 pass on this branch; 3 pre-existing live-Supabase failures (test_skips_self_edges, test_save_to_db, test_full_pipeline) unchanged from PR re-architecture: agentic document upload + AES-256-GCM column encryption + dev-context vault #67/refactor(quiz): convert generate_quiz to quiz_agent (refactor #2) #71. No regressions.

  • backend/tests/evals/chat_tutor.py — 15 cases (5 per mode) and 5 evaluators: NonEmpty, SocraticEndsWithQuestion, ExpositoryHasStructure, TeachBackProbes, NoToolMisuse.

  • docs/decisions/0015-refactor-3-chat-tutor-shipped.md — full ADR with surprises, consequences, rollback. Numbered 0015 because PR feat(quiz): adaptive iteration — spaced repetition + history + difficulty #77 (adaptive-quiz iteration) claimed 0014 between refactor Refine LLM Model selection for each function #2 ship and this work.

Scope split — flagged for review

This PR migrates only chat, not start_session or action. Both share enough plumbing with chat (same prompt assembly, same legacy call_gemini_multiturn call) that migrating all three would have doubled the route diff and bundled three independent rollback decisions. They carry TODO(refactor-3 follow-up) comments and remain on the legacy path. A follow-up PR will migrate them after the chat agent path proves stable in production.

What's NOT in this PR (per ADR 0001 migration contract)

  • services/gemini_service.py is NOT deleted. Still alive as the chat fallback target (and the quiz fallback). A separate small PR removes it after the agent path proves stable in production AND start_session / action get migrated.
  • Frontend Learn.tsx SSE wiring is NOT in this PR. That's sub-agent E and ships separately. The agent path uses non-streaming agent.run for now; streaming via run_stream_events is a follow-up.
  • Eval cassettes are NOT yet recorded. Cassettes get written on the next SAPLING_EVAL_MODE=record run; replay-mode CI continues to fail loudly when a cassette is missing, so neither the new nor existing cases silently no-op.

References

Test plan

  • pytest tests/test_chat_tutor_imports.py -q → 5 passed
  • pytest tests/test_chat_context_tools.py -q → 15 passed
  • pytest tests/test_learn_routes.py -q → 34 passed (24 prior + 10 new)
  • pytest tests/ -q --ignore=tests/evals → 577 passed, 3 pre-existing failures (unchanged)
  • Live-mode eval recording (SAPLING_EVAL_MODE=record python tests/evals/chat_tutor.py) — recommended before merge
  • Manual smoke: chat in each mode (Socratic / Expository / TeachBack) and confirm tool calls show up in Logfire
  • Latency check after ~50 chats — agent path may have higher round-trip count vs the legacy single multi-turn call; will measure in Logfire

Rollback

The legacy path is intact. Single revert of the merge commit drops the three new agent modules (chat_tutor.py, chat_context.py are pure-leaf), removes the chat_tutor task slot, reverts routes/learn.py::chat to the legacy path, and the messages table schema is unchanged so no migration to undo.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Introduced three distinct tutoring modes for the chat tutor: Socratic questioning, Expository teaching, and TeachBack learning techniques
    • Enhanced chat tutor with improved context awareness by integrating course materials, session history, and learner progress tracking for more personalized guidance

Closes the third refactor in the migration plan from ADR 0001 (and the
last one named in ADR 0005). The legacy `routes/learn.py::chat` path —
hand-built `build_system_prompt` + `services/gemini_service.py::call_gemini_multiturn`
— is replaced with three mode-specific Pydantic AI agents
(Socratic / Expository / TeachBack), each sharing the same four-tool
surface, with the established orchestrator-vs-legacy fallback contract.
What shipped:
- agents/chat_tutor.py — three Agent[SaplingDeps, str] instances built
from a shared preamble + per-mode body. Prompt versions:
Socratic 57f278a01d2d
Expository 8c840f43b6e2
TeachBack 70a34fb09224
- agents/tools/chat_context.py — three new tools:
search_course_materials, read_session_history, read_user_progress.
All decryption-aware (messages.content, documents.summary,
documents.concept_notes are encrypted at rest per CLAUDE.md).
- agents/_providers.py — chat_tutor task slot, default gemini-2.5-pro
(matches main's chat behavior post-PR #73), env override
SAPLING_MODEL_CHAT_TUTOR.
- routes/learn.py — _chat_via_agent (new), _legacy_chat (preserved per
ADR 0001), _load_message_history (Pydantic-AI ModelMessage adapter
with decryption), _resolve_model_pref (mirrors quiz). chat migrated
agent-first; start_session and action carry TODO(refactor-3 follow-up)
comments and remain on the legacy path for this PR.
- tests/test_chat_tutor_imports.py (5), test_chat_context_tools.py (15),
test_learn_routes.py +10 new in TestChatViaAgent class. 577 pass on
this branch (3 pre-existing failures: test_skips_self_edges,
test_save_to_db, test_full_pipeline — unchanged from PR #67/#71).
- tests/evals/chat_tutor.py — 15 cases (5 per mode) and 5 evaluators
(NonEmpty, SocraticEndsWithQuestion, ExpositoryHasStructure,
TeachBackProbes, NoToolMisuse). Cassettes recorded out-of-band.
Decisions:
- Scope split: only `chat` migrated this PR. start_session and action
share enough plumbing that bundling would have doubled the diff and
bundled three independent rollbacks. Follow-up PR.
- gemini_service.py NOT deleted — still alive as the chat fallback
target (and the quiz fallback). Separate PR removes it after the
agent path proves stable in production AND start_session/action get
migrated.
- Wire format unchanged: response still returns reply / graph_update /
mastery_changes; messages table schema untouched.
ADR 0015 captures the full rollback path and what we'd carry forward.
Note: original template numbered this 0014 but PR #77's adaptive-quiz
iteration claimed that slot, so this is 0015.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitaiBot commented May 5, 2026

Copy link
Copy Markdown

Caution

Review failed

Pull request was closed or merged during review

📝 Walkthrough

Walkthrough

This PR implements a comprehensive refactor of the chat tutor system, replacing a legacy Gemini-based multiturn chat with a typed Pydantic AI agent framework. The change introduces three mode-specific agents (socratic, expository, teachback) backed by on-demand data-access tools, integrates the new agent into the /chat route with graceful fallback to legacy behavior, and includes extensive test coverage and refactor documentation.

Changes

Chat Tutor Agent Refactor

Layer / File(s)Summary
Task & Provider Registration
backend/agents/_providers.py
chat_tutor is added to AgentTask Literal and mapped to gemini-2.5-pro in _DEFAULTS, with environment-variable override support via SAPLING_MODEL_CHAT_TUTOR.
Dependency Wiring
backend/agents/deps.py
SaplingDeps dataclass gains optional session_id: str | None = None field to scope tools to active chat sessions.
Agent Implementation
backend/agents/chat_tutor.py
Three mode-specific agents (socratic, expository, teachback) are constructed with distinct system prompts, per-mode SHA-256 prompt hashes for versioning, and a shared tool surface. agent_for_mode(mode) selects the agent instance, normalizing input and falling back to socratic for unknown/missing modes.
Data-Access Tools
backend/agents/tools/chat_context.py
Implements three async tools: search_course_materials_tool (keyword-ranked document search), read_session_history_tool (decrypted message retrieval), and read_user_progress_tool (mastery aggregation). Tool wrappers extract user_id/course_id/session_id from ctx.deps to enforce access control, while underlying functions handle decryption at the boundary, gracefully degrade failures to empty results, and filter out unusable rows.
Route & Request Handling
backend/routes/learn.py
The POST /api/learn/chat endpoint becomes async and agent-first: loads decrypted message history via _load_message_history(), attempts the new agent path, falls back to _legacy_chat() on guardrail/exception failures, and persists encrypted user/model messages. Model selection is unified via _resolve_model_pref() for the agent and _resolve_legacy_model() for fallback. Legacy routes (start_session, action) continue to use the legacy model resolver.
Unit & Integration Tests
backend/tests/test_chat_context_tools.py, backend/tests/test_chat_tutor_imports.py, backend/tests/test_learn_routes.py
Tool-level tests verify decryption/filtering/ranking, import smoke tests validate agent instantiation and tool registration, and route tests cover agent success, fallback paths, message encryption/decryption, and model-preference override behavior.
Evaluation Framework
backend/tests/evals/chat_tutor.py
Defines 15 fixed test cases (5 per mode) with mode-conditional evaluators enforcing reply length, ending-punctuation rules, and prohibition of raw tool names; integrates with the replay/record cassette system.
Documentation & Planning
backend/prompts/refactor-3-chat-tutor/..., docs/decisions/0015-refactor-3-chat-tutor-shipped.md
Comprehensive refactor orchestration plan, sub-agent task specs, ADR documenting shipped scope/decisions/consequences, and implementation guide for optional frontend SSE event wiring.

Sequence Diagram

sequenceDiagram
participant Client
participant Learn as learn.py<br/>POST /chat
participant Agent as chat_tutor_agent
participant Tools as chat_context tools
participant DB as Supabase
participant Legacy as _legacy_chat<br/>(fallback)
Client->>Learn: User message + mode
Learn->>DB: Load message history
DB-->>Learn: Encrypted messages
Learn->>Learn: Decrypt message history
Learn->>Agent: agent_for_mode(mode).run()
Agent->>Tools: search_course_materials_tool(query)
Tools->>DB: Fetch documents
DB-->>Tools: Encrypted results
Tools->>Tools: Decrypt, score, filter
Tools-->>Agent: Ranked materials
Agent->>Tools: read_session_history_tool()
Tools->>DB: Fetch messages
DB-->>Tools: Encrypted messages
Tools->>Tools: Decrypt, map roles, filter
Tools-->>Agent: Session history
Agent->>Tools: read_user_progress_tool()
Tools->>DB: Fetch mastery scores
DB-->>Tools: Raw scores
Tools->>Tools: Aggregate, clamp, bin
Tools-->>Agent: Progress summary
alt Agent succeeds
Agent-->>Learn: Reply string
Learn->>DB: Save encrypted messages
Learn-->>Client: Reply + empty graph_update
else Guardrail exception
Learn->>Legacy: _legacy_chat()
Legacy->>DB: Call legacy Gemini
DB-->>Legacy: Response
Legacy-->>Learn: Reply
Learn->>DB: Save messages
Learn-->>Client: Reply + empty graph_update
end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

This PR spans multiple interdependent subsystems (provider registration, agent implementation, tool layer, route integration) with diverse logic density: cryptographic boundary enforcement in tools, prompt-versioning via SHA-256 hashing, keyword-ranking algorithms, mode-based dispatch logic, message-role mapping with legacy fallback, and comprehensive fallback control flow in the route handler. The changes affect 15+ files across distinct concerns (configuration, agent/tool implementations, routes, tests, and documentation), and while individual tool implementations follow similar patterns (decrypt at boundary, filter, aggregate), each has distinct domain logic (scoring vs. decryption vs. aggregation). The test suite is substantial and heterogeneous, covering unit-level tool behavior, import assertions, route integration, and evaluation harness setup.

Possibly related PRs

  • SaplingLearn/Sapling#65: Implements message encryption/decryption primitives (encrypt_if_present, decrypt_if_present, decrypt_json) that this PR directly depends on for loading/decrypting message history and tool results.
  • SaplingLearn/Sapling#71: Extends the agent task framework in backend/agents/_providers.py for the quiz agent, establishing the same pattern (new AgentTask Literal value, _DEFAULTS mapping, environment-variable override) that this PR reuses for chat_tutor.
  • SaplingLearn/Sapling#67: Introduces the foundational Pydantic AI agent framework (task registration, SaplingDeps deps class, tool-wrapper patterns, event mapping) that this PR builds upon to create the chat tutor agents.

Poem

🐰 Hoppy hops with prompts so neat,
Three modes tutor—Socratic sweet,
Tools that search and track progress well,
Agent-first with fallback spell.
Legacy lanes become side streets,
Type-safe chat makes the heart beat! 💙

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 36.14% 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✅ PassedTitle clearly summarizes the main change: converting chat tutor to a Pydantic AI agent as part of refactor #3, with scope indicator.
Description check✅ PassedDescription comprehensively covers all required sections: Summary, What Shipped, Scope, What's NOT Included, References, Test Plan, and Rollback instructions.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch refactor/3-chat-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 and usage tips.


import asyncio
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented May 5, 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
frontendbe5b842Commit Preview URL

Branch Preview URL
May 05 2026, 03:21 AM

…ssion_id
Two correctness fixes from the self-review on PR #78.
1. Symmetric model defaults across agent + legacy paths.
When body.model_pref is None (the default), the legacy fallback used
to return MODEL_DEFAULT (gemini-2.5-flash) while the agent path
returned the agent's task-default (gemini-2.5-pro). A user with no
explicit pref silently downgraded from Pro -> Flash on fallback.
This is the same bug PR #71's commit a2fd5cd fixed for quiz; chat
now matches.
- routes/learn.py: _resolve_tutor_model -> _resolve_legacy_model.
Default fallback flipped from MODEL_DEFAULT to MODEL_SMART.
Comment block + three call sites (start_session, _legacy_chat,
action) updated.
- tests/test_learn_routes.py: TestResolveTutorModel ->
TestResolveLegacyModel. Four tests flipped their expected return
from MODEL_DEFAULT to MODEL_SMART to pin the new symmetric
contract. One new test (test_default_matches_agent_default_for_no_pref)
pins agent/legacy parity explicitly.
2. session_id is a declared field on SaplingDeps, not an attribute attach.
Before: routes/learn.py constructed SaplingDeps without session_id
and then did `deps.session_id = session_id # type: ignore`. The
tool wrapper read it via `getattr(..., None)`. Worked at runtime
(unfrozen dataclass), but type checkers couldn't validate it,
frozen=True would silently break it, and future SaplingDeps
consumers had no way to discover the seam.
- agents/deps.py: added `session_id: str | None = None` with a
docstring entry covering the eval/batch case (legitimately None).
- routes/learn.py: pass session_id via the constructor; removed
the imperative attach + the obsolete comment + the # type: ignore.
- agents/tools/chat_context.py: read_session_history_tool now does
a direct `ctx.deps.session_id` read; getattr ceremony gone.
- tests/test_chat_context_tools.py: missing-session test sets
session_id=None explicitly to mirror the declared default.
55 tests pass across the three touched test files. No regressions.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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.

1 participant

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

refactor(learn): convert chat tutor to chat_tutor_agent (refactor #3) - #78

Merged
Jose-Gael-Cruz-Lopez merged 2 commits into
mainfrom
refactor/3-chat-tutor
May 5, 2026
Merged

refactor(learn): convert chat tutor to chat_tutor_agent (refactor #3)#78
Jose-Gael-Cruz-Lopez merged 2 commits into
mainfrom
refactor/3-chat-tutor

Conversation

@Jose-Gael-Cruz-Lopez

@Jose-Gael-Cruz-LopezJose-Gael-Cruz-Lopez commented May 5, 2026

Copy link
Copy Markdown
Member

Summary

Third and final refactor named in the ADR 0001 migration plan: convert routes/learn.py::chat from build_system_prompt + call_gemini_multiturn to a typed Pydantic AI agent with three mode-specific instances and a four-tool surface. Same orchestrator-vs-legacy fallback pattern PR #67 (documents) and PR #71 (quiz) established. Wire format unchanged, encryption boundary preserved, legacy path intact per ADR 0001.

What shipped

  • backend/agents/chat_tutor.py — three Agent[SaplingDeps, str] instances built from a shared preamble + per-mode body. Prompt versions:

    ModeHash
    Socratic57f278a01d2d
    Expository8c840f43b6e2
    TeachBack70a34fb09224
  • backend/agents/tools/chat_context.py — three new context tools, all decryption-aware:

    • search_course_materials — keyword overlap on documents.summary + concept_notes
    • read_session_history — last N messages, messages.content decrypted at the boundary
    • read_user_progress — aggregates graph_nodes to mastered / weak / in-progress counts
  • backend/agents/_providers.py — added chat_tutor task slot, default gemini-2.5-pro (matches main's chat behavior post-PR Restore Google sign-in popup + add tutor Fast/Smart model toggle #73), env-var override SAPLING_MODEL_CHAT_TUTOR.

  • backend/routes/learn.py_chat_via_agent (new), _legacy_chat (preserved per ADR 0001), _load_message_history (Pydantic-AI ModelMessage adapter with decryption), _resolve_model_pref (mirrors quiz). chat migrated agent-first; start_session and action carry TODO(refactor-3 follow-up) comments and remain on the legacy path for this PR.

  • Teststest_chat_tutor_imports.py (5), test_chat_context_tools.py (15), test_learn_routes.py +10 new in TestChatViaAgent. 577 pass on this branch; 3 pre-existing live-Supabase failures (test_skips_self_edges, test_save_to_db, test_full_pipeline) unchanged from PR re-architecture: agentic document upload + AES-256-GCM column encryption + dev-context vault #67/refactor(quiz): convert generate_quiz to quiz_agent (refactor #2) #71. No regressions.

  • backend/tests/evals/chat_tutor.py — 15 cases (5 per mode) and 5 evaluators: NonEmpty, SocraticEndsWithQuestion, ExpositoryHasStructure, TeachBackProbes, NoToolMisuse.

  • docs/decisions/0015-refactor-3-chat-tutor-shipped.md — full ADR with surprises, consequences, rollback. Numbered 0015 because PR feat(quiz): adaptive iteration — spaced repetition + history + difficulty #77 (adaptive-quiz iteration) claimed 0014 between refactor Refine LLM Model selection for each function #2 ship and this work.

Scope split — flagged for review

This PR migrates only chat, not start_session or action. Both share enough plumbing with chat (same prompt assembly, same legacy call_gemini_multiturn call) that migrating all three would have doubled the route diff and bundled three independent rollback decisions. They carry TODO(refactor-3 follow-up) comments and remain on the legacy path. A follow-up PR will migrate them after the chat agent path proves stable in production.

What's NOT in this PR (per ADR 0001 migration contract)

  • services/gemini_service.py is NOT deleted. Still alive as the chat fallback target (and the quiz fallback). A separate small PR removes it after the agent path proves stable in production AND start_session / action get migrated.
  • Frontend Learn.tsx SSE wiring is NOT in this PR. That's sub-agent E and ships separately. The agent path uses non-streaming agent.run for now; streaming via run_stream_events is a follow-up.
  • Eval cassettes are NOT yet recorded. Cassettes get written on the next SAPLING_EVAL_MODE=record run; replay-mode CI continues to fail loudly when a cassette is missing, so neither the new nor existing cases silently no-op.

References

Test plan

  • pytest tests/test_chat_tutor_imports.py -q → 5 passed
  • pytest tests/test_chat_context_tools.py -q → 15 passed
  • pytest tests/test_learn_routes.py -q → 34 passed (24 prior + 10 new)
  • pytest tests/ -q --ignore=tests/evals → 577 passed, 3 pre-existing failures (unchanged)
  • Live-mode eval recording (SAPLING_EVAL_MODE=record python tests/evals/chat_tutor.py) — recommended before merge
  • Manual smoke: chat in each mode (Socratic / Expository / TeachBack) and confirm tool calls show up in Logfire
  • Latency check after ~50 chats — agent path may have higher round-trip count vs the legacy single multi-turn call; will measure in Logfire

Rollback

The legacy path is intact. Single revert of the merge commit drops the three new agent modules (chat_tutor.py, chat_context.py are pure-leaf), removes the chat_tutor task slot, reverts routes/learn.py::chat to the legacy path, and the messages table schema is unchanged so no migration to undo.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Introduced three distinct tutoring modes for the chat tutor: Socratic questioning, Expository teaching, and TeachBack learning techniques
    • Enhanced chat tutor with improved context awareness by integrating course materials, session history, and learner progress tracking for more personalized guidance

Closes the third refactor in the migration plan from ADR 0001 (and the
last one named in ADR 0005). The legacy `routes/learn.py::chat` path —
hand-built `build_system_prompt` + `services/gemini_service.py::call_gemini_multiturn`
— is replaced with three mode-specific Pydantic AI agents
(Socratic / Expository / TeachBack), each sharing the same four-tool
surface, with the established orchestrator-vs-legacy fallback contract.
What shipped:
- agents/chat_tutor.py — three Agent[SaplingDeps, str] instances built
from a shared preamble + per-mode body. Prompt versions:
Socratic 57f278a01d2d
Expository 8c840f43b6e2
TeachBack 70a34fb09224
- agents/tools/chat_context.py — three new tools:
search_course_materials, read_session_history, read_user_progress.
All decryption-aware (messages.content, documents.summary,
documents.concept_notes are encrypted at rest per CLAUDE.md).
- agents/_providers.py — chat_tutor task slot, default gemini-2.5-pro
(matches main's chat behavior post-PR #73), env override
SAPLING_MODEL_CHAT_TUTOR.
- routes/learn.py — _chat_via_agent (new), _legacy_chat (preserved per
ADR 0001), _load_message_history (Pydantic-AI ModelMessage adapter
with decryption), _resolve_model_pref (mirrors quiz). chat migrated
agent-first; start_session and action carry TODO(refactor-3 follow-up)
comments and remain on the legacy path for this PR.
- tests/test_chat_tutor_imports.py (5), test_chat_context_tools.py (15),
test_learn_routes.py +10 new in TestChatViaAgent class. 577 pass on
this branch (3 pre-existing failures: test_skips_self_edges,
test_save_to_db, test_full_pipeline — unchanged from PR #67/#71).
- tests/evals/chat_tutor.py — 15 cases (5 per mode) and 5 evaluators
(NonEmpty, SocraticEndsWithQuestion, ExpositoryHasStructure,
TeachBackProbes, NoToolMisuse). Cassettes recorded out-of-band.
Decisions:
- Scope split: only `chat` migrated this PR. start_session and action
share enough plumbing that bundling would have doubled the diff and
bundled three independent rollbacks. Follow-up PR.
- gemini_service.py NOT deleted — still alive as the chat fallback
target (and the quiz fallback). Separate PR removes it after the
agent path proves stable in production AND start_session/action get
migrated.
- Wire format unchanged: response still returns reply / graph_update /
mastery_changes; messages table schema untouched.
ADR 0015 captures the full rollback path and what we'd carry forward.
Note: original template numbered this 0014 but PR #77's adaptive-quiz
iteration claimed that slot, so this is 0015.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitaiBot commented May 5, 2026

Copy link
Copy Markdown

Caution

Review failed

Pull request was closed or merged during review

📝 Walkthrough

Walkthrough

This PR implements a comprehensive refactor of the chat tutor system, replacing a legacy Gemini-based multiturn chat with a typed Pydantic AI agent framework. The change introduces three mode-specific agents (socratic, expository, teachback) backed by on-demand data-access tools, integrates the new agent into the /chat route with graceful fallback to legacy behavior, and includes extensive test coverage and refactor documentation.

Changes

Chat Tutor Agent Refactor

Layer / File(s)Summary
Task & Provider Registration
backend/agents/_providers.py
chat_tutor is added to AgentTask Literal and mapped to gemini-2.5-pro in _DEFAULTS, with environment-variable override support via SAPLING_MODEL_CHAT_TUTOR.
Dependency Wiring
backend/agents/deps.py
SaplingDeps dataclass gains optional session_id: str | None = None field to scope tools to active chat sessions.
Agent Implementation
backend/agents/chat_tutor.py
Three mode-specific agents (socratic, expository, teachback) are constructed with distinct system prompts, per-mode SHA-256 prompt hashes for versioning, and a shared tool surface. agent_for_mode(mode) selects the agent instance, normalizing input and falling back to socratic for unknown/missing modes.
Data-Access Tools
backend/agents/tools/chat_context.py
Implements three async tools: search_course_materials_tool (keyword-ranked document search), read_session_history_tool (decrypted message retrieval), and read_user_progress_tool (mastery aggregation). Tool wrappers extract user_id/course_id/session_id from ctx.deps to enforce access control, while underlying functions handle decryption at the boundary, gracefully degrade failures to empty results, and filter out unusable rows.
Route & Request Handling
backend/routes/learn.py
The POST /api/learn/chat endpoint becomes async and agent-first: loads decrypted message history via _load_message_history(), attempts the new agent path, falls back to _legacy_chat() on guardrail/exception failures, and persists encrypted user/model messages. Model selection is unified via _resolve_model_pref() for the agent and _resolve_legacy_model() for fallback. Legacy routes (start_session, action) continue to use the legacy model resolver.
Unit & Integration Tests
backend/tests/test_chat_context_tools.py, backend/tests/test_chat_tutor_imports.py, backend/tests/test_learn_routes.py
Tool-level tests verify decryption/filtering/ranking, import smoke tests validate agent instantiation and tool registration, and route tests cover agent success, fallback paths, message encryption/decryption, and model-preference override behavior.
Evaluation Framework
backend/tests/evals/chat_tutor.py
Defines 15 fixed test cases (5 per mode) with mode-conditional evaluators enforcing reply length, ending-punctuation rules, and prohibition of raw tool names; integrates with the replay/record cassette system.
Documentation & Planning
backend/prompts/refactor-3-chat-tutor/..., docs/decisions/0015-refactor-3-chat-tutor-shipped.md
Comprehensive refactor orchestration plan, sub-agent task specs, ADR documenting shipped scope/decisions/consequences, and implementation guide for optional frontend SSE event wiring.

Sequence Diagram

sequenceDiagram
participant Client
participant Learn as learn.py<br/>POST /chat
participant Agent as chat_tutor_agent
participant Tools as chat_context tools
participant DB as Supabase
participant Legacy as _legacy_chat<br/>(fallback)
Client->>Learn: User message + mode
Learn->>DB: Load message history
DB-->>Learn: Encrypted messages
Learn->>Learn: Decrypt message history
Learn->>Agent: agent_for_mode(mode).run()
Agent->>Tools: search_course_materials_tool(query)
Tools->>DB: Fetch documents
DB-->>Tools: Encrypted results
Tools->>Tools: Decrypt, score, filter
Tools-->>Agent: Ranked materials
Agent->>Tools: read_session_history_tool()
Tools->>DB: Fetch messages
DB-->>Tools: Encrypted messages
Tools->>Tools: Decrypt, map roles, filter
Tools-->>Agent: Session history
Agent->>Tools: read_user_progress_tool()
Tools->>DB: Fetch mastery scores
DB-->>Tools: Raw scores
Tools->>Tools: Aggregate, clamp, bin
Tools-->>Agent: Progress summary
alt Agent succeeds
Agent-->>Learn: Reply string
Learn->>DB: Save encrypted messages
Learn-->>Client: Reply + empty graph_update
else Guardrail exception
Learn->>Legacy: _legacy_chat()
Legacy->>DB: Call legacy Gemini
DB-->>Legacy: Response
Legacy-->>Learn: Reply
Learn->>DB: Save messages
Learn-->>Client: Reply + empty graph_update
end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

This PR spans multiple interdependent subsystems (provider registration, agent implementation, tool layer, route integration) with diverse logic density: cryptographic boundary enforcement in tools, prompt-versioning via SHA-256 hashing, keyword-ranking algorithms, mode-based dispatch logic, message-role mapping with legacy fallback, and comprehensive fallback control flow in the route handler. The changes affect 15+ files across distinct concerns (configuration, agent/tool implementations, routes, tests, and documentation), and while individual tool implementations follow similar patterns (decrypt at boundary, filter, aggregate), each has distinct domain logic (scoring vs. decryption vs. aggregation). The test suite is substantial and heterogeneous, covering unit-level tool behavior, import assertions, route integration, and evaluation harness setup.

Possibly related PRs

  • SaplingLearn/Sapling#65: Implements message encryption/decryption primitives (encrypt_if_present, decrypt_if_present, decrypt_json) that this PR directly depends on for loading/decrypting message history and tool results.
  • SaplingLearn/Sapling#71: Extends the agent task framework in backend/agents/_providers.py for the quiz agent, establishing the same pattern (new AgentTask Literal value, _DEFAULTS mapping, environment-variable override) that this PR reuses for chat_tutor.
  • SaplingLearn/Sapling#67: Introduces the foundational Pydantic AI agent framework (task registration, SaplingDeps deps class, tool-wrapper patterns, event mapping) that this PR builds upon to create the chat tutor agents.

Poem

🐰 Hoppy hops with prompts so neat,
Three modes tutor—Socratic sweet,
Tools that search and track progress well,
Agent-first with fallback spell.
Legacy lanes become side streets,
Type-safe chat makes the heart beat! 💙

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 36.14% 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✅ PassedTitle clearly summarizes the main change: converting chat tutor to a Pydantic AI agent as part of refactor #3, with scope indicator.
Description check✅ PassedDescription comprehensively covers all required sections: Summary, What Shipped, Scope, What's NOT Included, References, Test Plan, and Rollback instructions.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch refactor/3-chat-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 and usage tips.


import asyncio
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented May 5, 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
frontendbe5b842Commit Preview URL

Branch Preview URL
May 05 2026, 03:21 AM

…ssion_id
Two correctness fixes from the self-review on PR #78.
1. Symmetric model defaults across agent + legacy paths.
When body.model_pref is None (the default), the legacy fallback used
to return MODEL_DEFAULT (gemini-2.5-flash) while the agent path
returned the agent's task-default (gemini-2.5-pro). A user with no
explicit pref silently downgraded from Pro -> Flash on fallback.
This is the same bug PR #71's commit a2fd5cd fixed for quiz; chat
now matches.
- routes/learn.py: _resolve_tutor_model -> _resolve_legacy_model.
Default fallback flipped from MODEL_DEFAULT to MODEL_SMART.
Comment block + three call sites (start_session, _legacy_chat,
action) updated.
- tests/test_learn_routes.py: TestResolveTutorModel ->
TestResolveLegacyModel. Four tests flipped their expected return
from MODEL_DEFAULT to MODEL_SMART to pin the new symmetric
contract. One new test (test_default_matches_agent_default_for_no_pref)
pins agent/legacy parity explicitly.
2. session_id is a declared field on SaplingDeps, not an attribute attach.
Before: routes/learn.py constructed SaplingDeps without session_id
and then did `deps.session_id = session_id # type: ignore`. The
tool wrapper read it via `getattr(..., None)`. Worked at runtime
(unfrozen dataclass), but type checkers couldn't validate it,
frozen=True would silently break it, and future SaplingDeps
consumers had no way to discover the seam.
- agents/deps.py: added `session_id: str | None = None` with a
docstring entry covering the eval/batch case (legitimately None).
- routes/learn.py: pass session_id via the constructor; removed
the imperative attach + the obsolete comment + the # type: ignore.
- agents/tools/chat_context.py: read_session_history_tool now does
a direct `ctx.deps.session_id` read; getattr ceremony gone.
- tests/test_chat_context_tools.py: missing-session test sets
session_id=None explicitly to mirror the declared default.
55 tests pass across the three touched test files. No regressions.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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.

1 participant

@Jose-Gael-Cruz-Lopez