Skip to content

Commit 90ba796

Browse files
AndresL230claude
andcommitted
feat(learn): add Fast/Smart model switcher for tutor chat
Tutor chat was hardcoded to gemini-2.5-pro (MODEL_SMART) in start_session, chat, and action -- great for reasoning quality, sluggish enough that users felt blocked. Adds a per-user UI toggle in the chat TopBar so the student decides when speed matters. Backend: StartSessionBody / ChatBody / ActionBody accept optional model_pref ("fast" | "smart"). New _resolve_tutor_model maps "fast" -> MODEL_DEFAULT (gemini-2.5-flash), "smart" -> MODEL_SMART (gemini-2.5-pro), unknown / missing -> fast. The default is fast on both sides so first-time chats are snappy by default; users opt in to Smart when they want depth. Frontend: new ModelToggle component (mirrors SharedContextToggle's look-and-feel and localStorage-persisted hook pattern, key sapling_model_pref). Mounted in the chat header next to the Class intel toggle. modelPref is threaded through startSession, sendChat, and learnAction. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent ac7a547 commit 90ba796

5 files changed

Lines changed: 185 additions & 13 deletions

File tree

backend/models/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ class StartSessionBody(BaseModel):
1010
mode: str = "socratic"
1111
use_shared_context: bool = True
1212
course_id: Optional[str] = None # Direct course_id lookup instead of resolving from topic
13+
model_pref: Optional[str] = None # "smart" (default, gemini-2.5-pro) or "fast" (gemini-2.5-flash)
1314

1415

1516
class ChatBody(BaseModel):
@@ -18,6 +19,7 @@ class ChatBody(BaseModel):
1819
message: str
1920
mode: str = "socratic"
2021
use_shared_context: bool = True
22+
model_pref: Optional[str] = None
2123

2224

2325
class EndSessionBody(BaseModel):
@@ -31,6 +33,7 @@ class ActionBody(BaseModel):
3133
action_type: str = "hint"
3234
mode: str = "socratic"
3335
use_shared_context: bool = True
36+
model_pref: Optional[str] = None
3437

3538

3639
# ── Quiz ──────────────────────────────────────────────────────────────────────

backend/routes/learn.py

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,12 @@
1111
from models import StartSessionBody, ChatBody, EndSessionBody, ActionBody, ModeSwitchBody
1212
from services.auth_guard import require_self, get_session_user_id
1313
from services.encryption import encrypt_if_present, encrypt_json, decrypt_if_present, decrypt_json
14-
from services.gemini_service import MODEL_SMART, call_gemini_multiturn, extract_graph_update
14+
from services.gemini_service import (
15+
MODEL_DEFAULT,
16+
MODEL_SMART,
17+
call_gemini_multiturn,
18+
extract_graph_update,
19+
)
1520
from services.graph_service import get_graph, apply_graph_update
1621

1722
router = APIRouter()
@@ -28,6 +33,18 @@
2833
"teachback": "Teach-back (you explain to me)",
2934
}
3035

36+
# User-facing speed/quality knob for the tutor chat.
37+
# "fast" = flash (default, faster), "smart" = pro (opt-in, slower but stronger reasoning).
38+
# Anything unrecognized falls back to fast so the default is the snappy one.
39+
_MODEL_PREF_TO_MODEL = {
40+
"fast": MODEL_DEFAULT,
41+
"smart": MODEL_SMART,
42+
}
43+
44+
45+
def _resolve_tutor_model(model_pref: str | None) -> str:
46+
return _MODEL_PREF_TO_MODEL.get(model_pref or "", MODEL_DEFAULT)
47+
3148

3249
def _load_prompt(name: str) -> str:
3350
with open(os.path.join(PROMPTS_DIR, name)) as f:
@@ -293,13 +310,15 @@ def start_session(body: StartSessionBody, request: Request):
293310
)
294311

295312
try:
296-
raw = call_gemini_multiturn(system_prompt, [], user_message, model=MODEL_SMART)
313+
raw = call_gemini_multiturn(
314+
system_prompt, [], user_message, model=_resolve_tutor_model(body.model_pref)
315+
)
297316
except Exception as e:
298317
raise HTTPException(status_code=502, detail=f"Gemini error: {e}")
299318

300319
reply, graph_update = extract_graph_update(raw)
301320
apply_graph_update(body.user_id, graph_update, course_id=course_id)
302-
321+
303322
PENDING_SESSIONS[session_id] = {
304323
"user_id": body.user_id,
305324
"mode": body.mode,
@@ -339,7 +358,9 @@ def chat(body: ChatBody, request: Request):
339358
)
340359

341360
try:
342-
raw = call_gemini_multiturn(system_prompt, history, body.message, model=MODEL_SMART)
361+
raw = call_gemini_multiturn(
362+
system_prompt, history, body.message, model=_resolve_tutor_model(body.model_pref)
363+
)
343364
except Exception as e:
344365
raise HTTPException(status_code=502, detail=f"Gemini error: {e}")
345366

@@ -558,7 +579,9 @@ def action(body: ActionBody, request: Request):
558579
action_message = f"[ACTION: {action_prompts.get(body.action_type, '')}]"
559580

560581
try:
561-
raw = call_gemini_multiturn(system_prompt, history, action_message, model=MODEL_SMART)
582+
raw = call_gemini_multiturn(
583+
system_prompt, history, action_message, model=_resolve_tutor_model(body.model_pref)
584+
)
562585
except Exception as e:
563586
raise HTTPException(status_code=502, detail=f"Gemini error: {e}")
564587

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
"use client";
2+
3+
import React, { useEffect, useState } from "react";
4+
5+
export type ModelPref = "smart" | "fast";
6+
7+
const STORAGE_KEY = "sapling_model_pref";
8+
9+
export function useModelPref(): [ModelPref, (v: ModelPref) => void] {
10+
const [pref, setPref] = useState<ModelPref>("fast");
11+
useEffect(() => {
12+
const raw = localStorage.getItem(STORAGE_KEY);
13+
if (raw === "fast" || raw === "smart") setPref(raw);
14+
}, []);
15+
const update = (v: ModelPref) => {
16+
setPref(v);
17+
localStorage.setItem(STORAGE_KEY, v);
18+
};
19+
return [pref, update];
20+
}
21+
22+
export function ModelToggle({
23+
pref,
24+
onChange,
25+
}: {
26+
pref: ModelPref;
27+
onChange: (v: ModelPref) => void;
28+
}) {
29+
const [tooltip, setTooltip] = useState(false);
30+
// Fast is the default; Smart is the opt-in upgrade, so it gets the highlight.
31+
const isSmart = pref === "smart";
32+
33+
return (
34+
<div
35+
style={{ position: "relative", display: "inline-block" }}
36+
onMouseEnter={() => setTooltip(true)}
37+
onMouseLeave={() => setTooltip(false)}
38+
onFocus={() => setTooltip(true)}
39+
onBlur={() => setTooltip(false)}
40+
>
41+
<button
42+
role="switch"
43+
aria-checked={isSmart}
44+
onClick={() => onChange(isSmart ? "fast" : "smart")}
45+
className="btn btn--sm"
46+
style={{
47+
padding: "5px 10px",
48+
background: isSmart ? "var(--accent-soft)" : "var(--bg-subtle)",
49+
color: isSmart ? "var(--accent)" : "var(--text-dim)",
50+
borderColor: isSmart ? "var(--accent-border)" : "var(--border)",
51+
fontSize: 12,
52+
display: "inline-flex",
53+
alignItems: "center",
54+
gap: 6,
55+
}}
56+
>
57+
<span
58+
aria-hidden
59+
style={{
60+
width: 24,
61+
height: 12,
62+
borderRadius: "var(--r-full)",
63+
background: isSmart ? "var(--accent)" : "var(--border-strong)",
64+
position: "relative",
65+
transition: "background var(--dur-fast) var(--ease)",
66+
}}
67+
>
68+
<span
69+
style={{
70+
position: "absolute",
71+
top: 1,
72+
left: isSmart ? 13 : 1,
73+
width: 10,
74+
height: 10,
75+
borderRadius: "50%",
76+
background: "#fff",
77+
transition: "left var(--dur-fast) var(--ease)",
78+
}}
79+
/>
80+
</span>
81+
{isSmart ? "Smart" : "Fast"}
82+
</button>
83+
{tooltip && (
84+
<div
85+
role="tooltip"
86+
style={{
87+
position: "absolute",
88+
top: "calc(100% + 6px)",
89+
right: 0,
90+
zIndex: 60,
91+
width: 240,
92+
padding: 10,
93+
background: "var(--bg-panel)",
94+
border: "1px solid var(--border-strong)",
95+
borderRadius: "var(--r-md)",
96+
boxShadow: "var(--shadow-md)",
97+
fontSize: 11,
98+
color: "var(--text-dim)",
99+
lineHeight: 1.5,
100+
}}
101+
>
102+
<strong style={{ color: "var(--text)", display: "block", marginBottom: 4 }}>
103+
Tutor model
104+
</strong>
105+
Fast is the default — quicker replies. Flip on Smart for stronger reasoning when you
106+
want depth and don&apos;t mind waiting.
107+
</div>
108+
)}
109+
</div>
110+
);
111+
}

frontend/src/components/screens/Learn.tsx

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { CustomSelect } from "../CustomSelect";
99
import { ChatPanel, type ChatMsg } from "../ChatPanel";
1010
import { SessionSummary } from "../SessionSummary";
1111
import { SharedContextToggle, useSharedContext } from "../SharedContextToggle";
12+
import { ModelToggle, useModelPref } from "../ModelToggle";
1213
import { DisclaimerModal } from "../DisclaimerModal";
1314
import { AIDisclaimerChip } from "../AIDisclaimerChip";
1415
import { QuizPanel } from "../QuizPanel";
@@ -89,6 +90,7 @@ function LearnInner() {
8990
const isMobile = useIsMobile();
9091

9192
const [sharedCtx, setSharedCtx] = useSharedContext();
93+
const [modelPref, setModelPref] = useModelPref();
9294

9395
const initialTopic = searchParams.get("topic") ?? "";
9496
const initialMode = normalizeMode(searchParams.get("mode"));
@@ -172,7 +174,7 @@ function LearnInner() {
172174
setMessages([{ id: msgId(), role: "assistant", content: "", loading: true }]);
173175
setStarting(true);
174176
try {
175-
const res = await startSession(userId, t, mode, selectedCourseId || undefined, sharedCtx);
177+
const res = await startSession(userId, t, mode, selectedCourseId || undefined, sharedCtx, modelPref);
176178
setSessionId(res.session_id);
177179
setMessages([{ id: msgId(), role: "assistant", content: res.initial_message || "Let's begin." }]);
178180
} catch (err) {
@@ -223,7 +225,7 @@ function LearnInner() {
223225
]);
224226
setSending(true);
225227
try {
226-
const res = await sendChat(sessionId, userId, userText, chatMode, sharedCtx);
228+
const res = await sendChat(sessionId, userId, userText, chatMode, sharedCtx, modelPref);
227229
setMessages(m => {
228230
const next = [...m];
229231
next[next.length - 1] = { id: next[next.length - 1].id, role: "assistant", content: res.reply || "" };
@@ -238,7 +240,7 @@ function LearnInner() {
238240
} finally {
239241
setSending(false);
240242
}
241-
}, [sessionId, userId, mode, sharedCtx]);
243+
}, [sessionId, userId, mode, sharedCtx, modelPref]);
242244

243245
const handleAction = async (action: "hint" | "confused" | "skip") => {
244246
if (!sessionId || !userId) return;
@@ -251,7 +253,7 @@ function LearnInner() {
251253
]);
252254
setSending(true);
253255
try {
254-
const res = await learnAction(sessionId, userId, action, chatMode, sharedCtx);
256+
const res = await learnAction(sessionId, userId, action, chatMode, sharedCtx, modelPref);
255257
setMessages(m => {
256258
const next = [...m];
257259
next[next.length - 1] = { id: next[next.length - 1].id, role: "assistant", content: res.reply || "" };
@@ -579,6 +581,7 @@ function LearnInner() {
579581
actions={
580582
<>
581583
<AIDisclaimerChip />
584+
<ModelToggle pref={modelPref} onChange={setModelPref} />
582585
<SharedContextToggle enabled={sharedCtx} onChange={setSharedCtx} />
583586
<button
584587
className={endConfirm.armed ? "btn btn--danger btn--sm" : "btn btn--sm"}

frontend/src/lib/api.ts

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -79,16 +79,46 @@ export const deleteGraphNode = (userId: string, nodeId: string) =>
7979
);
8080

8181
// Learn
82-
export const startSession = (userId: string, topic: string, mode: string, courseId?: string, useSharedContext = true) =>
82+
export type ModelPref = 'smart' | 'fast';
83+
84+
export const startSession = (
85+
userId: string,
86+
topic: string,
87+
mode: string,
88+
courseId?: string,
89+
useSharedContext = true,
90+
modelPref?: ModelPref,
91+
) =>
8392
fetchJSON<{ session_id: string; initial_message: string; graph_state: any }>('/api/learn/start-session', {
8493
method: 'POST',
85-
body: JSON.stringify({ user_id: userId, topic, mode, use_shared_context: useSharedContext, course_id: courseId }),
94+
body: JSON.stringify({
95+
user_id: userId,
96+
topic,
97+
mode,
98+
use_shared_context: useSharedContext,
99+
course_id: courseId,
100+
...(modelPref ? { model_pref: modelPref } : {}),
101+
}),
86102
});
87103

88-
export const sendChat = (sessionId: string, userId: string, message: string, mode: string, useSharedContext = true) =>
104+
export const sendChat = (
105+
sessionId: string,
106+
userId: string,
107+
message: string,
108+
mode: string,
109+
useSharedContext = true,
110+
modelPref?: ModelPref,
111+
) =>
89112
fetchJSON<{ reply: string; graph_update: any; mastery_changes: any[] }>('/api/learn/chat', {
90113
method: 'POST',
91-
body: JSON.stringify({ session_id: sessionId, user_id: userId, message, mode, use_shared_context: useSharedContext }),
114+
body: JSON.stringify({
115+
session_id: sessionId,
116+
user_id: userId,
117+
message,
118+
mode,
119+
use_shared_context: useSharedContext,
120+
...(modelPref ? { model_pref: modelPref } : {}),
121+
}),
92122
});
93123

94124
export interface SessionSummaryData {
@@ -111,6 +141,7 @@ export const learnAction = (
111141
actionType: 'hint' | 'confused' | 'skip',
112142
mode: string,
113143
useSharedContext = true,
144+
modelPref?: ModelPref,
114145
) =>
115146
fetchJSON<{ reply: string; graph_update: any }>('/api/learn/action', {
116147
method: 'POST',
@@ -120,6 +151,7 @@ export const learnAction = (
120151
action_type: actionType,
121152
mode,
122153
use_shared_context: useSharedContext,
154+
...(modelPref ? { model_pref: modelPref } : {}),
123155
}),
124156
});
125157

0 commit comments

Comments
 (0)