|
| 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) |
0 commit comments