Krishna111111 commited on
Commit
1829c17
Β·
1 Parent(s): 5c62ce3

claude code; tavily; bugs fix..

Browse files
Files changed (11) hide show
  1. app.py +21 -3
  2. config.py +3 -0
  3. db.py +23 -1
  4. graph.py +81 -10
  5. requirements.txt +1 -3
  6. static/app.js +62 -0
  7. static/index.html +20 -2
  8. static/manifest.json +2 -2
  9. static/style.css +45 -0
  10. sw.js +2 -2
  11. tools.py +68 -33
app.py CHANGED
@@ -67,6 +67,11 @@ class ApiKeyRequest(BaseModel):
67
  key: str
68
 
69
 
 
 
 
 
 
70
  class ProfileRequest(BaseModel):
71
  user_id: str
72
  profile: str
@@ -135,7 +140,8 @@ def google_login(req: GoogleAuthRequest):
135
  picture=idinfo.get("picture", ""),
136
  )
137
  has_key = bool(db.get_user_api_key(idinfo["sub"]))
138
- return {"user": user, "has_api_key": has_key}
 
139
 
140
 
141
  @app.get("/auth/me")
@@ -146,7 +152,11 @@ def get_me(user_id: str = ""):
146
  user = db.get_user(user_id)
147
  if not user:
148
  return JSONResponse({"error": "User not found"}, status_code=404)
149
- return {"user": user, "has_api_key": bool(user.get("openrouter_key"))}
 
 
 
 
150
 
151
 
152
  @app.get("/auth/client_id")
@@ -163,6 +173,12 @@ def save_api_key(req: ApiKeyRequest):
163
  return {"ok": True}
164
 
165
 
 
 
 
 
 
 
166
  @app.post("/user/profile")
167
  def save_profile(req: ProfileRequest):
168
  db.save_student_profile(req.user_id, req.profile)
@@ -255,9 +271,11 @@ def chat(request: ChatRequest, req: Request):
255
 
256
  # Resolve user-specific data
257
  user_api_key = ""
 
258
  student_profile = ""
259
  if request.user_id:
260
  user_api_key = db.get_user_api_key(request.user_id)
 
261
  user_data = db.get_user(request.user_id)
262
  if user_data:
263
  student_profile = user_data.get("student_profile", "")
@@ -302,7 +320,7 @@ def chat(request: ChatRequest, req: Request):
302
  if web_search_enabled:
303
  yield sse_tool_event("tool_start", "web_search")
304
  try:
305
- web_results = run_web_search(clean_message)
306
  print(f"[WEB SEARCH] Fetched {len(web_results)} chars for: {clean_message[:60]}", flush=True)
307
  context += f"\n\n[Web Search Results]\n{web_results}\n[End Web Search Results]\n"
308
  except Exception as e:
 
67
  key: str
68
 
69
 
70
+ class TavilyKeyRequest(BaseModel):
71
+ user_id: str
72
+ key: str
73
+
74
+
75
  class ProfileRequest(BaseModel):
76
  user_id: str
77
  profile: str
 
140
  picture=idinfo.get("picture", ""),
141
  )
142
  has_key = bool(db.get_user_api_key(idinfo["sub"]))
143
+ has_tavily = bool(db.get_tavily_key(idinfo["sub"]))
144
+ return {"user": user, "has_api_key": has_key, "has_tavily_key": has_tavily}
145
 
146
 
147
  @app.get("/auth/me")
 
152
  user = db.get_user(user_id)
153
  if not user:
154
  return JSONResponse({"error": "User not found"}, status_code=404)
155
+ return {
156
+ "user": user,
157
+ "has_api_key": bool(user.get("openrouter_key")),
158
+ "has_tavily_key": bool(user.get("tavily_key")),
159
+ }
160
 
161
 
162
  @app.get("/auth/client_id")
 
173
  return {"ok": True}
174
 
175
 
176
+ @app.post("/user/tavilykey")
177
+ def save_tavily_key(req: TavilyKeyRequest):
178
+ db.save_tavily_key(req.user_id, req.key)
179
+ return {"ok": True, "has_tavily_key": bool(req.key.strip())}
180
+
181
+
182
  @app.post("/user/profile")
183
  def save_profile(req: ProfileRequest):
184
  db.save_student_profile(req.user_id, req.profile)
 
271
 
272
  # Resolve user-specific data
273
  user_api_key = ""
274
+ tavily_key = ""
275
  student_profile = ""
276
  if request.user_id:
277
  user_api_key = db.get_user_api_key(request.user_id)
278
+ tavily_key = db.get_tavily_key(request.user_id)
279
  user_data = db.get_user(request.user_id)
280
  if user_data:
281
  student_profile = user_data.get("student_profile", "")
 
320
  if web_search_enabled:
321
  yield sse_tool_event("tool_start", "web_search")
322
  try:
323
+ web_results = run_web_search(clean_message, api_key=tavily_key)
324
  print(f"[WEB SEARCH] Fetched {len(web_results)} chars for: {clean_message[:60]}", flush=True)
325
  context += f"\n\n[Web Search Results]\n{web_results}\n[End Web Search Results]\n"
326
  except Exception as e:
config.py CHANGED
@@ -8,6 +8,9 @@ GOOGLE_CLIENT_ID = os.environ.get("GOOGLE_CLIENT_ID", "")
8
  # Optional: shared fallback key (users bring their own via BYOK)
9
  OPENROUTER_API_KEY = os.environ.get("OPENROUTER_API_KEY", "")
10
 
 
 
 
11
  # --- LangSmith (tracing & analytics) ---
12
  os.environ["LANGCHAIN_TRACING_V2"] = os.environ.get("LANGSMITH_TRACING", "true")
13
  os.environ["LANGCHAIN_API_KEY"] = os.environ.get("LANGSMITH_API_KEY", "")
 
8
  # Optional: shared fallback key (users bring their own via BYOK)
9
  OPENROUTER_API_KEY = os.environ.get("OPENROUTER_API_KEY", "")
10
 
11
+ # Optional: shared Tavily web-search fallback key (users bring their own via BYOK)
12
+ TAVILY_API_KEY = os.environ.get("TAVILY_API_KEY", "")
13
+
14
  # --- LangSmith (tracing & analytics) ---
15
  os.environ["LANGCHAIN_TRACING_V2"] = os.environ.get("LANGSMITH_TRACING", "true")
16
  os.environ["LANGCHAIN_API_KEY"] = os.environ.get("LANGSMITH_API_KEY", "")
db.py CHANGED
@@ -19,12 +19,19 @@ conn.execute("""
19
  name TEXT NOT NULL,
20
  picture TEXT,
21
  openrouter_key TEXT,
 
22
  student_profile TEXT,
23
  created_at REAL NOT NULL,
24
  last_login REAL NOT NULL
25
  )
26
  """)
27
 
 
 
 
 
 
 
28
  conn.execute("""
29
  CREATE TABLE IF NOT EXISTS thread_titles (
30
  thread_id TEXT PRIMARY KEY,
@@ -63,7 +70,7 @@ def upsert_user(google_id: str, email: str, name: str, picture: str = "") -> dic
63
 
64
  def get_user(google_id: str) -> dict | None:
65
  row = conn.execute(
66
- "SELECT google_id, email, name, picture, openrouter_key, student_profile "
67
  "FROM users WHERE google_id = ?",
68
  (google_id,),
69
  ).fetchone()
@@ -73,6 +80,7 @@ def get_user(google_id: str) -> dict | None:
73
  "google_id": row[0], "email": row[1], "name": row[2],
74
  "picture": row[3], "openrouter_key": row[4] or "",
75
  "student_profile": row[5] or "",
 
76
  }
77
 
78
 
@@ -83,6 +91,20 @@ def save_user_api_key(google_id: str, key: str):
83
  conn.commit()
84
 
85
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
86
  def save_student_profile(google_id: str, profile: str):
87
  conn.execute(
88
  "UPDATE users SET student_profile = ? WHERE google_id = ?", (profile, google_id)
 
19
  name TEXT NOT NULL,
20
  picture TEXT,
21
  openrouter_key TEXT,
22
+ tavily_key TEXT,
23
  student_profile TEXT,
24
  created_at REAL NOT NULL,
25
  last_login REAL NOT NULL
26
  )
27
  """)
28
 
29
+ # Upgrade path: add tavily_key column to pre-existing databases.
30
+ try:
31
+ conn.execute("ALTER TABLE users ADD COLUMN tavily_key TEXT")
32
+ except sqlite3.OperationalError:
33
+ pass # column already exists
34
+
35
  conn.execute("""
36
  CREATE TABLE IF NOT EXISTS thread_titles (
37
  thread_id TEXT PRIMARY KEY,
 
70
 
71
  def get_user(google_id: str) -> dict | None:
72
  row = conn.execute(
73
+ "SELECT google_id, email, name, picture, openrouter_key, student_profile, tavily_key "
74
  "FROM users WHERE google_id = ?",
75
  (google_id,),
76
  ).fetchone()
 
80
  "google_id": row[0], "email": row[1], "name": row[2],
81
  "picture": row[3], "openrouter_key": row[4] or "",
82
  "student_profile": row[5] or "",
83
+ "tavily_key": row[6] or "",
84
  }
85
 
86
 
 
91
  conn.commit()
92
 
93
 
94
+ def save_tavily_key(google_id: str, key: str):
95
+ conn.execute(
96
+ "UPDATE users SET tavily_key = ? WHERE google_id = ?", (key, google_id)
97
+ )
98
+ conn.commit()
99
+
100
+
101
+ def get_tavily_key(google_id: str) -> str:
102
+ row = conn.execute(
103
+ "SELECT tavily_key FROM users WHERE google_id = ?", (google_id,)
104
+ ).fetchone()
105
+ return row[0] if row and row[0] else ""
106
+
107
+
108
  def save_student_profile(google_id: str, profile: str):
109
  conn.execute(
110
  "UPDATE users SET student_profile = ? WHERE google_id = ?", (profile, google_id)
graph.py CHANGED
@@ -23,6 +23,7 @@ from typing import TypedDict, Annotated
23
  import sqlite3
24
  import time
25
  import json
 
26
  import urllib.request
27
  import threading
28
  from langgraph.checkpoint.sqlite import SqliteSaver # type:ignore
@@ -30,14 +31,68 @@ from config import OPENROUTER_API_KEY, DB_PATH, LLM_TIMEOUT
30
  import prompts
31
 
32
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
33
  # ── Checkpointer ──────────────────────────────────────────────
34
  _conn = sqlite3.connect(DB_PATH, check_same_thread=False)
35
  checkpointer = SqliteSaver(conn=_conn)
36
 
37
 
38
  # ── Constants ─────────────────────────────────────────────────
39
- DEFAULT_MODEL = "openai/gpt-oss-120b:free"
40
- MODELS_TTL = 10 * 60 # seconds
 
41
 
42
 
43
  def _is_guardrail(model_id: str) -> bool:
@@ -84,7 +139,7 @@ _SCIENCE_KW = [
84
  "wave", "wavelength", "frequency", "amplitude",
85
  "light", "optics", "lens", "mirror", "refraction", "reflection",
86
  "electric", "magnetic", "electromagnetic", "circuit", "resistance",
87
- "gravity", "gravitational", "newton", "coulomb",
88
  "reaction", "compound", "element", "bond", "orbital",
89
  "hybridisation", "hybridization", "valence",
90
  "thermodynamics", "entropy", "enthalpy",
@@ -143,6 +198,12 @@ _lock = threading.Lock()
143
  _counters: dict[str, int] = {}
144
 
145
 
 
 
 
 
 
 
146
  def _fetch_pools() -> dict:
147
  """Fetch free models, detect vision support, build sorted pools."""
148
  global _cache, _cache_at
@@ -185,11 +246,11 @@ def _fetch_pools() -> dict:
185
  vision_all.sort(key=lambda x: x["score"], reverse=True)
186
 
187
  pools = {
188
- "reasoning": text_all[:6],
189
- "science": text_all[:8],
190
- "general": text_all[:10],
191
  "casual": text_all[-5:][::-1] if len(text_all) >= 5 else text_all[:3],
192
- "vision": vision_all,
193
  }
194
 
195
  with _lock:
@@ -206,10 +267,14 @@ def _fetch_pools() -> dict:
206
 
207
  except Exception as exc:
208
  print(f"[ROUTER] Fetch failed: {exc}")
209
- fallback_entry = {"id": DEFAULT_MODEL, "score": 50, "vision": False}
 
210
  return _cache or {
211
- k: [fallback_entry]
212
- for k in ("reasoning", "science", "general", "casual", "vision")
 
 
 
213
  }
214
 
215
 
@@ -307,6 +372,12 @@ def chat_node(state: ChatState, config: RunnableConfig):
307
  override = cfg.get("model", "")
308
  search_enabled = cfg.get("search_enabled", False)
309
  has_image = cfg.get("has_image", False)
 
 
 
 
 
 
310
 
311
  user_text = _extract_text(state["messages"])
312
  category = _classify(user_text)
 
23
  import sqlite3
24
  import time
25
  import json
26
+ import base64
27
  import urllib.request
28
  import threading
29
  from langgraph.checkpoint.sqlite import SqliteSaver # type:ignore
 
31
  import prompts
32
 
33
 
34
+ # ── LangSmith document attachment (surfaces the raw uploaded file) ──
35
+ # Optional: only active if the installed langsmith SDK supports attachments.
36
+ try:
37
+ from langsmith import traceable
38
+ try:
39
+ from langsmith import Attachment # type:ignore
40
+ except ImportError:
41
+ from langsmith.schemas import Attachment # type:ignore
42
+ _LANGSMITH_ATTACH = True
43
+ except Exception:
44
+ traceable = None # type:ignore
45
+ Attachment = None # type:ignore
46
+ _LANGSMITH_ATTACH = False
47
+
48
+ _MIME_BY_EXT = {
49
+ "pdf": "application/pdf",
50
+ "docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
51
+ "doc": "application/msword",
52
+ "txt": "text/plain",
53
+ "md": "text/markdown",
54
+ "csv": "text/csv",
55
+ "json": "application/json",
56
+ }
57
+
58
+
59
+ def _guess_mime(filename: str) -> str:
60
+ ext = filename.rsplit(".", 1)[-1].lower() if "." in filename else ""
61
+ return _MIME_BY_EXT.get(ext, "application/octet-stream")
62
+
63
+
64
+ if _LANGSMITH_ATTACH:
65
+ @traceable(name="uploaded_document")
66
+ def _trace_document(filename: str, document) -> str: # noqa: ANN001
67
+ """Nested run that carries the raw user-uploaded file as a LangSmith attachment."""
68
+ return f"Attached document: {filename}"
69
+
70
+
71
+ def _attach_document(doc_name: str, doc_bytes_b64: str) -> None:
72
+ """Surface the original uploaded document in the active LangSmith trace.
73
+
74
+ Best-effort: any failure (old SDK, no API key, decode error) is swallowed so
75
+ tracing can never break the chat response.
76
+ """
77
+ if not (_LANGSMITH_ATTACH and doc_bytes_b64):
78
+ return
79
+ try:
80
+ raw = base64.b64decode(doc_bytes_b64)
81
+ name = doc_name or "uploaded_document"
82
+ _trace_document(name, Attachment(mime_type=_guess_mime(name), data=raw))
83
+ except Exception as exc:
84
+ print(f"[LANGSMITH ATTACH] skipped: {exc}", flush=True)
85
+
86
+
87
  # ── Checkpointer ──────────────────────────────────────────────
88
  _conn = sqlite3.connect(DB_PATH, check_same_thread=False)
89
  checkpointer = SqliteSaver(conn=_conn)
90
 
91
 
92
  # ── Constants ─────────────────────────────────────────────────
93
+ DEFAULT_MODEL = "openai/gpt-oss-120b:free" # pinned default for text
94
+ DEFAULT_VISION_MODEL = "nvidia/nemotron-nano-12b-v2-vl:free" # pinned default for vision
95
+ MODELS_TTL = 10 * 60 # seconds
96
 
97
 
98
  def _is_guardrail(model_id: str) -> bool:
 
139
  "wave", "wavelength", "frequency", "amplitude",
140
  "light", "optics", "lens", "mirror", "refraction", "reflection",
141
  "electric", "magnetic", "electromagnetic", "circuit", "resistance",
142
+ "gravity", "gravitational", "newton", "coulomb", "charge",
143
  "reaction", "compound", "element", "bond", "orbital",
144
  "hybridisation", "hybridization", "valence",
145
  "thermodynamics", "entropy", "enthalpy",
 
198
  _counters: dict[str, int] = {}
199
 
200
 
201
+ def _ensure_pinned(pool: list, pinned_id: str, vision: bool = False) -> list:
202
+ """Guarantee the pinned default model sits at the front of a pool."""
203
+ rest = [e for e in pool if e["id"] != pinned_id]
204
+ return [{"id": pinned_id, "score": 999, "vision": vision}] + rest
205
+
206
+
207
  def _fetch_pools() -> dict:
208
  """Fetch free models, detect vision support, build sorted pools."""
209
  global _cache, _cache_at
 
246
  vision_all.sort(key=lambda x: x["score"], reverse=True)
247
 
248
  pools = {
249
+ "reasoning": _ensure_pinned(text_all[:6], DEFAULT_MODEL),
250
+ "science": _ensure_pinned(text_all[:8], DEFAULT_MODEL),
251
+ "general": _ensure_pinned(text_all[:10], DEFAULT_MODEL),
252
  "casual": text_all[-5:][::-1] if len(text_all) >= 5 else text_all[:3],
253
+ "vision": _ensure_pinned(vision_all, DEFAULT_VISION_MODEL, vision=True),
254
  }
255
 
256
  with _lock:
 
267
 
268
  except Exception as exc:
269
  print(f"[ROUTER] Fetch failed: {exc}")
270
+ fallback_text = {"id": DEFAULT_MODEL, "score": 999, "vision": False}
271
+ fallback_vision = {"id": DEFAULT_VISION_MODEL, "score": 999, "vision": True}
272
  return _cache or {
273
+ "reasoning": [fallback_text],
274
+ "science": [fallback_text],
275
+ "general": [fallback_text],
276
+ "casual": [fallback_text],
277
+ "vision": [fallback_vision],
278
  }
279
 
280
 
 
372
  override = cfg.get("model", "")
373
  search_enabled = cfg.get("search_enabled", False)
374
  has_image = cfg.get("has_image", False)
375
+ doc_bytes = cfg.get("doc_bytes", "")
376
+ doc_name = cfg.get("doc_name", "")
377
+
378
+ # Surface the raw uploaded document in the LangSmith trace (not just its text).
379
+ if doc_bytes:
380
+ _attach_document(doc_name, doc_bytes)
381
 
382
  user_text = _extract_text(state["messages"])
383
  category = _classify(user_text)
requirements.txt CHANGED
@@ -11,9 +11,7 @@ resend
11
  torch --index-url https://download.pytorch.org/whl/cpu
12
  google-auth
13
  langsmith
14
- langchain-community
15
- duckduckgo-search
16
- ddgs
17
  youtube-transcript-api
18
  pdfplumber
19
  python-docx
 
11
  torch --index-url https://download.pytorch.org/whl/cpu
12
  google-auth
13
  langsmith
14
+ tavily-python
 
 
15
  youtube-transcript-api
16
  pdfplumber
17
  python-docx
static/app.js CHANGED
@@ -18,6 +18,7 @@ let isHeroMode = true;
18
 
19
  let isWebSearchEnabled = false;
20
  let isYtSearchEnabled = false;
 
21
  let pendingDocText = '';
22
  let pendingDocName = '';
23
  let pendingDocBytes = '';
@@ -78,6 +79,9 @@ const profileInput = document.getElementById('profileInput');
78
  const saveProfileBtn = document.getElementById('saveProfileBtn');
79
  const settingsApiKeyInput = document.getElementById('settingsApiKeyInput');
80
  const saveApiKeyBtn = document.getElementById('saveApiKeyBtn');
 
 
 
81
 
82
  const attachPlusBtn = document.getElementById('attachPlusBtn');
83
  const attachMenu = document.getElementById('attachMenu');
@@ -444,6 +448,7 @@ function handleGoogleCredential(response) {
444
  localStorage.setItem('stemcopilot_user', JSON.stringify(currentUser));
445
  currentUsername = currentUser.name;
446
  localStorage.setItem('stemcopilot_username', currentUsername);
 
447
  if (!data.has_api_key) showByok(); else showApp();
448
  }).catch(() => showToast('Login failed. Check your connection.', 'error'));
449
  }
@@ -458,6 +463,7 @@ function checkExistingSession() {
458
  .then(data => {
459
  if (data.error) { localStorage.removeItem('stemcopilot_user'); currentUser = null; initGoogleAuth(); return; }
460
  currentUser = data.user;
 
461
  if (!data.has_api_key) showByok(); else showApp();
462
  }).catch(() => initGoogleAuth());
463
  } else initGoogleAuth();
@@ -475,8 +481,16 @@ function showByok() {
475
  if (byokSubmitBtn) byokSubmitBtn.addEventListener('click', () => {
476
  const key = byokInput.value.trim();
477
  if (!key) { byokInput.focus(); return; }
 
478
  fetch('/user/apikey', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ user_id: currentUser.google_id, key: key }) })
479
  .then(r => { if (!r.ok) throw new Error(); return r.json(); })
 
 
 
 
 
 
 
480
  .then(() => showApp())
481
  .catch(() => showToast('Failed to save key.', 'error'));
482
  });
@@ -507,7 +521,10 @@ function showApp() {
507
  if (data.user) {
508
  if (data.user.student_profile && profileInput) profileInput.value = data.user.student_profile;
509
  if (data.user.openrouter_key && settingsApiKeyInput) settingsApiKeyInput.value = 'β€’β€’β€’β€’β€’β€’β€’β€’β€’β€’β€’β€’';
 
510
  }
 
 
511
  }).catch(() => { });
512
  }
513
 
@@ -1063,6 +1080,24 @@ if (saveApiKeyBtn) saveApiKeyBtn.addEventListener('click', () => {
1063
  .finally(() => { saveApiKeyBtn.disabled = false; saveApiKeyBtn.textContent = 'Save Key'; });
1064
  });
1065
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1066
  if (saveProfileBtn) saveProfileBtn.addEventListener('click', () => {
1067
  if (!currentUser) return;
1068
  fetch('/user/profile', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ user_id: currentUser.google_id, profile: profileInput.value }) })
@@ -1411,6 +1446,27 @@ function _syncToggles() {
1411
  });
1412
  }
1413
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1414
  function _updateInputPlaceholder() {
1415
  let placeholder = 'Ask STEM Copilot...';
1416
  if (isWebSearchEnabled) {
@@ -1446,6 +1502,12 @@ function setupAttachMenu(plusBtn, menuEl, webSearchToggleEl, ytSearchToggleEl, a
1446
  if (webSearchToggleEl) {
1447
  webSearchToggleEl.addEventListener('click', (e) => {
1448
  e.stopPropagation();
 
 
 
 
 
 
1449
  isWebSearchEnabled = !isWebSearchEnabled;
1450
  if (isWebSearchEnabled) isYtSearchEnabled = false;
1451
  _syncToggles();
 
18
 
19
  let isWebSearchEnabled = false;
20
  let isYtSearchEnabled = false;
21
+ let hasTavilyKey = false;
22
  let pendingDocText = '';
23
  let pendingDocName = '';
24
  let pendingDocBytes = '';
 
79
  const saveProfileBtn = document.getElementById('saveProfileBtn');
80
  const settingsApiKeyInput = document.getElementById('settingsApiKeyInput');
81
  const saveApiKeyBtn = document.getElementById('saveApiKeyBtn');
82
+ const settingsTavilyKeyInput = document.getElementById('settingsTavilyKeyInput');
83
+ const saveTavilyKeyBtn = document.getElementById('saveTavilyKeyBtn');
84
+ const byokTavilyInput = document.getElementById('byokTavilyInput');
85
 
86
  const attachPlusBtn = document.getElementById('attachPlusBtn');
87
  const attachMenu = document.getElementById('attachMenu');
 
448
  localStorage.setItem('stemcopilot_user', JSON.stringify(currentUser));
449
  currentUsername = currentUser.name;
450
  localStorage.setItem('stemcopilot_username', currentUsername);
451
+ hasTavilyKey = !!data.has_tavily_key;
452
  if (!data.has_api_key) showByok(); else showApp();
453
  }).catch(() => showToast('Login failed. Check your connection.', 'error'));
454
  }
 
463
  .then(data => {
464
  if (data.error) { localStorage.removeItem('stemcopilot_user'); currentUser = null; initGoogleAuth(); return; }
465
  currentUser = data.user;
466
+ hasTavilyKey = !!data.has_tavily_key;
467
  if (!data.has_api_key) showByok(); else showApp();
468
  }).catch(() => initGoogleAuth());
469
  } else initGoogleAuth();
 
481
  if (byokSubmitBtn) byokSubmitBtn.addEventListener('click', () => {
482
  const key = byokInput.value.trim();
483
  if (!key) { byokInput.focus(); return; }
484
+ const tavilyKey = byokTavilyInput ? byokTavilyInput.value.trim() : '';
485
  fetch('/user/apikey', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ user_id: currentUser.google_id, key: key }) })
486
  .then(r => { if (!r.ok) throw new Error(); return r.json(); })
487
+ .then(() => {
488
+ // Optionally save the Tavily key, then enter the app regardless.
489
+ if (tavilyKey) {
490
+ return fetch('/user/tavilykey', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ user_id: currentUser.google_id, key: tavilyKey }) })
491
+ .then(() => { hasTavilyKey = true; });
492
+ }
493
+ })
494
  .then(() => showApp())
495
  .catch(() => showToast('Failed to save key.', 'error'));
496
  });
 
521
  if (data.user) {
522
  if (data.user.student_profile && profileInput) profileInput.value = data.user.student_profile;
523
  if (data.user.openrouter_key && settingsApiKeyInput) settingsApiKeyInput.value = 'β€’β€’β€’β€’β€’β€’β€’β€’β€’β€’β€’β€’';
524
+ if (data.user.tavily_key && settingsTavilyKeyInput) settingsTavilyKeyInput.value = 'β€’β€’β€’β€’β€’β€’β€’β€’β€’β€’β€’β€’';
525
  }
526
+ hasTavilyKey = !!data.has_tavily_key;
527
+ _applyWebSearchAvailability();
528
  }).catch(() => { });
529
  }
530
 
 
1080
  .finally(() => { saveApiKeyBtn.disabled = false; saveApiKeyBtn.textContent = 'Save Key'; });
1081
  });
1082
 
1083
+ if (saveTavilyKeyBtn) saveTavilyKeyBtn.addEventListener('click', () => {
1084
+ const key = settingsTavilyKeyInput.value.trim();
1085
+ // Allow saving an empty value to clear/disable web search; ignore the masked placeholder.
1086
+ if (key.startsWith('β€’β€’')) return;
1087
+ if (!currentUser) return;
1088
+ saveTavilyKeyBtn.disabled = true; saveTavilyKeyBtn.textContent = 'Saving...';
1089
+ fetch('/user/tavilykey', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ user_id: currentUser.google_id, key: key }) })
1090
+ .then(r => { if (!r.ok) throw new Error(); return r.json(); })
1091
+ .then((data) => {
1092
+ hasTavilyKey = !!data.has_tavily_key;
1093
+ _applyWebSearchAvailability();
1094
+ if (settingsTavilyKeyInput) settingsTavilyKeyInput.value = hasTavilyKey ? 'β€’β€’β€’β€’β€’β€’β€’β€’β€’β€’β€’β€’' : '';
1095
+ showToast(hasTavilyKey ? 'Tavily key saved! Web search enabled.' : 'Tavily key cleared. Web search disabled.', 'success');
1096
+ })
1097
+ .catch(() => showToast('Failed to save Tavily key.', 'error'))
1098
+ .finally(() => { saveTavilyKeyBtn.disabled = false; saveTavilyKeyBtn.textContent = 'Save Key'; });
1099
+ });
1100
+
1101
  if (saveProfileBtn) saveProfileBtn.addEventListener('click', () => {
1102
  if (!currentUser) return;
1103
  fetch('/user/profile', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ user_id: currentUser.google_id, profile: profileInput.value }) })
 
1446
  });
1447
  }
1448
 
1449
+ function _applyWebSearchAvailability() {
1450
+ // Web search requires a Tavily API key. Without one, grey out the toggles.
1451
+ const webToggles = [
1452
+ webSearchToggle,
1453
+ document.getElementById('heroWebSearchToggle'),
1454
+ document.getElementById('heroWebSearchToggleDynamic')
1455
+ ].filter(Boolean);
1456
+
1457
+ webToggles.forEach(el => {
1458
+ el.classList.toggle('web-search-disabled', !hasTavilyKey);
1459
+ el.title = hasTavilyKey ? '' : 'Add a Tavily API key in Settings to enable web search';
1460
+ });
1461
+
1462
+ // If the key was removed while web search was on, turn it off.
1463
+ if (!hasTavilyKey && isWebSearchEnabled) {
1464
+ isWebSearchEnabled = false;
1465
+ _syncToggles();
1466
+ _updateInputPlaceholder();
1467
+ }
1468
+ }
1469
+
1470
  function _updateInputPlaceholder() {
1471
  let placeholder = 'Ask STEM Copilot...';
1472
  if (isWebSearchEnabled) {
 
1502
  if (webSearchToggleEl) {
1503
  webSearchToggleEl.addEventListener('click', (e) => {
1504
  e.stopPropagation();
1505
+ if (!hasTavilyKey) {
1506
+ showToast('Add a Tavily API key in Settings to enable web search.', 'info');
1507
+ menuEl.classList.remove('show');
1508
+ plusBtn.classList.remove('open');
1509
+ return;
1510
+ }
1511
  isWebSearchEnabled = !isWebSearchEnabled;
1512
  if (isWebSearchEnabled) isYtSearchEnabled = false;
1513
  _syncToggles();
static/index.html CHANGED
@@ -15,7 +15,7 @@
15
  <meta name="theme-color" content="#0a0a0a">
16
  <link rel="icon" type="image/png" href="/assets/bot.png">
17
  <link rel="manifest" href="/manifest.json">
18
- <link rel="apple-touch-icon" href="/assets/stem_transparent.png">
19
  <!-- Preconnect for faster font/CDN loading -->
20
  <link rel="preconnect" href="https://fonts.googleapis.com">
21
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
@@ -89,8 +89,17 @@
89
  </ol>
90
  <input type="text" id="byokInput" class="settings-input" placeholder="Paste your OpenRouter API key here"
91
  autocomplete="off" spellcheck="false">
 
 
 
 
 
 
 
 
 
92
  <button class="byok-submit-btn" id="byokSubmitBtn">Start Learning</button>
93
- <p class="byok-note">Your key is stored securely and never shared.</p>
94
  </div>
95
  </div>
96
 
@@ -564,6 +573,15 @@
564
  Get your free key at <a href="https://openrouter.ai/workspaces/default/keys" target="_blank"
565
  rel="noopener">openrouter.ai/keys</a>
566
  </p>
 
 
 
 
 
 
 
 
 
567
  </div>
568
 
569
  <!-- Profile Tab -->
 
15
  <meta name="theme-color" content="#0a0a0a">
16
  <link rel="icon" type="image/png" href="/assets/bot.png">
17
  <link rel="manifest" href="/manifest.json">
18
+ <link rel="apple-touch-icon" href="/assets/stem_black.png">
19
  <!-- Preconnect for faster font/CDN loading -->
20
  <link rel="preconnect" href="https://fonts.googleapis.com">
21
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
 
89
  </ol>
90
  <input type="text" id="byokInput" class="settings-input" placeholder="Paste your OpenRouter API key here"
91
  autocomplete="off" spellcheck="false">
92
+ <div class="byok-optional">
93
+ <p class="byok-optional-label">Tavily API key <span class="byok-optional-badge">Optional</span></p>
94
+ <p class="byok-optional-desc">Add it to unlock live web search. Skip it and web search stays disabled.</p>
95
+ <input type="text" id="byokTavilyInput" class="settings-input" placeholder="Paste your Tavily API key (optional)"
96
+ autocomplete="off" spellcheck="false">
97
+ <p class="byok-optional-hint">Get a free Tavily key at
98
+ <a href="https://app.tavily.com/home" target="_blank" rel="noopener">app.tavily.com</a>
99
+ </p>
100
+ </div>
101
  <button class="byok-submit-btn" id="byokSubmitBtn">Start Learning</button>
102
+ <p class="byok-note">Your keys are stored securely and never shared.</p>
103
  </div>
104
  </div>
105
 
 
573
  Get your free key at <a href="https://openrouter.ai/workspaces/default/keys" target="_blank"
574
  rel="noopener">openrouter.ai/keys</a>
575
  </p>
576
+
577
+ <h3 class="settings-section-title">Tavily API Key <span class="settings-optional-badge">Optional</span></h3>
578
+ <input type="password" class="settings-input" id="settingsTavilyKeyInput" placeholder="tvly-..."
579
+ autocomplete="off" spellcheck="false">
580
+ <button class="settings-save-btn" id="saveTavilyKeyBtn">Save Key</button>
581
+ <p class="settings-hint">
582
+ Enables live web search. Get a free key at
583
+ <a href="https://app.tavily.com/home" target="_blank" rel="noopener">app.tavily.com</a>
584
+ </p>
585
  </div>
586
 
587
  <!-- Profile Tab -->
static/manifest.json CHANGED
@@ -11,13 +11,13 @@
11
  "prefer_related_applications": false,
12
  "icons": [
13
  {
14
- "src": "/assets/stem_transparent.png",
15
  "sizes": "512x512",
16
  "type": "image/png",
17
  "purpose": "any"
18
  },
19
  {
20
- "src": "/assets/stem_transparent.png",
21
  "sizes": "512x512",
22
  "type": "image/png",
23
  "purpose": "maskable"
 
11
  "prefer_related_applications": false,
12
  "icons": [
13
  {
14
+ "src": "/assets/stem_black.png",
15
  "sizes": "512x512",
16
  "type": "image/png",
17
  "purpose": "any"
18
  },
19
  {
20
+ "src": "/assets/stem_black.png",
21
  "sizes": "512x512",
22
  "type": "image/png",
23
  "purpose": "maskable"
static/style.css CHANGED
@@ -297,6 +297,51 @@ textarea, input, select {
297
  margin-top: 12px;
298
  }
299
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
300
 
301
  /* ============================================================
302
  PWA INSTALL BANNER
 
297
  margin-top: 12px;
298
  }
299
 
300
+ /* Optional Tavily key block on the BYOK screen */
301
+ .byok-optional {
302
+ margin-top: 18px;
303
+ padding-top: 16px;
304
+ border-top: 1px solid var(--border);
305
+ text-align: left;
306
+ }
307
+ .byok-optional-label {
308
+ font-size: 13px;
309
+ font-weight: 600;
310
+ margin: 0 0 4px;
311
+ display: flex;
312
+ align-items: center;
313
+ gap: 8px;
314
+ }
315
+ .byok-optional-badge,
316
+ .settings-optional-badge {
317
+ font-size: 10px;
318
+ font-weight: 600;
319
+ text-transform: uppercase;
320
+ letter-spacing: 0.04em;
321
+ color: var(--brand);
322
+ background: color-mix(in srgb, var(--brand) 14%, transparent);
323
+ padding: 2px 7px;
324
+ border-radius: 999px;
325
+ }
326
+ .byok-optional-desc {
327
+ font-size: 12px;
328
+ color: var(--text-muted);
329
+ margin: 0 0 10px;
330
+ }
331
+ .byok-optional-hint {
332
+ font-size: 11px;
333
+ color: var(--text-muted);
334
+ margin-top: 8px;
335
+ }
336
+ .byok-optional-hint a { color: var(--brand); text-decoration: none; }
337
+ .byok-optional-hint a:hover { text-decoration: underline; }
338
+
339
+ /* Disabled web-search toggle (no Tavily key) */
340
+ .web-search-disabled {
341
+ opacity: 0.45;
342
+ cursor: not-allowed;
343
+ }
344
+
345
 
346
  /* ============================================================
347
  PWA INSTALL BANNER
sw.js CHANGED
@@ -1,11 +1,11 @@
1
  // STEM Copilot β€” Service Worker (PWA installability + basic cache)
2
- const CACHE_NAME = 'stemcopilot-v1';
3
  const SHELL_URLS = [
4
  '/',
5
  '/static/style.css',
6
  '/static/app.js',
7
  '/assets/bot.png',
8
- '/assets/stem_transparent.png',
9
  '/assets/stembotix.png',
10
  ];
11
 
 
1
  // STEM Copilot β€” Service Worker (PWA installability + basic cache)
2
+ const CACHE_NAME = 'stemcopilot-v2';
3
  const SHELL_URLS = [
4
  '/',
5
  '/static/style.css',
6
  '/static/app.js',
7
  '/assets/bot.png',
8
+ '/assets/stem_black.png',
9
  '/assets/stembotix.png',
10
  ];
11
 
tools.py CHANGED
@@ -1,46 +1,81 @@
1
  from __future__ import annotations
2
  import re
3
- from langchain_community.tools import DuckDuckGoSearchResults
4
  from langchain_core.tools import tool
5
- from duckduckgo_search import DDGS #type:ignore
6
  from youtube_transcript_api import YouTubeTranscriptApi #type:ignore
 
7
 
8
- # ── Web Search ────────────────────────────────────────────────
9
- web_search = DuckDuckGoSearchResults(
10
- name="web_search",
11
- num_results=4,
12
- description=(
13
- "Search the internet for current information. "
14
- "Use when the student asks about recent events, specific facts, "
15
- "or anything not covered by the NCERT curriculum context. "
16
- "Input: a concise search query string."
17
- ),
18
- )
19
 
20
-
21
- def run_web_search(query: str) -> str:
 
 
 
 
 
 
 
 
22
  """
23
- Run web search programmatically using DDGS.
 
 
 
 
 
 
 
24
  """
 
 
 
 
 
 
 
25
  try:
26
- with DDGS(timeout=10) as ddgs:
27
- results = list(ddgs.text(query, max_results=4))
28
- if not results:
29
- return "No search results found."
30
-
31
- formatted = []
32
- for r in results:
33
- title = r.get("title", "No Title")
34
- href = r.get("href", "")
35
- body = r.get("body", "")
36
- formatted.append(f"Title: {title}\nURL: {href}\nSnippet: {body}\n")
37
- return "\n".join(formatted)
38
  except Exception as e:
39
- print(f"[SEARCH DDGS ERROR] {e}. Trying LangChain fallback...", flush=True)
40
- try:
41
- return web_search.invoke(query)
42
- except Exception as fallback_err:
43
- return f"Web search is temporarily unavailable. (Error: {fallback_err})"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
44
 
45
 
46
  # ── YouTube Transcript ────────────────────────────────────────
 
1
  from __future__ import annotations
2
  import re
 
3
  from langchain_core.tools import tool
4
+ from tavily import TavilyClient # type:ignore
5
  from youtube_transcript_api import YouTubeTranscriptApi #type:ignore
6
+ from config import TAVILY_API_KEY
7
 
 
 
 
 
 
 
 
 
 
 
 
8
 
9
+ # ── Web Search (Tavily) ───────────────────────────────────────
10
+ def run_web_search(
11
+ query: str,
12
+ api_key: str = "",
13
+ *,
14
+ search_depth: str = "advanced",
15
+ topic: str = "general",
16
+ max_results: int = 5,
17
+ include_answer: bool = True,
18
+ ) -> str:
19
  """
20
+ Run a web search using the Tavily API.
21
+
22
+ `api_key` is the user's own Tavily key (BYOK). Falls back to the shared
23
+ server key if the user didn't provide one. If no key is available at all,
24
+ web search is treated as disabled.
25
+
26
+ Returns a formatted block: an optional LLM-ready answer followed by
27
+ result cards (title, URL, relevance score, content snippet).
28
  """
29
+ key = api_key or TAVILY_API_KEY
30
+ if not key:
31
+ return (
32
+ "Web search is unavailable: no Tavily API key configured. "
33
+ "Add a Tavily API key in Settings to enable web search."
34
+ )
35
+
36
  try:
37
+ client = TavilyClient(api_key=key)
38
+ resp = client.search(
39
+ query,
40
+ search_depth=search_depth,
41
+ topic=topic,
42
+ max_results=max_results,
43
+ include_answer=include_answer,
44
+ include_raw_content=False,
45
+ chunks_per_source=3,
46
+ )
 
 
47
  except Exception as e:
48
+ print(f"[TAVILY SEARCH ERROR] {e}", flush=True)
49
+ return f"Web search is temporarily unavailable. (Error: {e})"
50
+
51
+ results = resp.get("results", []) if isinstance(resp, dict) else []
52
+ if not results and not (isinstance(resp, dict) and resp.get("answer")):
53
+ return "No search results found."
54
+
55
+ blocks = []
56
+ answer = resp.get("answer") if isinstance(resp, dict) else None
57
+ if answer:
58
+ blocks.append(f"Answer: {answer}\n")
59
+
60
+ for r in results:
61
+ title = r.get("title", "No Title")
62
+ url = r.get("url", "")
63
+ score = r.get("score", "")
64
+ content = r.get("content", "")
65
+ score_str = f" (relevance: {score:.2f})" if isinstance(score, (int, float)) else ""
66
+ blocks.append(f"Title: {title}{score_str}\nURL: {url}\nSnippet: {content}\n")
67
+
68
+ return "\n".join(blocks)
69
+
70
+
71
+ @tool
72
+ def web_search(query: str) -> str:
73
+ """
74
+ Search the internet for current information. Use when the student asks
75
+ about recent events, specific facts, or anything not covered by the NCERT
76
+ curriculum context. Input: a concise search query string.
77
+ """
78
+ return run_web_search(query)
79
 
80
 
81
  # ── YouTube Transcript ────────────────────────────────────────