elkay: api.py
Browse files- utils/api.py +21 -50
utils/api.py
CHANGED
|
@@ -1,21 +1,19 @@
|
|
| 1 |
-
import os, json, requests
|
| 2 |
from urllib3.util.retry import Retry
|
| 3 |
from requests.adapters import HTTPAdapter
|
| 4 |
|
| 5 |
# ---- Setup ----
|
| 6 |
BACKEND = (os.getenv("BACKEND_URL") or "").strip().rstrip("/")
|
| 7 |
if not BACKEND:
|
| 8 |
-
# Fail fast at import; Streamlit will surface this in the sidebar on first run
|
| 9 |
raise RuntimeError("BACKEND_URL is not set in Space secrets.")
|
| 10 |
|
| 11 |
-
# Accept either BACKEND_TOKEN or HF_TOKEN
|
| 12 |
TOKEN = (os.getenv("BACKEND_TOKEN") or os.getenv("HF_TOKEN") or "").strip()
|
| 13 |
|
| 14 |
-
|
|
|
|
| 15 |
|
| 16 |
_session = requests.Session()
|
| 17 |
|
| 18 |
-
# Light retry for transient network/server blips
|
| 19 |
retry = Retry(
|
| 20 |
total=3,
|
| 21 |
connect=3,
|
|
@@ -27,7 +25,6 @@ retry = Retry(
|
|
| 27 |
_session.mount("https://", HTTPAdapter(max_retries=retry))
|
| 28 |
_session.mount("http://", HTTPAdapter(max_retries=retry))
|
| 29 |
|
| 30 |
-
# Default headers
|
| 31 |
_session.headers.update({
|
| 32 |
"Accept": "application/json, */*;q=0.1",
|
| 33 |
"User-Agent": "FinEdu-Frontend/1.0 (+spaces)",
|
|
@@ -35,11 +32,14 @@ _session.headers.update({
|
|
| 35 |
if TOKEN:
|
| 36 |
_session.headers["Authorization"] = f"Bearer {TOKEN}"
|
| 37 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
def _json_or_raise(resp: requests.Response):
|
| 39 |
ctype = resp.headers.get("content-type", "")
|
| 40 |
if "application/json" in ctype:
|
| 41 |
return resp.json()
|
| 42 |
-
# Try to parse anyway; show a helpful error if not JSON
|
| 43 |
try:
|
| 44 |
return resp.json()
|
| 45 |
except Exception:
|
|
@@ -61,7 +61,6 @@ def _req(method: str, path: str, **kw):
|
|
| 61 |
except Exception:
|
| 62 |
pass
|
| 63 |
status = getattr(r, "status_code", "?")
|
| 64 |
-
# Give nicer hints for common auth misconfigs
|
| 65 |
if status in (401, 403):
|
| 66 |
raise RuntimeError(
|
| 67 |
f"{method} {path} failed [{status}] – auth rejected. "
|
|
@@ -74,11 +73,9 @@ def _req(method: str, path: str, **kw):
|
|
| 74 |
|
| 75 |
# ---- Health ----
|
| 76 |
def health():
|
| 77 |
-
# Prefer /health but allow root fallback if you change the backend later
|
| 78 |
try:
|
| 79 |
return _json_or_raise(_req("GET", "/health"))
|
| 80 |
except Exception:
|
| 81 |
-
# best-effort fallback
|
| 82 |
try:
|
| 83 |
_req("GET", "/")
|
| 84 |
return {"ok": True}
|
|
@@ -89,18 +86,11 @@ def health():
|
|
| 89 |
API_PREFIX_ENV = (os.getenv("BACKEND_API_PREFIX") or "").strip().rstrip("/")
|
| 90 |
|
| 91 |
def _prefixes():
|
| 92 |
-
"""Try only the configured prefix (if any) and the bare root."""
|
| 93 |
if API_PREFIX_ENV:
|
| 94 |
return ["/" + API_PREFIX_ENV.strip("/"), ""]
|
| 95 |
return [""]
|
| 96 |
|
| 97 |
def _try_candidates(method: str, candidates: list[tuple[str, dict]]):
|
| 98 |
-
"""
|
| 99 |
-
candidates: list of (path, request_kwargs) where path starts with "/" and
|
| 100 |
-
kwargs may include {'params':..., 'json':...}.
|
| 101 |
-
Tries multiple prefixes (e.g., "", "/api", "/v1") and returns JSON for first 2xx.
|
| 102 |
-
Auth errors (401/403) are raised immediately.
|
| 103 |
-
"""
|
| 104 |
tried = []
|
| 105 |
for pref in _prefixes():
|
| 106 |
for path, kw in candidates:
|
|
@@ -109,14 +99,12 @@ def _try_candidates(method: str, candidates: list[tuple[str, dict]]):
|
|
| 109 |
try:
|
| 110 |
r = _session.request(method, url, timeout=DEFAULT_TIMEOUT, **kw)
|
| 111 |
except requests.RequestException as e:
|
| 112 |
-
# transient error: keep trying others
|
| 113 |
continue
|
| 114 |
if r.status_code in (401, 403):
|
| 115 |
snippet = (r.text or "")[:200]
|
| 116 |
raise RuntimeError(f"{method} {path} auth failed [{r.status_code}]: {snippet}")
|
| 117 |
if 200 <= r.status_code < 300:
|
| 118 |
return _json_or_raise(r)
|
| 119 |
-
# 404/405/etc.: try next candidate
|
| 120 |
raise RuntimeError("No matching endpoint for this operation. Tried:\n- " + "\n- ".join(tried))
|
| 121 |
|
| 122 |
# -- Helpers for student_db.py
|
|
@@ -126,19 +114,16 @@ def list_assignments_for_student(student_id: int):
|
|
| 126 |
return _req("GET", f"/students/{student_id}/assignments").json()
|
| 127 |
def student_quiz_average(student_id: int):
|
| 128 |
d = _req("GET", f"/students/{student_id}/quiz_avg").json()
|
| 129 |
-
# Normalize common shapes: {"avg": 82}, {"score_pct": "82"}, "82", 82
|
| 130 |
if isinstance(d, dict):
|
| 131 |
for k in ("avg", "average", "score_pct", "score", "value"):
|
| 132 |
if k in d:
|
| 133 |
v = d[k]
|
| 134 |
break
|
| 135 |
else:
|
| 136 |
-
# fallback: first numeric-ish value
|
| 137 |
v = next((vv for vv in d.values() if isinstance(vv, (int, float, str))), 0)
|
| 138 |
else:
|
| 139 |
v = d
|
| 140 |
try:
|
| 141 |
-
# handle strings like "82" or "82%"
|
| 142 |
return int(round(float(str(v).strip().rstrip("%"))))
|
| 143 |
except Exception:
|
| 144 |
return 0
|
|
@@ -149,7 +134,6 @@ def recent_lessons_for_student(student_id: int, limit: int = 5):
|
|
| 149 |
def create_class(teacher_id: int, name: str):
|
| 150 |
return _try_candidates("POST", [
|
| 151 |
(f"/teachers/{teacher_id}/classes", {"json": {"name": name}}),
|
| 152 |
-
# fallbacks if you ever rename:
|
| 153 |
("/classes", {"json": {"teacher_id": teacher_id, "name": name}}),
|
| 154 |
])
|
| 155 |
|
|
@@ -164,9 +148,8 @@ def list_classes_by_teacher(teacher_id: int):
|
|
| 164 |
def class_student_metrics(class_id: int):
|
| 165 |
return _try_candidates("GET", [
|
| 166 |
(f"/classes/{class_id}/students/metrics", {}),
|
| 167 |
-
# tolerant fallbacks:
|
| 168 |
(f"/classes/{class_id}/student_metrics", {}),
|
| 169 |
-
(f"/classes/{class_id}/students", {}),
|
| 170 |
])
|
| 171 |
|
| 172 |
def class_weekly_activity(class_id: int):
|
|
@@ -190,14 +173,12 @@ def class_recent_activity(class_id: int, limit=6, days=30):
|
|
| 190 |
def list_students_in_class(class_id: int):
|
| 191 |
return _json_or_raise(_req("GET", f"/classes/{class_id}/students"))
|
| 192 |
|
| 193 |
-
# Optional if you want to compute levels server-side
|
| 194 |
def level_from_xp(xp: int):
|
| 195 |
return _json_or_raise(_req("GET", "/levels/from_xp", params={"xp": xp}))["level"]
|
| 196 |
|
| 197 |
# -- teacherlink.py helpers
|
| 198 |
def join_class_by_code(student_id: int, code: str):
|
| 199 |
d = _json_or_raise(_req("POST", f"/students/{student_id}/classes/join", json={"code": code}))
|
| 200 |
-
# backend may return {"class_id": ...} or full class object; both are fine
|
| 201 |
return d.get("class_id", d)
|
| 202 |
|
| 203 |
def list_classes_for_student(student_id: int):
|
|
@@ -213,7 +194,6 @@ def student_class_progress(student_id: int, class_id: int):
|
|
| 213 |
return _json_or_raise(_req("GET", f"/classes/{class_id}/students/{student_id}/progress"))
|
| 214 |
|
| 215 |
def leave_class(student_id: int, class_id: int):
|
| 216 |
-
# could also be DELETE /classes/{class_id}/students/{student_id}
|
| 217 |
_json_or_raise(_req("POST", f"/classes/{class_id}/leave", json={"student_id": student_id}))
|
| 218 |
return True
|
| 219 |
|
|
@@ -221,12 +201,9 @@ def student_assignments_for_class(student_id: int, class_id: int):
|
|
| 221 |
return _json_or_raise(_req("GET", f"/classes/{class_id}/students/{student_id}/assignments"))
|
| 222 |
|
| 223 |
# ---------- TEACHERS / CLASSES / CONTENT (BACKEND ROUTES THAT EXIST) ----------
|
| 224 |
-
|
| 225 |
-
# Classes
|
| 226 |
def create_class(teacher_id: int, name: str):
|
| 227 |
return _try_candidates("POST", [
|
| 228 |
(f"/teachers/{teacher_id}/classes", {"json": {"name": name}}),
|
| 229 |
-
# fallbacks if you ever rename:
|
| 230 |
("/classes", {"json": {"teacher_id": teacher_id, "name": name}}),
|
| 231 |
])
|
| 232 |
|
|
@@ -236,7 +213,6 @@ def list_classes_by_teacher(teacher_id: int):
|
|
| 236 |
])
|
| 237 |
|
| 238 |
def list_students_in_class(class_id: int):
|
| 239 |
-
# exact route in backend
|
| 240 |
return _json_or_raise(_req("GET", f"/classes/{class_id}/students"))
|
| 241 |
|
| 242 |
def class_content_counts(class_id: int):
|
|
@@ -257,9 +233,8 @@ def teacher_tiles(teacher_id: int):
|
|
| 257 |
def class_student_metrics(class_id: int):
|
| 258 |
return _try_candidates("GET", [
|
| 259 |
(f"/classes/{class_id}/students/metrics", {}),
|
| 260 |
-
# tolerant fallbacks:
|
| 261 |
(f"/classes/{class_id}/student_metrics", {}),
|
| 262 |
-
(f"/classes/{class_id}/students", {}),
|
| 263 |
])
|
| 264 |
|
| 265 |
def class_weekly_activity(class_id: int):
|
|
@@ -295,7 +270,6 @@ def create_lesson(teacher_id: int, title: str, description: str,
|
|
| 295 |
}
|
| 296 |
d = _try_candidates("POST", [
|
| 297 |
(f"/teachers/{teacher_id}/lessons", {"json": payload}),
|
| 298 |
-
# fallback if you later add a flat /lessons route:
|
| 299 |
("/lessons", {"json": {"teacher_id": teacher_id, **payload}}),
|
| 300 |
])
|
| 301 |
return d.get("lesson_id", d.get("id", d))
|
|
@@ -352,11 +326,11 @@ def submit_quiz(*, lesson_id: int | None, level_slug: str | None,
|
|
| 352 |
payload = {
|
| 353 |
"lesson_id": int(lesson_id) if lesson_id is not None else None,
|
| 354 |
"level_slug": level_slug,
|
| 355 |
-
"user_answers": user_answers,
|
| 356 |
-
"original_quiz": original_quiz,
|
| 357 |
}
|
| 358 |
return _try_candidates("POST", [
|
| 359 |
-
("/quiz/submit", {"json": payload}),
|
| 360 |
])
|
| 361 |
|
| 362 |
def update_quiz(quiz_id: int,
|
|
@@ -392,10 +366,6 @@ def assign_to_class(lesson_id: int | None, quiz_id: int | None, class_id: int, t
|
|
| 392 |
|
| 393 |
# ---------- LLM-based quiz generation (backend uses GEN_MODEL) ----------
|
| 394 |
def generate_quiz_from_text(content: str, n_questions: int = 5, subject: str = "finance", level: str = "beginner"):
|
| 395 |
-
"""
|
| 396 |
-
Backend should read GEN_MODEL from env and use your chosen model (llama-3.1-8b-instruct).
|
| 397 |
-
Return shape: {"items":[{"question":...,"options":[...],"answer_key":"A"}]}
|
| 398 |
-
"""
|
| 399 |
return _req("POST", "/quiz/generate", json={
|
| 400 |
"content": content, "n_questions": n_questions, "subject": subject, "level": level
|
| 401 |
}).json()
|
|
@@ -406,17 +376,18 @@ def generate_quiz(*, lesson_id: int | None, level_slug: str | None, lesson_title
|
|
| 406 |
"level_slug": level_slug,
|
| 407 |
"lesson_title": lesson_title,
|
| 408 |
}
|
| 409 |
-
|
| 410 |
try:
|
| 411 |
-
resp =
|
| 412 |
-
|
| 413 |
-
|
| 414 |
except RuntimeError as e:
|
| 415 |
-
|
|
|
|
| 416 |
|
| 417 |
-
if isinstance(
|
| 418 |
-
return
|
| 419 |
-
return
|
| 420 |
|
| 421 |
# ---- Legacy agent endpoints (keep) ----
|
| 422 |
def start_agent(student_id: int, lesson_id: int, level_slug: str):
|
|
|
|
| 1 |
+
import os, json, requests, logging
|
| 2 |
from urllib3.util.retry import Retry
|
| 3 |
from requests.adapters import HTTPAdapter
|
| 4 |
|
| 5 |
# ---- Setup ----
|
| 6 |
BACKEND = (os.getenv("BACKEND_URL") or "").strip().rstrip("/")
|
| 7 |
if not BACKEND:
|
|
|
|
| 8 |
raise RuntimeError("BACKEND_URL is not set in Space secrets.")
|
| 9 |
|
|
|
|
| 10 |
TOKEN = (os.getenv("BACKEND_TOKEN") or os.getenv("HF_TOKEN") or "").strip()
|
| 11 |
|
| 12 |
+
# Increased timeout for potential late-night latency
|
| 13 |
+
DEFAULT_TIMEOUT = int(os.getenv("BACKEND_TIMEOUT", "60"))
|
| 14 |
|
| 15 |
_session = requests.Session()
|
| 16 |
|
|
|
|
| 17 |
retry = Retry(
|
| 18 |
total=3,
|
| 19 |
connect=3,
|
|
|
|
| 25 |
_session.mount("https://", HTTPAdapter(max_retries=retry))
|
| 26 |
_session.mount("http://", HTTPAdapter(max_retries=retry))
|
| 27 |
|
|
|
|
| 28 |
_session.headers.update({
|
| 29 |
"Accept": "application/json, */*;q=0.1",
|
| 30 |
"User-Agent": "FinEdu-Frontend/1.0 (+spaces)",
|
|
|
|
| 32 |
if TOKEN:
|
| 33 |
_session.headers["Authorization"] = f"Bearer {TOKEN}"
|
| 34 |
|
| 35 |
+
# Setup logging for debugging
|
| 36 |
+
logging.basicConfig(level=logging.DEBUG)
|
| 37 |
+
logger = logging.getLogger(__name__)
|
| 38 |
+
|
| 39 |
def _json_or_raise(resp: requests.Response):
|
| 40 |
ctype = resp.headers.get("content-type", "")
|
| 41 |
if "application/json" in ctype:
|
| 42 |
return resp.json()
|
|
|
|
| 43 |
try:
|
| 44 |
return resp.json()
|
| 45 |
except Exception:
|
|
|
|
| 61 |
except Exception:
|
| 62 |
pass
|
| 63 |
status = getattr(r, "status_code", "?")
|
|
|
|
| 64 |
if status in (401, 403):
|
| 65 |
raise RuntimeError(
|
| 66 |
f"{method} {path} failed [{status}] – auth rejected. "
|
|
|
|
| 73 |
|
| 74 |
# ---- Health ----
|
| 75 |
def health():
|
|
|
|
| 76 |
try:
|
| 77 |
return _json_or_raise(_req("GET", "/health"))
|
| 78 |
except Exception:
|
|
|
|
| 79 |
try:
|
| 80 |
_req("GET", "/")
|
| 81 |
return {"ok": True}
|
|
|
|
| 86 |
API_PREFIX_ENV = (os.getenv("BACKEND_API_PREFIX") or "").strip().rstrip("/")
|
| 87 |
|
| 88 |
def _prefixes():
|
|
|
|
| 89 |
if API_PREFIX_ENV:
|
| 90 |
return ["/" + API_PREFIX_ENV.strip("/"), ""]
|
| 91 |
return [""]
|
| 92 |
|
| 93 |
def _try_candidates(method: str, candidates: list[tuple[str, dict]]):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 94 |
tried = []
|
| 95 |
for pref in _prefixes():
|
| 96 |
for path, kw in candidates:
|
|
|
|
| 99 |
try:
|
| 100 |
r = _session.request(method, url, timeout=DEFAULT_TIMEOUT, **kw)
|
| 101 |
except requests.RequestException as e:
|
|
|
|
| 102 |
continue
|
| 103 |
if r.status_code in (401, 403):
|
| 104 |
snippet = (r.text or "")[:200]
|
| 105 |
raise RuntimeError(f"{method} {path} auth failed [{r.status_code}]: {snippet}")
|
| 106 |
if 200 <= r.status_code < 300:
|
| 107 |
return _json_or_raise(r)
|
|
|
|
| 108 |
raise RuntimeError("No matching endpoint for this operation. Tried:\n- " + "\n- ".join(tried))
|
| 109 |
|
| 110 |
# -- Helpers for student_db.py
|
|
|
|
| 114 |
return _req("GET", f"/students/{student_id}/assignments").json()
|
| 115 |
def student_quiz_average(student_id: int):
|
| 116 |
d = _req("GET", f"/students/{student_id}/quiz_avg").json()
|
|
|
|
| 117 |
if isinstance(d, dict):
|
| 118 |
for k in ("avg", "average", "score_pct", "score", "value"):
|
| 119 |
if k in d:
|
| 120 |
v = d[k]
|
| 121 |
break
|
| 122 |
else:
|
|
|
|
| 123 |
v = next((vv for vv in d.values() if isinstance(vv, (int, float, str))), 0)
|
| 124 |
else:
|
| 125 |
v = d
|
| 126 |
try:
|
|
|
|
| 127 |
return int(round(float(str(v).strip().rstrip("%"))))
|
| 128 |
except Exception:
|
| 129 |
return 0
|
|
|
|
| 134 |
def create_class(teacher_id: int, name: str):
|
| 135 |
return _try_candidates("POST", [
|
| 136 |
(f"/teachers/{teacher_id}/classes", {"json": {"name": name}}),
|
|
|
|
| 137 |
("/classes", {"json": {"teacher_id": teacher_id, "name": name}}),
|
| 138 |
])
|
| 139 |
|
|
|
|
| 148 |
def class_student_metrics(class_id: int):
|
| 149 |
return _try_candidates("GET", [
|
| 150 |
(f"/classes/{class_id}/students/metrics", {}),
|
|
|
|
| 151 |
(f"/classes/{class_id}/student_metrics", {}),
|
| 152 |
+
(f"/classes/{class_id}/students", {}),
|
| 153 |
])
|
| 154 |
|
| 155 |
def class_weekly_activity(class_id: int):
|
|
|
|
| 173 |
def list_students_in_class(class_id: int):
|
| 174 |
return _json_or_raise(_req("GET", f"/classes/{class_id}/students"))
|
| 175 |
|
|
|
|
| 176 |
def level_from_xp(xp: int):
|
| 177 |
return _json_or_raise(_req("GET", "/levels/from_xp", params={"xp": xp}))["level"]
|
| 178 |
|
| 179 |
# -- teacherlink.py helpers
|
| 180 |
def join_class_by_code(student_id: int, code: str):
|
| 181 |
d = _json_or_raise(_req("POST", f"/students/{student_id}/classes/join", json={"code": code}))
|
|
|
|
| 182 |
return d.get("class_id", d)
|
| 183 |
|
| 184 |
def list_classes_for_student(student_id: int):
|
|
|
|
| 194 |
return _json_or_raise(_req("GET", f"/classes/{class_id}/students/{student_id}/progress"))
|
| 195 |
|
| 196 |
def leave_class(student_id: int, class_id: int):
|
|
|
|
| 197 |
_json_or_raise(_req("POST", f"/classes/{class_id}/leave", json={"student_id": student_id}))
|
| 198 |
return True
|
| 199 |
|
|
|
|
| 201 |
return _json_or_raise(_req("GET", f"/classes/{class_id}/students/{student_id}/assignments"))
|
| 202 |
|
| 203 |
# ---------- TEACHERS / CLASSES / CONTENT (BACKEND ROUTES THAT EXIST) ----------
|
|
|
|
|
|
|
| 204 |
def create_class(teacher_id: int, name: str):
|
| 205 |
return _try_candidates("POST", [
|
| 206 |
(f"/teachers/{teacher_id}/classes", {"json": {"name": name}}),
|
|
|
|
| 207 |
("/classes", {"json": {"teacher_id": teacher_id, "name": name}}),
|
| 208 |
])
|
| 209 |
|
|
|
|
| 213 |
])
|
| 214 |
|
| 215 |
def list_students_in_class(class_id: int):
|
|
|
|
| 216 |
return _json_or_raise(_req("GET", f"/classes/{class_id}/students"))
|
| 217 |
|
| 218 |
def class_content_counts(class_id: int):
|
|
|
|
| 233 |
def class_student_metrics(class_id: int):
|
| 234 |
return _try_candidates("GET", [
|
| 235 |
(f"/classes/{class_id}/students/metrics", {}),
|
|
|
|
| 236 |
(f"/classes/{class_id}/student_metrics", {}),
|
| 237 |
+
(f"/classes/{class_id}/students", {}),
|
| 238 |
])
|
| 239 |
|
| 240 |
def class_weekly_activity(class_id: int):
|
|
|
|
| 270 |
}
|
| 271 |
d = _try_candidates("POST", [
|
| 272 |
(f"/teachers/{teacher_id}/lessons", {"json": payload}),
|
|
|
|
| 273 |
("/lessons", {"json": {"teacher_id": teacher_id, **payload}}),
|
| 274 |
])
|
| 275 |
return d.get("lesson_id", d.get("id", d))
|
|
|
|
| 326 |
payload = {
|
| 327 |
"lesson_id": int(lesson_id) if lesson_id is not None else None,
|
| 328 |
"level_slug": level_slug,
|
| 329 |
+
"user_answers": user_answers,
|
| 330 |
+
"original_quiz": original_quiz,
|
| 331 |
}
|
| 332 |
return _try_candidates("POST", [
|
| 333 |
+
("/quiz/submit", {"json": payload}),
|
| 334 |
])
|
| 335 |
|
| 336 |
def update_quiz(quiz_id: int,
|
|
|
|
| 366 |
|
| 367 |
# ---------- LLM-based quiz generation (backend uses GEN_MODEL) ----------
|
| 368 |
def generate_quiz_from_text(content: str, n_questions: int = 5, subject: str = "finance", level: str = "beginner"):
|
|
|
|
|
|
|
|
|
|
|
|
|
| 369 |
return _req("POST", "/quiz/generate", json={
|
| 370 |
"content": content, "n_questions": n_questions, "subject": subject, "level": level
|
| 371 |
}).json()
|
|
|
|
| 376 |
"level_slug": level_slug,
|
| 377 |
"lesson_title": lesson_title,
|
| 378 |
}
|
| 379 |
+
logger.debug(f"Generating quiz with POST method and payload: {payload}")
|
| 380 |
try:
|
| 381 |
+
resp = _req("POST", "/quiz/auto", json=payload)
|
| 382 |
+
logger.debug(f"Raw response from /quiz/auto: {resp.text}")
|
| 383 |
+
result = _json_or_raise(resp)
|
| 384 |
except RuntimeError as e:
|
| 385 |
+
logger.error(f"Quiz generation failed: {str(e)}")
|
| 386 |
+
return [] # Return empty list to prevent app crash
|
| 387 |
|
| 388 |
+
if isinstance(result, dict):
|
| 389 |
+
return result.get("items") or result.get("quiz") or []
|
| 390 |
+
return result if isinstance(result, list) else []
|
| 391 |
|
| 392 |
# ---- Legacy agent endpoints (keep) ----
|
| 393 |
def start_agent(student_id: int, lesson_id: int, level_slug: str):
|