Spaces:
Runtime error
Runtime error
Commit Β·
1829c17
1
Parent(s): 5c62ce3
claude code; tavily; bugs fix..
Browse files- app.py +21 -3
- config.py +3 -0
- db.py +23 -1
- graph.py +81 -10
- requirements.txt +1 -3
- static/app.js +62 -0
- static/index.html +20 -2
- static/manifest.json +2 -2
- static/style.css +45 -0
- sw.js +2 -2
- 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 |
-
|
|
|
|
| 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 {
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 40 |
-
|
|
|
|
| 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 |
-
|
|
|
|
| 210 |
return _cache or {
|
| 211 |
-
|
| 212 |
-
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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/
|
| 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
|
| 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/
|
| 15 |
"sizes": "512x512",
|
| 16 |
"type": "image/png",
|
| 17 |
"purpose": "any"
|
| 18 |
},
|
| 19 |
{
|
| 20 |
-
"src": "/assets/
|
| 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-
|
| 3 |
const SHELL_URLS = [
|
| 4 |
'/',
|
| 5 |
'/static/style.css',
|
| 6 |
'/static/app.js',
|
| 7 |
'/assets/bot.png',
|
| 8 |
-
'/assets/
|
| 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
|
| 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(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
"""
|
| 23 |
-
Run web search
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
try:
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 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
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 ββββββββββββββββββββββββββββββββββββββββ
|