Subh775 commited on
Commit
ad5ee08
Β·
1 Parent(s): 5c258b5
Files changed (5) hide show
  1. app.py +10 -17
  2. graph.py +11 -18
  3. static/app.js +26 -36
  4. static/style.css +0 -16
  5. tools.py +8 -20
app.py CHANGED
@@ -20,8 +20,7 @@ import retriever
20
  import auth as auth_module
21
  import feed as feed_module
22
  from config import GOOGLE_CLIENT_ID
23
- from graph import chatbot, _classify, _pick
24
- from tools import run_web_search, fetch_yt_transcript
25
 
26
 
27
  app = FastAPI()
@@ -300,27 +299,24 @@ def chat(request: ChatRequest, req: Request):
300
  if web_search_enabled:
301
  yield sse_tool_event("tool_start", "web_search")
302
  try:
 
303
  web_results = run_web_search(clean_message)
304
- print(f"[WEB SEARCH] Fetched {len(web_results)} chars for query: {clean_message[:60]}", flush=True)
305
  context += f"\n\n[Web Search Results]\n{web_results}\n[End Web Search Results]\n"
306
  except Exception as e:
307
- print(f"[SEARCH ERROR] {e}", flush=True)
308
  context += "\n\nWeb search failed to complete.\n"
309
  yield sse_tool_event("tool_end", "web_search")
310
 
311
  elif yt_search_enabled:
312
  yield sse_tool_event("tool_start", "yt_transcript")
313
  try:
 
 
314
  transcript = fetch_yt_transcript(clean_message)
315
- if transcript.startswith("TRANSCRIPT_UNAVAILABLE") or transcript.startswith("Could not"):
316
- print(f"[YT TRANSCRIPT] Unavailable: {transcript[:120]}", flush=True)
317
- context += f"\n\n[YouTube Transcript Status]\n{transcript}\n"
318
- else:
319
- print(f"[YT TRANSCRIPT] Fetched {len(transcript)} chars for query: {clean_message[:60]}", flush=True)
320
- context += f"\n\n[YouTube Video Transcript]\n{transcript[:15_000]}\n[End Transcript]\n"
321
  except Exception as e:
322
- print(f"[YT TRANSCRIPT ERROR] Unexpected exception: {e}", flush=True)
323
- context += "\n\n[YouTube Transcript Status]\nYouTube transcript extraction failed unexpectedly.\n"
324
  yield sse_tool_event("tool_end", "yt_transcript")
325
 
326
  # Step 4: Handle document context
@@ -349,6 +345,7 @@ def chat(request: ChatRequest, req: Request):
349
 
350
  # Resolve target model dynamically to print/trace and pass to LangGraph config
351
  try:
 
352
  category = _classify(clean_message)
353
  target_model, _ = _pick(category, has_image=has_image)
354
  except Exception as e:
@@ -381,12 +378,8 @@ def chat(request: ChatRequest, req: Request):
381
  if isinstance(chunk, AIMessage) and chunk.content:
382
  yield sse_token(chunk.content)
383
  except Exception as e:
384
- print(f"[CHAT STREAM EXCEPTION] Final failure: {e}", flush=True)
385
- import traceback; traceback.print_exc()
386
  error_str = str(e).lower()
387
- if "timeout" in error_str or "timed out" in error_str or "readtimeout" in error_str:
388
- yield sse_error("Models are responding slowly. Please try again in a moment.")
389
- elif "429" in str(e) or "rate" in error_str:
390
  yield sse_error("Rate limited. Please wait a moment and try again.")
391
  elif "402" in str(e) or "payment" in error_str or "credits" in error_str:
392
  yield sse_error("Free credits exhausted on OpenRouter.")
 
20
  import auth as auth_module
21
  import feed as feed_module
22
  from config import GOOGLE_CLIENT_ID
23
+ from graph import chatbot
 
24
 
25
 
26
  app = FastAPI()
 
299
  if web_search_enabled:
300
  yield sse_tool_event("tool_start", "web_search")
301
  try:
302
+ from tools import run_web_search
303
  web_results = run_web_search(clean_message)
 
304
  context += f"\n\n[Web Search Results]\n{web_results}\n[End Web Search Results]\n"
305
  except Exception as e:
306
+ print(f"[SEARCH ERROR] {e}")
307
  context += "\n\nWeb search failed to complete.\n"
308
  yield sse_tool_event("tool_end", "web_search")
309
 
310
  elif yt_search_enabled:
311
  yield sse_tool_event("tool_start", "yt_transcript")
312
  try:
313
+ from tools import fetch_yt_transcript
314
+ # Find video URL or ID in clean_message
315
  transcript = fetch_yt_transcript(clean_message)
316
+ context += f"\n\n[YouTube Video Transcript]\n{transcript[:15_000]}\n[End Transcript]\n"
 
 
 
 
 
317
  except Exception as e:
318
+ print(f"[YT TRANSCRIPT ERROR] {e}")
319
+ context += "\n\nYouTube transcript extraction failed.\n"
320
  yield sse_tool_event("tool_end", "yt_transcript")
321
 
322
  # Step 4: Handle document context
 
345
 
346
  # Resolve target model dynamically to print/trace and pass to LangGraph config
347
  try:
348
+ from graph import _classify, _pick
349
  category = _classify(clean_message)
350
  target_model, _ = _pick(category, has_image=has_image)
351
  except Exception as e:
 
378
  if isinstance(chunk, AIMessage) and chunk.content:
379
  yield sse_token(chunk.content)
380
  except Exception as e:
 
 
381
  error_str = str(e).lower()
382
+ if "429" in str(e) or "rate" in error_str:
 
 
383
  yield sse_error("Rate limited. Please wait a moment and try again.")
384
  elif "402" in str(e) or "payment" in error_str or "credits" in error_str:
385
  yield sse_error("Free credits exhausted on OpenRouter.")
graph.py CHANGED
@@ -287,7 +287,6 @@ def _make_llm(api_key: str, model_id: str):
287
  max_tokens=4096,
288
  max_retries=0,
289
  streaming=True,
290
- timeout=LLM_TIMEOUT,
291
  )
292
 
293
 
@@ -316,15 +315,13 @@ def chat_node(state: ChatState, config: RunnableConfig):
316
  base_prompt = prompts.build(persona, context, language, username, profile)
317
 
318
  if search_enabled:
319
- scope_override = (
320
- "## IMPORTANT OVERRIDE β€” READ THIS FIRST BEFORE ANY OTHER INSTRUCTION:\n"
321
- "The student has activated Web Search or YouTube mode. "
322
- "In this mode you are a general-purpose assistant. "
323
- "The CBSE/PCM-only scope restriction is SUSPENDED for this message. "
324
- "Answer whatever the student asks using the provided search results or transcript. "
325
- "Saying 'I can only help with Physics, Chemistry, and Maths' is FORBIDDEN in this mode.\n\n"
326
  )
327
- base_prompt = scope_override + base_prompt
328
 
329
  if has_image:
330
  base_prompt += (
@@ -354,16 +351,12 @@ def chat_node(state: ChatState, config: RunnableConfig):
354
  return {"messages": [resp]}
355
  except Exception as e:
356
  err_str = str(e)
357
- err_lower = err_str.lower()
358
- if "timeout" in err_lower or "timed out" in err_lower or "readtimeout" in err_lower:
359
- print(f"[ROUTER TIMEOUT] Model {model_id} timed out after {LLM_TIMEOUT}s β€” rotating to next model", flush=True)
360
- elif "429" in err_str or "rate" in err_lower:
361
- print(f"[ROUTER 429] Model {model_id} rate-limited β€” rotating to next model", flush=True)
362
- else:
363
- print(f"[ROUTER ERROR] Model {model_id} failed: {err_str[:120]}", flush=True)
364
  last_error = e
365
- skip_models.add(model_id)
366
- continue
 
 
 
367
 
368
  raise last_error
369
 
 
287
  max_tokens=4096,
288
  max_retries=0,
289
  streaming=True,
 
290
  )
291
 
292
 
 
315
  base_prompt = prompts.build(persona, context, language, username, profile)
316
 
317
  if search_enabled:
318
+ base_prompt += (
319
+ "\n\nSCOPE OVERRIDE (ACTIVE SEARCH/YT DETECTED):\n"
320
+ "The user has enabled Web Search / YouTube Video Search. You are acting as a general information assistant. "
321
+ "The strict Physics, Chemistry, and Mathematics scope restriction is temporarily suspended. "
322
+ "Answer the user's query comprehensively using the web search or YouTube transcript context provided, "
323
+ "regardless of the academic or non-academic nature of the query. Do NOT output a scope refusal message.\n"
 
324
  )
 
325
 
326
  if has_image:
327
  base_prompt += (
 
351
  return {"messages": [resp]}
352
  except Exception as e:
353
  err_str = str(e)
 
 
 
 
 
 
 
354
  last_error = e
355
+ if "429" in err_str or "TooManyRequests" in err_str or "rate" in err_str.lower():
356
+ print(f"[ROUTER] 429 on {model_id}, rotating...")
357
+ skip_models.add(model_id)
358
+ continue
359
+ raise
360
 
361
  raise last_error
362
 
static/app.js CHANGED
@@ -696,10 +696,7 @@ function _renderOverlayHistory() {
696
  e.stopPropagation();
697
  const wasOpen = ctx.classList.contains('show');
698
  _closeAllCtxMenus();
699
- if (!wasOpen) {
700
- ctx.classList.add('show');
701
- item.classList.add('menu-active');
702
- }
703
  });
704
  // Rename
705
  item.querySelector('[data-action="rename"]').addEventListener('click', (e) => {
@@ -737,7 +734,6 @@ function _renderOverlayHistory() {
737
 
738
  function _closeAllCtxMenus() {
739
  document.querySelectorAll('.overlay-ctx-menu.show').forEach(m => m.classList.remove('show'));
740
- document.querySelectorAll('.overlay-history-item.menu-active').forEach(i => i.classList.remove('menu-active'));
741
  }
742
  document.addEventListener('click', () => _closeAllCtxMenus());
743
 
@@ -974,7 +970,6 @@ function toggleMenu(e, btn) {
974
  e.stopPropagation(); closeAllMenus();
975
  btn.nextElementSibling.classList.add('show');
976
  btn.classList.add('menu-open');
977
- btn.closest('.history-item').classList.add('menu-active');
978
  }
979
 
980
  document.addEventListener('click', closeAllMenus);
@@ -982,7 +977,6 @@ document.addEventListener('click', closeAllMenus);
982
  function closeAllMenus() {
983
  document.querySelectorAll('.options-menu.show').forEach(m => m.classList.remove('show'));
984
  document.querySelectorAll('.options-btn.menu-open').forEach(b => b.classList.remove('menu-open'));
985
- document.querySelectorAll('.history-item.menu-active').forEach(i => i.classList.remove('menu-active'));
986
  if (userMenu) userMenu.classList.remove('show');
987
  // Close ALL style dropdowns β€” static and dynamic hero ones
988
  document.querySelectorAll('.style-dropdown.show').forEach(d => d.classList.remove('show'));
@@ -994,44 +988,40 @@ function closeAllMenus() {
994
 
995
  function deleteChat(e, optionEl) {
996
  e.stopPropagation();
997
- closeAllMenus();
998
  const item = optionEl.closest('.history-item');
999
  const threadId = item.getAttribute('data-thread-id');
1000
- const thread = threads.find(t => t.id === threadId);
1001
- if (!thread) return;
1002
- pwaConfirm('Delete this chat?').then(yes => {
1003
- if (yes) {
1004
- fetch('/thread/' + threadId, { method: 'DELETE' })
1005
- .then(() => {
1006
- const idx = threads.findIndex(t => t.id === threadId);
1007
- if (idx !== -1) threads.splice(idx, 1);
1008
- if (threadId === currentThreadId) startNewChat();
1009
- _renderOverlayHistory();
1010
- renderHistory();
1011
- })
1012
- .catch(() => showToast('Failed to delete.', 'error'));
1013
- }
1014
- });
1015
  }
1016
 
1017
  function renameChat(e, optionEl) {
1018
  e.stopPropagation();
1019
- closeAllMenus();
1020
  const item = optionEl.closest('.history-item');
 
1021
  const threadId = item.getAttribute('data-thread-id');
1022
- const thread = threads.find(t => t.id === threadId);
1023
- if (!thread) return;
1024
- pwaPrompt('Rename chat:', thread.title).then(newTitle => {
1025
- if (newTitle && newTitle.trim()) {
1026
- fetch('/rename', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ thread_id: threadId, title: newTitle.trim() }) })
1027
- .then(() => {
1028
- thread.title = newTitle.trim();
1029
- _renderOverlayHistory();
1030
- renderHistory();
1031
- })
1032
- .catch(() => showToast('Failed to rename.', 'error'));
1033
- }
 
 
 
 
 
 
1034
  });
 
1035
  }
1036
 
1037
 
 
696
  e.stopPropagation();
697
  const wasOpen = ctx.classList.contains('show');
698
  _closeAllCtxMenus();
699
+ if (!wasOpen) ctx.classList.add('show');
 
 
 
700
  });
701
  // Rename
702
  item.querySelector('[data-action="rename"]').addEventListener('click', (e) => {
 
734
 
735
  function _closeAllCtxMenus() {
736
  document.querySelectorAll('.overlay-ctx-menu.show').forEach(m => m.classList.remove('show'));
 
737
  }
738
  document.addEventListener('click', () => _closeAllCtxMenus());
739
 
 
970
  e.stopPropagation(); closeAllMenus();
971
  btn.nextElementSibling.classList.add('show');
972
  btn.classList.add('menu-open');
 
973
  }
974
 
975
  document.addEventListener('click', closeAllMenus);
 
977
  function closeAllMenus() {
978
  document.querySelectorAll('.options-menu.show').forEach(m => m.classList.remove('show'));
979
  document.querySelectorAll('.options-btn.menu-open').forEach(b => b.classList.remove('menu-open'));
 
980
  if (userMenu) userMenu.classList.remove('show');
981
  // Close ALL style dropdowns β€” static and dynamic hero ones
982
  document.querySelectorAll('.style-dropdown.show').forEach(d => d.classList.remove('show'));
 
988
 
989
  function deleteChat(e, optionEl) {
990
  e.stopPropagation();
 
991
  const item = optionEl.closest('.history-item');
992
  const threadId = item.getAttribute('data-thread-id');
993
+ const idx = threads.findIndex(t => t.id === threadId);
994
+ if (idx !== -1) threads.splice(idx, 1);
995
+ item.style.opacity = '0';
996
+ setTimeout(() => item.remove(), 200);
997
+ fetch('/thread/' + threadId, { method: 'DELETE' });
 
 
 
 
 
 
 
 
 
 
998
  }
999
 
1000
  function renameChat(e, optionEl) {
1001
  e.stopPropagation();
 
1002
  const item = optionEl.closest('.history-item');
1003
+ const titleSpan = item.querySelector('.chat-title');
1004
  const threadId = item.getAttribute('data-thread-id');
1005
+ closeAllMenus();
1006
+ const currentTitle = titleSpan.innerText;
1007
+ const input = document.createElement('input');
1008
+ input.type = 'text'; input.value = currentTitle; input.className = 'rename-input';
1009
+ titleSpan.replaceWith(input); input.focus();
1010
+ input.selectionStart = input.selectionEnd = input.value.length;
1011
+
1012
+ function saveRename() {
1013
+ const newTitle = input.value.trim() || 'Untitled Chat';
1014
+ titleSpan.innerText = newTitle; input.replaceWith(titleSpan);
1015
+ const thread = threads.find(t => t.id === threadId);
1016
+ if (thread) thread.title = newTitle;
1017
+ fetch('/rename', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ thread_id: threadId, title: newTitle }) });
1018
+ }
1019
+ input.addEventListener('blur', saveRename);
1020
+ input.addEventListener('keydown', evt => {
1021
+ if (evt.key === 'Enter') saveRename();
1022
+ if (evt.key === 'Escape') { titleSpan.innerText = currentTitle; input.replaceWith(titleSpan); }
1023
  });
1024
+ input.addEventListener('click', evt => evt.stopPropagation());
1025
  }
1026
 
1027
 
static/style.css CHANGED
@@ -613,14 +613,6 @@ textarea, input, select {
613
  position: relative;
614
  }
615
 
616
- .overlay-history-item.menu-active {
617
- z-index: 15;
618
- }
619
-
620
- .overlay-history-item:hover {
621
- z-index: 5;
622
- }
623
-
624
  .overlay-history-main {
625
  display: flex;
626
  align-items: center;
@@ -846,14 +838,6 @@ textarea, input, select {
846
  transition: background 0.2s, color 0.2s, transform 0.15s;
847
  }
848
 
849
- .history-item.menu-active {
850
- z-index: 15;
851
- }
852
-
853
- .history-item:hover {
854
- z-index: 5;
855
- }
856
-
857
  .history-item:hover { background: var(--bg-hover); color: var(--text-primary); }
858
  .history-item:active { transform: scale(0.98); }
859
 
 
613
  position: relative;
614
  }
615
 
 
 
 
 
 
 
 
 
616
  .overlay-history-main {
617
  display: flex;
618
  align-items: center;
 
838
  transition: background 0.2s, color 0.2s, transform 0.15s;
839
  }
840
 
 
 
 
 
 
 
 
 
841
  .history-item:hover { background: var(--bg-hover); color: var(--text-primary); }
842
  .history-item:active { transform: scale(0.98); }
843
 
tools.py CHANGED
@@ -74,32 +74,20 @@ def fetch_yt_transcript(youtube_url: str) -> str:
74
  return f"Could not extract a valid YouTube video ID from: {youtube_url}"
75
 
76
  try:
77
- ytt = YouTubeTranscriptApi()
78
- fetched = ytt.fetch(video_id, languages=["en", "hi", "en-IN", "en-US"])
79
- transcript = " ".join(snippet.text for snippet in fetched)
80
  if not transcript.strip():
81
- print(f"[YT TRANSCRIPT] Empty transcript for video_id={video_id}", flush=True)
82
- return "TRANSCRIPT_UNAVAILABLE: Transcript is empty for this video."
83
- print(f"[YT TRANSCRIPT] OK β€” {len(transcript)} chars for video_id={video_id}", flush=True)
84
  return transcript
85
  except Exception as exc:
86
- print(f"[YT TRANSCRIPT EXCEPTION] video_id={video_id} | error={exc}", flush=True)
87
  err = str(exc).lower()
88
- if "disabled" in err or "no transcript" in err or "no element" in err:
89
  return (
90
- "TRANSCRIPT_UNAVAILABLE: This video has no transcript available "
91
- "(subtitles are disabled or no captions exist for this video)."
92
  )
93
- if "too many requests" in err or "429" in err:
94
- return (
95
- "TRANSCRIPT_UNAVAILABLE: YouTube is rate-limiting transcript requests. "
96
- "Please try again in a few minutes."
97
- )
98
- return (
99
- f"TRANSCRIPT_UNAVAILABLE: Could not retrieve transcript. "
100
- f"Reason: {exc}. "
101
- f"Do NOT fabricate β€” inform the student the transcript could not be fetched."
102
- )
103
 
104
 
105
  # ── Exported list ─────────────────────────────────────────────
 
74
  return f"Could not extract a valid YouTube video ID from: {youtube_url}"
75
 
76
  try:
77
+ transcript_list = YouTubeTranscriptApi.get_transcript(video_id)
78
+ transcript = " ".join(seg["text"] for seg in transcript_list)
 
79
  if not transcript.strip():
80
+ return "Transcript is empty for this video."
 
 
81
  return transcript
82
  except Exception as exc:
83
+ print(f"[YT TRANSCRIPT EXCEPTION] Video ID: {video_id}, Error: {exc}", flush=True)
84
  err = str(exc).lower()
85
+ if "disabled" in err or "no transcript" in err:
86
  return (
87
+ "This video has no available transcript (subtitles may be disabled "
88
+ "or auto-generated captions are not available)."
89
  )
90
+ return f"Error fetching transcript: {exc}"
 
 
 
 
 
 
 
 
 
91
 
92
 
93
  # ── Exported list ─────────────────────────────────────────────