Spaces:
Sleeping
Sleeping
File size: 18,075 Bytes
8db761b 9972aa3 8db761b 3cdbd24 981881b 3cdbd24 8db761b 3cdbd24 8db761b 9972aa3 8db761b 3cdbd24 7c131c9 8db761b 9972aa3 8db761b 8eb87a5 8db761b 8eb87a5 7c131c9 8db761b 8eb87a5 8db761b 981881b 7c131c9 981881b 7c131c9 981881b 8db761b 981881b 7c131c9 981881b 8db761b 3cdbd24 8db761b 3cdbd24 981881b 3cdbd24 981881b 3cdbd24 981881b 3cdbd24 981881b 3cdbd24 981881b 3cdbd24 981881b 7c131c9 981881b 7c131c9 981881b 7c131c9 981881b 3cdbd24 8db761b | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 | """FastAPI backend for the custom (Docker-Space) frontend.
Reuses the whole engine via dependency injection: a StudyService (topics, ask,
summary, download), a UserStore (invite-gated registration + login) and a
HistoryStore (per-user activity). Auth is a signed session token, accepted from
an httponly cookie or an Authorization: Bearer header — the header path matters
because huggingface.co embeds Spaces in an iframe where browsers block
third-party cookies, so the frontend keeps the token in localStorage as well.
"""
import base64
import hashlib
import hmac
import json
import time
from typing import Optional
_MAX_AGE = 7 * 24 * 3600
def _sign(secret: str, username: str) -> str:
payload = json.dumps({"u": username, "t": int(time.time())})
sig = hmac.new(secret.encode(), payload.encode(), hashlib.sha256).hexdigest()
return base64.urlsafe_b64encode(json.dumps({"p": payload, "s": sig}).encode()).decode()
def _verify(secret: str, token: str) -> Optional[str]:
try:
obj = json.loads(base64.urlsafe_b64decode(token.encode()))
expected = hmac.new(secret.encode(), obj["p"].encode(), hashlib.sha256).hexdigest()
if not hmac.compare_digest(obj["s"], expected):
return None
data = json.loads(obj["p"])
if time.time() - data["t"] > _MAX_AGE:
return None
return data["u"]
except Exception:
return None
def create_app(service, users, history, *, session_secret: str, static_dir: Optional[str] = None,
dataset_name: str = "", decks=None, notes=None, ingest=None):
import re as _re
import time as _time
from fastapi import FastAPI, HTTPException, Request, UploadFile
from fastapi.responses import FileResponse, JSONResponse
from ..accounts.uploads import upload_path, validate_upload
app = FastAPI()
def current_user(request: Request) -> Optional[str]:
tok = request.cookies.get("sh_session")
if not tok:
auth = request.headers.get("authorization", "")
if auth.lower().startswith("bearer "):
tok = auth[7:].strip()
return _verify(session_secret, tok) if tok else None
def require_user(request: Request) -> str:
u = current_user(request)
if not u:
raise HTTPException(status_code=401, detail="Not logged in")
return u
def require_admin(request: Request) -> str:
u = require_user(request)
if users.role(u) != "admin":
raise HTTPException(status_code=403, detail="Admins only")
return u
def _result_json(r) -> dict:
return {"answer": r.answer, "sources_md": r.sources_md, "sources": [s.__dict__ for s in r.sources]}
def _call_model(fn, *a, **k):
"""Generation can fail for reasons that are nobody's bug (provider daily caps,
bad failover key, outage) — surface those as a clean 503 the UI can show,
never a naked 500."""
try:
return fn(*a, **k)
except Exception as e:
raise HTTPException(
status_code=503,
detail="The AI model is unavailable right now (provider limit or outage). "
"Try again in a few minutes.") from e
@app.post("/api/register")
async def register(req: Request):
b = await req.json()
ok, msg = users.register(b.get("invite", ""), b.get("username", ""), b.get("password", ""))
return JSONResponse({"ok": ok, "message": msg}, status_code=200 if ok else 400)
@app.post("/api/login")
async def login(req: Request):
b = await req.json()
username = b.get("username", "")
if not users.verify(username, b.get("password", "")):
raise HTTPException(status_code=401, detail="Invalid username or password")
token = _sign(session_secret, username)
resp = JSONResponse({"ok": True, "username": username, "role": users.role(username), "token": token})
resp.set_cookie("sh_session", token, httponly=True, samesite="none", secure=True,
max_age=_MAX_AGE)
# Partitioned (CHIPS) keeps the cookie alive inside the huggingface.co iframe.
resp.headers["set-cookie"] = resp.headers["set-cookie"] + "; Partitioned"
return resp
@app.post("/api/logout")
def logout():
resp = JSONResponse({"ok": True})
resp.delete_cookie("sh_session")
return resp
@app.get("/api/me")
def me(req: Request):
u = current_user(req)
return {"username": u, "role": users.role(u) if u else None}
@app.get("/api/modules")
def modules(req: Request):
require_user(req)
return service.modules()
@app.get("/api/modules/{mid}/topics")
def topics(mid: str, req: Request):
require_user(req)
return service.topics(mid)
@app.get("/api/topics/{tid}/sources")
def topic_sources(tid: str, req: Request):
require_user(req)
return service.topic_sources(tid)
@app.get("/api/topics/{tid}/summary")
def topic_summary(tid: str, req: Request, force: str = ""):
# Summaries are derived from shared material and are the most expensive
# generation (map-reduce over the whole topic) — cache class-wide like
# mind maps. ?force=1 regenerates.
u = require_user(req)
key = "app/summaries/" + _re.sub(r"[^A-Za-z0-9._-]+", "_", tid) + ".json"
if not force:
cached = users.store.read_text(key)
if cached:
try:
out = json.loads(cached)
history.append(u, {"kind": "topic_summary", "topic": tid, "answer": out["answer"]})
return out
except Exception:
pass
r = _call_model(service.topic_summary, tid)
history.append(u, {"kind": "topic_summary", "topic": tid, "answer": r.answer})
out = _result_json(r)
if not r.answer.startswith(("Unknown topic", "No material")):
users.store.write_text(key, json.dumps(out, ensure_ascii=False))
return out
@app.post("/api/topics/{tid}/ask")
async def topic_ask(tid: str, req: Request):
u = require_user(req)
b = await req.json()
q = b.get("query", "")
thread_id = b.get("thread_id") or ""
if thread_id:
return _continue_thread(u, thread_id, q, lambda qq, prior: service.topic_ask_thread(tid, qq, prior))
r = _call_model(service.topic_ask, tid, q)
eid = history.append(u, {"kind": "topic_ask", "topic": tid, "query": q, "answer": r.answer,
"exchanges": [{"q": q, "a": r.answer}]})
return {**_result_json(r), "thread_id": eid}
def _continue_thread(u: str, thread_id: str, q: str, runner) -> dict:
entry = history.get(u, thread_id)
if entry is None:
raise HTTPException(status_code=404, detail="Unknown thread")
prior = entry.get("exchanges") or (
[{"q": entry["query"], "a": entry.get("answer", "")}] if entry.get("query") else [])
r = _call_model(runner, q, prior)
history.append_exchange(u, thread_id, q, r.answer)
return {**_result_json(r), "thread_id": thread_id}
@app.post("/api/ask")
async def ask(req: Request):
u = require_user(req)
b = await req.json()
q = b.get("query", "")
thread_id = b.get("thread_id") or ""
if thread_id:
return _continue_thread(u, thread_id, q, service.ask_thread)
r = _call_model(service.ask, q)
eid = history.append(u, {"kind": "ask", "query": q, "answer": r.answer,
"exchanges": [{"q": q, "a": r.answer}]})
return {**_result_json(r), "thread_id": eid}
@app.get("/api/threads/{thread_id}")
def get_thread(thread_id: str, req: Request):
u = require_user(req)
entry = history.get(u, thread_id)
if entry is None:
raise HTTPException(status_code=404, detail="Unknown thread")
return entry
@app.post("/api/history/pin")
async def history_pin(req: Request):
u = require_user(req)
b = await req.json()
if not history.set_pinned(u, b.get("id", ""), bool(b.get("pinned", True))):
raise HTTPException(status_code=404, detail="Unknown entry")
return {"ok": True}
@app.post("/api/history/delete")
async def history_delete(req: Request):
u = require_user(req)
if not history.delete(u, (await req.json()).get("id", "")):
raise HTTPException(status_code=404, detail="Unknown entry")
return {"ok": True}
@app.get("/api/history")
def get_history(req: Request):
return history.load(require_user(req))
@app.get("/api/files")
def files(req: Request):
require_user(req)
from ..app.service import _source_type
return [{"file": f, "type": _source_type(f)} for f in service.list_files()]
@app.post("/api/upload")
async def upload(req: Request, file: UploadFile):
u = require_user(req)
data = await file.read()
ok, name_or_err = validate_upload(file.filename or "", len(data))
if not ok:
raise HTTPException(status_code=400, detail=name_or_err)
path = upload_path(u, name_or_err)
users.store.save_bytes(path, data)
history.append(u, {"kind": "upload", "file": name_or_err})
if ingest is not None:
ingest.enqueue(path, data)
return {"ok": True, "message": "Uploaded — indexing now, searchable in a few minutes.",
"path": path}
return {"ok": True, "message": "Uploaded — searchable after the next index rebuild.", "path": path}
@app.get("/api/uploads/mine")
def my_uploads(req: Request):
u = require_user(req)
prefix = f"uploads/{u}/"
return [{"path": p, "name": p.split("/")[-1],
"status": ingest.status(p) if ingest is not None else "pending-rebuild"}
for p in users.store.list_files(prefix)]
@app.get("/api/admin/overview")
def admin_overview(req: Request):
require_admin(req)
from ..app.service import _source_type
uploads = [{"path": p, "user": p.split("/")[1] if p.count("/") >= 2 else "?", "name": p.split("/")[-1]}
for p in users.store.list_files("uploads/")]
for up in uploads:
up["status"] = ingest.status(up["path"]) if ingest is not None else "pending-rebuild"
return {"invite": users.invite_code, "dataset": dataset_name,
"users": users.users_with_roles(), "seed": sorted(users.seed),
"invites": users.invites.rows() if users.invites else [],
"originals": [{"file": f, "type": _source_type(f)} for f in service.list_files()],
"uploads": uploads}
@app.post("/api/admin/invites")
async def admin_mint_invite(req: Request):
require_admin(req)
if not users.invites:
raise HTTPException(status_code=503, detail="Invite links not configured")
label = ((await req.json()).get("label") or "").strip()
if not label:
raise HTTPException(status_code=400, detail="Give the link a name (who is it for?)")
token = users.invites.mint(label)
return {"token": token, "path": f"/?invite={token}"}
@app.post("/api/admin/invites/revoke")
async def admin_revoke_invite(req: Request):
require_admin(req)
if not users.invites:
raise HTTPException(status_code=503, detail="Invite links not configured")
if not users.invites.revoke((await req.json()).get("token", "")):
raise HTTPException(status_code=404, detail="Unknown or already-used link")
return {"ok": True}
@app.post("/api/admin/role")
async def admin_role(req: Request):
require_admin(req)
b = await req.json()
if not users.set_role(b.get("username", ""), b.get("role", "")):
raise HTTPException(status_code=400, detail="Unknown user or invalid role")
return {"ok": True}
@app.post("/api/admin/remove")
async def admin_remove(req: Request):
require_admin(req)
users.remove((await req.json()).get("username", ""))
return {"ok": True}
@app.post("/api/admin/uploads/delete")
async def admin_delete_upload(req: Request):
require_admin(req)
path = (await req.json()).get("path", "")
if not path.startswith("uploads/") or ".." in path:
raise HTTPException(status_code=400, detail="Only files under uploads/ can be deleted here")
users.store.delete(path)
return {"ok": True}
def _topic_title(tid: str) -> str:
try:
t = service.graph.topic_by_id(tid)
return t.title if t else tid
except AttributeError:
return tid
@app.post("/api/topics/{tid}/quiz")
async def topic_quiz(tid: str, req: Request):
u = require_user(req)
n = int((await req.json()).get("n", 5) or 5)
r = _call_model(service.topic_quiz, tid, n=max(1, min(n, 10)))
history.append(u, {"kind": "quiz", "topic": tid})
return r
@app.post("/api/topics/{tid}/flashcards")
async def topic_flashcards(tid: str, req: Request):
u = require_user(req)
if decks is None:
raise HTTPException(status_code=503, detail="Flashcards not configured")
n = int((await req.json()).get("n", 10) or 10)
gen = _call_model(service.topic_flashcards, tid, n=max(1, min(n, 20)))
now = _time.time()
added = decks.add_cards(u, tid, _topic_title(tid), gen.get("cards") or [], now=now)
history.append(u, {"kind": "flashcards", "topic": tid})
out = {"added": added, **decks.stats(u, now)}
if gen.get("error"):
out["error"] = gen["error"]
return out
@app.get("/api/flashcards")
def flashcards_due(req: Request):
u = require_user(req)
if decks is None:
raise HTTPException(status_code=503, detail="Flashcards not configured")
now = _time.time()
due = [{"id": c["id"], "topic": c.get("topic", ""), "front": c["front"], "back": c["back"]}
for c in decks.due_cards(u, now)]
return {"due": due, **decks.stats(u, now)}
@app.post("/api/flashcards/grade")
async def flashcards_grade(req: Request):
u = require_user(req)
if decks is None:
raise HTTPException(status_code=503, detail="Flashcards not configured")
b = await req.json()
d = decks.grade(u, b.get("id", ""), bool(b.get("good")), _time.time())
if d < 0:
raise HTTPException(status_code=404, detail="Unknown card")
return {"ok": True, "next_due_days": d}
@app.get("/api/topics/{tid}/mindmap")
def topic_mindmap(tid: str, req: Request):
require_user(req)
# Mind maps are derived from shared material, so the first request renders
# and caches for the whole class.
key = "app/mindmaps/" + _re.sub(r"[^A-Za-z0-9._-]+", "_", tid) + ".json"
cached = users.store.read_text(key)
if cached:
try:
return json.loads(cached)
except Exception:
pass
r = _call_model(service.topic_mindmap, tid)
if not r.get("error"):
users.store.write_text(key, json.dumps(r, ensure_ascii=False))
return r
@app.get("/api/notes")
def notes_list(req: Request):
u = require_user(req)
if notes is None:
raise HTTPException(status_code=503, detail="Notes not configured")
return notes.list_notes(u)
@app.post("/api/notes")
async def notes_add(req: Request):
u = require_user(req)
if notes is None:
raise HTTPException(status_code=503, detail="Notes not configured")
b = await req.json()
title, body = (b.get("title") or "").strip(), (b.get("body") or "").strip()
if not title and not body:
raise HTTPException(status_code=400, detail="Empty note")
return {"id": notes.add(u, title, body, b.get("topic_id") or "", now=_time.time())}
@app.post("/api/notes/update")
async def notes_update(req: Request):
u = require_user(req)
if notes is None:
raise HTTPException(status_code=503, detail="Notes not configured")
b = await req.json()
if not notes.update(u, b.get("id", ""), (b.get("title") or "").strip(),
(b.get("body") or "").strip(), now=_time.time()):
raise HTTPException(status_code=404, detail="Unknown note")
return {"ok": True}
@app.post("/api/notes/delete")
async def notes_delete(req: Request):
u = require_user(req)
if notes is None:
raise HTTPException(status_code=503, detail="Notes not configured")
if not notes.delete(u, (await req.json()).get("id", "")):
raise HTTPException(status_code=404, detail="Unknown note")
return {"ok": True}
@app.get("/api/download")
def download(file: str, req: Request, t: str = ""):
# New-tab navigation can't carry the Authorization header, so the citation
# links pass the session token as ?t= instead.
u = current_user(req) or (_verify(session_secret, t) if t else None)
if not u:
raise HTTPException(status_code=401, detail="Not logged in")
path = service.download_path(file)
if not path:
raise HTTPException(status_code=404, detail="Not found")
return FileResponse(path)
if static_dir:
from fastapi.staticfiles import StaticFiles
app.mount("/static", StaticFiles(directory=static_dir), name="static")
@app.get("/")
def index():
return FileResponse(f"{static_dir}/index.html")
return app
|