Subh775 commited on
Commit
5c4367e
Β·
1 Parent(s): 7505c44

claude code

Browse files
Files changed (5) hide show
  1. app.py +70 -23
  2. graph.py +203 -139
  3. static/app.js +36 -1
  4. static/style.css +31 -0
  5. tools.py +14 -5
app.py CHANGED
@@ -271,7 +271,25 @@ def chat(request: ChatRequest, req: Request):
271
  print(f"[PINECONE ERROR] Failed to retrieve context: {exc}")
272
  context = "No relevant context found due to a temporary search error."
273
 
274
- # Step 3: Build message (text or multimodal with image / document)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
275
  extra_context = ""
276
  if request.doc_text:
277
  extra_context = (
@@ -282,14 +300,14 @@ def chat(request: ChatRequest, req: Request):
282
 
283
  if request.image:
284
  msg_content = [
285
- {"type": "text", "text": request.message + extra_context},
286
  {
287
  "type": "image_url",
288
  "image_url": {"url": f"data:image/jpeg;base64,{request.image}"},
289
  },
290
  ]
291
  else:
292
- msg_content = request.message + extra_context
293
 
294
  # Step 4: Stream LLM response via LangGraph
295
  has_image = bool(request.image)
@@ -303,33 +321,62 @@ def chat(request: ChatRequest, req: Request):
303
  "student_profile": student_profile,
304
  "user_api_key": user_api_key,
305
  "has_image": has_image,
 
306
  }
307
  }
308
 
309
  try:
310
  active_tools: set[str] = set()
311
- for chunk, _metadata in chatbot.stream(
312
- {"messages": [HumanMessage(content=msg_content)]},
 
 
 
 
 
 
 
 
 
 
313
  config=config,
314
- stream_mode="messages",
315
  ):
316
- # Tool call started β€” emit tool_start so UI shows progress bar
317
- if isinstance(chunk, AIMessage) and getattr(chunk, "tool_calls", None):
318
- for tc in chunk.tool_calls:
319
- tn = tc.get("name", "tool")
320
- if tn not in active_tools:
321
- active_tools.add(tn)
322
- yield sse_tool_event("tool_start", tn)
323
-
324
- # Tool result received β€” emit tool_end
325
- elif isinstance(chunk, ToolMessage):
326
- tn = chunk.name or "tool"
327
- active_tools.discard(tn)
328
- yield sse_tool_event("tool_end", tn)
329
-
330
- # Regular AI text token
331
- elif isinstance(chunk, AIMessage) and chunk.content:
332
- yield sse_token(chunk.content)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
333
 
334
  except Exception as e:
335
  error_str = str(e).lower()
 
271
  print(f"[PINECONE ERROR] Failed to retrieve context: {exc}")
272
  context = "No relevant context found due to a temporary search error."
273
 
274
+ # Step 3: Parse search mode sentinel injected by frontend, clean message
275
+ _WEB_SENTINEL = "[System Instruction: Web Search is enabled."
276
+ _YT_SENTINEL = "[System Instruction: YouTube Video Search is enabled."
277
+
278
+ raw_message = request.message
279
+ force_tool = ""
280
+ clean_message = raw_message
281
+
282
+ if raw_message.startswith(_WEB_SENTINEL):
283
+ force_tool = "web_search"
284
+ parts = raw_message.split("User Query: ", 1)
285
+ clean_message = parts[1].strip() if len(parts) > 1 else raw_message
286
+
287
+ elif raw_message.startswith(_YT_SENTINEL):
288
+ force_tool = "yt_transcript"
289
+ parts = raw_message.split("User Query: ", 1)
290
+ clean_message = parts[1].strip() if len(parts) > 1 else raw_message
291
+
292
+ # Build message content (multimodal or text)
293
  extra_context = ""
294
  if request.doc_text:
295
  extra_context = (
 
300
 
301
  if request.image:
302
  msg_content = [
303
+ {"type": "text", "text": clean_message + extra_context},
304
  {
305
  "type": "image_url",
306
  "image_url": {"url": f"data:image/jpeg;base64,{request.image}"},
307
  },
308
  ]
309
  else:
310
+ msg_content = clean_message + extra_context
311
 
312
  # Step 4: Stream LLM response via LangGraph
313
  has_image = bool(request.image)
 
321
  "student_profile": student_profile,
322
  "user_api_key": user_api_key,
323
  "has_image": has_image,
324
+ "force_tool": force_tool,
325
  }
326
  }
327
 
328
  try:
329
  active_tools: set[str] = set()
330
+ initial_state = {
331
+ "messages": [HumanMessage(content=msg_content)],
332
+ "model_attempt": 0,
333
+ "skipped_models": [],
334
+ "selected_model": "",
335
+ "retry_status": "ok",
336
+ "force_tool": force_tool,
337
+ }
338
+ last_retry_status = "ok"
339
+
340
+ for event in chatbot.stream(
341
+ initial_state,
342
  config=config,
343
+ stream_mode="updates",
344
  ):
345
+ # event is a dict like {"llm": {...state fields...}} or {"tools": {...}}
346
+
347
+ # ── Retry status ──────────────────────────────────
348
+ if "llm" in event:
349
+ node_output = event["llm"]
350
+ rs = node_output.get("retry_status", "")
351
+ if rs and rs != "ok" and rs != last_retry_status:
352
+ last_retry_status = rs
353
+ attempt_num = node_output.get("model_attempt", 0)
354
+ yield f"data: {json.dumps({'retry_status': rs, 'attempt': attempt_num})}\n\n"
355
+
356
+ # ── Tool events ───────────────────────────────────
357
+ if "tools" in event:
358
+ tool_messages = event["tools"].get("messages", [])
359
+ for tm in tool_messages:
360
+ if hasattr(tm, "name") and tm.name:
361
+ active_tools.discard(tm.name)
362
+ yield sse_tool_event("tool_end", tm.name)
363
+
364
+ # ── AI message tokens & tool_calls ────────────────
365
+ all_msgs = []
366
+ for node_output in event.values():
367
+ if isinstance(node_output, dict):
368
+ all_msgs.extend(node_output.get("messages", []))
369
+
370
+ for msg in all_msgs:
371
+ if isinstance(msg, AIMessage):
372
+ if getattr(msg, "tool_calls", None):
373
+ for tc in msg.tool_calls:
374
+ tn = tc.get("name", "tool")
375
+ if tn not in active_tools:
376
+ active_tools.add(tn)
377
+ yield sse_tool_event("tool_start", tn)
378
+ elif msg.content:
379
+ yield sse_token(msg.content)
380
 
381
  except Exception as e:
382
  error_str = str(e).lower()
graph.py CHANGED
@@ -1,35 +1,54 @@
1
  """
2
- LangGraph β€” Intelligent Model Router for STEM Copilot.
3
-
4
- Routes queries to the best free OpenRouter model based on intent.
5
- Uses static model pools (excluding Venice) to ensure stability and low latency.
6
- Supports tool-calling (web search, YouTube transcript) inside a ReAct loop.
 
 
 
 
 
 
 
 
 
7
  """
8
 
9
- from langgraph.graph import StateGraph, START, END
10
- from langgraph.graph.message import add_messages
11
- from langchain_core.messages import (
12
- BaseMessage, SystemMessage, HumanMessage, AIMessage, ToolMessage
13
- )
14
- from langchain_core.runnables import RunnableConfig
15
- from langchain_openrouter import ChatOpenRouter # type:ignore
16
- from typing import TypedDict, Annotated
17
 
18
  import sqlite3
19
- import time
20
- from langgraph.checkpoint.sqlite import SqliteSaver # type:ignore
21
- from config import OPENROUTER_API_KEY, DB_PATH, LLM_TIMEOUT
 
 
 
 
 
 
 
22
  import prompts
 
23
  from tools import TOOLS
24
 
 
 
 
 
 
25
 
26
  # ── Checkpointer ──────────────────────────────────────────────
27
  _conn = sqlite3.connect(DB_PATH, check_same_thread=False)
28
  checkpointer = SqliteSaver(conn=_conn)
29
 
30
 
31
- # ── Static Model Pools (excluding Venice provider) ──
32
- _STATIC_POOLS = {
 
 
 
 
33
  "reasoning": [
34
  "nvidia/nemotron-3-ultra-550b-a55b:free",
35
  "nex-agi/nex-n2-pro:free",
@@ -50,12 +69,13 @@ _STATIC_POOLS = {
50
  "vision": [
51
  "nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free",
52
  "google/gemma-4-31b-it:free",
53
- ]
54
  }
55
 
 
56
 
57
- # ── Query Classification ─────────────────────────────────────
58
 
 
59
  _CASUAL_EXACT = frozenset([
60
  "hi", "hii", "hiii", "hello", "hey", "yo", "sup", "hola",
61
  "thanks", "thank you", "thankyou", "thx", "ty",
@@ -117,15 +137,13 @@ def _classify(text: str) -> str:
117
  return "general"
118
 
119
 
120
- # ── Model Picker (round-robin) ────────────────────────────────
121
-
122
- _counters: dict[str, int] = {}
123
-
124
-
125
- def _pick(category: str, has_image: bool = False, skip_models: set | None = None) -> tuple[str, str]:
126
- """
127
- Return (model_id, actual_category) from static pools.
128
- """
129
  skip = skip_models or set()
130
 
131
  if has_image:
@@ -137,9 +155,8 @@ def _pick(category: str, has_image: bool = False, skip_models: set | None = None
137
 
138
  pool = [m for m in (_STATIC_POOLS.get(category) or _STATIC_POOLS["general"]) if m not in skip]
139
  if not pool:
140
- # Fallback to general list if no model is left in active pool
141
- fallback_list = _STATIC_POOLS["casual"] + _STATIC_POOLS["general"]
142
- pool = [m for m in fallback_list if m not in skip]
143
  if not pool:
144
  return "nvidia/nemotron-3-nano-30b-a3b:free", category
145
 
@@ -148,8 +165,7 @@ def _pick(category: str, has_image: bool = False, skip_models: set | None = None
148
  return pool[idx], category
149
 
150
 
151
- # ── Message helpers ────────────────────────────────────────────
152
-
153
  def _extract_text(messages: list) -> str:
154
  """Get raw text from the last user message (handles multimodal)."""
155
  if not messages:
@@ -157,14 +173,15 @@ def _extract_text(messages: list) -> str:
157
  content = messages[-1].content if hasattr(messages[-1], "content") else ""
158
  if isinstance(content, list):
159
  return " ".join(
160
- p.get("text", "") for p in content
 
161
  if isinstance(p, dict) and p.get("type") == "text"
162
  )
163
  return str(content)
164
 
165
 
166
  def _strip_images(messages: list) -> list:
167
- """Remove all image_url content from messages. Preserves message types."""
168
  out = []
169
  for msg in messages:
170
  if isinstance(msg.content, list):
@@ -183,7 +200,6 @@ def _strip_images(messages: list) -> list:
183
 
184
 
185
  # ── LLM factory ───────────────────────────────────────────────
186
-
187
  def _make_llm(api_key: str, model_id: str, bind_tools: bool = False):
188
  key = (api_key or "").strip() or (OPENROUTER_API_KEY or "").strip()
189
  if not key:
@@ -195,7 +211,7 @@ def _make_llm(api_key: str, model_id: str, bind_tools: bool = False):
195
  max_tokens=4096,
196
  max_retries=0,
197
  streaming=True,
198
- timeout=60, # 60 seconds timeout per request
199
  openrouter_provider={"ignore": ["Venice"]},
200
  )
201
  if bind_tools:
@@ -203,48 +219,48 @@ def _make_llm(api_key: str, model_id: str, bind_tools: bool = False):
203
  return llm
204
 
205
 
206
- # ── Tool dispatcher ───────────────────────────────────────
207
-
208
- _TOOL_MAP = {t.name: t for t in TOOLS}
209
-
210
-
211
- def _run_tool(tool_call: dict) -> ToolMessage:
212
- """Execute a single tool call dict and return a ToolMessage."""
213
- name = tool_call.get("name", "")
214
- args = tool_call.get("args", {})
215
- tc_id = tool_call.get("id", name)
216
- tool_fn = _TOOL_MAP.get(name)
217
- if tool_fn is None:
218
- result = f"Unknown tool: {name}"
219
- else:
220
- try:
221
- result = tool_fn.invoke(args)
222
- except Exception as exc:
223
- result = f"Tool error: {exc}"
224
- return ToolMessage(content=str(result), tool_call_id=tc_id)
225
-
226
-
227
- # ── LangGraph state & node ────────────────────────────────────
228
-
229
  class ChatState(TypedDict):
230
- messages: Annotated[list[BaseMessage], add_messages]
231
-
 
 
 
 
232
 
233
- def chat_node(state: ChatState, config: RunnableConfig):
234
- cfg = config.get("configurable", {})
235
- persona = cfg.get("persona", "nerd")
236
- context = cfg.get("context", "")
237
- language = cfg.get("language", "auto")
238
- username = cfg.get("username", "")
239
- profile = cfg.get("student_profile", "")
240
- api_key = cfg.get("user_api_key", "")
241
- override = cfg.get("model", "")
242
- has_image = cfg.get("has_image", False)
243
 
244
- user_text = _extract_text(state["messages"])
245
- category = _classify(user_text)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
246
 
247
- messages = list(state["messages"])
248
  base_prompt = prompts.build(persona, context, language, username, profile)
249
 
250
  if has_image:
@@ -254,70 +270,118 @@ def chat_node(state: ChatState, config: RunnableConfig):
254
  "Do NOT output safety classifications or moderation labels.\n"
255
  )
256
 
257
- sys = SystemMessage(content=base_prompt)
258
-
259
- # Try up to 3 models on failures / rate limits
260
- skip_models: set[str] = set()
261
- last_error = None
 
 
 
 
 
 
 
 
 
 
 
262
 
263
- for attempt in range(3):
264
- if override and attempt == 0:
265
- model_id, actual = override, category
266
- else:
267
- model_id, actual = _pick(category, has_image=has_image, skip_models=skip_models)
268
 
269
- send_messages = messages if actual == "vision" else _strip_images(messages)
270
- print(f"[ROUTER] attempt={attempt+1} category={category} model={model_id} vision={actual == 'vision'}")
271
 
 
 
272
  try:
273
- llm = _make_llm(api_key, model_id, bind_tools=True)
274
-
275
- # ── ReAct tool-calling loop (max 5 iterations) ───
276
- loop_msgs = list(send_messages)
277
- for _iter in range(5):
278
- resp = llm.invoke([sys] + loop_msgs, config=config)
279
-
280
- # No tool calls β†’ final answer
281
- if not getattr(resp, "tool_calls", None):
282
- return {"messages": [resp]}
283
-
284
- # Execute requested tools and collect results
285
- print(f"[TOOLS] iter={_iter+1} calls={[tc['name'] for tc in resp.tool_calls]}")
286
- loop_msgs.append(resp)
287
- for tc in resp.tool_calls:
288
- tool_msg = _run_tool(tc)
289
- loop_msgs.append(tool_msg)
290
-
291
- # Exceeded iterations β†’ final invoke without tools to force answer
292
- final_llm = _make_llm(api_key, model_id, bind_tools=False)
293
- resp = final_llm.invoke([sys] + loop_msgs, config=config)
294
- return {"messages": [resp]}
295
-
296
- except Exception as e:
297
- err_str = str(e)
298
- last_error = e
299
-
300
- # Rotate on rate-limits, provider auth, timeout, or provider failure
301
- is_rate = "429" in err_str or "TooManyRequests" in err_str or "rate" in err_str.lower()
302
- is_auth_err = "401" in err_str or "User not found" in err_str or "Unauthorized" in err_str
303
- is_ignored = "ignored" in err_str.lower() or "no provider" in err_str.lower() or "404" in err_str
304
- is_timeout = "timeout" in err_str.lower() or "timed out" in err_str.lower()
305
- is_connection = "connection" in err_str.lower() or "connect" in err_str.lower()
306
-
307
- if is_rate or is_auth_err or is_ignored or is_timeout or is_connection:
308
- reason = "rate-limit" if is_rate else ("auth" if is_auth_err else ("ignored" if is_ignored else "timeout/connection"))
309
- print(f"[ROUTER] Error ({reason}) on {model_id}, rotating...")
310
- skip_models.add(model_id)
311
- continue
312
- raise
313
-
314
- raise last_error
315
-
316
-
317
- # ── Compile graph ─────────────────────────────────────────────
318
- _g = StateGraph(ChatState)
319
- _g.add_node("chat_node", chat_node)
320
- _g.add_edge(START, "chat_node")
321
- _g.add_edge("chat_node", END)
322
-
323
- chatbot = _g.compile(checkpointer=checkpointer)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  """
2
+ LangGraph β€” Stem Copilot chat graph.
3
+
4
+ Architecture (official LangGraph pattern):
5
+ START β†’ llm_node β†’ (should_retry) ──► tools_node β†’ llm_node (tool loop)
6
+ └──► END (final answer)
7
+ └──► llm_node (model retry)
8
+
9
+ State fields:
10
+ messages β€” full conversation (add_messages reducer)
11
+ model_attempt β€” how many model attempts have been made (for retry routing)
12
+ skipped_models β€” list of model IDs that failed this turn
13
+ selected_model β€” the model ID that produced the final response (for LangSmith)
14
+ retry_status β€” human-readable retry string emitted to frontend ("ok" | "retrying:1/2")
15
+ force_tool β€” "web_search" | "yt_transcript" | None (set from app.py config)
16
  """
17
 
18
+ from __future__ import annotations
 
 
 
 
 
 
 
19
 
20
  import sqlite3
21
+ from typing import Annotated, TypedDict
22
+
23
+ from langchain_core.messages import AIMessage, BaseMessage, SystemMessage
24
+ from langchain_core.runnables import RunnableConfig
25
+ from langchain_openrouter import ChatOpenRouter # type: ignore
26
+ from langgraph.checkpoint.sqlite import SqliteSaver # type: ignore
27
+ from langgraph.graph import END, START, StateGraph
28
+ from langgraph.graph.message import add_messages
29
+ from langgraph.prebuilt import ToolNode, tools_condition
30
+
31
  import prompts
32
+ from config import DB_PATH, OPENROUTER_API_KEY
33
  from tools import TOOLS
34
 
35
+ try:
36
+ from langsmith import get_current_run_tree # type: ignore
37
+ except ImportError:
38
+ get_current_run_tree = None # graceful fallback if langsmith not installed
39
+
40
 
41
  # ── Checkpointer ──────────────────────────────────────────────
42
  _conn = sqlite3.connect(DB_PATH, check_same_thread=False)
43
  checkpointer = SqliteSaver(conn=_conn)
44
 
45
 
46
+ # ── Constants ─────────────────────────────────────────────────
47
+ MAX_RETRIES = 2 # 3 total attempts: attempt 0, 1, 2
48
+
49
+
50
+ # ── Static Model Pools ────────────────────────────────────────
51
+ _STATIC_POOLS: dict[str, list[str]] = {
52
  "reasoning": [
53
  "nvidia/nemotron-3-ultra-550b-a55b:free",
54
  "nex-agi/nex-n2-pro:free",
 
69
  "vision": [
70
  "nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free",
71
  "google/gemma-4-31b-it:free",
72
+ ],
73
  }
74
 
75
+ _counters: dict[str, int] = {}
76
 
 
77
 
78
+ # ── Query Classification ──────────────────────────────────────
79
  _CASUAL_EXACT = frozenset([
80
  "hi", "hii", "hiii", "hello", "hey", "yo", "sup", "hola",
81
  "thanks", "thank you", "thankyou", "thx", "ty",
 
137
  return "general"
138
 
139
 
140
+ # ── Model Picker ──────────────────────────────────────────────
141
+ def _pick(
142
+ category: str,
143
+ has_image: bool = False,
144
+ skip_models: set[str] | None = None,
145
+ ) -> tuple[str, str]:
146
+ """Return (model_id, actual_category) from static pools."""
 
 
147
  skip = skip_models or set()
148
 
149
  if has_image:
 
155
 
156
  pool = [m for m in (_STATIC_POOLS.get(category) or _STATIC_POOLS["general"]) if m not in skip]
157
  if not pool:
158
+ fallback = _STATIC_POOLS["casual"] + _STATIC_POOLS["general"]
159
+ pool = [m for m in fallback if m not in skip]
 
160
  if not pool:
161
  return "nvidia/nemotron-3-nano-30b-a3b:free", category
162
 
 
165
  return pool[idx], category
166
 
167
 
168
+ # ── Message helpers ───────────────────────────────────────────
 
169
  def _extract_text(messages: list) -> str:
170
  """Get raw text from the last user message (handles multimodal)."""
171
  if not messages:
 
173
  content = messages[-1].content if hasattr(messages[-1], "content") else ""
174
  if isinstance(content, list):
175
  return " ".join(
176
+ p.get("text", "")
177
+ for p in content
178
  if isinstance(p, dict) and p.get("type") == "text"
179
  )
180
  return str(content)
181
 
182
 
183
  def _strip_images(messages: list) -> list:
184
+ """Remove all image_url content from messages."""
185
  out = []
186
  for msg in messages:
187
  if isinstance(msg.content, list):
 
200
 
201
 
202
  # ── LLM factory ───────────────────────────────────────────────
 
203
  def _make_llm(api_key: str, model_id: str, bind_tools: bool = False):
204
  key = (api_key or "").strip() or (OPENROUTER_API_KEY or "").strip()
205
  if not key:
 
211
  max_tokens=4096,
212
  max_retries=0,
213
  streaming=True,
214
+ timeout=20, # 20s per attempt; 3 attempts = 60s max total
215
  openrouter_provider={"ignore": ["Venice"]},
216
  )
217
  if bind_tools:
 
219
  return llm
220
 
221
 
222
+ # ── Graph State ───────────────────────────────────────────────
223
+ # Extends MessagesState pattern (add_messages reducer on messages field)
224
+ # with retry tracking fields needed by should_retry edge.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
225
  class ChatState(TypedDict):
226
+ messages: Annotated[list[BaseMessage], add_messages]
227
+ model_attempt: int # 0, 1, 2 β€” incremented on failure
228
+ skipped_models: list[str] # model IDs that failed this turn
229
+ selected_model: str # model ID that succeeded (for LangSmith)
230
+ retry_status: str # "ok" | "retrying:1/2" | "retrying:2/2"
231
+ force_tool: str # "web_search" | "yt_transcript" | ""
232
 
 
 
 
 
 
 
 
 
 
 
233
 
234
+ # ── LLM Node ─────────────────────────────────────────────────
235
+ def llm_node(state: ChatState, config: RunnableConfig) -> dict:
236
+ """
237
+ Calls the LLM. On success returns the AI message.
238
+ On failure increments model_attempt and adds model to skipped_models
239
+ so should_retry can route back here with the next model.
240
+ """
241
+ cfg = config.get("configurable", {})
242
+ persona = cfg.get("persona", "nerd")
243
+ context = cfg.get("context", "")
244
+ language = cfg.get("language", "auto")
245
+ username = cfg.get("username", "")
246
+ profile = cfg.get("student_profile", "")
247
+ api_key = cfg.get("user_api_key", "")
248
+ override = cfg.get("model", "")
249
+ has_image = cfg.get("has_image", False)
250
+ force_tool = cfg.get("force_tool", "")
251
+
252
+ attempt = state.get("model_attempt", 0)
253
+ skipped = list(state.get("skipped_models", []))
254
+ user_text = _extract_text(state["messages"])
255
+ category = _classify(user_text)
256
+
257
+ # Pick model β€” respect override only on first attempt
258
+ if override and attempt == 0:
259
+ model_id, actual = override, category
260
+ else:
261
+ model_id, actual = _pick(category, has_image=has_image, skip_models=set(skipped))
262
 
263
+ # Build system prompt
264
  base_prompt = prompts.build(persona, context, language, username, profile)
265
 
266
  if has_image:
 
270
  "Do NOT output safety classifications or moderation labels.\n"
271
  )
272
 
273
+ # Inject tool-forcing directive into system prompt (authoritative β€” not user message)
274
+ if force_tool == "web_search":
275
+ base_prompt += (
276
+ "\n\n## ACTIVE MODE: WEB SEARCH\n"
277
+ "The student has explicitly enabled Web Search. "
278
+ "You MUST call the `web_search` tool with a concise query before answering. "
279
+ "Do NOT answer from memory alone.\n"
280
+ )
281
+ elif force_tool == "yt_transcript":
282
+ base_prompt += (
283
+ "\n\n## ACTIVE MODE: YOUTUBE TRANSCRIPT\n"
284
+ "The student has enabled YouTube mode. "
285
+ "Extract the YouTube URL or video ID from the user message and call the "
286
+ "`yt_transcript` tool to retrieve its transcript before answering. "
287
+ "Do NOT answer without fetching the transcript first.\n"
288
+ )
289
 
290
+ sys_msg = SystemMessage(content=base_prompt)
291
+ messages = state["messages"]
292
+ send_messages = messages if actual == "vision" else _strip_images(list(messages))
 
 
293
 
294
+ retry_status = "ok" if attempt == 0 else f"retrying:{attempt}/{MAX_RETRIES}"
295
+ print(f"[ROUTER] attempt={attempt + 1} category={category} model={model_id}")
296
 
297
+ # Tag current LangSmith run with selected model
298
+ if get_current_run_tree is not None:
299
  try:
300
+ run = get_current_run_tree()
301
+ if run:
302
+ meta = run.extra.setdefault("metadata", {})
303
+ meta["selected_model"] = model_id
304
+ meta["model_attempt"] = attempt
305
+ meta["model_category"] = actual
306
+ except Exception:
307
+ pass # Never let LangSmith tagging break the chat
308
+
309
+ try:
310
+ llm = _make_llm(api_key, model_id, bind_tools=True)
311
+ resp = llm.invoke([sys_msg] + list(send_messages), config=config)
312
+
313
+ return {
314
+ "messages": [resp],
315
+ "selected_model": model_id,
316
+ "retry_status": "ok",
317
+ # Do NOT reset model_attempt/skipped here β€” they stay for the tool loop
318
+ # but will reset on the next user turn (app.py always starts a fresh state input)
319
+ }
320
+
321
+ except Exception as e:
322
+ err_str = str(e)
323
+ print(f"[ROUTER] Error on {model_id}: {err_str[:120]}")
324
+ return {
325
+ "skipped_models": skipped + [model_id],
326
+ "model_attempt": attempt + 1,
327
+ "retry_status": retry_status,
328
+ }
329
+
330
+
331
+ # ── Retry / Route Edge ────────────────────────────────────────
332
+ def should_retry(state: ChatState) -> str:
333
+ """
334
+ Conditional edge leaving llm_node.
335
+
336
+ Three possible routes:
337
+ "tools" β€” LLM succeeded and wants to call a tool
338
+ "__end__" β€” LLM succeeded with a final answer (tools_condition returns __end__)
339
+ "llm" β€” LLM failed, retry with next model if attempts remain
340
+ """
341
+ messages = state.get("messages", [])
342
+ if not messages:
343
+ return END
344
+
345
+ last = messages[-1]
346
+
347
+ # If last message is an AIMessage the LLM succeeded β€” delegate to tools_condition
348
+ if isinstance(last, AIMessage):
349
+ # tools_condition returns "tools" or "__end__"
350
+ return tools_condition(state)
351
+
352
+ # No AIMessage = node returned failure dict without a message
353
+ attempt = state.get("model_attempt", 0)
354
+ if attempt <= MAX_RETRIES:
355
+ return "llm" # retry
356
+
357
+ # All attempts exhausted β€” return empty response rather than crashing
358
+ print("[ROUTER] All model attempts exhausted.")
359
+ return END
360
+
361
+
362
+ # ── Tool Node ─────────────────────────────────────────────────
363
+ # handle_tool_errors=True: DDG rate limits and YT fetch errors are caught
364
+ # automatically and returned as ToolMessage strings β€” graph never crashes.
365
+ tools_node = ToolNode(TOOLS, handle_tool_errors=True)
366
+
367
+
368
+ # ── Build Graph ───────────────────────────────────────────────
369
+ _builder = StateGraph(ChatState)
370
+ _builder.add_node("llm", llm_node)
371
+ _builder.add_node("tools", tools_node)
372
+
373
+ _builder.add_edge(START, "llm")
374
+
375
+ _builder.add_conditional_edges(
376
+ "llm",
377
+ should_retry,
378
+ {
379
+ "tools": "tools", # LLM wants to call a tool
380
+ "llm": "llm", # model failed β†’ retry with next model
381
+ END: END, # final answer
382
+ },
383
+ )
384
+
385
+ _builder.add_edge("tools", "llm") # after tool execution, back to LLM
386
+
387
+ chatbot = _builder.compile(checkpointer=checkpointer)
static/app.js CHANGED
@@ -970,9 +970,39 @@ function closeAllMenus() {
970
  document.querySelectorAll('.options-menu.show').forEach(m => m.classList.remove('show'));
971
  document.querySelectorAll('.options-btn.menu-open').forEach(b => b.classList.remove('menu-open'));
972
  if (userMenu) userMenu.classList.remove('show');
973
- // Close ALL style dropdowns β€” static and dynamic hero ones
974
  document.querySelectorAll('.style-dropdown.show').forEach(d => d.classList.remove('show'));
975
  document.querySelectorAll('.style-selector-btn.open').forEach(b => b.classList.remove('open'));
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
976
  }
977
 
978
  function deleteChat(e, optionEl) {
@@ -1670,6 +1700,10 @@ function streamResponse(text) {
1670
  _showActionsBar(actionsEl, contentEl);
1671
  isSending = false; _showAdaptiveBtn(); currentStreamStopped = true; return;
1672
  }
 
 
 
 
1673
  if (data.tool_event) {
1674
  if (data.tool_event === 'tool_start') {
1675
  if (pLabel) pLabel.textContent = data.tool === 'web_search' ? 'Searching the web...' : 'Fetching YouTube transcript...';
@@ -1757,6 +1791,7 @@ function _showActionsBar(actionsEl, contentEl) {
1757
  }
1758
 
1759
  function finishStream(thinkingEl, contentEl, rawText, timer, actionsEl) {
 
1760
  if (timer) clearTimeout(timer);
1761
  currentRenderTimer = null;
1762
  thinkingEl.style.display = 'none';
 
970
  document.querySelectorAll('.options-menu.show').forEach(m => m.classList.remove('show'));
971
  document.querySelectorAll('.options-btn.menu-open').forEach(b => b.classList.remove('menu-open'));
972
  if (userMenu) userMenu.classList.remove('show');
 
973
  document.querySelectorAll('.style-dropdown.show').forEach(d => d.classList.remove('show'));
974
  document.querySelectorAll('.style-selector-btn.open').forEach(b => b.classList.remove('open'));
975
+ // Fix: close attach menus and reset + button icon
976
+ document.querySelectorAll('.attach-menu.show').forEach(m => m.classList.remove('show'));
977
+ document.querySelectorAll('.attach-plus-btn.open').forEach(b => b.classList.remove('open'));
978
+ }
979
+
980
+ function _showRetryBanner(status, attempt) {
981
+ // Remove any existing banner
982
+ const existing = document.querySelector('.retry-banner');
983
+ if (existing) existing.remove();
984
+
985
+ if (!status || status === 'ok') return;
986
+
987
+ // Build display string: "retrying:1/2" β†’ "Retrying model 1 of 2..."
988
+ const parts = status.split(':');
989
+ const label = parts[1] ? `Retrying model ${parts[1].replace('/', ' of ')}…` : 'Retrying…';
990
+
991
+ const banner = document.createElement('div');
992
+ banner.className = 'retry-banner';
993
+ banner.textContent = label;
994
+
995
+ // Attach to the active AI message row
996
+ const rows = chatContainer.querySelectorAll('.message-row.ai');
997
+ const lastRow = rows[rows.length - 1];
998
+ if (lastRow) {
999
+ const thinkingEl = lastRow.querySelector('.thinking-indicator');
1000
+ if (thinkingEl) thinkingEl.appendChild(banner);
1001
+ }
1002
+ }
1003
+
1004
+ function _clearRetryBanner() {
1005
+ document.querySelectorAll('.retry-banner').forEach(b => b.remove());
1006
  }
1007
 
1008
  function deleteChat(e, optionEl) {
 
1700
  _showActionsBar(actionsEl, contentEl);
1701
  isSending = false; _showAdaptiveBtn(); currentStreamStopped = true; return;
1702
  }
1703
+ // Retry status banner
1704
+ if (data.retry_status) {
1705
+ _showRetryBanner(data.retry_status, data.attempt || 0);
1706
+ }
1707
  if (data.tool_event) {
1708
  if (data.tool_event === 'tool_start') {
1709
  if (pLabel) pLabel.textContent = data.tool === 'web_search' ? 'Searching the web...' : 'Fetching YouTube transcript...';
 
1791
  }
1792
 
1793
  function finishStream(thinkingEl, contentEl, rawText, timer, actionsEl) {
1794
+ _clearRetryBanner();
1795
  if (timer) clearTimeout(timer);
1796
  currentRenderTimer = null;
1797
  thinkingEl.style.display = 'none';
static/style.css CHANGED
@@ -2640,4 +2640,35 @@ textarea, input, select {
2640
  .user .message-content { font-size: 12.5px; }
2641
  .katex-display { overflow-x: auto; font-size: 0.95em; }
2642
  .message-content pre { font-size: 11px; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2643
  }
 
2640
  .user .message-content { font-size: 12.5px; }
2641
  .katex-display { overflow-x: auto; font-size: 0.95em; }
2642
  .message-content pre { font-size: 11px; }
2643
+ }
2644
+
2645
+ /* ── Retry Banner ───────────────────────────────────────────── */
2646
+ .retry-banner {
2647
+ display: inline-flex;
2648
+ align-items: center;
2649
+ gap: 6px;
2650
+ margin-left: 10px;
2651
+ padding: 2px 10px;
2652
+ background: var(--bg-card);
2653
+ border: 1px solid var(--border-color);
2654
+ border-radius: 20px;
2655
+ font-size: 11px;
2656
+ color: var(--text-muted);
2657
+ letter-spacing: 0.3px;
2658
+ animation: fadeIn 0.25s ease-out;
2659
+ }
2660
+
2661
+ .retry-banner::before {
2662
+ content: '';
2663
+ width: 6px;
2664
+ height: 6px;
2665
+ border-radius: 50%;
2666
+ background: var(--brand);
2667
+ animation: dotPulse 1s ease-in-out infinite;
2668
+ flex-shrink: 0;
2669
+ }
2670
+
2671
+ @keyframes dotPulse {
2672
+ 0%, 100% { opacity: 0.3; transform: scale(0.8); }
2673
+ 50% { opacity: 1; transform: scale(1.1); }
2674
  }
tools.py CHANGED
@@ -1,19 +1,26 @@
1
  from __future__ import annotations
2
  import re
3
- from langchain_community.tools import DuckDuckGoSearchRun
4
  from langchain_core.tools import tool
5
 
6
- # DuckDuckGo Search Run tool
7
- web_search = DuckDuckGoSearchRun(
 
 
 
 
8
  name="web_search",
 
9
  description=(
10
  "Search the internet for current information. "
11
- "Use this when the student asks about recent events, specific facts, "
12
  "or anything not covered by the NCERT curriculum context. "
13
  "Input: a concise search query string."
14
  ),
15
  )
16
 
 
 
17
  def _extract_video_id(url_or_id: str) -> str | None:
18
  """Extract YouTube 11-character video ID from URL or bare ID."""
19
  patterns = [
@@ -26,6 +33,7 @@ def _extract_video_id(url_or_id: str) -> str | None:
26
  return m.group(1)
27
  return None
28
 
 
29
  @tool
30
  def yt_transcript(youtube_url: str) -> str:
31
  """
@@ -59,5 +67,6 @@ def yt_transcript(youtube_url: str) -> str:
59
  )
60
  return f"Error fetching transcript: {exc}"
61
 
62
- # Exported tools list for LangGraph
 
63
  TOOLS = [web_search, yt_transcript]
 
1
  from __future__ import annotations
2
  import re
3
+ from langchain_community.tools import DuckDuckGoSearchResults
4
  from langchain_core.tools import tool
5
 
6
+
7
+ # ── Web Search ────────────────────────────────────────────────
8
+ # Using DuckDuckGoSearchResults (structured) over DuckDuckGoSearchRun (plain string).
9
+ # num_results=4 keeps context tight for free-tier models.
10
+ # handle_tool_error is handled at the ToolNode level in graph.py.
11
+ web_search = DuckDuckGoSearchResults(
12
  name="web_search",
13
+ num_results=4,
14
  description=(
15
  "Search the internet for current information. "
16
+ "Use when the student asks about recent events, specific facts, "
17
  "or anything not covered by the NCERT curriculum context. "
18
  "Input: a concise search query string."
19
  ),
20
  )
21
 
22
+
23
+ # ── YouTube Transcript ────────────────────────────────────────
24
  def _extract_video_id(url_or_id: str) -> str | None:
25
  """Extract YouTube 11-character video ID from URL or bare ID."""
26
  patterns = [
 
33
  return m.group(1)
34
  return None
35
 
36
+
37
  @tool
38
  def yt_transcript(youtube_url: str) -> str:
39
  """
 
67
  )
68
  return f"Error fetching transcript: {exc}"
69
 
70
+
71
+ # ── Exported list ─────────────────────────────────────────────
72
  TOOLS = [web_search, yt_transcript]