Spaces:
Sleeping
Sleeping
| """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 | |
| 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) | |
| 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 | |
| def logout(): | |
| resp = JSONResponse({"ok": True}) | |
| resp.delete_cookie("sh_session") | |
| return resp | |
| def me(req: Request): | |
| u = current_user(req) | |
| return {"username": u, "role": users.role(u) if u else None} | |
| def modules(req: Request): | |
| require_user(req) | |
| return service.modules() | |
| def topics(mid: str, req: Request): | |
| require_user(req) | |
| return service.topics(mid) | |
| def topic_sources(tid: str, req: Request): | |
| require_user(req) | |
| return service.topic_sources(tid) | |
| 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 | |
| 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} | |
| 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} | |
| 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 | |
| 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} | |
| 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} | |
| def get_history(req: Request): | |
| return history.load(require_user(req)) | |
| 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()] | |
| 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} | |
| 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)] | |
| 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} | |
| 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}"} | |
| 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} | |
| 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} | |
| async def admin_remove(req: Request): | |
| require_admin(req) | |
| users.remove((await req.json()).get("username", "")) | |
| return {"ok": True} | |
| 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 | |
| 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 | |
| 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 | |
| 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)} | |
| 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} | |
| 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 | |
| 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) | |
| 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())} | |
| 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} | |
| 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} | |
| 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") | |
| def index(): | |
| return FileResponse(f"{static_dir}/index.html") | |
| return app | |