Kerikim commited on
Commit
579d254
·
1 Parent(s): fbe1d49

elkay: api.py

Browse files
Files changed (1) hide show
  1. 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
- DEFAULT_TIMEOUT = int(os.getenv("BACKEND_TIMEOUT", "30"))
 
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", {}), # older shape (list of 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", {}), # older shape (list of 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, # [{"question":"...", "answer":"A"}...]
356
- "original_quiz": original_quiz, # [{"question","options","answer_key"}...]
357
  }
358
  return _try_candidates("POST", [
359
- ("/quiz/submit", {"json": payload}), # Updated to match backend
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 = _try_candidates("POST", [
412
- ("/quiz/auto", {"json": payload}),
413
- ])
414
  except RuntimeError as e:
415
- raise RuntimeError(f"Failed to generate quiz: {str(e)}")
 
416
 
417
- if isinstance(resp, dict):
418
- return resp.get("items") or resp.get("quiz") or []
419
- return resp if isinstance(resp, list) else []
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):