Spaces:
Sleeping
Sleeping
claude code
Browse files- app.py +70 -23
- graph.py +203 -139
- static/app.js +36 -1
- static/style.css +31 -0
- 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:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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":
|
| 286 |
{
|
| 287 |
"type": "image_url",
|
| 288 |
"image_url": {"url": f"data:image/jpeg;base64,{request.image}"},
|
| 289 |
},
|
| 290 |
]
|
| 291 |
else:
|
| 292 |
-
msg_content =
|
| 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 |
-
|
| 312 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 313 |
config=config,
|
| 314 |
-
stream_mode="
|
| 315 |
):
|
| 316 |
-
#
|
| 317 |
-
|
| 318 |
-
|
| 319 |
-
|
| 320 |
-
|
| 321 |
-
|
| 322 |
-
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
|
| 328 |
-
|
| 329 |
-
|
| 330 |
-
|
| 331 |
-
|
| 332 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 β
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
"""
|
| 8 |
|
| 9 |
-
from
|
| 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
|
| 20 |
-
|
| 21 |
-
from
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
# ββ
|
| 32 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 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 |
-
|
| 141 |
-
|
| 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", "")
|
|
|
|
| 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.
|
| 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=
|
| 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 |
-
# ββ
|
| 207 |
-
|
| 208 |
-
|
| 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:
|
| 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 |
-
|
| 245 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 246 |
|
| 247 |
-
|
| 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 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 262 |
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
else:
|
| 267 |
-
model_id, actual = _pick(category, has_image=has_image, skip_models=skip_models)
|
| 268 |
|
| 269 |
-
|
| 270 |
-
|
| 271 |
|
|
|
|
|
|
|
| 272 |
try:
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
#
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
|
| 313 |
-
|
| 314 |
-
|
| 315 |
-
|
| 316 |
-
|
| 317 |
-
|
| 318 |
-
|
| 319 |
-
|
| 320 |
-
|
| 321 |
-
|
| 322 |
-
|
| 323 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 4 |
from langchain_core.tools import tool
|
| 5 |
|
| 6 |
-
|
| 7 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
name="web_search",
|
|
|
|
| 9 |
description=(
|
| 10 |
"Search the internet for current information. "
|
| 11 |
-
"Use
|
| 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 |
-
|
|
|
|
| 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]
|