Spaces:
Running
Running
rollback check: 3f7ecbf12d52376c19152837f9f3b9c161ed1570
Browse files- app.py +10 -17
- graph.py +11 -18
- static/app.js +26 -36
- static/style.css +0 -16
- 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
|
| 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}"
|
| 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 |
-
|
| 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]
|
| 323 |
-
context += "\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 "
|
| 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 |
-
|
| 320 |
-
"
|
| 321 |
-
"The
|
| 322 |
-
"
|
| 323 |
-
"
|
| 324 |
-
"
|
| 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 |
-
|
| 366 |
-
|
|
|
|
|
|
|
|
|
|
| 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
|
| 1001 |
-
if (!
|
| 1002 |
-
|
| 1003 |
-
|
| 1004 |
-
|
| 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 |
-
|
| 1023 |
-
|
| 1024 |
-
|
| 1025 |
-
|
| 1026 |
-
|
| 1027 |
-
|
| 1028 |
-
|
| 1029 |
-
|
| 1030 |
-
|
| 1031 |
-
|
| 1032 |
-
|
| 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 |
-
|
| 78 |
-
|
| 79 |
-
transcript = " ".join(snippet.text for snippet in fetched)
|
| 80 |
if not transcript.strip():
|
| 81 |
-
|
| 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]
|
| 87 |
err = str(exc).lower()
|
| 88 |
-
if "disabled" in err or "no transcript" in err
|
| 89 |
return (
|
| 90 |
-
"
|
| 91 |
-
"
|
| 92 |
)
|
| 93 |
-
|
| 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 βββββββββββββββββββββββββββββββββββββββββββββ
|