| |
| import os, json, requests |
| from urllib3.util.retry import Retry |
| from requests.adapters import HTTPAdapter |
|
|
|
|
| |
| BACKEND = (os.getenv("BACKEND_URL") or "").strip().rstrip("/") |
| if not BACKEND: |
| |
| raise RuntimeError("BACKEND_URL is not set in Space secrets.") |
|
|
| |
| TOKEN = (os.getenv("BACKEND_TOKEN") or os.getenv("HF_TOKEN") or "").strip() |
|
|
| DEFAULT_TIMEOUT = int(os.getenv("BACKEND_TIMEOUT", "30")) |
|
|
| _session = requests.Session() |
|
|
| |
| retry = Retry( |
| total=3, |
| connect=3, |
| read=3, |
| backoff_factor=0.5, |
| status_forcelist=(429, 500, 502, 503, 504), |
| allowed_methods=frozenset(["GET", "POST", "PUT", "PATCH", "DELETE"]), |
| ) |
| _session.mount("https://", HTTPAdapter(max_retries=retry)) |
| _session.mount("http://", HTTPAdapter(max_retries=retry)) |
|
|
| |
| _session.headers.update({ |
| "Accept": "application/json, */*;q=0.1", |
| "User-Agent": "FinEdu-Frontend/1.0 (+spaces)", |
| }) |
| if TOKEN: |
| _session.headers["Authorization"] = f"Bearer {TOKEN}" |
|
|
| def _json_or_raise(resp: requests.Response): |
| ctype = resp.headers.get("content-type", "") |
| if "application/json" in ctype: |
| return resp.json() |
| |
| try: |
| return resp.json() |
| except Exception: |
| snippet = (resp.text or "")[:300] |
| raise RuntimeError(f"Expected JSON but got {ctype or 'unknown'}:\n{snippet}") |
|
|
| def _req(method: str, path: str, **kw): |
| if not path.startswith("/"): |
| path = "/" + path |
| url = f"{BACKEND}{path}" |
| kw.setdefault("timeout", DEFAULT_TIMEOUT) |
| try: |
| r = _session.request(method, url, **kw) |
| r.raise_for_status() |
| except requests.HTTPError as e: |
| body = "" |
| try: |
| body = r.text[:500] |
| except Exception: |
| pass |
| status = getattr(r, "status_code", "?") |
| |
| if status in (401, 403): |
| raise RuntimeError( |
| f"{method} {path} failed [{status}] – auth rejected. " |
| f"Check BACKEND_TOKEN/HF_TOKEN permissions and that the backend Space is private/readable." |
| ) from e |
| raise RuntimeError(f"{method} {path} failed [{status}]: {body}") from e |
| except requests.RequestException as e: |
| raise RuntimeError(f"{method} {path} failed: {e.__class__.__name__}: {e}") from e |
| return r |
|
|
| |
| def health(): |
| |
| try: |
| return _json_or_raise(_req("GET", "/health")) |
| except Exception: |
| |
| try: |
| _req("GET", "/") |
| return {"ok": True} |
| except Exception: |
| return {"ok": False} |
| |
| |
|
|
| |
| API_PREFIX_ENV = (os.getenv("BACKEND_API_PREFIX") or "").strip().rstrip("/") |
|
|
| def _prefixes(): |
| |
| seen, out = set(), [] |
| for p in [API_PREFIX_ENV, "", "/api", "/v1", "/api/v1"]: |
| p = (p or "").strip() |
| p = "" if p == "" else ("/" + p.strip("/")) |
| if p not in seen: |
| out.append(p) |
| seen.add(p) |
| return out |
|
|
| def _try_candidates(method: str, candidates: list[tuple[str, dict]]): |
| """ |
| candidates: list of (path, request_kwargs) where path starts with "/" and |
| kwargs may include {'params':..., 'json':...}. |
| Tries multiple prefixes (e.g., "", "/api", "/v1") and returns JSON for first 2xx. |
| Auth errors (401/403) are raised immediately. |
| """ |
| tried = [] |
| for pref in _prefixes(): |
| for path, kw in candidates: |
| url = f"{BACKEND}{pref}{path}" |
| tried.append(f"{method} {url}") |
| try: |
| r = _session.request(method, url, timeout=DEFAULT_TIMEOUT, **kw) |
| except requests.RequestException as e: |
| |
| continue |
| if r.status_code in (401, 403): |
| snippet = (r.text or "")[:200] |
| raise RuntimeError(f"{method} {path} auth failed [{r.status_code}]: {snippet}") |
| if 200 <= r.status_code < 300: |
| return _json_or_raise(r) |
| |
| raise RuntimeError("No matching endpoint for this operation. Tried:\n- " + "\n- ".join(tried)) |
|
|
|
|
| |
| def user_stats(student_id: int): |
| return _req("GET", f"/students/{student_id}/stats").json() |
| def list_assignments_for_student(student_id: int): |
| return _req("GET", f"/students/{student_id}/assignments").json() |
| def student_quiz_average(student_id: int): |
| d = _req("GET", f"/students/{student_id}/quiz_avg").json() |
| |
| if isinstance(d, dict): |
| for k in ("avg", "average", "score_pct", "score", "value"): |
| if k in d: |
| v = d[k] |
| break |
| else: |
| |
| v = next((vv for vv in d.values() if isinstance(vv, (int, float, str))), 0) |
| else: |
| v = d |
| try: |
| |
| return int(round(float(str(v).strip().rstrip("%")))) |
| except Exception: |
| return 0 |
| def recent_lessons_for_student(student_id: int, limit: int = 5): |
| return _req("GET", f"/students/{student_id}/recent", params={"limit": limit}).json() |
|
|
| |
| |
| |
| |
|
|
| |
| |
|
|
| |
| |
|
|
| |
| |
|
|
| |
| |
|
|
| |
| |
|
|
| |
| |
| |
|
|
| |
| |
|
|
| |
| def level_from_xp(xp: int): |
| return _json_or_raise(_req("GET", "/levels/from_xp", params={"xp": xp}))["level"] |
|
|
| |
| def join_class_by_code(student_id: int, code: str): |
| d = _json_or_raise(_req("POST", f"/students/{student_id}/classes/join", json={"code": code})) |
| |
| return d.get("class_id", d) |
|
|
| def list_classes_for_student(student_id: int): |
| return _json_or_raise(_req("GET", f"/students/{student_id}/classes")) |
|
|
| def class_content_counts(class_id: int): |
| return _json_or_raise(_req("GET", f"/classes/{class_id}/counts")) |
|
|
| def student_class_progress(student_id: int, class_id: int): |
| return _json_or_raise(_req("GET", f"/classes/{class_id}/students/{student_id}/progress")) |
|
|
| def leave_class(student_id: int, class_id: int): |
| |
| _json_or_raise(_req("POST", f"/classes/{class_id}/leave", json={"student_id": student_id})) |
| return True |
|
|
| def student_assignments_for_class(student_id: int, class_id: int): |
| return _json_or_raise(_req("GET", f"/classes/{class_id}/students/{student_id}/assignments")) |
|
|
|
|
|
|
|
|
| |
|
|
| |
| def create_class(teacher_id: int, name: str): |
| |
| return _try_candidates("POST", [ |
| (f"/teachers/{teacher_id}/classes", {"json": {"name": name}}), |
| |
| ("/classes", {"json": {"teacher_id": teacher_id, "name": name}}), |
| ]) |
|
|
| def list_classes_by_teacher(teacher_id: int): |
| return _try_candidates("GET", [ |
| (f"/teachers/{teacher_id}/classes", {}), |
| ]) |
|
|
| def list_students_in_class(class_id: int): |
| |
| return _json_or_raise(_req("GET", f"/classes/{class_id}/students")) |
|
|
| def class_content_counts(class_id: int): |
| return _try_candidates("GET", [ |
| (f"/classes/{class_id}/content_counts", {}), |
| (f"/classes/{class_id}/counts", {}), |
| ]) |
|
|
| def list_class_assignments(class_id: int): |
| return _json_or_raise(_req("GET", f"/classes/{class_id}/assignments")) |
|
|
| def class_analytics(class_id: int): |
| return _json_or_raise(_req("GET", f"/classes/{class_id}/analytics")) |
|
|
| def teacher_tiles(teacher_id: int): |
| return _json_or_raise(_req("GET", f"/teachers/{teacher_id}/tiles")) |
|
|
| def class_student_metrics(class_id: int): |
| |
| return _try_candidates("GET", [ |
| (f"/classes/{class_id}/students/metrics", {}), |
| |
| (f"/classes/{class_id}/student_metrics", {}), |
| (f"/classes/{class_id}/students", {}), |
| ]) |
|
|
| def class_weekly_activity(class_id: int): |
| |
| return _try_candidates("GET", [ |
| (f"/classes/{class_id}/activity/weekly", {}), |
| (f"/classes/{class_id}/weekly_activity", {}), |
| ]) |
|
|
| def class_progress_overview(class_id: int): |
| |
| return _try_candidates("GET", [ |
| (f"/classes/{class_id}/progress", {}), |
| (f"/classes/{class_id}/progress_overview", {}), |
| ]) |
|
|
| def class_recent_activity(class_id: int, limit=6, days=30): |
| |
| return _try_candidates("GET", [ |
| (f"/classes/{class_id}/activity/recent", {"params": {"limit": limit, "days": days}}), |
| (f"/classes/{class_id}/recent_activity", {"params": {"limit": limit, "days": days}}), |
| ]) |
|
|
| |
| def list_lessons_by_teacher(teacher_id: int): |
| return _json_or_raise(_req("GET", f"/teachers/{teacher_id}/lessons")) |
|
|
| def create_lesson(teacher_id: int, title: str, description: str, |
| subject: str, level: str, sections: list[dict]): |
| payload = { |
| "title": title, |
| "description": description, |
| "subject": subject, |
| "level": level, |
| "sections": sections, |
| } |
| |
| d = _try_candidates("POST", [ |
| (f"/teachers/{teacher_id}/lessons", {"json": payload}), |
| |
| ("/lessons", {"json": {"teacher_id": teacher_id, **payload}}), |
| ]) |
| |
| return d.get("lesson_id", d.get("id", d)) |
|
|
| def get_lesson(lesson_id: int): |
| return _json_or_raise(_req("GET", f"/lessons/{lesson_id}")) |
|
|
| def update_lesson(lesson_id: int, teacher_id: int, title: str, description: str, |
| subject: str, level: str, sections: list[dict]): |
| d = _req("PUT", f"/lessons/{lesson_id}", json={ |
| "teacher_id": teacher_id, |
| "title": title, |
| "description": description, |
| "subject": subject, |
| "level": level, |
| "sections": sections |
| }).json() |
| return bool(d.get("ok", True)) |
|
|
| def delete_lesson(lesson_id: int, teacher_id: int): |
| d = _req("DELETE", f"/lessons/{lesson_id}", json={"teacher_id": teacher_id}).json() |
| return bool(d.get("ok", True)), d.get("message", "") |
|
|
| |
| def list_quizzes_by_teacher(teacher_id: int): |
| return _json_or_raise(_req("GET", f"/teachers/{teacher_id}/quizzes")) |
|
|
| def create_quiz(lesson_id: int, title: str, items: list[dict], settings: dict): |
| d = _req("POST", "/quizzes", json={ |
| "lesson_id": lesson_id, "title": title, "items": items, "settings": settings |
| }).json() |
| return d.get("quiz_id", d.get("id", d)) |
|
|
| def get_quiz(quiz_id: int): |
| return _json_or_raise(_req("GET", f"/quizzes/{quiz_id}")) |
|
|
| def get_quiz(quiz_id: int): |
| |
| return _req("GET", f"/quizzes/{quiz_id}") |
|
|
| def submit_quiz(student_id, assignment_id, quiz_id, score, total, details): |
| payload = {"student_id": student_id, "assignment_id": assignment_id, |
| "quiz_id": quiz_id, "score": score, "total": total, "details": details} |
| return _req("POST", "/quizzes/submit", json=payload) |
|
|
| def update_quiz(quiz_id: int, teacher_id: int, title: str, items: list[dict], settings: dict): |
| d = _req("PUT", f"/quizzes/{quiz_id}", json={ |
| "teacher_id": teacher_id, "title": title, "items": items, "settings": settings |
| }).json() |
| return bool(d.get("ok", True)) |
|
|
| def delete_quiz(quiz_id: int, teacher_id: int): |
| d = _req("DELETE", f"/quizzes/{quiz_id}", json={"teacher_id": teacher_id}).json() |
| return bool(d.get("ok", True)), d.get("message", "") |
|
|
| def list_assigned_students_for_lesson(lesson_id: int): |
| return _json_or_raise(_req("GET", f"/lessons/{lesson_id}/assignees")) |
|
|
| def list_assigned_students_for_quiz(quiz_id: int): |
| return _json_or_raise(_req("GET", f"/quizzes/{quiz_id}/assignees")) |
|
|
| |
| def assign_to_class(lesson_id: int | None, quiz_id: int | None, class_id: int, teacher_id: int, due_at: str | None = None): |
| |
| d = _try_candidates("POST", [ |
| ("/assign", {"json": { |
| "lesson_id": lesson_id, "quiz_id": quiz_id, |
| "class_id": class_id, "teacher_id": teacher_id, "due_at": due_at |
| }}), |
| ("/assignments", {"json": { |
| "lesson_id": lesson_id, "quiz_id": quiz_id, |
| "class_id": class_id, "teacher_id": teacher_id, "due_at": due_at |
| }}), |
| ]) |
| return bool(d.get("ok", True)) |
|
|
|
|
|
|
|
|
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| |
|
|
| |
| |
| |
| |
| |
|
|
|
|
| |
|
|
| |
| |
| |
|
|
| |
| |
|
|
| |
| |
|
|
| |
| |
|
|
| |
| |
| |
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| |
|
|
| |
| |
| |
|
|
| |
| |
|
|
| |
| |
| |
|
|
| |
| |
| |
|
|
| |
| |
| |
|
|
| |
| |
|
|
| |
| |
|
|
| |
| |
| |
| |
| |
|
|
| |
|
|
| |
| |
|
|
| |
| |
|
|
| |
| |
| |
|
|
| |
| |
|
|
| |
| def generate_quiz_from_text(content: str, n_questions: int = 5, subject: str = "finance", level: str = "beginner"): |
| """ |
| Backend should read GEN_MODEL from env and use your chosen model (llama-3.1-8b-instruct). |
| Return shape: {"items":[{"question":...,"options":[...],"answer_key":"A"}]} |
| """ |
| return _req("POST", "/quiz/generate", json={ |
| "content": content, "n_questions": n_questions, "subject": subject, "level": level |
| }).json() |
|
|
|
|
| |
| def start_agent(student_id: int, lesson_id: int, level_slug: str): |
| return _json_or_raise(_req("POST", "/agent/start", |
| json={"student_id": student_id, "lesson_id": lesson_id, "level_slug": level_slug})) |
|
|
| def get_quiz(student_id: int, lesson_id: int, level_slug: str): |
| d = _json_or_raise(_req("POST", "/agent/quiz", |
| json={"student_id": student_id, "lesson_id": lesson_id, "level_slug": level_slug})) |
| return d["items"] |
|
|
| def grade_quiz(student_id: int, lesson_id: int, level_slug: str, |
| answers: list[str], assignment_id: int | None = None): |
| d = _json_or_raise(_req("POST", "/agent/grade", |
| json={"student_id": student_id, "lesson_id": lesson_id, "level_slug": level_slug, |
| "answers": answers, "assignment_id": assignment_id})) |
| return d["score"], d["total"] |
|
|
| def next_step(student_id: int, lesson_id: int, level_slug: str, |
| answers: list[str], assignment_id: int | None = None): |
| return _json_or_raise(_req("POST", "/agent/coach_or_celebrate", |
| json={"student_id": student_id, "lesson_id": lesson_id, "level_slug": level_slug, |
| "answers": answers, "assignment_id": assignment_id})) |
|
|
| |
| def login(email: str, password: str): |
| return _json_or_raise(_req("POST", "/auth/login", json={"email": email, "password": password})) |
|
|
| def signup_student(name: str, email: str, password: str, level_label: str, country_label: str): |
| payload_student = { |
| "name": name, "email": email, "password": password, |
| "level_label": level_label, "country_label": country_label |
| } |
| |
| return _try_candidates("POST", [ |
| ("/auth/signup/student", {"json": payload_student}), |
| ("/auth/register", {"json": { |
| "role": "student", "name": name, "email": email, "password": password, |
| "level": level_label, "country": country_label |
| }}), |
| ]) |
|
|
| def signup_teacher(title: str, name: str, email: str, password: str): |
| payload_teacher = {"title": title, "name": name, "email": email, "password": password} |
| return _try_candidates("POST", [ |
| ("/auth/signup/teacher", {"json": payload_teacher}), |
| ("/auth/register", {"json": { |
| "role": "teacher", "title": title, "name": name, "email": email, "password": password |
| }}), |
| ]) |
|
|
| |
| def fetch_lesson_content(lesson: str, module: str, topic: str): |
| r = _json_or_raise(_req("POST", "/lesson", |
| json={"lesson": lesson, "module": module, "topic": topic})) |
| return r["lesson_content"] |
|
|
| def submit_lesson_quiz(lesson: str, module: str, topic: str, responses: dict): |
| return _json_or_raise(_req("POST", "/lesson-quiz", |
| json={"lesson": lesson, "module": module, "topic": topic, "responses": responses})) |
|
|
| def submit_practice_quiz(lesson: str, responses: dict): |
| return _json_or_raise(_req("POST", "/practice-quiz", |
| json={"lesson": lesson, "responses": responses})) |
|
|
| def send_to_chatbot(messages: list[dict]): |
| return _json_or_raise(_req("POST", "/chatbot", json={"messages": messages})) |
|
|
|
|
| |
|
|
| def record_money_match_play(user_id: int, target: int, total: int, |
| elapsed_ms: int, matched: bool, gained_xp: int): |
| payload = { |
| "user_id": user_id, "target": target, "total": total, |
| "elapsed_ms": elapsed_ms, "matched": matched, "gained_xp": gained_xp, |
| } |
| return _try_candidates("POST", [ |
| ("/games/money_match/record", {"json": payload}), |
| ]) |
|
|
| def record_budget_builder_play(user_id: int, weekly_allowance: int, budget_score: int, |
| elapsed_ms: int, allocations: list[dict], gained_xp: int | None): |
| payload = { |
| "user_id": user_id, |
| "weekly_allowance": weekly_allowance, |
| "budget_score": budget_score, |
| "elapsed_ms": elapsed_ms, |
| "allocations": allocations, |
| "gained_xp": gained_xp, |
| } |
| return _try_candidates("POST", [ |
| ("/games/budget_builder/record", {"json": payload}), |
| ]) |
|
|
|
|
| def record_debt_dilemma_play(user_id: int, loans_cleared: int, |
| mistakes: int, elapsed_ms: int, gained_xp: int): |
| payload = { |
| "user_id": user_id, |
| "loans_cleared": loans_cleared, |
| "mistakes": mistakes, |
| "elapsed_ms": elapsed_ms, |
| "gained_xp": gained_xp, |
| } |
| return _try_candidates("POST", [ |
| ("/games/debt_dilemma/record", {"json": payload}), |
| ("/api/games/debt_dilemma/record", {"json": payload}), |
| ("/api/v1/games/debt_dilemma/record", {"json": payload}), |
| ]) |
|
|
|
|
| def record_profit_puzzler_play(user_id: int, puzzles_solved: int, mistakes: int, elapsed_ms: int, gained_xp: int | None = None): |
| payload = {"user_id": user_id, "puzzles_solved": puzzles_solved, "mistakes": mistakes, "elapsed_ms": elapsed_ms} |
| if gained_xp is not None: |
| payload["gained_xp"] = gained_xp |
| return _try_candidates("POST", [("/games/profit_puzzler/record", {"json": payload})]) |
|
|
|
|
| def generate_quiz(lesson_id: int, level_slug: str, lesson_title: str): |
| r = requests.post(f"{BACKEND}/generate_quiz", json={ |
| "lesson_id": lesson_id, |
| "level_slug": level_slug, |
| "lesson_title": lesson_title |
| }, timeout=60) |
| r.raise_for_status() |
| return r.json()["quiz"] |
|
|
| def submit_quiz(lesson_id: int, level_slug: str, user_answers: list[dict], original_quiz: list[dict]): |
| r = requests.post(f"{BACKEND}/submit_quiz", json={ |
| "lesson_id": lesson_id, |
| "level_slug": level_slug, |
| "user_answers": user_answers, |
| "original_quiz": original_quiz |
| }, timeout=90) |
| r.raise_for_status() |
| return r.json() |
|
|
| def tutor_explain(lesson_id: int, level_slug: str, wrong: list[dict]): |
| r = requests.post(f"{BACKEND}/tutor/explain", json={ |
| "lesson_id": lesson_id, |
| "level_slug": level_slug, |
| "wrong": wrong |
| }, timeout=60) |
| r.raise_for_status() |
| return r.json()["feedback"] |