Uh oh!
There was an error while loading. Please reload this page.
feat(learn): Tutor knowledge-map rail overhaul + AI concept descriptions - #334
Conversation
Constrain the KnowledgeGraph2D view transform and node dragging so the graph can't be flung into empty space or clipped outside its box: - clampView() keeps the content's bounding box inside the window (or covering it when larger), applied on pan, wheel-zoom, and both zoom buttons - node drag is clamped to the visible bounds mapped back through the current transform Force-simulation parameters are untouched — only the camera/drag bounds. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a tool-less Pydantic AI agent that returns a one-sentence,
student-facing description for a concept, plus the route that drives it:
- agents/concept_describe.py: concept_describe_agent (typed
ConceptDescription output) + build_message helper
- agents/_providers.py: register the concept_describe task on the
gemini-2.5-flash-lite tier (short single-shot generation)
- routes/graph.py: POST /api/graph/{user_id}/concept-description
Backs the Tutor knowledge-map rail's focus card for concepts that lack a
stored description (e.g. manually-added ones).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>Redesign the active-session rail to the Tutor Session design and make it a working navigator over the course map: - rail content matches the mockup: Knowledge-map header (course code + name), graph on a transparent radial-glow surface, tier legend, Focused concept card, "In this branch" list, "Elsewhere in course" chips - graph is filtered to the focused course's tree only (not the full multi-course graph) - focus is decoupled from the chat: clicking a node (or a list item) focuses it in the rail without touching the conversation; the focus card's Resume/Start button — or a double-click — switches the session (resume existing session for that concept, else start fresh) - focus card anchors on the course when no concept is focused - per-concept descriptions: shown from stored data, else lazily fetched from the concept-description endpoint and cached (skipped in local mode, which has no AI, falling back to the connected-concepts line) - manually add a concept (links to the focused node / course root) or remove the focused concept; remove also hits deleteGraphNode on real backends - local dataset: real course_id + course codes on nodes, richer concept lists, and one-line descriptions per concept Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Deploying with |
| Status | Name | Latest Commit | Preview URL | Updated (UTC) |
|---|---|---|---|---|
| ✅ Deployment successful! View logs | frontend-staging | adcd11d | Commit Preview URL Branch Preview URL | Jul 14 2026, 04:53 PM |
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 1
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
frontend/src/components/screens/Learn.tsx (1)
214-237: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
beginSession/switchToConceptnever pass the focused concept'scourse_id— new sessions can be created under the wrong or missing course.
cardCourseId(derived fromtopicNode?.course_id) can diverge from theselectedCourseIdstate — e.g. when the active topic doesn't match a concept name but a later-focused rail node does, or the session was started with no course selected.beginSessionalways usesselectedCourseId, andswitchToConcept/handleNodeClick's double-click path have no way to override it. The "Start session" button on the focus card (switchToConcept(focusConcept.name), Line 902) will therefore callstartSessionwith a stale/empty course context instead offocusConcept.course_id. Separately, bothswitchToConcept's existing-session lookup (Lines 423-425) andfocusHasSession(Lines 580-582) match sessions by topic name only, so two courses sharing a concept name can resume the wrong session.🐛 Suggested fix
- const beginSession = async (t: string) => {+ const beginSession = async (t: string, courseId?: string) => { const topicName = t.trim(); if (!topicName || !userId) return; setFocusedNodeId(null); setTopic(topicName); setTopicDraft(topicName); setMessages([{ id: msgId(), role: "assistant", content: "", loading: true }]); setStarting(true); try { - const res = await startSession(userId, topicName, mode, selectedCourseId || undefined, sharedCtx, modelPref);+ const res = await startSession(userId, topicName, mode, (courseId ?? selectedCourseId) || undefined, sharedCtx, modelPref); ... - const switchToConcept = (name: string) => {- const existing = recentSessions.find(- s => s.topic.trim().toLowerCase() === name.trim().toLowerCase(),- );+ const switchToConcept = (name: string, courseId?: string) => {+ const existing = recentSessions.find(+ s => s.topic.trim().toLowerCase() === name.trim().toLowerCase()+ && (!courseId || s.course_id === courseId),+ ); if (existing) { setFocusedNodeId(null); handleResume(existing); } else { - beginSession(name);+ beginSession(name, courseId); } };And update call sites:
switchToConcept(n.name, n.course_id)inhandleNodeClick, andswitchToConcept(focusConcept.name, focusConcept.course_id)on the focus-card button.Also applies to: 419-446
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@frontend/src/components/screens/Learn.tsx` around lines 214 - 237, Update beginSession to accept an optional course ID override and pass it to startSession, falling back to selectedCourseId only when no override is provided. Update switchToConcept and its callers, including handleNodeClick and the focus-card button, to pass each concept’s course_id. Include course_id in switchToConcept’s existing-session lookup and focusHasSession matching so sessions are scoped by both topic name and course.
🧹 Nitpick comments (2)
backend/routes/graph.py (1)
50-54: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick winNo length bounds on
concept/course_labelbefore hitting a paid LLM call.Unbounded strings increase per-call cost/latency risk and widen the attack surface for abuse against an external API. Consider adding
max_length(e.g. viaField) on both fields.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@backend/routes/graph.py` around lines 50 - 54, Add maximum length validation to both fields in ConceptDescriptionBody, using Pydantic Field constraints for concept and optional course_label before the request reaches the LLM call. Choose appropriate bounded limits and preserve course_label’s optional default.frontend/src/components/screens/Learn.tsx (1)
633-640: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick winBackend delete failure is silently swallowed — UI and persisted graph can drift out of sync.
deleteGraphNode(...).catch(() => {})means a failed backend delete leaves the node removed locally but still present server-side; a later refetch (e.g. page reload) will make it reappear with no explanation. Surface the failure so the user can retry.♻️ Suggested fix
- if (!IS_LOCAL_MODE && userId) deleteGraphNode(userId, nodeId).catch(() => {});+ if (!IS_LOCAL_MODE && userId) {+ deleteGraphNode(userId, nodeId).catch(() => {+ toast.error("Couldn't remove concept on the server — it may reappear after reload.");+ });+ }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@frontend/src/components/screens/Learn.tsx` around lines 633 - 640, Update removeConcept to handle deleteGraphNode failures instead of silently swallowing them: preserve or restore the removed node and its edges as appropriate, and surface a clear user-facing error with an option to retry. Keep the optimistic local removal and focused-node clearing behavior for successful deletes, using the existing notification/error-handling patterns in Learn.tsx.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@backend/routes/graph.py`:
- Around line 104-129: In describe_concept, limit or truncate the normalized
concept and course_label values before passing them to build_message. Wrap the
concept_describe_agent.run invocation executed by run_agent_sync in targeted
exception handling for model, transport, and validation failures, translating
those errors into HTTPException(status_code=502) while preserving unexpected
exceptions for the generic handler.
---
Outside diff comments:
In `@frontend/src/components/screens/Learn.tsx`:
- Around line 214-237: Update beginSession to accept an optional course ID
override and pass it to startSession, falling back to selectedCourseId only when
no override is provided. Update switchToConcept and its callers, including
handleNodeClick and the focus-card button, to pass each concept’s course_id.
Include course_id in switchToConcept’s existing-session lookup and
focusHasSession matching so sessions are scoped by both topic name and course.
---
Nitpick comments:
In `@backend/routes/graph.py`:
- Around line 50-54: Add maximum length validation to both fields in
ConceptDescriptionBody, using Pydantic Field constraints for concept and
optional course_label before the request reaches the LLM call. Choose
appropriate bounded limits and preserve course_label’s optional default.
In `@frontend/src/components/screens/Learn.tsx`:
- Around line 633-640: Update removeConcept to handle deleteGraphNode failures
instead of silently swallowing them: preserve or restore the removed node and
its edges as appropriate, and surface a clear user-facing error with an option
to retry. Keep the optimistic local removal and focused-node clearing behavior
for successful deletes, using the existing notification/error-handling patterns
in Learn.tsx.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: 4267cc9e-0428-4f93-abce-16ea44370928
📒 Files selected for processing (9)
backend/agents/_providers.pybackend/agents/concept_describe.pybackend/routes/graph.pyfrontend/src/components/KnowledgeGraph2D.tsxfrontend/src/components/screens/Learn.tsxfrontend/src/lib/api.tsfrontend/src/lib/data.tsfrontend/src/lib/localData.tsfrontend/src/lib/types.ts
| @router.post("/{user_id}/concept-description") | ||
| def describe_concept(user_id: str, body: ConceptDescriptionBody, request: Request): | ||
| """Generate a one-sentence, student-facing description for a concept. | ||
| Backs the knowledge-map rail's focus card for concepts without a stored | ||
| description. Tool-less LLM call — the concept name and course label are | ||
| handed straight to the agent. | ||
| """ | ||
| require_self(user_id, request) | ||
| concept = body.concept.strip() | ||
| if not concept: | ||
| raise HTTPException(status_code=400, detail="concept is required") | ||
| deps = SaplingDeps( | ||
| user_id=user_id, | ||
| course_id=None, | ||
| supabase=None, | ||
| request_id=current_request_id() or str(uuid.uuid4()), | ||
| ) | ||
| result = run_agent_sync( | ||
| concept_describe_agent.run( | ||
| build_message(concept, body.course_label), | ||
| deps=deps, | ||
| usage_limits=WORKER_LIMITS, | ||
| ) | ||
| ) | ||
| return {"description": result.output.description} |
There was a problem hiding this comment.
🩺 Stability & Availability | 🟠 Major | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bashset -euo pipefail
printf'\n== Files ==\n'
git ls-files | rg 'backend/(routes/graph.py|agents/.*concept|.*concept.*describe|.*concept.*description|agents/|schemas/)'||trueprintf'\n== Search symbols ==\n'
rg -n "concept_describe_agent|ConceptDescription|describe_concept|build_message|run_agent_sync|WORKER_LIMITS" backend -S
printf'\n== Outline candidate files ==\n'forfin backend/routes/graph.py backend/agents/concept_describe.py backend/agents/*concept* backend/agents/*.py;do
[ -f"$f" ] && { echo"--- $f"; wc -l "$f"; ast-grep outline "$f" --view expanded ||true; }
doneRepository: SaplingLearn/Sapling
Length of output: 17293
🏁 Script executed:
#!/bin/bashset -euo pipefail
echo'== backend/agents/concept_describe.py =='
cat -n backend/agents/concept_describe.py
echoecho'== backend/agents/_run.py =='
cat -n backend/agents/_run.py
echoecho'== backend/routes/graph.py (100-130) =='
sed -n '100,130p' backend/routes/graph.py
echoecho'== exception handlers in backend/main.py =='
rg -n "exception_handler|HTTPException|RequestValidationError|except Exception|traceback" backend/main.py backend/routes -SRepository: SaplingLearn/Sapling
Length of output: 30158
Guard the concept-description agent call. Wrap run_agent_sync(concept_describe_agent.run(...)) and return a 502 for model/transport/validation failures; otherwise this route falls through to the generic 500 handler. Add a length cap or truncation for concept and course_label before building the prompt.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/routes/graph.py` around lines 104 - 129, In describe_concept, limit
or truncate the normalized concept and course_label values before passing them
to build_message. Wrap the concept_describe_agent.run invocation executed by
run_agent_sync in targeted exception handling for model, transport, and
validation failures, translating those errors into
HTTPException(status_code=502) while preserving unexpected exceptions for the
generic handler.
The knowledge-map rail's /concept-description endpoint handed the concept name and course label straight to the LLM with no length bound, and ran the agent with no error handling — so an oversized payload could bloat the prompt and any model/transport/validation failure surfaced as an opaque 500. - Truncate concept (200) and course_label (120) before build_message. - Wrap the agent run: (AgentRunError, httpx.HTTPError, ValidationError) -> 502, leaving unexpected exceptions to the generic 500 handler. - Add route tests: happy path, truncation, 502 translation, 400 empty concept, and unexpected-exception-falls-through-to-500. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Rebuilds the Tutor active-session right rail into a working navigator over
the course knowledge map, plus supporting graph and backend changes.
Frontend
name), graph on a transparent radial-glow surface, tier legend, Focused-
concept card, "In this branch" list, "Elsewhere in course" chips.
the rail without touching the conversation; Resume/Start (or double-click)
switches the session — resume existing session for that concept, else new.
fallback. Fetch is skipped in local mode (no AI).
focused one.
Graph
box. Force-simulation params untouched.
Backend
concept_describePydantic AI agent (tool-less, one-sentence output) onthe flash-lite tier, exposed via
POST /api/graph/{user_id}/concept-description.Notes
per-concept descriptions.
end-to-end (no backend + Gemini key in the dev environment) — worth a
live check.
Summary by CodeRabbit
New Features
Improvements