Skip to content

Commit dacf813

Browse files
Merge pull request #78 from SaplingLearn/refactor/3-chat-tutor
refactor(learn): convert chat tutor to chat_tutor_agent (refactor #3)
2 parents 5fdd29f + be5b842 commit dacf813

18 files changed

Lines changed: 3424 additions & 39 deletions

backend/agents/_providers.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,11 @@
88
SAPLING_MODEL_CONCEPTS=gemini-2.5-flash
99
SAPLING_MODEL_SYLLABUS=gemini-2.5-flash
1010
SAPLING_MODEL_QUIZ=gemini-2.5-flash-lite
11+
SAPLING_MODEL_CHAT_TUTOR=gemini-2.5-pro
1112
1213
Defaults are tuned per task: cheaper models for simpler classifications,
13-
flagship Flash for tasks where output quality drives downstream UX.
14+
flagship Flash for tasks where output quality drives downstream UX, and
15+
the Pro tier for the conversational tutor where reasoning depth matters.
1416
"""
1517

1618
from __future__ import annotations
@@ -24,7 +26,7 @@
2426
from config import GEMINI_API_KEY
2527

2628

27-
AgentTask = Literal["classifier", "summary", "concepts", "syllabus", "quiz"]
29+
AgentTask = Literal["classifier", "summary", "concepts", "syllabus", "quiz", "chat_tutor"]
2830

2931

3032
# Defaults are conservative. Bumping a model up costs more; the env var
@@ -38,6 +40,12 @@
3840
# call where the agent pulls structured graph data via tools, so the
3941
# bulk of the value is in tool wiring, not raw model strength.
4042
"quiz": "gemini-2.5-flash-lite",
43+
# Chat tutor runs on Pro: it streams a multi-turn pedagogical
44+
# conversation where reasoning depth and instruction following drive
45+
# perceived quality. Matches main's tutor default after PR #73
46+
# (`feat(learn): use gemini-2.5-pro for tutor chat`) and PR #74
47+
# (`fix(learn): allow thinking on gemini-2.5-pro multiturn calls`).
48+
"chat_tutor": "gemini-2.5-pro",
4149
}
4250

4351

backend/agents/chat_tutor.py

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
"""Chat tutor agent for the Learn route's three teaching modes.
2+
3+
Replaces routes/learn.py:152's build_system_prompt + call_gemini_multiturn
4+
with a typed Pydantic AI agent. Tools handle the data lookups that used
5+
to be string-stuffed: search_course_materials, read_session_history,
6+
read_user_progress, apply_graph_update_tool.
7+
8+
Modes (Socratic, Expository, TeachBack) are gated by selecting different
9+
system prompts at construction time. The route picks the right agent
10+
instance per request based on body.mode.
11+
"""
12+
13+
from __future__ import annotations
14+
15+
import hashlib
16+
from typing import Literal
17+
18+
from pydantic_ai import Agent
19+
20+
from agents._providers import model_for
21+
from agents.deps import SaplingDeps
22+
from agents.tools.chat_context import (
23+
read_session_history_tool,
24+
read_user_progress_tool,
25+
search_course_materials_tool,
26+
)
27+
from agents.tools.graph import apply_graph_update_tool
28+
29+
30+
TutorMode = Literal["socratic", "expository", "teachback"]
31+
32+
33+
# ── System prompts (one per mode) ──────────────────────────────────────────
34+
35+
# The shared preamble is identical across modes so a prompt-version bump
36+
# in shared guidance shows up as a hash change for every mode at once.
37+
_SHARED_PREAMBLE = (
38+
"You are Sapling, an AI tutor that helps a student build mastery in "
39+
"their course material. You have tools to fetch the student's "
40+
"progress, search their uploaded course documents, and update their "
41+
"knowledge graph mastery scores. Use tools when relevant — don't "
42+
"fabricate context.\n\n"
43+
"Tone: warm, concise, no filler. Use math/code blocks where helpful "
44+
"(LaTeX `$x^2$`, ```mermaid```, ```plot```). Don't over-explain.\n\n"
45+
)
46+
47+
_SOCRATIC_PROMPT = _SHARED_PREAMBLE + (
48+
"MODE: Socratic. Lead the student to the answer through questions, "
49+
"not lectures. Each turn: ask one focused question that reveals what "
50+
"they already know or where they're confused. Avoid giving the answer "
51+
"directly; provide hints only after they've made an attempt. End "
52+
"every response with a question."
53+
)
54+
55+
_EXPOSITORY_PROMPT = _SHARED_PREAMBLE + (
56+
"MODE: Expository. Explain the concept directly and thoroughly. "
57+
"Structure your response: brief overview → detailed explanation → "
58+
"concrete example or worked problem. Don't ask questions back unless "
59+
"the student's prompt is genuinely ambiguous."
60+
)
61+
62+
_TEACHBACK_PROMPT = _SHARED_PREAMBLE + (
63+
"MODE: TeachBack. The student is teaching you a concept. Listen to "
64+
"their explanation, then identify what's correct, what's missing, "
65+
"and any specific misconceptions. Praise accuracy where it exists. "
66+
"End with one targeted question that probes the weakest spot in "
67+
"their understanding."
68+
)
69+
70+
_PROMPTS: dict[TutorMode, str] = {
71+
"socratic": _SOCRATIC_PROMPT,
72+
"expository": _EXPOSITORY_PROMPT,
73+
"teachback": _TEACHBACK_PROMPT,
74+
}
75+
76+
# Hash of each mode's full prompt (preamble + body), for span versioning.
77+
# Logfire spans on chat-tutor runs include this so a prompt revision
78+
# shows up as a clean delta when comparing run metadata across deploys.
79+
_PROMPT_HASHES: dict[TutorMode, str] = {
80+
mode: hashlib.sha256(prompt.encode("utf-8")).hexdigest()[:12]
81+
for mode, prompt in _PROMPTS.items()
82+
}
83+
84+
85+
# ── Agent (one per mode, sharing the same tool surface) ────────────────────
86+
87+
# Output type is plain str — chat tutor produces free-form Markdown that
88+
# the frontend renders via MarkdownChat. No structured output here; that
89+
# is reserved for routes that grade or extract.
90+
91+
# All four tools are registered on every mode. The system prompt steers
92+
# WHEN to call them; the surface stays uniform so a Pro-tier model can
93+
# decide for itself which lookups are worth the round trip.
94+
_TOOLS = [
95+
search_course_materials_tool,
96+
read_session_history_tool,
97+
read_user_progress_tool,
98+
apply_graph_update_tool,
99+
]
100+
101+
102+
def _build_agent(mode: TutorMode) -> Agent[SaplingDeps, str]:
103+
return Agent[SaplingDeps, str](
104+
model=model_for("chat_tutor"),
105+
deps_type=SaplingDeps,
106+
output_type=str,
107+
system_prompt=_PROMPTS[mode],
108+
metadata={
109+
"prompt_version": _PROMPT_HASHES[mode],
110+
"agent": "chat_tutor",
111+
"mode": mode,
112+
},
113+
tools=_TOOLS,
114+
)
115+
116+
117+
socratic_agent = _build_agent("socratic")
118+
expository_agent = _build_agent("expository")
119+
teachback_agent = _build_agent("teachback")
120+
121+
122+
def agent_for_mode(mode: str | None) -> Agent[SaplingDeps, str]:
123+
"""Return the agent instance for a given mode string.
124+
125+
Falls back to Socratic if the mode is unrecognized (or missing) —
126+
same default the legacy `build_system_prompt` used when no mode
127+
matched the MODE_PROMPTS dict.
128+
"""
129+
normalized = (mode or "socratic").lower()
130+
return {
131+
"socratic": socratic_agent,
132+
"expository": expository_agent,
133+
"teachback": teachback_agent,
134+
}.get(normalized, socratic_agent)

backend/agents/deps.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,14 @@ class SaplingDeps:
2323
version.
2424
request_id: A correlation ID for tracing across a single
2525
user-facing request. Used by Logfire spans.
26+
session_id: The active chat session, when applicable. Used by tools
27+
that need to scope reads to *this* conversation (e.g.
28+
read_session_history_tool). Optional — agent runs that don't
29+
happen inside a session (eval mode, batch tasks) leave it None.
2630
"""
2731

2832
user_id: str
2933
course_id: str | None
3034
supabase: Any
3135
request_id: str
36+
session_id: str | None = None

0 commit comments

Comments
 (0)