Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion backend/agents/chat_tutor.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -32,7 +32,7 @@
read_user_progress_tool,
search_course_materials_tool,
)
from agents.tools.graph import apply_graph_update_tool
from agents.tools.graph import apply_graph_update_tool, update_mastery_tool


TutorMode = Literal["socratic", "expository", "teachback"]
Expand All@@ -50,6 +50,12 @@
"fabricate context.\n\n"
"Tone: warm, concise, no filler. Use math/code blocks where helpful "
"(LaTeX `$x^2$`, ```mermaid```, ```plot```). Don't over-explain.\n\n"
"Knowledge graph tools:\n"
"- apply_graph_update_tool: register NEW concepts the student hasn't seen before.\n"
"- update_mastery_tool: adjust mastery on EXISTING concepts this turn. "
"Use +0.1 to +0.3 when they answer correctly; −0.05 to −0.1 for gaps. "
"Call this at the END of every turn where the student demonstrated "
"understanding or revealed a misconception.\n\n"
)

_SOCRATIC_PROMPT = _SHARED_PREAMBLE + (
Expand DownExpand Up@@ -104,6 +110,7 @@
read_session_history_tool,
read_user_progress_tool,
apply_graph_update_tool,
update_mastery_tool,
]


Expand Down
10 changes: 9 additions & 1 deletion backend/agents/deps.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,7 +7,7 @@

from __future__ import annotations

from dataclasses import dataclass
from dataclasses import dataclass, field
from typing import Any


Expand All@@ -27,10 +27,18 @@ class SaplingDeps:
that need to scope reads to *this* conversation (e.g.
read_session_history_tool). Optional — agent runs that don't
happen inside a session (eval mode, batch tasks) leave it None.
graph_updates: Accumulates graph update payloads emitted by tools
during a run so the route can persist them in graph_update_json
for concepts_covered derivation in end_session.
mastery_changes: Accumulates the real before/after mastery deltas
returned by apply_graph_update so the route can surface them in
the chat response for parity with the legacy path.
"""

user_id: str
course_id: str | None
supabase: Any
request_id: str
session_id: str | None = None
graph_updates: list = field(default_factory=list)
mastery_changes: list = field(default_factory=list)
135 changes: 121 additions & 14 deletions backend/agents/tools/graph.py
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,24 @@
"""Graph-update helpers and a Pydantic AI tool wrapper.
"""Graph-update helpers and Pydantic AI tool wrappers.

The core merge logic lives in `apply_concepts_to_graph` — a plain async
function callable from routes directly. `apply_graph_update_tool` is a
thin Pydantic AI wrapper around it for future agents that need a tool
to register on an `Agent`. Neither contains LLM-specific logic; that
stays in `services.graph_service`.
Two tools are exposed:
- apply_graph_update_tool — registers new concepts (new_nodes, initial_mastery 0.0)
- update_mastery_tool — adjusts mastery on existing concepts (updated_nodes + delta)

Both append their payload to ctx.deps.graph_updates so the route can
persist graph_update_json on the assistant message, enabling end_session
to derive concepts_covered correctly for agent-path chats.
"""

from __future__ import annotations

import asyncio
from typing import Literal

from pydantic import BaseModel, Field
from pydantic_ai import RunContext

from agents.deps import SaplingDeps
from services.graph_service import apply_graph_update
from services.graph_service import _normalize_concept, apply_graph_update


class GraphUpdateInput(BaseModel):
Expand All@@ -27,6 +30,41 @@ class GraphUpdateInput(BaseModel):
)


class ConceptMasteryUpdate(BaseModel):
concept_name: str = Field(
description="Exact name of the concept whose mastery score to change."
)
mastery_delta: float = Field(
ge=-1.0,
le=1.0,
description=(
"Fractional mastery change, −1.0 to +1.0. "
"Use +0.1 to +0.3 when the student answers correctly; "
"−0.05 to −0.1 when they reveal a gap or misconception."
),
)
reason: str = Field(
default="",
description="Short phrase shown in the mastery-event log (e.g. 'answered correctly').",
)
event_type: Literal["interaction", "correction", "quiz"] = Field(
default="interaction",
description="Event category for the mastery-event log.",
)


class MasteryUpdateInput(BaseModel):
"""Typed input for the update_mastery tool."""

updates: list[ConceptMasteryUpdate] = Field(
description=(
"One entry per concept whose mastery changed this turn. "
"Only include concepts that already exist in the graph "
"(or were just added via apply_graph_update_tool)."
)
)


async def apply_concepts_to_graph(
user_id: str,
course_id: str | None,
Expand DownExpand Up@@ -58,13 +96,82 @@ async def apply_graph_update_tool(
ctx: RunContext[SaplingDeps],
update: GraphUpdateInput,
) -> str:
"""Pydantic AI tool wrapper around apply_concepts_to_graph.
"""Register new concepts in the student's knowledge graph.

Returns a short summary string for the agent to confirm the operation.
Call this when a new topic comes up that isn't already tracked.
To raise or lower mastery on an existing concept, call update_mastery_tool.
"""
count = await apply_concepts_to_graph(
ctx.deps.user_id, ctx.deps.course_id, update.concepts,
)
if count == 0:
new_nodes = [
{"concept_name": name.strip(), "initial_mastery": 0.0}
for name in update.concepts
if name and name.strip()
]
Comment thread
coderabbitai[bot] marked this conversation as resolved.
if not new_nodes:
return "Graph update skipped: no concepts to add."
return f"Graph updated: {count} concept(s) merged."
await asyncio.to_thread(
apply_graph_update,
ctx.deps.user_id,
{"new_nodes": new_nodes},
ctx.deps.course_id,
)
ctx.deps.graph_updates.append({"new_nodes": new_nodes})
return f"Graph updated: {len(new_nodes)} concept(s) merged."


async def update_mastery_tool(
ctx: RunContext[SaplingDeps],
update: MasteryUpdateInput,
) -> str:
"""Adjust mastery scores for concepts the student engaged with this turn.

Positive delta (e.g. +0.15) when they demonstrate understanding;
negative (e.g. −0.08) when they reveal a gap. Concepts must already
exist in the graph — call apply_graph_update_tool first if needed.
"""
updated_nodes = [
{
"concept_name": u.concept_name.strip(),
"mastery_delta": u.mastery_delta,
"reason": u.reason,
"event_type": u.event_type,
}
for u in update.updates
if u.concept_name and u.concept_name.strip()
]
if not updated_nodes:
return "Mastery update skipped: no concepts provided."

changes = await asyncio.to_thread(
apply_graph_update,
ctx.deps.user_id,
{"updated_nodes": updated_nodes},
ctx.deps.course_id,
)

# Only persist concepts that actually produced a change. A concept the
# model named but that doesn't exist in the graph yields no `changes`
# and is never written, so it must not leak into graph_update_json (it
# would over-report concepts_covered in end_session). Rebuild the
# appended updated_nodes from the concepts that genuinely changed.
#
# `changes` carries the *stored* concept_name while `updated_nodes` holds
# the *model-provided* spelling; match on the normalized form (the same
# case/whitespace-insensitive key apply_graph_update dedups on) so a
# casing/spacing drift doesn't drop a genuinely-changed concept.
if changes:
changed_names = {_normalize_concept(c["concept"]) for c in changes}
persisted_nodes = [
n
for n in updated_nodes
if _normalize_concept(n["concept_name"]) in changed_names
]
if persisted_nodes:
ctx.deps.graph_updates.append({"updated_nodes": persisted_nodes})
# Surface the real before/after deltas for parity with the legacy path.
ctx.deps.mastery_changes.extend(changes)
parts = [f"{c['concept']} {c['before']:.2f}→{c['after']:.2f}" for c in changes]
return f"Mastery updated: {', '.join(parts)}."
return (
f"Mastery update processed ({len(updated_nodes)} concept(s)); "
"no score change — concept may not exist yet. Call apply_graph_update_tool first."
)
35 changes: 27 additions & 8 deletions backend/routes/learn.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -11,6 +11,7 @@
from pydantic_ai.exceptions import UsageLimitExceeded, UnexpectedModelBehavior
from pydantic_ai.messages import ModelRequest, ModelResponse, TextPart, UserPromptPart

from agents import ORCHESTRATOR_LIMITS
from agents.chat_tutor import agent_for_mode
from agents.deps import SaplingDeps
from db.connection import table
Expand DownExpand Up@@ -512,10 +513,12 @@ async def _chat_via_agent(
"""Run chat_tutor_agent and return the legacy response shape.

Returns ``{"reply": str, "graph_update": dict, "mastery_changes": list}``.
`graph_update` and `mastery_changes` come back empty here because
`apply_graph_update_tool` (registered on chat_tutor) already
persisted any graph changes during the agent run. The frontend's
Learn-page reducer accepts empty values gracefully.
Graph changes are persisted in-band during the agent run by
`apply_graph_update_tool` / `update_mastery_tool` (registered on
chat_tutor); the tools also accumulate their payloads on `deps` so the
route can echo `graph_update` (for graph_update_json / concepts_covered)
and the real `mastery_changes` deltas back to the client, matching the
legacy path. Both are empty when nothing changed this turn.

`use_shared_context=False` flips the model into "no class-aggregate"
mode by appending a constraint instruction to the user message —
Expand DownExpand Up@@ -563,7 +566,11 @@ async def _chat_via_agent(
)

model_override = _resolve_model_pref(model_pref)
run_kwargs: dict = {"deps": deps, "message_history": message_history}
run_kwargs: dict = {
"deps": deps,
"message_history": message_history,
"usage_limits": ORCHESTRATOR_LIMITS,
}
if model_override is not None:
run_kwargs["model"] = model_override

Expand All@@ -576,10 +583,21 @@ async def _chat_via_agent(
result = await agent.run(user_message, **run_kwargs)
reply = result.output # str — chat_tutor agents return plain Markdown.

# Merge all graph update payloads accumulated by tools during this run
# into a single dict so the route can persist graph_update_json and
# end_session can derive concepts_covered correctly.
merged_graph_update: dict = {}
for gu in deps.graph_updates:
for key, items in gu.items():
merged_graph_update.setdefault(key, []).extend(items)

return {
"reply": reply,
"graph_update": {},
"mastery_changes": [],
"graph_update": merged_graph_update,
# Real before/after deltas accumulated by update_mastery_tool, for
# parity with the legacy path (which returns apply_graph_update's
# changes directly). Empty when no mastery moved this turn.
"mastery_changes": deps.mastery_changes,
}


Expand DownExpand Up@@ -690,7 +708,8 @@ async def chat(body: ChatBody, request: Request):
# own writes so a fallback doesn't double-insert. Encryption happens
# inside save_message (`encrypt_if_present`).
save_message(body.session_id, "user", body.message)
save_message(body.session_id, "assistant", response["reply"])
graph_update = response.get("graph_update") or None
save_message(body.session_id, "assistant", response["reply"], graph_update)

return response

Expand Down
3 changes: 2 additions & 1 deletion backend/routes/quiz.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -8,6 +8,7 @@

from pydantic_ai.exceptions import UsageLimitExceeded, UnexpectedModelBehavior

from agents import ORCHESTRATOR_LIMITS
from agents.quiz import quiz_agent, Quiz, QuizQuestion
from agents.deps import SaplingDeps
from agents._run import run_agent_sync
Expand DownExpand Up@@ -186,7 +187,7 @@ async def _quiz_via_agent(
)

model_override = _resolve_model_pref(model_pref)
run_kwargs: dict = {"deps": deps}
run_kwargs: dict = {"deps": deps, "usage_limits": ORCHESTRATOR_LIMITS}
if model_override is not None:
run_kwargs["model"] = model_override
result = await quiz_agent.run(user_message, **run_kwargs)
Expand Down
5 changes: 3 additions & 2 deletions backend/tests/test_chat_tutor_imports.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -34,13 +34,14 @@ def test_unknown_mode_falls_back_to_socratic():
assert agent_for_mode(None) is socratic_agent


def test_all_four_tools_registered():
"""Chat tutor needs three context tools + the graph-update tool."""
def test_all_tools_registered():
"""Chat tutor needs three context tools + two graph tools (add and update mastery)."""
expected = {
"search_course_materials_tool",
"read_session_history_tool",
"read_user_progress_tool",
"apply_graph_update_tool",
"update_mastery_tool",
}
# Pydantic AI 1.89's tool registry is at agent._function_toolset.tools
# (dict keyed by tool name) — see commit a850d31 for the gotcha.
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
fix: restore mastery updates, persist graph_update_json, wire usage limits by Darkest-Teddy · Pull Request #243 · SaplingLearn/Sapling · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion backend/agents/chat_tutor.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -32,7 +32,7 @@
read_user_progress_tool,
search_course_materials_tool,
)
from agents.tools.graph import apply_graph_update_tool
from agents.tools.graph import apply_graph_update_tool, update_mastery_tool


TutorMode = Literal["socratic", "expository", "teachback"]
Expand All@@ -50,6 +50,12 @@
"fabricate context.\n\n"
"Tone: warm, concise, no filler. Use math/code blocks where helpful "
"(LaTeX `$x^2$`, ```mermaid```, ```plot```). Don't over-explain.\n\n"
"Knowledge graph tools:\n"
"- apply_graph_update_tool: register NEW concepts the student hasn't seen before.\n"
"- update_mastery_tool: adjust mastery on EXISTING concepts this turn. "
"Use +0.1 to +0.3 when they answer correctly; −0.05 to −0.1 for gaps. "
"Call this at the END of every turn where the student demonstrated "
"understanding or revealed a misconception.\n\n"
)

_SOCRATIC_PROMPT = _SHARED_PREAMBLE + (
Expand DownExpand Up@@ -104,6 +110,7 @@
read_session_history_tool,
read_user_progress_tool,
apply_graph_update_tool,
update_mastery_tool,
]


Expand Down
10 changes: 9 additions & 1 deletion backend/agents/deps.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,7 +7,7 @@

from __future__ import annotations

from dataclasses import dataclass
from dataclasses import dataclass, field
from typing import Any


Expand All@@ -27,10 +27,18 @@ class SaplingDeps:
that need to scope reads to *this* conversation (e.g.
read_session_history_tool). Optional — agent runs that don't
happen inside a session (eval mode, batch tasks) leave it None.
graph_updates: Accumulates graph update payloads emitted by tools
during a run so the route can persist them in graph_update_json
for concepts_covered derivation in end_session.
mastery_changes: Accumulates the real before/after mastery deltas
returned by apply_graph_update so the route can surface them in
the chat response for parity with the legacy path.
"""

user_id: str
course_id: str | None
supabase: Any
request_id: str
session_id: str | None = None
graph_updates: list = field(default_factory=list)
mastery_changes: list = field(default_factory=list)
135 changes: 121 additions & 14 deletions backend/agents/tools/graph.py
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,24 @@
"""Graph-update helpers and a Pydantic AI tool wrapper.
"""Graph-update helpers and Pydantic AI tool wrappers.

The core merge logic lives in `apply_concepts_to_graph` — a plain async
function callable from routes directly. `apply_graph_update_tool` is a
thin Pydantic AI wrapper around it for future agents that need a tool
to register on an `Agent`. Neither contains LLM-specific logic; that
stays in `services.graph_service`.
Two tools are exposed:
- apply_graph_update_tool — registers new concepts (new_nodes, initial_mastery 0.0)
- update_mastery_tool — adjusts mastery on existing concepts (updated_nodes + delta)

Both append their payload to ctx.deps.graph_updates so the route can
persist graph_update_json on the assistant message, enabling end_session
to derive concepts_covered correctly for agent-path chats.
"""

from __future__ import annotations

import asyncio
from typing import Literal

from pydantic import BaseModel, Field
from pydantic_ai import RunContext

from agents.deps import SaplingDeps
from services.graph_service import apply_graph_update
from services.graph_service import _normalize_concept, apply_graph_update


class GraphUpdateInput(BaseModel):
Expand All@@ -27,6 +30,41 @@ class GraphUpdateInput(BaseModel):
)


class ConceptMasteryUpdate(BaseModel):
concept_name: str = Field(
description="Exact name of the concept whose mastery score to change."
)
mastery_delta: float = Field(
ge=-1.0,
le=1.0,
description=(
"Fractional mastery change, −1.0 to +1.0. "
"Use +0.1 to +0.3 when the student answers correctly; "
"−0.05 to −0.1 when they reveal a gap or misconception."
),
)
reason: str = Field(
default="",
description="Short phrase shown in the mastery-event log (e.g. 'answered correctly').",
)
event_type: Literal["interaction", "correction", "quiz"] = Field(
default="interaction",
description="Event category for the mastery-event log.",
)


class MasteryUpdateInput(BaseModel):
"""Typed input for the update_mastery tool."""

updates: list[ConceptMasteryUpdate] = Field(
description=(
"One entry per concept whose mastery changed this turn. "
"Only include concepts that already exist in the graph "
"(or were just added via apply_graph_update_tool)."
)
)


async def apply_concepts_to_graph(
user_id: str,
course_id: str | None,
Expand DownExpand Up@@ -58,13 +96,82 @@ async def apply_graph_update_tool(
ctx: RunContext[SaplingDeps],
update: GraphUpdateInput,
) -> str:
"""Pydantic AI tool wrapper around apply_concepts_to_graph.
"""Register new concepts in the student's knowledge graph.

Returns a short summary string for the agent to confirm the operation.
Call this when a new topic comes up that isn't already tracked.
To raise or lower mastery on an existing concept, call update_mastery_tool.
"""
count = await apply_concepts_to_graph(
ctx.deps.user_id, ctx.deps.course_id, update.concepts,
)
if count == 0:
new_nodes = [
{"concept_name": name.strip(), "initial_mastery": 0.0}
for name in update.concepts
if name and name.strip()
]
Comment thread
coderabbitai[bot] marked this conversation as resolved.
if not new_nodes:
return "Graph update skipped: no concepts to add."
return f"Graph updated: {count} concept(s) merged."
await asyncio.to_thread(
apply_graph_update,
ctx.deps.user_id,
{"new_nodes": new_nodes},
ctx.deps.course_id,
)
ctx.deps.graph_updates.append({"new_nodes": new_nodes})
return f"Graph updated: {len(new_nodes)} concept(s) merged."


async def update_mastery_tool(
ctx: RunContext[SaplingDeps],
update: MasteryUpdateInput,
) -> str:
"""Adjust mastery scores for concepts the student engaged with this turn.

Positive delta (e.g. +0.15) when they demonstrate understanding;
negative (e.g. −0.08) when they reveal a gap. Concepts must already
exist in the graph — call apply_graph_update_tool first if needed.
"""
updated_nodes = [
{
"concept_name": u.concept_name.strip(),
"mastery_delta": u.mastery_delta,
"reason": u.reason,
"event_type": u.event_type,
}
for u in update.updates
if u.concept_name and u.concept_name.strip()
]
if not updated_nodes:
return "Mastery update skipped: no concepts provided."

changes = await asyncio.to_thread(
apply_graph_update,
ctx.deps.user_id,
{"updated_nodes": updated_nodes},
ctx.deps.course_id,
)

# Only persist concepts that actually produced a change. A concept the
# model named but that doesn't exist in the graph yields no `changes`
# and is never written, so it must not leak into graph_update_json (it
# would over-report concepts_covered in end_session). Rebuild the
# appended updated_nodes from the concepts that genuinely changed.
#
# `changes` carries the *stored* concept_name while `updated_nodes` holds
# the *model-provided* spelling; match on the normalized form (the same
# case/whitespace-insensitive key apply_graph_update dedups on) so a
# casing/spacing drift doesn't drop a genuinely-changed concept.
if changes:
changed_names = {_normalize_concept(c["concept"]) for c in changes}
persisted_nodes = [
n
for n in updated_nodes
if _normalize_concept(n["concept_name"]) in changed_names
]
if persisted_nodes:
ctx.deps.graph_updates.append({"updated_nodes": persisted_nodes})
# Surface the real before/after deltas for parity with the legacy path.
ctx.deps.mastery_changes.extend(changes)
parts = [f"{c['concept']} {c['before']:.2f}→{c['after']:.2f}" for c in changes]
return f"Mastery updated: {', '.join(parts)}."
return (
f"Mastery update processed ({len(updated_nodes)} concept(s)); "
"no score change — concept may not exist yet. Call apply_graph_update_tool first."
)
35 changes: 27 additions & 8 deletions backend/routes/learn.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -11,6 +11,7 @@
from pydantic_ai.exceptions import UsageLimitExceeded, UnexpectedModelBehavior
from pydantic_ai.messages import ModelRequest, ModelResponse, TextPart, UserPromptPart

from agents import ORCHESTRATOR_LIMITS
from agents.chat_tutor import agent_for_mode
from agents.deps import SaplingDeps
from db.connection import table
Expand DownExpand Up@@ -512,10 +513,12 @@ async def _chat_via_agent(
"""Run chat_tutor_agent and return the legacy response shape.

Returns ``{"reply": str, "graph_update": dict, "mastery_changes": list}``.
`graph_update` and `mastery_changes` come back empty here because
`apply_graph_update_tool` (registered on chat_tutor) already
persisted any graph changes during the agent run. The frontend's
Learn-page reducer accepts empty values gracefully.
Graph changes are persisted in-band during the agent run by
`apply_graph_update_tool` / `update_mastery_tool` (registered on
chat_tutor); the tools also accumulate their payloads on `deps` so the
route can echo `graph_update` (for graph_update_json / concepts_covered)
and the real `mastery_changes` deltas back to the client, matching the
legacy path. Both are empty when nothing changed this turn.

`use_shared_context=False` flips the model into "no class-aggregate"
mode by appending a constraint instruction to the user message —
Expand DownExpand Up@@ -563,7 +566,11 @@ async def _chat_via_agent(
)

model_override = _resolve_model_pref(model_pref)
run_kwargs: dict = {"deps": deps, "message_history": message_history}
run_kwargs: dict = {
"deps": deps,
"message_history": message_history,
"usage_limits": ORCHESTRATOR_LIMITS,
}
if model_override is not None:
run_kwargs["model"] = model_override

Expand All@@ -576,10 +583,21 @@ async def _chat_via_agent(
result = await agent.run(user_message, **run_kwargs)
reply = result.output # str — chat_tutor agents return plain Markdown.

# Merge all graph update payloads accumulated by tools during this run
# into a single dict so the route can persist graph_update_json and
# end_session can derive concepts_covered correctly.
merged_graph_update: dict = {}
for gu in deps.graph_updates:
for key, items in gu.items():
merged_graph_update.setdefault(key, []).extend(items)

return {
"reply": reply,
"graph_update": {},
"mastery_changes": [],
"graph_update": merged_graph_update,
# Real before/after deltas accumulated by update_mastery_tool, for
# parity with the legacy path (which returns apply_graph_update's
# changes directly). Empty when no mastery moved this turn.
"mastery_changes": deps.mastery_changes,
}


Expand DownExpand Up@@ -690,7 +708,8 @@ async def chat(body: ChatBody, request: Request):
# own writes so a fallback doesn't double-insert. Encryption happens
# inside save_message (`encrypt_if_present`).
save_message(body.session_id, "user", body.message)
save_message(body.session_id, "assistant", response["reply"])
graph_update = response.get("graph_update") or None
save_message(body.session_id, "assistant", response["reply"], graph_update)

return response

Expand Down
3 changes: 2 additions & 1 deletion backend/routes/quiz.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -8,6 +8,7 @@

from pydantic_ai.exceptions import UsageLimitExceeded, UnexpectedModelBehavior

from agents import ORCHESTRATOR_LIMITS
from agents.quiz import quiz_agent, Quiz, QuizQuestion
from agents.deps import SaplingDeps
from agents._run import run_agent_sync
Expand DownExpand Up@@ -186,7 +187,7 @@ async def _quiz_via_agent(
)

model_override = _resolve_model_pref(model_pref)
run_kwargs: dict = {"deps": deps}
run_kwargs: dict = {"deps": deps, "usage_limits": ORCHESTRATOR_LIMITS}
if model_override is not None:
run_kwargs["model"] = model_override
result = await quiz_agent.run(user_message, **run_kwargs)
Expand Down
5 changes: 3 additions & 2 deletions backend/tests/test_chat_tutor_imports.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -34,13 +34,14 @@ def test_unknown_mode_falls_back_to_socratic():
assert agent_for_mode(None) is socratic_agent


def test_all_four_tools_registered():
"""Chat tutor needs three context tools + the graph-update tool."""
def test_all_tools_registered():
"""Chat tutor needs three context tools + two graph tools (add and update mastery)."""
expected = {
"search_course_materials_tool",
"read_session_history_tool",
"read_user_progress_tool",
"apply_graph_update_tool",
"update_mastery_tool",
}
# Pydantic AI 1.89's tool registry is at agent._function_toolset.tools
# (dict keyed by tool name) — see commit a850d31 for the gotcha.
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix: restore mastery updates, persist graph_update_json, wire usage limits by Darkest-Teddy · Pull Request #243 · SaplingLearn/Sapling · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion backend/agents/chat_tutor.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -32,7 +32,7 @@
read_user_progress_tool,
search_course_materials_tool,
)
from agents.tools.graph import apply_graph_update_tool
from agents.tools.graph import apply_graph_update_tool, update_mastery_tool


TutorMode = Literal["socratic", "expository", "teachback"]
Expand All@@ -50,6 +50,12 @@
"fabricate context.\n\n"
"Tone: warm, concise, no filler. Use math/code blocks where helpful "
"(LaTeX `$x^2$`, ```mermaid```, ```plot```). Don't over-explain.\n\n"
"Knowledge graph tools:\n"
"- apply_graph_update_tool: register NEW concepts the student hasn't seen before.\n"
"- update_mastery_tool: adjust mastery on EXISTING concepts this turn. "
"Use +0.1 to +0.3 when they answer correctly; −0.05 to −0.1 for gaps. "
"Call this at the END of every turn where the student demonstrated "
"understanding or revealed a misconception.\n\n"
)

_SOCRATIC_PROMPT = _SHARED_PREAMBLE + (
Expand DownExpand Up@@ -104,6 +110,7 @@
read_session_history_tool,
read_user_progress_tool,
apply_graph_update_tool,
update_mastery_tool,
]


Expand Down
10 changes: 9 additions & 1 deletion backend/agents/deps.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,7 +7,7 @@

from __future__ import annotations

from dataclasses import dataclass
from dataclasses import dataclass, field
from typing import Any


Expand All@@ -27,10 +27,18 @@ class SaplingDeps:
that need to scope reads to *this* conversation (e.g.
read_session_history_tool). Optional — agent runs that don't
happen inside a session (eval mode, batch tasks) leave it None.
graph_updates: Accumulates graph update payloads emitted by tools
during a run so the route can persist them in graph_update_json
for concepts_covered derivation in end_session.
mastery_changes: Accumulates the real before/after mastery deltas
returned by apply_graph_update so the route can surface them in
the chat response for parity with the legacy path.
"""

user_id: str
course_id: str | None
supabase: Any
request_id: str
session_id: str | None = None
graph_updates: list = field(default_factory=list)
mastery_changes: list = field(default_factory=list)
135 changes: 121 additions & 14 deletions backend/agents/tools/graph.py
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,24 @@
"""Graph-update helpers and a Pydantic AI tool wrapper.
"""Graph-update helpers and Pydantic AI tool wrappers.

The core merge logic lives in `apply_concepts_to_graph` — a plain async
function callable from routes directly. `apply_graph_update_tool` is a
thin Pydantic AI wrapper around it for future agents that need a tool
to register on an `Agent`. Neither contains LLM-specific logic; that
stays in `services.graph_service`.
Two tools are exposed:
- apply_graph_update_tool — registers new concepts (new_nodes, initial_mastery 0.0)
- update_mastery_tool — adjusts mastery on existing concepts (updated_nodes + delta)

Both append their payload to ctx.deps.graph_updates so the route can
persist graph_update_json on the assistant message, enabling end_session
to derive concepts_covered correctly for agent-path chats.
"""

from __future__ import annotations

import asyncio
from typing import Literal

from pydantic import BaseModel, Field
from pydantic_ai import RunContext

from agents.deps import SaplingDeps
from services.graph_service import apply_graph_update
from services.graph_service import _normalize_concept, apply_graph_update


class GraphUpdateInput(BaseModel):
Expand All@@ -27,6 +30,41 @@ class GraphUpdateInput(BaseModel):
)


class ConceptMasteryUpdate(BaseModel):
concept_name: str = Field(
description="Exact name of the concept whose mastery score to change."
)
mastery_delta: float = Field(
ge=-1.0,
le=1.0,
description=(
"Fractional mastery change, −1.0 to +1.0. "
"Use +0.1 to +0.3 when the student answers correctly; "
"−0.05 to −0.1 when they reveal a gap or misconception."
),
)
reason: str = Field(
default="",
description="Short phrase shown in the mastery-event log (e.g. 'answered correctly').",
)
event_type: Literal["interaction", "correction", "quiz"] = Field(
default="interaction",
description="Event category for the mastery-event log.",
)


class MasteryUpdateInput(BaseModel):
"""Typed input for the update_mastery tool."""

updates: list[ConceptMasteryUpdate] = Field(
description=(
"One entry per concept whose mastery changed this turn. "
"Only include concepts that already exist in the graph "
"(or were just added via apply_graph_update_tool)."
)
)


async def apply_concepts_to_graph(
user_id: str,
course_id: str | None,
Expand DownExpand Up@@ -58,13 +96,82 @@ async def apply_graph_update_tool(
ctx: RunContext[SaplingDeps],
update: GraphUpdateInput,
) -> str:
"""Pydantic AI tool wrapper around apply_concepts_to_graph.
"""Register new concepts in the student's knowledge graph.

Returns a short summary string for the agent to confirm the operation.
Call this when a new topic comes up that isn't already tracked.
To raise or lower mastery on an existing concept, call update_mastery_tool.
"""
count = await apply_concepts_to_graph(
ctx.deps.user_id, ctx.deps.course_id, update.concepts,
)
if count == 0:
new_nodes = [
{"concept_name": name.strip(), "initial_mastery": 0.0}
for name in update.concepts
if name and name.strip()
]
Comment thread
coderabbitai[bot] marked this conversation as resolved.
if not new_nodes:
return "Graph update skipped: no concepts to add."
return f"Graph updated: {count} concept(s) merged."
await asyncio.to_thread(
apply_graph_update,
ctx.deps.user_id,
{"new_nodes": new_nodes},
ctx.deps.course_id,
)
ctx.deps.graph_updates.append({"new_nodes": new_nodes})
return f"Graph updated: {len(new_nodes)} concept(s) merged."


async def update_mastery_tool(
ctx: RunContext[SaplingDeps],
update: MasteryUpdateInput,
) -> str:
"""Adjust mastery scores for concepts the student engaged with this turn.

Positive delta (e.g. +0.15) when they demonstrate understanding;
negative (e.g. −0.08) when they reveal a gap. Concepts must already
exist in the graph — call apply_graph_update_tool first if needed.
"""
updated_nodes = [
{
"concept_name": u.concept_name.strip(),
"mastery_delta": u.mastery_delta,
"reason": u.reason,
"event_type": u.event_type,
}
for u in update.updates
if u.concept_name and u.concept_name.strip()
]
if not updated_nodes:
return "Mastery update skipped: no concepts provided."

changes = await asyncio.to_thread(
apply_graph_update,
ctx.deps.user_id,
{"updated_nodes": updated_nodes},
ctx.deps.course_id,
)

# Only persist concepts that actually produced a change. A concept the
# model named but that doesn't exist in the graph yields no `changes`
# and is never written, so it must not leak into graph_update_json (it
# would over-report concepts_covered in end_session). Rebuild the
# appended updated_nodes from the concepts that genuinely changed.
#
# `changes` carries the *stored* concept_name while `updated_nodes` holds
# the *model-provided* spelling; match on the normalized form (the same
# case/whitespace-insensitive key apply_graph_update dedups on) so a
# casing/spacing drift doesn't drop a genuinely-changed concept.
if changes:
changed_names = {_normalize_concept(c["concept"]) for c in changes}
persisted_nodes = [
n
for n in updated_nodes
if _normalize_concept(n["concept_name"]) in changed_names
]
if persisted_nodes:
ctx.deps.graph_updates.append({"updated_nodes": persisted_nodes})
# Surface the real before/after deltas for parity with the legacy path.
ctx.deps.mastery_changes.extend(changes)
parts = [f"{c['concept']} {c['before']:.2f}→{c['after']:.2f}" for c in changes]
return f"Mastery updated: {', '.join(parts)}."
return (
f"Mastery update processed ({len(updated_nodes)} concept(s)); "
"no score change — concept may not exist yet. Call apply_graph_update_tool first."
)
35 changes: 27 additions & 8 deletions backend/routes/learn.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -11,6 +11,7 @@
from pydantic_ai.exceptions import UsageLimitExceeded, UnexpectedModelBehavior
from pydantic_ai.messages import ModelRequest, ModelResponse, TextPart, UserPromptPart

from agents import ORCHESTRATOR_LIMITS
from agents.chat_tutor import agent_for_mode
from agents.deps import SaplingDeps
from db.connection import table
Expand DownExpand Up@@ -512,10 +513,12 @@ async def _chat_via_agent(
"""Run chat_tutor_agent and return the legacy response shape.

Returns ``{"reply": str, "graph_update": dict, "mastery_changes": list}``.
`graph_update` and `mastery_changes` come back empty here because
`apply_graph_update_tool` (registered on chat_tutor) already
persisted any graph changes during the agent run. The frontend's
Learn-page reducer accepts empty values gracefully.
Graph changes are persisted in-band during the agent run by
`apply_graph_update_tool` / `update_mastery_tool` (registered on
chat_tutor); the tools also accumulate their payloads on `deps` so the
route can echo `graph_update` (for graph_update_json / concepts_covered)
and the real `mastery_changes` deltas back to the client, matching the
legacy path. Both are empty when nothing changed this turn.

`use_shared_context=False` flips the model into "no class-aggregate"
mode by appending a constraint instruction to the user message —
Expand DownExpand Up@@ -563,7 +566,11 @@ async def _chat_via_agent(
)

model_override = _resolve_model_pref(model_pref)
run_kwargs: dict = {"deps": deps, "message_history": message_history}
run_kwargs: dict = {
"deps": deps,
"message_history": message_history,
"usage_limits": ORCHESTRATOR_LIMITS,
}
if model_override is not None:
run_kwargs["model"] = model_override

Expand All@@ -576,10 +583,21 @@ async def _chat_via_agent(
result = await agent.run(user_message, **run_kwargs)
reply = result.output # str — chat_tutor agents return plain Markdown.

# Merge all graph update payloads accumulated by tools during this run
# into a single dict so the route can persist graph_update_json and
# end_session can derive concepts_covered correctly.
merged_graph_update: dict = {}
for gu in deps.graph_updates:
for key, items in gu.items():
merged_graph_update.setdefault(key, []).extend(items)

return {
"reply": reply,
"graph_update": {},
"mastery_changes": [],
"graph_update": merged_graph_update,
# Real before/after deltas accumulated by update_mastery_tool, for
# parity with the legacy path (which returns apply_graph_update's
# changes directly). Empty when no mastery moved this turn.
"mastery_changes": deps.mastery_changes,
}


Expand DownExpand Up@@ -690,7 +708,8 @@ async def chat(body: ChatBody, request: Request):
# own writes so a fallback doesn't double-insert. Encryption happens
# inside save_message (`encrypt_if_present`).
save_message(body.session_id, "user", body.message)
save_message(body.session_id, "assistant", response["reply"])
graph_update = response.get("graph_update") or None
save_message(body.session_id, "assistant", response["reply"], graph_update)

return response

Expand Down
3 changes: 2 additions & 1 deletion backend/routes/quiz.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -8,6 +8,7 @@

from pydantic_ai.exceptions import UsageLimitExceeded, UnexpectedModelBehavior

from agents import ORCHESTRATOR_LIMITS
from agents.quiz import quiz_agent, Quiz, QuizQuestion
from agents.deps import SaplingDeps
from agents._run import run_agent_sync
Expand DownExpand Up@@ -186,7 +187,7 @@ async def _quiz_via_agent(
)

model_override = _resolve_model_pref(model_pref)
run_kwargs: dict = {"deps": deps}
run_kwargs: dict = {"deps": deps, "usage_limits": ORCHESTRATOR_LIMITS}
if model_override is not None:
run_kwargs["model"] = model_override
result = await quiz_agent.run(user_message, **run_kwargs)
Expand Down
5 changes: 3 additions & 2 deletions backend/tests/test_chat_tutor_imports.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -34,13 +34,14 @@ def test_unknown_mode_falls_back_to_socratic():
assert agent_for_mode(None) is socratic_agent


def test_all_four_tools_registered():
"""Chat tutor needs three context tools + the graph-update tool."""
def test_all_tools_registered():
"""Chat tutor needs three context tools + two graph tools (add and update mastery)."""
expected = {
"search_course_materials_tool",
"read_session_history_tool",
"read_user_progress_tool",
"apply_graph_update_tool",
"update_mastery_tool",
}
# Pydantic AI 1.89's tool registry is at agent._function_toolset.tools
# (dict keyed by tool name) — see commit a850d31 for the gotcha.
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix: restore mastery updates, persist graph_update_json, wire usage limits by Darkest-Teddy · Pull Request #243 · SaplingLearn/Sapling · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion backend/agents/chat_tutor.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -32,7 +32,7 @@
read_user_progress_tool,
search_course_materials_tool,
)
from agents.tools.graph import apply_graph_update_tool
from agents.tools.graph import apply_graph_update_tool, update_mastery_tool


TutorMode = Literal["socratic", "expository", "teachback"]
Expand All@@ -50,6 +50,12 @@
"fabricate context.\n\n"
"Tone: warm, concise, no filler. Use math/code blocks where helpful "
"(LaTeX `$x^2$`, ```mermaid```, ```plot```). Don't over-explain.\n\n"
"Knowledge graph tools:\n"
"- apply_graph_update_tool: register NEW concepts the student hasn't seen before.\n"
"- update_mastery_tool: adjust mastery on EXISTING concepts this turn. "
"Use +0.1 to +0.3 when they answer correctly; −0.05 to −0.1 for gaps. "
"Call this at the END of every turn where the student demonstrated "
"understanding or revealed a misconception.\n\n"
)

_SOCRATIC_PROMPT = _SHARED_PREAMBLE + (
Expand DownExpand Up@@ -104,6 +110,7 @@
read_session_history_tool,
read_user_progress_tool,
apply_graph_update_tool,
update_mastery_tool,
]


Expand Down
10 changes: 9 additions & 1 deletion backend/agents/deps.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,7 +7,7 @@

from __future__ import annotations

from dataclasses import dataclass
from dataclasses import dataclass, field
from typing import Any


Expand All@@ -27,10 +27,18 @@ class SaplingDeps:
that need to scope reads to *this* conversation (e.g.
read_session_history_tool). Optional — agent runs that don't
happen inside a session (eval mode, batch tasks) leave it None.
graph_updates: Accumulates graph update payloads emitted by tools
during a run so the route can persist them in graph_update_json
for concepts_covered derivation in end_session.
mastery_changes: Accumulates the real before/after mastery deltas
returned by apply_graph_update so the route can surface them in
the chat response for parity with the legacy path.
"""

user_id: str
course_id: str | None
supabase: Any
request_id: str
session_id: str | None = None
graph_updates: list = field(default_factory=list)
mastery_changes: list = field(default_factory=list)
135 changes: 121 additions & 14 deletions backend/agents/tools/graph.py
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,24 @@
"""Graph-update helpers and a Pydantic AI tool wrapper.
"""Graph-update helpers and Pydantic AI tool wrappers.

The core merge logic lives in `apply_concepts_to_graph` — a plain async
function callable from routes directly. `apply_graph_update_tool` is a
thin Pydantic AI wrapper around it for future agents that need a tool
to register on an `Agent`. Neither contains LLM-specific logic; that
stays in `services.graph_service`.
Two tools are exposed:
- apply_graph_update_tool — registers new concepts (new_nodes, initial_mastery 0.0)
- update_mastery_tool — adjusts mastery on existing concepts (updated_nodes + delta)

Both append their payload to ctx.deps.graph_updates so the route can
persist graph_update_json on the assistant message, enabling end_session
to derive concepts_covered correctly for agent-path chats.
"""

from __future__ import annotations

import asyncio
from typing import Literal

from pydantic import BaseModel, Field
from pydantic_ai import RunContext

from agents.deps import SaplingDeps
from services.graph_service import apply_graph_update
from services.graph_service import _normalize_concept, apply_graph_update


class GraphUpdateInput(BaseModel):
Expand All@@ -27,6 +30,41 @@ class GraphUpdateInput(BaseModel):
)


class ConceptMasteryUpdate(BaseModel):
concept_name: str = Field(
description="Exact name of the concept whose mastery score to change."
)
mastery_delta: float = Field(
ge=-1.0,
le=1.0,
description=(
"Fractional mastery change, −1.0 to +1.0. "
"Use +0.1 to +0.3 when the student answers correctly; "
"−0.05 to −0.1 when they reveal a gap or misconception."
),
)
reason: str = Field(
default="",
description="Short phrase shown in the mastery-event log (e.g. 'answered correctly').",
)
event_type: Literal["interaction", "correction", "quiz"] = Field(
default="interaction",
description="Event category for the mastery-event log.",
)


class MasteryUpdateInput(BaseModel):
"""Typed input for the update_mastery tool."""

updates: list[ConceptMasteryUpdate] = Field(
description=(
"One entry per concept whose mastery changed this turn. "
"Only include concepts that already exist in the graph "
"(or were just added via apply_graph_update_tool)."
)
)


async def apply_concepts_to_graph(
user_id: str,
course_id: str | None,
Expand DownExpand Up@@ -58,13 +96,82 @@ async def apply_graph_update_tool(
ctx: RunContext[SaplingDeps],
update: GraphUpdateInput,
) -> str:
"""Pydantic AI tool wrapper around apply_concepts_to_graph.
"""Register new concepts in the student's knowledge graph.

Returns a short summary string for the agent to confirm the operation.
Call this when a new topic comes up that isn't already tracked.
To raise or lower mastery on an existing concept, call update_mastery_tool.
"""
count = await apply_concepts_to_graph(
ctx.deps.user_id, ctx.deps.course_id, update.concepts,
)
if count == 0:
new_nodes = [
{"concept_name": name.strip(), "initial_mastery": 0.0}
for name in update.concepts
if name and name.strip()
]
Comment thread
coderabbitai[bot] marked this conversation as resolved.
if not new_nodes:
return "Graph update skipped: no concepts to add."
return f"Graph updated: {count} concept(s) merged."
await asyncio.to_thread(
apply_graph_update,
ctx.deps.user_id,
{"new_nodes": new_nodes},
ctx.deps.course_id,
)
ctx.deps.graph_updates.append({"new_nodes": new_nodes})
return f"Graph updated: {len(new_nodes)} concept(s) merged."


async def update_mastery_tool(
ctx: RunContext[SaplingDeps],
update: MasteryUpdateInput,
) -> str:
"""Adjust mastery scores for concepts the student engaged with this turn.

Positive delta (e.g. +0.15) when they demonstrate understanding;
negative (e.g. −0.08) when they reveal a gap. Concepts must already
exist in the graph — call apply_graph_update_tool first if needed.
"""
updated_nodes = [
{
"concept_name": u.concept_name.strip(),
"mastery_delta": u.mastery_delta,
"reason": u.reason,
"event_type": u.event_type,
}
for u in update.updates
if u.concept_name and u.concept_name.strip()
]
if not updated_nodes:
return "Mastery update skipped: no concepts provided."

changes = await asyncio.to_thread(
apply_graph_update,
ctx.deps.user_id,
{"updated_nodes": updated_nodes},
ctx.deps.course_id,
)

# Only persist concepts that actually produced a change. A concept the
# model named but that doesn't exist in the graph yields no `changes`
# and is never written, so it must not leak into graph_update_json (it
# would over-report concepts_covered in end_session). Rebuild the
# appended updated_nodes from the concepts that genuinely changed.
#
# `changes` carries the *stored* concept_name while `updated_nodes` holds
# the *model-provided* spelling; match on the normalized form (the same
# case/whitespace-insensitive key apply_graph_update dedups on) so a
# casing/spacing drift doesn't drop a genuinely-changed concept.
if changes:
changed_names = {_normalize_concept(c["concept"]) for c in changes}
persisted_nodes = [
n
for n in updated_nodes
if _normalize_concept(n["concept_name"]) in changed_names
]
if persisted_nodes:
ctx.deps.graph_updates.append({"updated_nodes": persisted_nodes})
# Surface the real before/after deltas for parity with the legacy path.
ctx.deps.mastery_changes.extend(changes)
parts = [f"{c['concept']} {c['before']:.2f}→{c['after']:.2f}" for c in changes]
return f"Mastery updated: {', '.join(parts)}."
return (
f"Mastery update processed ({len(updated_nodes)} concept(s)); "
"no score change — concept may not exist yet. Call apply_graph_update_tool first."
)
35 changes: 27 additions & 8 deletions backend/routes/learn.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -11,6 +11,7 @@
from pydantic_ai.exceptions import UsageLimitExceeded, UnexpectedModelBehavior
from pydantic_ai.messages import ModelRequest, ModelResponse, TextPart, UserPromptPart

from agents import ORCHESTRATOR_LIMITS
from agents.chat_tutor import agent_for_mode
from agents.deps import SaplingDeps
from db.connection import table
Expand DownExpand Up@@ -512,10 +513,12 @@ async def _chat_via_agent(
"""Run chat_tutor_agent and return the legacy response shape.

Returns ``{"reply": str, "graph_update": dict, "mastery_changes": list}``.
`graph_update` and `mastery_changes` come back empty here because
`apply_graph_update_tool` (registered on chat_tutor) already
persisted any graph changes during the agent run. The frontend's
Learn-page reducer accepts empty values gracefully.
Graph changes are persisted in-band during the agent run by
`apply_graph_update_tool` / `update_mastery_tool` (registered on
chat_tutor); the tools also accumulate their payloads on `deps` so the
route can echo `graph_update` (for graph_update_json / concepts_covered)
and the real `mastery_changes` deltas back to the client, matching the
legacy path. Both are empty when nothing changed this turn.

`use_shared_context=False` flips the model into "no class-aggregate"
mode by appending a constraint instruction to the user message —
Expand DownExpand Up@@ -563,7 +566,11 @@ async def _chat_via_agent(
)

model_override = _resolve_model_pref(model_pref)
run_kwargs: dict = {"deps": deps, "message_history": message_history}
run_kwargs: dict = {
"deps": deps,
"message_history": message_history,
"usage_limits": ORCHESTRATOR_LIMITS,
}
if model_override is not None:
run_kwargs["model"] = model_override

Expand All@@ -576,10 +583,21 @@ async def _chat_via_agent(
result = await agent.run(user_message, **run_kwargs)
reply = result.output # str — chat_tutor agents return plain Markdown.

# Merge all graph update payloads accumulated by tools during this run
# into a single dict so the route can persist graph_update_json and
# end_session can derive concepts_covered correctly.
merged_graph_update: dict = {}
for gu in deps.graph_updates:
for key, items in gu.items():
merged_graph_update.setdefault(key, []).extend(items)

return {
"reply": reply,
"graph_update": {},
"mastery_changes": [],
"graph_update": merged_graph_update,
# Real before/after deltas accumulated by update_mastery_tool, for
# parity with the legacy path (which returns apply_graph_update's
# changes directly). Empty when no mastery moved this turn.
"mastery_changes": deps.mastery_changes,
}


Expand DownExpand Up@@ -690,7 +708,8 @@ async def chat(body: ChatBody, request: Request):
# own writes so a fallback doesn't double-insert. Encryption happens
# inside save_message (`encrypt_if_present`).
save_message(body.session_id, "user", body.message)
save_message(body.session_id, "assistant", response["reply"])
graph_update = response.get("graph_update") or None
save_message(body.session_id, "assistant", response["reply"], graph_update)

return response

Expand Down
3 changes: 2 additions & 1 deletion backend/routes/quiz.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -8,6 +8,7 @@

from pydantic_ai.exceptions import UsageLimitExceeded, UnexpectedModelBehavior

from agents import ORCHESTRATOR_LIMITS
from agents.quiz import quiz_agent, Quiz, QuizQuestion
from agents.deps import SaplingDeps
from agents._run import run_agent_sync
Expand DownExpand Up@@ -186,7 +187,7 @@ async def _quiz_via_agent(
)

model_override = _resolve_model_pref(model_pref)
run_kwargs: dict = {"deps": deps}
run_kwargs: dict = {"deps": deps, "usage_limits": ORCHESTRATOR_LIMITS}
if model_override is not None:
run_kwargs["model"] = model_override
result = await quiz_agent.run(user_message, **run_kwargs)
Expand Down
5 changes: 3 additions & 2 deletions backend/tests/test_chat_tutor_imports.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -34,13 +34,14 @@ def test_unknown_mode_falls_back_to_socratic():
assert agent_for_mode(None) is socratic_agent


def test_all_four_tools_registered():
"""Chat tutor needs three context tools + the graph-update tool."""
def test_all_tools_registered():
"""Chat tutor needs three context tools + two graph tools (add and update mastery)."""
expected = {
"search_course_materials_tool",
"read_session_history_tool",
"read_user_progress_tool",
"apply_graph_update_tool",
"update_mastery_tool",
}
# Pydantic AI 1.89's tool registry is at agent._function_toolset.tools
# (dict keyed by tool name) — see commit a850d31 for the gotcha.
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' fix: restore mastery updates, persist graph_update_json, wire usage limits by Darkest-Teddy · Pull Request #243 · SaplingLearn/Sapling · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion backend/agents/chat_tutor.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -32,7 +32,7 @@
read_user_progress_tool,
search_course_materials_tool,
)
from agents.tools.graph import apply_graph_update_tool
from agents.tools.graph import apply_graph_update_tool, update_mastery_tool


TutorMode = Literal["socratic", "expository", "teachback"]
Expand All@@ -50,6 +50,12 @@
"fabricate context.\n\n"
"Tone: warm, concise, no filler. Use math/code blocks where helpful "
"(LaTeX `$x^2$`, ```mermaid```, ```plot```). Don't over-explain.\n\n"
"Knowledge graph tools:\n"
"- apply_graph_update_tool: register NEW concepts the student hasn't seen before.\n"
"- update_mastery_tool: adjust mastery on EXISTING concepts this turn. "
"Use +0.1 to +0.3 when they answer correctly; −0.05 to −0.1 for gaps. "
"Call this at the END of every turn where the student demonstrated "
"understanding or revealed a misconception.\n\n"
)

_SOCRATIC_PROMPT = _SHARED_PREAMBLE + (
Expand DownExpand Up@@ -104,6 +110,7 @@
read_session_history_tool,
read_user_progress_tool,
apply_graph_update_tool,
update_mastery_tool,
]


Expand Down
10 changes: 9 additions & 1 deletion backend/agents/deps.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,7 +7,7 @@

from __future__ import annotations

from dataclasses import dataclass
from dataclasses import dataclass, field
from typing import Any


Expand All@@ -27,10 +27,18 @@ class SaplingDeps:
that need to scope reads to *this* conversation (e.g.
read_session_history_tool). Optional — agent runs that don't
happen inside a session (eval mode, batch tasks) leave it None.
graph_updates: Accumulates graph update payloads emitted by tools
during a run so the route can persist them in graph_update_json
for concepts_covered derivation in end_session.
mastery_changes: Accumulates the real before/after mastery deltas
returned by apply_graph_update so the route can surface them in
the chat response for parity with the legacy path.
"""

user_id: str
course_id: str | None
supabase: Any
request_id: str
session_id: str | None = None
graph_updates: list = field(default_factory=list)
mastery_changes: list = field(default_factory=list)
135 changes: 121 additions & 14 deletions backend/agents/tools/graph.py
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,24 @@
"""Graph-update helpers and a Pydantic AI tool wrapper.
"""Graph-update helpers and Pydantic AI tool wrappers.

The core merge logic lives in `apply_concepts_to_graph` — a plain async
function callable from routes directly. `apply_graph_update_tool` is a
thin Pydantic AI wrapper around it for future agents that need a tool
to register on an `Agent`. Neither contains LLM-specific logic; that
stays in `services.graph_service`.
Two tools are exposed:
- apply_graph_update_tool — registers new concepts (new_nodes, initial_mastery 0.0)
- update_mastery_tool — adjusts mastery on existing concepts (updated_nodes + delta)

Both append their payload to ctx.deps.graph_updates so the route can
persist graph_update_json on the assistant message, enabling end_session
to derive concepts_covered correctly for agent-path chats.
"""

from __future__ import annotations

import asyncio
from typing import Literal

from pydantic import BaseModel, Field
from pydantic_ai import RunContext

from agents.deps import SaplingDeps
from services.graph_service import apply_graph_update
from services.graph_service import _normalize_concept, apply_graph_update


class GraphUpdateInput(BaseModel):
Expand All@@ -27,6 +30,41 @@ class GraphUpdateInput(BaseModel):
)


class ConceptMasteryUpdate(BaseModel):
concept_name: str = Field(
description="Exact name of the concept whose mastery score to change."
)
mastery_delta: float = Field(
ge=-1.0,
le=1.0,
description=(
"Fractional mastery change, −1.0 to +1.0. "
"Use +0.1 to +0.3 when the student answers correctly; "
"−0.05 to −0.1 when they reveal a gap or misconception."
),
)
reason: str = Field(
default="",
description="Short phrase shown in the mastery-event log (e.g. 'answered correctly').",
)
event_type: Literal["interaction", "correction", "quiz"] = Field(
default="interaction",
description="Event category for the mastery-event log.",
)


class MasteryUpdateInput(BaseModel):
"""Typed input for the update_mastery tool."""

updates: list[ConceptMasteryUpdate] = Field(
description=(
"One entry per concept whose mastery changed this turn. "
"Only include concepts that already exist in the graph "
"(or were just added via apply_graph_update_tool)."
)
)


async def apply_concepts_to_graph(
user_id: str,
course_id: str | None,
Expand DownExpand Up@@ -58,13 +96,82 @@ async def apply_graph_update_tool(
ctx: RunContext[SaplingDeps],
update: GraphUpdateInput,
) -> str:
"""Pydantic AI tool wrapper around apply_concepts_to_graph.
"""Register new concepts in the student's knowledge graph.

Returns a short summary string for the agent to confirm the operation.
Call this when a new topic comes up that isn't already tracked.
To raise or lower mastery on an existing concept, call update_mastery_tool.
"""
count = await apply_concepts_to_graph(
ctx.deps.user_id, ctx.deps.course_id, update.concepts,
)
if count == 0:
new_nodes = [
{"concept_name": name.strip(), "initial_mastery": 0.0}
for name in update.concepts
if name and name.strip()
]
Comment thread
coderabbitai[bot] marked this conversation as resolved.
if not new_nodes:
return "Graph update skipped: no concepts to add."
return f"Graph updated: {count} concept(s) merged."
await asyncio.to_thread(
apply_graph_update,
ctx.deps.user_id,
{"new_nodes": new_nodes},
ctx.deps.course_id,
)
ctx.deps.graph_updates.append({"new_nodes": new_nodes})
return f"Graph updated: {len(new_nodes)} concept(s) merged."


async def update_mastery_tool(
ctx: RunContext[SaplingDeps],
update: MasteryUpdateInput,
) -> str:
"""Adjust mastery scores for concepts the student engaged with this turn.

Positive delta (e.g. +0.15) when they demonstrate understanding;
negative (e.g. −0.08) when they reveal a gap. Concepts must already
exist in the graph — call apply_graph_update_tool first if needed.
"""
updated_nodes = [
{
"concept_name": u.concept_name.strip(),
"mastery_delta": u.mastery_delta,
"reason": u.reason,
"event_type": u.event_type,
}
for u in update.updates
if u.concept_name and u.concept_name.strip()
]
if not updated_nodes:
return "Mastery update skipped: no concepts provided."

changes = await asyncio.to_thread(
apply_graph_update,
ctx.deps.user_id,
{"updated_nodes": updated_nodes},
ctx.deps.course_id,
)

# Only persist concepts that actually produced a change. A concept the
# model named but that doesn't exist in the graph yields no `changes`
# and is never written, so it must not leak into graph_update_json (it
# would over-report concepts_covered in end_session). Rebuild the
# appended updated_nodes from the concepts that genuinely changed.
#
# `changes` carries the *stored* concept_name while `updated_nodes` holds
# the *model-provided* spelling; match on the normalized form (the same
# case/whitespace-insensitive key apply_graph_update dedups on) so a
# casing/spacing drift doesn't drop a genuinely-changed concept.
if changes:
changed_names = {_normalize_concept(c["concept"]) for c in changes}
persisted_nodes = [
n
for n in updated_nodes
if _normalize_concept(n["concept_name"]) in changed_names
]
if persisted_nodes:
ctx.deps.graph_updates.append({"updated_nodes": persisted_nodes})
# Surface the real before/after deltas for parity with the legacy path.
ctx.deps.mastery_changes.extend(changes)
parts = [f"{c['concept']} {c['before']:.2f}→{c['after']:.2f}" for c in changes]
return f"Mastery updated: {', '.join(parts)}."
return (
f"Mastery update processed ({len(updated_nodes)} concept(s)); "
"no score change — concept may not exist yet. Call apply_graph_update_tool first."
)
35 changes: 27 additions & 8 deletions backend/routes/learn.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -11,6 +11,7 @@
from pydantic_ai.exceptions import UsageLimitExceeded, UnexpectedModelBehavior
from pydantic_ai.messages import ModelRequest, ModelResponse, TextPart, UserPromptPart

from agents import ORCHESTRATOR_LIMITS
from agents.chat_tutor import agent_for_mode
from agents.deps import SaplingDeps
from db.connection import table
Expand DownExpand Up@@ -512,10 +513,12 @@ async def _chat_via_agent(
"""Run chat_tutor_agent and return the legacy response shape.

Returns ``{"reply": str, "graph_update": dict, "mastery_changes": list}``.
`graph_update` and `mastery_changes` come back empty here because
`apply_graph_update_tool` (registered on chat_tutor) already
persisted any graph changes during the agent run. The frontend's
Learn-page reducer accepts empty values gracefully.
Graph changes are persisted in-band during the agent run by
`apply_graph_update_tool` / `update_mastery_tool` (registered on
chat_tutor); the tools also accumulate their payloads on `deps` so the
route can echo `graph_update` (for graph_update_json / concepts_covered)
and the real `mastery_changes` deltas back to the client, matching the
legacy path. Both are empty when nothing changed this turn.

`use_shared_context=False` flips the model into "no class-aggregate"
mode by appending a constraint instruction to the user message —
Expand DownExpand Up@@ -563,7 +566,11 @@ async def _chat_via_agent(
)

model_override = _resolve_model_pref(model_pref)
run_kwargs: dict = {"deps": deps, "message_history": message_history}
run_kwargs: dict = {
"deps": deps,
"message_history": message_history,
"usage_limits": ORCHESTRATOR_LIMITS,
}
if model_override is not None:
run_kwargs["model"] = model_override

Expand All@@ -576,10 +583,21 @@ async def _chat_via_agent(
result = await agent.run(user_message, **run_kwargs)
reply = result.output # str — chat_tutor agents return plain Markdown.

# Merge all graph update payloads accumulated by tools during this run
# into a single dict so the route can persist graph_update_json and
# end_session can derive concepts_covered correctly.
merged_graph_update: dict = {}
for gu in deps.graph_updates:
for key, items in gu.items():
merged_graph_update.setdefault(key, []).extend(items)

return {
"reply": reply,
"graph_update": {},
"mastery_changes": [],
"graph_update": merged_graph_update,
# Real before/after deltas accumulated by update_mastery_tool, for
# parity with the legacy path (which returns apply_graph_update's
# changes directly). Empty when no mastery moved this turn.
"mastery_changes": deps.mastery_changes,
}


Expand DownExpand Up@@ -690,7 +708,8 @@ async def chat(body: ChatBody, request: Request):
# own writes so a fallback doesn't double-insert. Encryption happens
# inside save_message (`encrypt_if_present`).
save_message(body.session_id, "user", body.message)
save_message(body.session_id, "assistant", response["reply"])
graph_update = response.get("graph_update") or None
save_message(body.session_id, "assistant", response["reply"], graph_update)

return response

Expand Down
3 changes: 2 additions & 1 deletion backend/routes/quiz.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -8,6 +8,7 @@

from pydantic_ai.exceptions import UsageLimitExceeded, UnexpectedModelBehavior

from agents import ORCHESTRATOR_LIMITS
from agents.quiz import quiz_agent, Quiz, QuizQuestion
from agents.deps import SaplingDeps
from agents._run import run_agent_sync
Expand DownExpand Up@@ -186,7 +187,7 @@ async def _quiz_via_agent(
)

model_override = _resolve_model_pref(model_pref)
run_kwargs: dict = {"deps": deps}
run_kwargs: dict = {"deps": deps, "usage_limits": ORCHESTRATOR_LIMITS}
if model_override is not None:
run_kwargs["model"] = model_override
result = await quiz_agent.run(user_message, **run_kwargs)
Expand Down
5 changes: 3 additions & 2 deletions backend/tests/test_chat_tutor_imports.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -34,13 +34,14 @@ def test_unknown_mode_falls_back_to_socratic():
assert agent_for_mode(None) is socratic_agent


def test_all_four_tools_registered():
"""Chat tutor needs three context tools + the graph-update tool."""
def test_all_tools_registered():
"""Chat tutor needs three context tools + two graph tools (add and update mastery)."""
expected = {
"search_course_materials_tool",
"read_session_history_tool",
"read_user_progress_tool",
"apply_graph_update_tool",
"update_mastery_tool",
}
# Pydantic AI 1.89's tool registry is at agent._function_toolset.tools
# (dict keyed by tool name) — see commit a850d31 for the gotcha.
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix: restore mastery updates, persist graph_update_json, wire usage limits by Darkest-Teddy · Pull Request #243 · SaplingLearn/Sapling · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion backend/agents/chat_tutor.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -32,7 +32,7 @@
read_user_progress_tool,
search_course_materials_tool,
)
from agents.tools.graph import apply_graph_update_tool
from agents.tools.graph import apply_graph_update_tool, update_mastery_tool


TutorMode = Literal["socratic", "expository", "teachback"]
Expand All@@ -50,6 +50,12 @@
"fabricate context.\n\n"
"Tone: warm, concise, no filler. Use math/code blocks where helpful "
"(LaTeX `$x^2$`, ```mermaid```, ```plot```). Don't over-explain.\n\n"
"Knowledge graph tools:\n"
"- apply_graph_update_tool: register NEW concepts the student hasn't seen before.\n"
"- update_mastery_tool: adjust mastery on EXISTING concepts this turn. "
"Use +0.1 to +0.3 when they answer correctly; −0.05 to −0.1 for gaps. "
"Call this at the END of every turn where the student demonstrated "
"understanding or revealed a misconception.\n\n"
)

_SOCRATIC_PROMPT = _SHARED_PREAMBLE + (
Expand DownExpand Up@@ -104,6 +110,7 @@
read_session_history_tool,
read_user_progress_tool,
apply_graph_update_tool,
update_mastery_tool,
]


Expand Down
10 changes: 9 additions & 1 deletion backend/agents/deps.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,7 +7,7 @@

from __future__ import annotations

from dataclasses import dataclass
from dataclasses import dataclass, field
from typing import Any


Expand All@@ -27,10 +27,18 @@ class SaplingDeps:
that need to scope reads to *this* conversation (e.g.
read_session_history_tool). Optional — agent runs that don't
happen inside a session (eval mode, batch tasks) leave it None.
graph_updates: Accumulates graph update payloads emitted by tools
during a run so the route can persist them in graph_update_json
for concepts_covered derivation in end_session.
mastery_changes: Accumulates the real before/after mastery deltas
returned by apply_graph_update so the route can surface them in
the chat response for parity with the legacy path.
"""

user_id: str
course_id: str | None
supabase: Any
request_id: str
session_id: str | None = None
graph_updates: list = field(default_factory=list)
mastery_changes: list = field(default_factory=list)
135 changes: 121 additions & 14 deletions backend/agents/tools/graph.py
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,24 @@
"""Graph-update helpers and a Pydantic AI tool wrapper.
"""Graph-update helpers and Pydantic AI tool wrappers.

The core merge logic lives in `apply_concepts_to_graph` — a plain async
function callable from routes directly. `apply_graph_update_tool` is a
thin Pydantic AI wrapper around it for future agents that need a tool
to register on an `Agent`. Neither contains LLM-specific logic; that
stays in `services.graph_service`.
Two tools are exposed:
- apply_graph_update_tool — registers new concepts (new_nodes, initial_mastery 0.0)
- update_mastery_tool — adjusts mastery on existing concepts (updated_nodes + delta)

Both append their payload to ctx.deps.graph_updates so the route can
persist graph_update_json on the assistant message, enabling end_session
to derive concepts_covered correctly for agent-path chats.
"""

from __future__ import annotations

import asyncio
from typing import Literal

from pydantic import BaseModel, Field
from pydantic_ai import RunContext

from agents.deps import SaplingDeps
from services.graph_service import apply_graph_update
from services.graph_service import _normalize_concept, apply_graph_update


class GraphUpdateInput(BaseModel):
Expand All@@ -27,6 +30,41 @@ class GraphUpdateInput(BaseModel):
)


class ConceptMasteryUpdate(BaseModel):
concept_name: str = Field(
description="Exact name of the concept whose mastery score to change."
)
mastery_delta: float = Field(
ge=-1.0,
le=1.0,
description=(
"Fractional mastery change, −1.0 to +1.0. "
"Use +0.1 to +0.3 when the student answers correctly; "
"−0.05 to −0.1 when they reveal a gap or misconception."
),
)
reason: str = Field(
default="",
description="Short phrase shown in the mastery-event log (e.g. 'answered correctly').",
)
event_type: Literal["interaction", "correction", "quiz"] = Field(
default="interaction",
description="Event category for the mastery-event log.",
)


class MasteryUpdateInput(BaseModel):
"""Typed input for the update_mastery tool."""

updates: list[ConceptMasteryUpdate] = Field(
description=(
"One entry per concept whose mastery changed this turn. "
"Only include concepts that already exist in the graph "
"(or were just added via apply_graph_update_tool)."
)
)


async def apply_concepts_to_graph(
user_id: str,
course_id: str | None,
Expand DownExpand Up@@ -58,13 +96,82 @@ async def apply_graph_update_tool(
ctx: RunContext[SaplingDeps],
update: GraphUpdateInput,
) -> str:
"""Pydantic AI tool wrapper around apply_concepts_to_graph.
"""Register new concepts in the student's knowledge graph.

Returns a short summary string for the agent to confirm the operation.
Call this when a new topic comes up that isn't already tracked.
To raise or lower mastery on an existing concept, call update_mastery_tool.
"""
count = await apply_concepts_to_graph(
ctx.deps.user_id, ctx.deps.course_id, update.concepts,
)
if count == 0:
new_nodes = [
{"concept_name": name.strip(), "initial_mastery": 0.0}
for name in update.concepts
if name and name.strip()
]
Comment thread
coderabbitai[bot] marked this conversation as resolved.
if not new_nodes:
return "Graph update skipped: no concepts to add."
return f"Graph updated: {count} concept(s) merged."
await asyncio.to_thread(
apply_graph_update,
ctx.deps.user_id,
{"new_nodes": new_nodes},
ctx.deps.course_id,
)
ctx.deps.graph_updates.append({"new_nodes": new_nodes})
return f"Graph updated: {len(new_nodes)} concept(s) merged."


async def update_mastery_tool(
ctx: RunContext[SaplingDeps],
update: MasteryUpdateInput,
) -> str:
"""Adjust mastery scores for concepts the student engaged with this turn.

Positive delta (e.g. +0.15) when they demonstrate understanding;
negative (e.g. −0.08) when they reveal a gap. Concepts must already
exist in the graph — call apply_graph_update_tool first if needed.
"""
updated_nodes = [
{
"concept_name": u.concept_name.strip(),
"mastery_delta": u.mastery_delta,
"reason": u.reason,
"event_type": u.event_type,
}
for u in update.updates
if u.concept_name and u.concept_name.strip()
]
if not updated_nodes:
return "Mastery update skipped: no concepts provided."

changes = await asyncio.to_thread(
apply_graph_update,
ctx.deps.user_id,
{"updated_nodes": updated_nodes},
ctx.deps.course_id,
)

# Only persist concepts that actually produced a change. A concept the
# model named but that doesn't exist in the graph yields no `changes`
# and is never written, so it must not leak into graph_update_json (it
# would over-report concepts_covered in end_session). Rebuild the
# appended updated_nodes from the concepts that genuinely changed.
#
# `changes` carries the *stored* concept_name while `updated_nodes` holds
# the *model-provided* spelling; match on the normalized form (the same
# case/whitespace-insensitive key apply_graph_update dedups on) so a
# casing/spacing drift doesn't drop a genuinely-changed concept.
if changes:
changed_names = {_normalize_concept(c["concept"]) for c in changes}
persisted_nodes = [
n
for n in updated_nodes
if _normalize_concept(n["concept_name"]) in changed_names
]
if persisted_nodes:
ctx.deps.graph_updates.append({"updated_nodes": persisted_nodes})
# Surface the real before/after deltas for parity with the legacy path.
ctx.deps.mastery_changes.extend(changes)
parts = [f"{c['concept']} {c['before']:.2f}→{c['after']:.2f}" for c in changes]
return f"Mastery updated: {', '.join(parts)}."
return (
f"Mastery update processed ({len(updated_nodes)} concept(s)); "
"no score change — concept may not exist yet. Call apply_graph_update_tool first."
)
35 changes: 27 additions & 8 deletions backend/routes/learn.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -11,6 +11,7 @@
from pydantic_ai.exceptions import UsageLimitExceeded, UnexpectedModelBehavior
from pydantic_ai.messages import ModelRequest, ModelResponse, TextPart, UserPromptPart

from agents import ORCHESTRATOR_LIMITS
from agents.chat_tutor import agent_for_mode
from agents.deps import SaplingDeps
from db.connection import table
Expand DownExpand Up@@ -512,10 +513,12 @@ async def _chat_via_agent(
"""Run chat_tutor_agent and return the legacy response shape.

Returns ``{"reply": str, "graph_update": dict, "mastery_changes": list}``.
`graph_update` and `mastery_changes` come back empty here because
`apply_graph_update_tool` (registered on chat_tutor) already
persisted any graph changes during the agent run. The frontend's
Learn-page reducer accepts empty values gracefully.
Graph changes are persisted in-band during the agent run by
`apply_graph_update_tool` / `update_mastery_tool` (registered on
chat_tutor); the tools also accumulate their payloads on `deps` so the
route can echo `graph_update` (for graph_update_json / concepts_covered)
and the real `mastery_changes` deltas back to the client, matching the
legacy path. Both are empty when nothing changed this turn.

`use_shared_context=False` flips the model into "no class-aggregate"
mode by appending a constraint instruction to the user message —
Expand DownExpand Up@@ -563,7 +566,11 @@ async def _chat_via_agent(
)

model_override = _resolve_model_pref(model_pref)
run_kwargs: dict = {"deps": deps, "message_history": message_history}
run_kwargs: dict = {
"deps": deps,
"message_history": message_history,
"usage_limits": ORCHESTRATOR_LIMITS,
}
if model_override is not None:
run_kwargs["model"] = model_override

Expand All@@ -576,10 +583,21 @@ async def _chat_via_agent(
result = await agent.run(user_message, **run_kwargs)
reply = result.output # str — chat_tutor agents return plain Markdown.

# Merge all graph update payloads accumulated by tools during this run
# into a single dict so the route can persist graph_update_json and
# end_session can derive concepts_covered correctly.
merged_graph_update: dict = {}
for gu in deps.graph_updates:
for key, items in gu.items():
merged_graph_update.setdefault(key, []).extend(items)

return {
"reply": reply,
"graph_update": {},
"mastery_changes": [],
"graph_update": merged_graph_update,
# Real before/after deltas accumulated by update_mastery_tool, for
# parity with the legacy path (which returns apply_graph_update's
# changes directly). Empty when no mastery moved this turn.
"mastery_changes": deps.mastery_changes,
}


Expand DownExpand Up@@ -690,7 +708,8 @@ async def chat(body: ChatBody, request: Request):
# own writes so a fallback doesn't double-insert. Encryption happens
# inside save_message (`encrypt_if_present`).
save_message(body.session_id, "user", body.message)
save_message(body.session_id, "assistant", response["reply"])
graph_update = response.get("graph_update") or None
save_message(body.session_id, "assistant", response["reply"], graph_update)

return response

Expand Down
3 changes: 2 additions & 1 deletion backend/routes/quiz.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -8,6 +8,7 @@

from pydantic_ai.exceptions import UsageLimitExceeded, UnexpectedModelBehavior

from agents import ORCHESTRATOR_LIMITS
from agents.quiz import quiz_agent, Quiz, QuizQuestion
from agents.deps import SaplingDeps
from agents._run import run_agent_sync
Expand DownExpand Up@@ -186,7 +187,7 @@ async def _quiz_via_agent(
)

model_override = _resolve_model_pref(model_pref)
run_kwargs: dict = {"deps": deps}
run_kwargs: dict = {"deps": deps, "usage_limits": ORCHESTRATOR_LIMITS}
if model_override is not None:
run_kwargs["model"] = model_override
result = await quiz_agent.run(user_message, **run_kwargs)
Expand Down
5 changes: 3 additions & 2 deletions backend/tests/test_chat_tutor_imports.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -34,13 +34,14 @@ def test_unknown_mode_falls_back_to_socratic():
assert agent_for_mode(None) is socratic_agent


def test_all_four_tools_registered():
"""Chat tutor needs three context tools + the graph-update tool."""
def test_all_tools_registered():
"""Chat tutor needs three context tools + two graph tools (add and update mastery)."""
expected = {
"search_course_materials_tool",
"read_session_history_tool",
"read_user_progress_tool",
"apply_graph_update_tool",
"update_mastery_tool",
}
# Pydantic AI 1.89's tool registry is at agent._function_toolset.tools
# (dict keyed by tool name) — see commit a850d31 for the gotcha.
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix: restore mastery updates, persist graph_update_json, wire usage limits by Darkest-Teddy · Pull Request #243 · SaplingLearn/Sapling · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion backend/agents/chat_tutor.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -32,7 +32,7 @@
read_user_progress_tool,
search_course_materials_tool,
)
from agents.tools.graph import apply_graph_update_tool
from agents.tools.graph import apply_graph_update_tool, update_mastery_tool


TutorMode = Literal["socratic", "expository", "teachback"]
Expand All@@ -50,6 +50,12 @@
"fabricate context.\n\n"
"Tone: warm, concise, no filler. Use math/code blocks where helpful "
"(LaTeX `$x^2$`, ```mermaid```, ```plot```). Don't over-explain.\n\n"
"Knowledge graph tools:\n"
"- apply_graph_update_tool: register NEW concepts the student hasn't seen before.\n"
"- update_mastery_tool: adjust mastery on EXISTING concepts this turn. "
"Use +0.1 to +0.3 when they answer correctly; −0.05 to −0.1 for gaps. "
"Call this at the END of every turn where the student demonstrated "
"understanding or revealed a misconception.\n\n"
)

_SOCRATIC_PROMPT = _SHARED_PREAMBLE + (
Expand DownExpand Up@@ -104,6 +110,7 @@
read_session_history_tool,
read_user_progress_tool,
apply_graph_update_tool,
update_mastery_tool,
]


Expand Down
10 changes: 9 additions & 1 deletion backend/agents/deps.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,7 +7,7 @@

from __future__ import annotations

from dataclasses import dataclass
from dataclasses import dataclass, field
from typing import Any


Expand All@@ -27,10 +27,18 @@ class SaplingDeps:
that need to scope reads to *this* conversation (e.g.
read_session_history_tool). Optional — agent runs that don't
happen inside a session (eval mode, batch tasks) leave it None.
graph_updates: Accumulates graph update payloads emitted by tools
during a run so the route can persist them in graph_update_json
for concepts_covered derivation in end_session.
mastery_changes: Accumulates the real before/after mastery deltas
returned by apply_graph_update so the route can surface them in
the chat response for parity with the legacy path.
"""

user_id: str
course_id: str | None
supabase: Any
request_id: str
session_id: str | None = None
graph_updates: list = field(default_factory=list)
mastery_changes: list = field(default_factory=list)
135 changes: 121 additions & 14 deletions backend/agents/tools/graph.py
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,24 @@
"""Graph-update helpers and a Pydantic AI tool wrapper.
"""Graph-update helpers and Pydantic AI tool wrappers.

The core merge logic lives in `apply_concepts_to_graph` — a plain async
function callable from routes directly. `apply_graph_update_tool` is a
thin Pydantic AI wrapper around it for future agents that need a tool
to register on an `Agent`. Neither contains LLM-specific logic; that
stays in `services.graph_service`.
Two tools are exposed:
- apply_graph_update_tool — registers new concepts (new_nodes, initial_mastery 0.0)
- update_mastery_tool — adjusts mastery on existing concepts (updated_nodes + delta)

Both append their payload to ctx.deps.graph_updates so the route can
persist graph_update_json on the assistant message, enabling end_session
to derive concepts_covered correctly for agent-path chats.
"""

from __future__ import annotations

import asyncio
from typing import Literal

from pydantic import BaseModel, Field
from pydantic_ai import RunContext

from agents.deps import SaplingDeps
from services.graph_service import apply_graph_update
from services.graph_service import _normalize_concept, apply_graph_update


class GraphUpdateInput(BaseModel):
Expand All@@ -27,6 +30,41 @@ class GraphUpdateInput(BaseModel):
)


class ConceptMasteryUpdate(BaseModel):
concept_name: str = Field(
description="Exact name of the concept whose mastery score to change."
)
mastery_delta: float = Field(
ge=-1.0,
le=1.0,
description=(
"Fractional mastery change, −1.0 to +1.0. "
"Use +0.1 to +0.3 when the student answers correctly; "
"−0.05 to −0.1 when they reveal a gap or misconception."
),
)
reason: str = Field(
default="",
description="Short phrase shown in the mastery-event log (e.g. 'answered correctly').",
)
event_type: Literal["interaction", "correction", "quiz"] = Field(
default="interaction",
description="Event category for the mastery-event log.",
)


class MasteryUpdateInput(BaseModel):
"""Typed input for the update_mastery tool."""

updates: list[ConceptMasteryUpdate] = Field(
description=(
"One entry per concept whose mastery changed this turn. "
"Only include concepts that already exist in the graph "
"(or were just added via apply_graph_update_tool)."
)
)


async def apply_concepts_to_graph(
user_id: str,
course_id: str | None,
Expand DownExpand Up@@ -58,13 +96,82 @@ async def apply_graph_update_tool(
ctx: RunContext[SaplingDeps],
update: GraphUpdateInput,
) -> str:
"""Pydantic AI tool wrapper around apply_concepts_to_graph.
"""Register new concepts in the student's knowledge graph.

Returns a short summary string for the agent to confirm the operation.
Call this when a new topic comes up that isn't already tracked.
To raise or lower mastery on an existing concept, call update_mastery_tool.
"""
count = await apply_concepts_to_graph(
ctx.deps.user_id, ctx.deps.course_id, update.concepts,
)
if count == 0:
new_nodes = [
{"concept_name": name.strip(), "initial_mastery": 0.0}
for name in update.concepts
if name and name.strip()
]
Comment thread
coderabbitai[bot] marked this conversation as resolved.
if not new_nodes:
return "Graph update skipped: no concepts to add."
return f"Graph updated: {count} concept(s) merged."
await asyncio.to_thread(
apply_graph_update,
ctx.deps.user_id,
{"new_nodes": new_nodes},
ctx.deps.course_id,
)
ctx.deps.graph_updates.append({"new_nodes": new_nodes})
return f"Graph updated: {len(new_nodes)} concept(s) merged."


async def update_mastery_tool(
ctx: RunContext[SaplingDeps],
update: MasteryUpdateInput,
) -> str:
"""Adjust mastery scores for concepts the student engaged with this turn.

Positive delta (e.g. +0.15) when they demonstrate understanding;
negative (e.g. −0.08) when they reveal a gap. Concepts must already
exist in the graph — call apply_graph_update_tool first if needed.
"""
updated_nodes = [
{
"concept_name": u.concept_name.strip(),
"mastery_delta": u.mastery_delta,
"reason": u.reason,
"event_type": u.event_type,
}
for u in update.updates
if u.concept_name and u.concept_name.strip()
]
if not updated_nodes:
return "Mastery update skipped: no concepts provided."

changes = await asyncio.to_thread(
apply_graph_update,
ctx.deps.user_id,
{"updated_nodes": updated_nodes},
ctx.deps.course_id,
)

# Only persist concepts that actually produced a change. A concept the
# model named but that doesn't exist in the graph yields no `changes`
# and is never written, so it must not leak into graph_update_json (it
# would over-report concepts_covered in end_session). Rebuild the
# appended updated_nodes from the concepts that genuinely changed.
#
# `changes` carries the *stored* concept_name while `updated_nodes` holds
# the *model-provided* spelling; match on the normalized form (the same
# case/whitespace-insensitive key apply_graph_update dedups on) so a
# casing/spacing drift doesn't drop a genuinely-changed concept.
if changes:
changed_names = {_normalize_concept(c["concept"]) for c in changes}
persisted_nodes = [
n
for n in updated_nodes
if _normalize_concept(n["concept_name"]) in changed_names
]
if persisted_nodes:
ctx.deps.graph_updates.append({"updated_nodes": persisted_nodes})
# Surface the real before/after deltas for parity with the legacy path.
ctx.deps.mastery_changes.extend(changes)
parts = [f"{c['concept']} {c['before']:.2f}→{c['after']:.2f}" for c in changes]
return f"Mastery updated: {', '.join(parts)}."
return (
f"Mastery update processed ({len(updated_nodes)} concept(s)); "
"no score change — concept may not exist yet. Call apply_graph_update_tool first."
)
35 changes: 27 additions & 8 deletions backend/routes/learn.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -11,6 +11,7 @@
from pydantic_ai.exceptions import UsageLimitExceeded, UnexpectedModelBehavior
from pydantic_ai.messages import ModelRequest, ModelResponse, TextPart, UserPromptPart

from agents import ORCHESTRATOR_LIMITS
from agents.chat_tutor import agent_for_mode
from agents.deps import SaplingDeps
from db.connection import table
Expand DownExpand Up@@ -512,10 +513,12 @@ async def _chat_via_agent(
"""Run chat_tutor_agent and return the legacy response shape.

Returns ``{"reply": str, "graph_update": dict, "mastery_changes": list}``.
`graph_update` and `mastery_changes` come back empty here because
`apply_graph_update_tool` (registered on chat_tutor) already
persisted any graph changes during the agent run. The frontend's
Learn-page reducer accepts empty values gracefully.
Graph changes are persisted in-band during the agent run by
`apply_graph_update_tool` / `update_mastery_tool` (registered on
chat_tutor); the tools also accumulate their payloads on `deps` so the
route can echo `graph_update` (for graph_update_json / concepts_covered)
and the real `mastery_changes` deltas back to the client, matching the
legacy path. Both are empty when nothing changed this turn.

`use_shared_context=False` flips the model into "no class-aggregate"
mode by appending a constraint instruction to the user message —
Expand DownExpand Up@@ -563,7 +566,11 @@ async def _chat_via_agent(
)

model_override = _resolve_model_pref(model_pref)
run_kwargs: dict = {"deps": deps, "message_history": message_history}
run_kwargs: dict = {
"deps": deps,
"message_history": message_history,
"usage_limits": ORCHESTRATOR_LIMITS,
}
if model_override is not None:
run_kwargs["model"] = model_override

Expand All@@ -576,10 +583,21 @@ async def _chat_via_agent(
result = await agent.run(user_message, **run_kwargs)
reply = result.output # str — chat_tutor agents return plain Markdown.

# Merge all graph update payloads accumulated by tools during this run
# into a single dict so the route can persist graph_update_json and
# end_session can derive concepts_covered correctly.
merged_graph_update: dict = {}
for gu in deps.graph_updates:
for key, items in gu.items():
merged_graph_update.setdefault(key, []).extend(items)

return {
"reply": reply,
"graph_update": {},
"mastery_changes": [],
"graph_update": merged_graph_update,
# Real before/after deltas accumulated by update_mastery_tool, for
# parity with the legacy path (which returns apply_graph_update's
# changes directly). Empty when no mastery moved this turn.
"mastery_changes": deps.mastery_changes,
}


Expand DownExpand Up@@ -690,7 +708,8 @@ async def chat(body: ChatBody, request: Request):
# own writes so a fallback doesn't double-insert. Encryption happens
# inside save_message (`encrypt_if_present`).
save_message(body.session_id, "user", body.message)
save_message(body.session_id, "assistant", response["reply"])
graph_update = response.get("graph_update") or None
save_message(body.session_id, "assistant", response["reply"], graph_update)

return response

Expand Down
3 changes: 2 additions & 1 deletion backend/routes/quiz.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -8,6 +8,7 @@

from pydantic_ai.exceptions import UsageLimitExceeded, UnexpectedModelBehavior

from agents import ORCHESTRATOR_LIMITS
from agents.quiz import quiz_agent, Quiz, QuizQuestion
from agents.deps import SaplingDeps
from agents._run import run_agent_sync
Expand DownExpand Up@@ -186,7 +187,7 @@ async def _quiz_via_agent(
)

model_override = _resolve_model_pref(model_pref)
run_kwargs: dict = {"deps": deps}
run_kwargs: dict = {"deps": deps, "usage_limits": ORCHESTRATOR_LIMITS}
if model_override is not None:
run_kwargs["model"] = model_override
result = await quiz_agent.run(user_message, **run_kwargs)
Expand Down
5 changes: 3 additions & 2 deletions backend/tests/test_chat_tutor_imports.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -34,13 +34,14 @@ def test_unknown_mode_falls_back_to_socratic():
assert agent_for_mode(None) is socratic_agent


def test_all_four_tools_registered():
"""Chat tutor needs three context tools + the graph-update tool."""
def test_all_tools_registered():
"""Chat tutor needs three context tools + two graph tools (add and update mastery)."""
expected = {
"search_course_materials_tool",
"read_session_history_tool",
"read_user_progress_tool",
"apply_graph_update_tool",
"update_mastery_tool",
}
# Pydantic AI 1.89's tool registry is at agent._function_toolset.tools
# (dict keyed by tool name) — see commit a850d31 for the gotcha.
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })(); fix: restore mastery updates, persist graph_update_json, wire usage limits by Darkest-Teddy · Pull Request #243 · SaplingLearn/Sapling · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion backend/agents/chat_tutor.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -32,7 +32,7 @@
read_user_progress_tool,
search_course_materials_tool,
)
from agents.tools.graph import apply_graph_update_tool
from agents.tools.graph import apply_graph_update_tool, update_mastery_tool


TutorMode = Literal["socratic", "expository", "teachback"]
Expand All@@ -50,6 +50,12 @@
"fabricate context.\n\n"
"Tone: warm, concise, no filler. Use math/code blocks where helpful "
"(LaTeX `$x^2$`, ```mermaid```, ```plot```). Don't over-explain.\n\n"
"Knowledge graph tools:\n"
"- apply_graph_update_tool: register NEW concepts the student hasn't seen before.\n"
"- update_mastery_tool: adjust mastery on EXISTING concepts this turn. "
"Use +0.1 to +0.3 when they answer correctly; −0.05 to −0.1 for gaps. "
"Call this at the END of every turn where the student demonstrated "
"understanding or revealed a misconception.\n\n"
)

_SOCRATIC_PROMPT = _SHARED_PREAMBLE + (
Expand DownExpand Up@@ -104,6 +110,7 @@
read_session_history_tool,
read_user_progress_tool,
apply_graph_update_tool,
update_mastery_tool,
]


Expand Down
10 changes: 9 additions & 1 deletion backend/agents/deps.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,7 +7,7 @@

from __future__ import annotations

from dataclasses import dataclass
from dataclasses import dataclass, field
from typing import Any


Expand All@@ -27,10 +27,18 @@ class SaplingDeps:
that need to scope reads to *this* conversation (e.g.
read_session_history_tool). Optional — agent runs that don't
happen inside a session (eval mode, batch tasks) leave it None.
graph_updates: Accumulates graph update payloads emitted by tools
during a run so the route can persist them in graph_update_json
for concepts_covered derivation in end_session.
mastery_changes: Accumulates the real before/after mastery deltas
returned by apply_graph_update so the route can surface them in
the chat response for parity with the legacy path.
"""

user_id: str
course_id: str | None
supabase: Any
request_id: str
session_id: str | None = None
graph_updates: list = field(default_factory=list)
mastery_changes: list = field(default_factory=list)
135 changes: 121 additions & 14 deletions backend/agents/tools/graph.py
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,24 @@
"""Graph-update helpers and a Pydantic AI tool wrapper.
"""Graph-update helpers and Pydantic AI tool wrappers.

The core merge logic lives in `apply_concepts_to_graph` — a plain async
function callable from routes directly. `apply_graph_update_tool` is a
thin Pydantic AI wrapper around it for future agents that need a tool
to register on an `Agent`. Neither contains LLM-specific logic; that
stays in `services.graph_service`.
Two tools are exposed:
- apply_graph_update_tool — registers new concepts (new_nodes, initial_mastery 0.0)
- update_mastery_tool — adjusts mastery on existing concepts (updated_nodes + delta)

Both append their payload to ctx.deps.graph_updates so the route can
persist graph_update_json on the assistant message, enabling end_session
to derive concepts_covered correctly for agent-path chats.
"""

from __future__ import annotations

import asyncio
from typing import Literal

from pydantic import BaseModel, Field
from pydantic_ai import RunContext

from agents.deps import SaplingDeps
from services.graph_service import apply_graph_update
from services.graph_service import _normalize_concept, apply_graph_update


class GraphUpdateInput(BaseModel):
Expand All@@ -27,6 +30,41 @@ class GraphUpdateInput(BaseModel):
)


class ConceptMasteryUpdate(BaseModel):
concept_name: str = Field(
description="Exact name of the concept whose mastery score to change."
)
mastery_delta: float = Field(
ge=-1.0,
le=1.0,
description=(
"Fractional mastery change, −1.0 to +1.0. "
"Use +0.1 to +0.3 when the student answers correctly; "
"−0.05 to −0.1 when they reveal a gap or misconception."
),
)
reason: str = Field(
default="",
description="Short phrase shown in the mastery-event log (e.g. 'answered correctly').",
)
event_type: Literal["interaction", "correction", "quiz"] = Field(
default="interaction",
description="Event category for the mastery-event log.",
)


class MasteryUpdateInput(BaseModel):
"""Typed input for the update_mastery tool."""

updates: list[ConceptMasteryUpdate] = Field(
description=(
"One entry per concept whose mastery changed this turn. "
"Only include concepts that already exist in the graph "
"(or were just added via apply_graph_update_tool)."
)
)


async def apply_concepts_to_graph(
user_id: str,
course_id: str | None,
Expand DownExpand Up@@ -58,13 +96,82 @@ async def apply_graph_update_tool(
ctx: RunContext[SaplingDeps],
update: GraphUpdateInput,
) -> str:
"""Pydantic AI tool wrapper around apply_concepts_to_graph.
"""Register new concepts in the student's knowledge graph.

Returns a short summary string for the agent to confirm the operation.
Call this when a new topic comes up that isn't already tracked.
To raise or lower mastery on an existing concept, call update_mastery_tool.
"""
count = await apply_concepts_to_graph(
ctx.deps.user_id, ctx.deps.course_id, update.concepts,
)
if count == 0:
new_nodes = [
{"concept_name": name.strip(), "initial_mastery": 0.0}
for name in update.concepts
if name and name.strip()
]
Comment thread
coderabbitai[bot] marked this conversation as resolved.
if not new_nodes:
return "Graph update skipped: no concepts to add."
return f"Graph updated: {count} concept(s) merged."
await asyncio.to_thread(
apply_graph_update,
ctx.deps.user_id,
{"new_nodes": new_nodes},
ctx.deps.course_id,
)
ctx.deps.graph_updates.append({"new_nodes": new_nodes})
return f"Graph updated: {len(new_nodes)} concept(s) merged."


async def update_mastery_tool(
ctx: RunContext[SaplingDeps],
update: MasteryUpdateInput,
) -> str:
"""Adjust mastery scores for concepts the student engaged with this turn.

Positive delta (e.g. +0.15) when they demonstrate understanding;
negative (e.g. −0.08) when they reveal a gap. Concepts must already
exist in the graph — call apply_graph_update_tool first if needed.
"""
updated_nodes = [
{
"concept_name": u.concept_name.strip(),
"mastery_delta": u.mastery_delta,
"reason": u.reason,
"event_type": u.event_type,
}
for u in update.updates
if u.concept_name and u.concept_name.strip()
]
if not updated_nodes:
return "Mastery update skipped: no concepts provided."

changes = await asyncio.to_thread(
apply_graph_update,
ctx.deps.user_id,
{"updated_nodes": updated_nodes},
ctx.deps.course_id,
)

# Only persist concepts that actually produced a change. A concept the
# model named but that doesn't exist in the graph yields no `changes`
# and is never written, so it must not leak into graph_update_json (it
# would over-report concepts_covered in end_session). Rebuild the
# appended updated_nodes from the concepts that genuinely changed.
#
# `changes` carries the *stored* concept_name while `updated_nodes` holds
# the *model-provided* spelling; match on the normalized form (the same
# case/whitespace-insensitive key apply_graph_update dedups on) so a
# casing/spacing drift doesn't drop a genuinely-changed concept.
if changes:
changed_names = {_normalize_concept(c["concept"]) for c in changes}
persisted_nodes = [
n
for n in updated_nodes
if _normalize_concept(n["concept_name"]) in changed_names
]
if persisted_nodes:
ctx.deps.graph_updates.append({"updated_nodes": persisted_nodes})
# Surface the real before/after deltas for parity with the legacy path.
ctx.deps.mastery_changes.extend(changes)
parts = [f"{c['concept']} {c['before']:.2f}→{c['after']:.2f}" for c in changes]
return f"Mastery updated: {', '.join(parts)}."
return (
f"Mastery update processed ({len(updated_nodes)} concept(s)); "
"no score change — concept may not exist yet. Call apply_graph_update_tool first."
)
35 changes: 27 additions & 8 deletions backend/routes/learn.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -11,6 +11,7 @@
from pydantic_ai.exceptions import UsageLimitExceeded, UnexpectedModelBehavior
from pydantic_ai.messages import ModelRequest, ModelResponse, TextPart, UserPromptPart

from agents import ORCHESTRATOR_LIMITS
from agents.chat_tutor import agent_for_mode
from agents.deps import SaplingDeps
from db.connection import table
Expand DownExpand Up@@ -512,10 +513,12 @@ async def _chat_via_agent(
"""Run chat_tutor_agent and return the legacy response shape.

Returns ``{"reply": str, "graph_update": dict, "mastery_changes": list}``.
`graph_update` and `mastery_changes` come back empty here because
`apply_graph_update_tool` (registered on chat_tutor) already
persisted any graph changes during the agent run. The frontend's
Learn-page reducer accepts empty values gracefully.
Graph changes are persisted in-band during the agent run by
`apply_graph_update_tool` / `update_mastery_tool` (registered on
chat_tutor); the tools also accumulate their payloads on `deps` so the
route can echo `graph_update` (for graph_update_json / concepts_covered)
and the real `mastery_changes` deltas back to the client, matching the
legacy path. Both are empty when nothing changed this turn.

`use_shared_context=False` flips the model into "no class-aggregate"
mode by appending a constraint instruction to the user message —
Expand DownExpand Up@@ -563,7 +566,11 @@ async def _chat_via_agent(
)

model_override = _resolve_model_pref(model_pref)
run_kwargs: dict = {"deps": deps, "message_history": message_history}
run_kwargs: dict = {
"deps": deps,
"message_history": message_history,
"usage_limits": ORCHESTRATOR_LIMITS,
}
if model_override is not None:
run_kwargs["model"] = model_override

Expand All@@ -576,10 +583,21 @@ async def _chat_via_agent(
result = await agent.run(user_message, **run_kwargs)
reply = result.output # str — chat_tutor agents return plain Markdown.

# Merge all graph update payloads accumulated by tools during this run
# into a single dict so the route can persist graph_update_json and
# end_session can derive concepts_covered correctly.
merged_graph_update: dict = {}
for gu in deps.graph_updates:
for key, items in gu.items():
merged_graph_update.setdefault(key, []).extend(items)

return {
"reply": reply,
"graph_update": {},
"mastery_changes": [],
"graph_update": merged_graph_update,
# Real before/after deltas accumulated by update_mastery_tool, for
# parity with the legacy path (which returns apply_graph_update's
# changes directly). Empty when no mastery moved this turn.
"mastery_changes": deps.mastery_changes,
}


Expand DownExpand Up@@ -690,7 +708,8 @@ async def chat(body: ChatBody, request: Request):
# own writes so a fallback doesn't double-insert. Encryption happens
# inside save_message (`encrypt_if_present`).
save_message(body.session_id, "user", body.message)
save_message(body.session_id, "assistant", response["reply"])
graph_update = response.get("graph_update") or None
save_message(body.session_id, "assistant", response["reply"], graph_update)

return response

Expand Down
3 changes: 2 additions & 1 deletion backend/routes/quiz.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -8,6 +8,7 @@

from pydantic_ai.exceptions import UsageLimitExceeded, UnexpectedModelBehavior

from agents import ORCHESTRATOR_LIMITS
from agents.quiz import quiz_agent, Quiz, QuizQuestion
from agents.deps import SaplingDeps
from agents._run import run_agent_sync
Expand DownExpand Up@@ -186,7 +187,7 @@ async def _quiz_via_agent(
)

model_override = _resolve_model_pref(model_pref)
run_kwargs: dict = {"deps": deps}
run_kwargs: dict = {"deps": deps, "usage_limits": ORCHESTRATOR_LIMITS}
if model_override is not None:
run_kwargs["model"] = model_override
result = await quiz_agent.run(user_message, **run_kwargs)
Expand Down
5 changes: 3 additions & 2 deletions backend/tests/test_chat_tutor_imports.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -34,13 +34,14 @@ def test_unknown_mode_falls_back_to_socratic():
assert agent_for_mode(None) is socratic_agent


def test_all_four_tools_registered():
"""Chat tutor needs three context tools + the graph-update tool."""
def test_all_tools_registered():
"""Chat tutor needs three context tools + two graph tools (add and update mastery)."""
expected = {
"search_course_materials_tool",
"read_session_history_tool",
"read_user_progress_tool",
"apply_graph_update_tool",
"update_mastery_tool",
}
# Pydantic AI 1.89's tool registry is at agent._function_toolset.tools
# (dict keyed by tool name) — see commit a850d31 for the gotcha.
Expand Down
Loading
Loading