Kerikim commited on
Commit
73dcc9c
·
1 Parent(s): 88fdb8a

elkay: api.py, chatbot

Browse files
Files changed (1) hide show
  1. utils/api.py +457 -241
utils/api.py CHANGED
@@ -1,29 +1,22 @@
1
- """
2
- Frontend HTTP client for the FinBack backend (running on HF Spaces).
3
- - Uses a single requests.Session with retries
4
- - Exposes small helpers for tutor chat, retrieval, auth, classes, lessons, quizzes, games
5
- - Assumes the environment var BACKEND_URL is set in the Frontend Space
6
- """
7
- from __future__ import annotations
8
- import os
9
- import logging
10
- from typing import Any, Dict, List, Optional
11
- import requests
12
  from urllib3.util.retry import Retry
13
  from requests.adapters import HTTPAdapter
14
 
15
- # ----------------------------------------------------------------------------
16
- # Config & session
17
- # ----------------------------------------------------------------------------
18
  BACKEND = (os.getenv("BACKEND_URL") or "").strip().rstrip("/")
19
  if not BACKEND:
20
  raise RuntimeError("BACKEND_URL is not set in Space secrets.")
21
 
 
 
22
  TOKEN = (os.getenv("BACKEND_TOKEN") or os.getenv("HF_TOKEN") or "").strip()
 
 
23
  DEFAULT_TIMEOUT = int(os.getenv("BACKEND_TIMEOUT", "60"))
24
 
25
  _session = requests.Session()
26
- _retry = Retry(
 
27
  total=3,
28
  connect=3,
29
  read=3,
@@ -31,8 +24,9 @@ _retry = Retry(
31
  status_forcelist=(429, 500, 502, 503, 504),
32
  allowed_methods=frozenset(["GET", "POST", "PUT", "PATCH", "DELETE"]),
33
  )
34
- _session.mount("https://", HTTPAdapter(max_retries=_retry))
35
- _session.mount("http://", HTTPAdapter(max_retries=_retry))
 
36
  _session.headers.update({
37
  "Accept": "application/json, */*;q=0.1",
38
  "User-Agent": "FinEdu-Frontend/1.0 (+spaces)",
@@ -40,302 +34,524 @@ _session.headers.update({
40
  if TOKEN:
41
  _session.headers["Authorization"] = f"Bearer {TOKEN}"
42
 
 
 
43
  logger = logging.getLogger(__name__)
44
- logging.basicConfig(level=logging.INFO)
45
-
46
- # ----------------------------------------------------------------------------
47
- # Low-level HTTP helpers
48
- # ----------------------------------------------------------------------------
49
-
50
- def _json_or_raise(resp: requests.Response) -> Dict[str, Any] | List[Any]:
51
- """Return JSON or raise a readable error."""
52
- try:
53
- resp.raise_for_status()
54
- except requests.HTTPError as e:
55
- body = (getattr(resp, "text", "") or "")[:500]
56
- status = getattr(resp, "status_code", "?")
57
- if status in (401, 403):
58
- raise RuntimeError(
59
- f"Auth rejected [{status}]. Check BACKEND_TOKEN/HF_TOKEN and backend privacy settings. Body: {body}"
60
- ) from e
61
- raise RuntimeError(f"HTTP {status}: {body}") from e
62
 
 
63
  ctype = resp.headers.get("content-type", "")
64
  if "application/json" in ctype:
65
  return resp.json()
66
- # best-effort
67
  try:
68
  return resp.json()
69
  except Exception:
70
  snippet = (resp.text or "")[:300]
71
- raise RuntimeError(f"Expected JSON but got {ctype or 'unknown'}: {snippet}")
72
 
73
- def _req(method: str, path: str, **kw) -> requests.Response:
74
  if not path.startswith("/"):
75
  path = "/" + path
76
  url = f"{BACKEND}{path}"
77
  kw.setdefault("timeout", DEFAULT_TIMEOUT)
78
- return _session.request(method, url, **kw)
79
-
80
- # ----------------------------------------------------------------------------
81
- # Health
82
- # ----------------------------------------------------------------------------
83
-
84
- def health() -> Dict[str, Any]:
85
- return _json_or_raise(_req("GET", "/health")) # should return local_model, embedding_model
 
 
 
 
 
 
 
 
 
 
 
86
 
87
- # ----------------------------------------------------------------------------
88
- # RAG + Chat
89
- # ----------------------------------------------------------------------------
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
90
 
91
  def retrieve(query: str, lesson_id: int, level_slug: str = "beginner", k: int = 5) -> str:
92
- data = _json_or_raise(_req(
93
- "GET", "/retrieve",
94
- params={"query": query, "lesson_id": lesson_id, "level_slug": level_slug, "k": k},
95
- ))
96
- return str(data.get("context", ""))
97
 
98
- def chat_ai(query: str, lesson_id: int, level_slug: str, history: Optional[List[Dict[str, str]]] = None) -> str:
99
  payload = {
100
  "query": query,
101
- "lesson_id": int(lesson_id) if lesson_id is not None else 0,
102
  "level_slug": level_slug or "beginner",
103
- "history": history or [],
104
  }
105
- data = _json_or_raise(_req("POST", "/chat", json=payload, timeout=90))
106
- return str(data.get("answer", ""))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
107
 
108
- # ----------------------------------------------------------------------------
109
- # Auth
110
- # ----------------------------------------------------------------------------
111
 
112
- def login(email: str, password: str) -> Dict[str, Any]:
113
- return _json_or_raise(_req("POST", "/auth/login", json={"email": email, "password": password}))
 
 
114
 
115
- def signup_student(name: str, email: str, password: str, level_label: str, country_label: str) -> Dict[str, Any]:
116
- payload = {
117
- "name": name, "email": email, "password": password,
118
- "level_label": level_label, "country_label": country_label,
119
- }
120
- return _json_or_raise(_req("POST", "/auth/signup/student", json=payload))
 
 
121
 
122
- def signup_teacher(title: str, name: str, email: str, password: str) -> Dict[str, Any]:
123
- payload = {"title": title, "name": name, "email": email, "password": password}
124
- return _json_or_raise(_req("POST", "/auth/signup/teacher", json=payload))
125
 
126
- # ----------------------------------------------------------------------------
127
- # Classes
128
- # ----------------------------------------------------------------------------
129
 
130
- def create_class(teacher_id: int, name: str) -> Dict[str, Any]:
131
- return _json_or_raise(_req("POST", f"/teachers/{teacher_id}/classes", json={"name": name}))
132
 
133
- def list_classes_by_teacher(teacher_id: int) -> List[Dict[str, Any]]:
134
- return _json_or_raise(_req("GET", f"/teachers/{teacher_id}/classes"))
 
 
 
 
135
 
136
- def list_students_in_class(class_id: int) -> List[Dict[str, Any]]:
 
 
 
 
 
137
  return _json_or_raise(_req("GET", f"/classes/{class_id}/students"))
138
 
139
- def class_content_counts(class_id: int) -> Dict[str, Any]:
140
- return _json_or_raise(_req("GET", f"/classes/{class_id}/content_counts"))
 
 
 
141
 
142
- def list_class_assignments(class_id: int) -> List[Dict[str, Any]]:
143
  return _json_or_raise(_req("GET", f"/classes/{class_id}/assignments"))
144
 
145
- def class_analytics(class_id: int) -> Dict[str, Any]:
146
  return _json_or_raise(_req("GET", f"/classes/{class_id}/analytics"))
147
 
148
- def class_student_metrics(class_id: int) -> Dict[str, Any]:
149
- return _json_or_raise(_req("GET", f"/classes/{class_id}/students/metrics"))
150
-
151
- def student_class_progress(student_id: int, class_id: int) -> Dict[str, Any]:
152
- return _json_or_raise(_req("GET", f"/classes/{class_id}/students/{student_id}/progress"))
153
-
154
- def student_assignments_for_class(student_id: int, class_id: int) -> List[Dict[str, Any]]:
155
- return _json_or_raise(_req("GET", f"/classes/{class_id}/students/{student_id}/assignments"))
156
-
157
- def join_class_by_code(student_id: int, code: str) -> Dict[str, Any]:
158
- return _json_or_raise(_req("POST", f"/students/{student_id}/classes/join", json={"code": code}))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
159
 
160
- def leave_class(student_id: int, class_id: int) -> bool:
161
- _json_or_raise(_req("POST", f"/classes/{class_id}/leave", json={"student_id": student_id}))
162
- return True
163
-
164
- # ----------------------------------------------------------------------------
165
  # Lessons
166
- # ----------------------------------------------------------------------------
167
-
168
- def list_lessons_by_teacher(teacher_id: int) -> List[Dict[str, Any]]:
169
  return _json_or_raise(_req("GET", f"/teachers/{teacher_id}/lessons"))
170
 
171
- def create_lesson(teacher_id: int, title: str, description: str, subject: str, level: str, sections: List[Dict[str, Any]]) -> int:
172
- payload = {"title": title, "description": description, "subject": subject, "level": level, "sections": sections}
173
- d = _json_or_raise(_req("POST", f"/teachers/{teacher_id}/lessons", json=payload))
174
- return int(d.get("lesson_id", d.get("id", 0)))
 
 
 
 
 
 
 
 
 
 
175
 
176
- def get_lesson(lesson_id: int) -> Dict[str, Any]:
177
  return _json_or_raise(_req("GET", f"/lessons/{lesson_id}"))
178
 
179
- def update_lesson(lesson_id: int, teacher_id: int, title: str, description: str, subject: str, level: str, sections: List[Dict[str, Any]]) -> bool:
180
- d = _json_or_raise(_req("PUT", f"/lessons/{lesson_id}", json={
181
- "teacher_id": teacher_id, "title": title, "description": description,
182
- "subject": subject, "level": level, "sections": sections,
183
- }))
 
 
 
 
 
184
  return bool(d.get("ok", True))
185
 
186
- def delete_lesson(lesson_id: int, teacher_id: int) -> tuple[bool, str]:
187
- d = _json_or_raise(_req("DELETE", f"/lessons/{lesson_id}", json={"teacher_id": teacher_id}))
188
- return bool(d.get("ok", True)), str(d.get("message", ""))
189
-
190
- def list_assigned_students_for_lesson(lesson_id: int) -> List[Dict[str, Any]]:
191
- return _json_or_raise(_req("GET", f"/lessons/{lesson_id}/assignees"))
192
 
193
- # ----------------------------------------------------------------------------
194
  # Quizzes
195
- # ----------------------------------------------------------------------------
196
-
197
- def list_quizzes_by_teacher(teacher_id: int) -> List[Dict[str, Any]]:
198
  return _json_or_raise(_req("GET", f"/teachers/{teacher_id}/quizzes"))
199
 
200
- def create_quiz(lesson_id: int, title: str, items: List[Dict[str, Any]], settings: Dict[str, Any]) -> int:
201
- d = _json_or_raise(_req("POST", "/quizzes", json={"lesson_id": lesson_id, "title": title, "items": items, "settings": settings}))
202
- return int(d.get("quiz_id", d.get("id", 0)))
203
-
204
- def get_quiz(quiz_id: int) -> Dict[str, Any]:
205
- return _json_or_raise(_req("GET", f"/quizzes/{quiz_id}"))
206
-
207
- def update_quiz(quiz_id: int, teacher_id: int, title: str, items: List[Dict[str, Any]], settings: Dict[str, Any]) -> bool:
208
- d = _json_or_raise(_req("PUT", f"/quizzes/{quiz_id}", json={
209
- "teacher_id": teacher_id, "title": title, "items": items, "settings": settings,
210
- }))
211
- return bool(d.get("ok", True))
212
-
213
- def delete_quiz(quiz_id: int, teacher_id: int) -> tuple[bool, str]:
214
- d = _json_or_raise(_req("DELETE", f"/quizzes/{quiz_id}", json={"teacher_id": teacher_id}))
215
- return bool(d.get("ok", True)), str(d.get("message", ""))
216
 
217
- def list_assigned_students_for_quiz(quiz_id: int) -> List[Dict[str, Any]]:
218
- return _json_or_raise(_req("GET", f"/quizzes/{quiz_id}/assignees"))
 
 
219
 
220
- # Backend-side auto quiz generation from lesson chunks
 
 
 
221
 
222
- def generate_quiz(lesson_id: Optional[int], level_slug: Optional[str], lesson_title: Optional[str]) -> List[Dict[str, Any]]:
223
- payload = {"lesson_id": lesson_id, "level_slug": level_slug, "lesson_title": lesson_title}
224
- d = _json_or_raise(_req("POST", "/quiz/auto", json=payload))
225
- return d["items"] if isinstance(d, dict) and "items" in d else (d if isinstance(d, list) else [])
226
-
227
- # Submit quiz for grading (backend computes feedback with local model)
228
 
229
- def submit_quiz(student_id: int, lesson_id: int, level_slug: str,
230
- user_answers: List[Dict[str, Any]], original_quiz: List[Dict[str, Any]],
231
- assignment_id: Optional[int] = None) -> Dict[str, Any]:
 
 
 
232
  payload = {
233
- "student_id": student_id,
234
- "lesson_id": lesson_id,
235
  "level_slug": level_slug,
236
  "user_answers": user_answers,
237
  "original_quiz": original_quiz,
238
- "assignment_id": assignment_id,
239
  }
240
- return _json_or_raise(_req("POST", "/quiz/grade", json=payload, timeout=90))
241
-
242
- # Tutor explanation bullets for wrong answers
243
-
244
- def tutor_explain(lesson_id: int, level_slug: str, wrong: List[Dict[str, Any]]) -> Dict[str, Any]:
245
- return _json_or_raise(_req("POST", "/tutor/explain", json={
246
- "lesson_id": lesson_id, "level_slug": level_slug, "wrong": wrong
247
- }))
248
-
249
- # ----------------------------------------------------------------------------
250
- # Assignments & Progress
251
- # ----------------------------------------------------------------------------
252
-
253
- def assign_to_class(class_id: int, teacher_id: int, lesson_id: Optional[int] = None, quiz_id: Optional[int] = None) -> Dict[str, Any]:
254
- return _json_or_raise(_req("POST", "/assign", json={
255
- "class_id": class_id, "teacher_id": teacher_id, "lesson_id": lesson_id, "quiz_id": quiz_id,
256
- }))
257
 
258
- def mark_assignment_started(student_id: int, assignment_id: int) -> Dict[str, Any]:
259
- return _json_or_raise(_req("POST", f"/assignments/{assignment_id}/start", json={"student_id": student_id}))
 
260
 
261
- def set_assignment_progress(student_id: int, assignment_id: int, current_pos: int) -> Dict[str, Any]:
262
- return _json_or_raise(_req("PATCH", f"/assignments/{assignment_id}/progress", json={
263
- "student_id": student_id, "current_pos": current_pos,
264
- }))
265
 
266
- # ----------------------------------------------------------------------------
267
- # Student dashboard
268
- # ----------------------------------------------------------------------------
269
 
270
- def user_stats(student_id: int) -> Dict[str, Any]:
271
- return _json_or_raise(_req("GET", f"/students/{student_id}/stats"))
 
 
 
 
 
 
 
 
 
 
 
272
 
273
- def list_assignments_for_student(student_id: int) -> List[Dict[str, Any]]:
274
- return _json_or_raise(_req("GET", f"/students/{student_id}/assignments"))
 
 
 
275
 
276
- def student_quiz_average(student_id: int) -> int:
277
- d = _json_or_raise(_req("GET", f"/students/{student_id}/quiz_avg"))
278
- # Accept various shapes {"avg_pct": int} or similar
279
- if isinstance(d, dict):
280
- for k in ("avg_pct", "avg", "average", "score_pct", "score", "value"):
281
- if k in d:
282
- v = d[k]
283
- break
284
- else:
285
- v = next((vv for vv in d.values() if isinstance(vv, (int, float, str))), 0)
286
- else:
287
- v = d
288
  try:
289
- return int(round(float(str(v).strip().rstrip("%"))))
290
- except Exception:
291
- return 0
292
-
293
- def recent_lessons_for_student(student_id: int, limit: int = 5) -> List[Dict[str, Any]]:
294
- return _json_or_raise(_req("GET", f"/students/{student_id}/recent", params={"limit": int(limit)}))
295
-
296
- def list_classes_for_student(student_id: int) -> List[Dict[str, Any]]:
297
- return _json_or_raise(_req("GET", f"/students/{student_id}/classes"))
298
-
299
- # ----------------------------------------------------------------------------
300
- # Games
301
- # ----------------------------------------------------------------------------
302
-
303
- def record_money_match_play(user_id: int, target: int, total: int, elapsed_ms: int, matched: bool, gained_xp: Optional[int] = None) -> Dict[str, Any]:
304
- payload = {"user_id": user_id, "target": target, "total": total, "elapsed_ms": elapsed_ms, "matched": matched}
305
- if gained_xp is not None:
306
- payload["gained_xp"] = int(gained_xp)
307
- return _json_or_raise(_req("POST", "/games/money_match/record", json=payload))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
308
 
309
- def record_budget_builder_play(user_id: int, weekly_allowance: int, allocations: List[Dict[str, Any]], gained_xp: Optional[int] = None) -> Dict[str, Any]:
310
- payload = {"user_id": user_id, "weekly_allowance": weekly_allowance, "allocations": allocations}
311
- if gained_xp is not None:
312
- payload["gained_xp"] = int(gained_xp)
313
- return _json_or_raise(_req("POST", "/games/budget_builder/record", json=payload))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
314
 
315
- def record_debt_dilemma_play(user_id: int, level: int, round_no: int, wallet: int, health: int, happiness: int, credit_score: int, event_json: Dict[str, Any], outcome: str, gained_xp: Optional[int] = None) -> Dict[str, Any]:
 
316
  payload = {
317
- "user_id": user_id, "level": level, "round_no": round_no, "wallet": wallet,
318
- "health": health, "happiness": happiness, "credit_score": credit_score,
319
- "event_json": event_json, "outcome": outcome,
 
 
 
320
  }
321
- if gained_xp is not None:
322
- payload["gained_xp"] = int(gained_xp)
323
- return _json_or_raise(_req("POST", "/games/debt_dilemma/record", json=payload))
324
 
325
- def record_profit_puzzler_play(user_id: int, scenario_id: str, title: str, units: int, price: int, cost: int, user_answer: float, actual_profit: float, is_correct: bool, gained_xp: Optional[int] = None) -> Dict[str, Any]:
 
326
  payload = {
327
- "user_id": user_id, "scenario_id": scenario_id, "title": title, "units": units,
328
- "price": price, "cost": cost, "user_answer": user_answer, "actual_profit": actual_profit,
329
- "is_correct": is_correct,
 
 
330
  }
331
- if gained_xp is not None:
332
- payload["gained_xp"] = int(gained_xp)
333
- return _json_or_raise(_req("POST", "/games/profit_puzzler/record", json=payload))
334
 
335
- # ----------------------------------------------------------------------------
336
- # Utilities
337
- # ----------------------------------------------------------------------------
 
 
338
 
339
- def level_from_xp(xp: int) -> int:
340
- d = _json_or_raise(_req("GET", "/level-from-xp", params={"xp": int(xp)}))
341
- return int(d.get("level", 1))
 
 
 
 
 
 
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
+ BACKEND_URL = os.getenv("BACKEND_URL")
11
+
12
  TOKEN = (os.getenv("BACKEND_TOKEN") or os.getenv("HF_TOKEN") or "").strip()
13
+
14
+ # Increased timeout for potential late-night latency
15
  DEFAULT_TIMEOUT = int(os.getenv("BACKEND_TIMEOUT", "60"))
16
 
17
  _session = requests.Session()
18
+
19
+ retry = Retry(
20
  total=3,
21
  connect=3,
22
  read=3,
 
24
  status_forcelist=(429, 500, 502, 503, 504),
25
  allowed_methods=frozenset(["GET", "POST", "PUT", "PATCH", "DELETE"]),
26
  )
27
+ _session.mount("https://", HTTPAdapter(max_retries=retry))
28
+ _session.mount("http://", HTTPAdapter(max_retries=retry))
29
+
30
  _session.headers.update({
31
  "Accept": "application/json, */*;q=0.1",
32
  "User-Agent": "FinEdu-Frontend/1.0 (+spaces)",
 
34
  if TOKEN:
35
  _session.headers["Authorization"] = f"Bearer {TOKEN}"
36
 
37
+ # Setup logging for debugging
38
+ logging.basicConfig(level=logging.DEBUG)
39
  logger = logging.getLogger(__name__)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
40
 
41
+ def _json_or_raise(resp: requests.Response):
42
  ctype = resp.headers.get("content-type", "")
43
  if "application/json" in ctype:
44
  return resp.json()
 
45
  try:
46
  return resp.json()
47
  except Exception:
48
  snippet = (resp.text or "")[:300]
49
+ raise RuntimeError(f"Expected JSON but got {ctype or 'unknown'}:\n{snippet}")
50
 
51
+ def _req(method: str, path: str, **kw):
52
  if not path.startswith("/"):
53
  path = "/" + path
54
  url = f"{BACKEND}{path}"
55
  kw.setdefault("timeout", DEFAULT_TIMEOUT)
56
+ try:
57
+ r = _session.request(method, url, **kw)
58
+ r.raise_for_status()
59
+ except requests.HTTPError as e:
60
+ body = ""
61
+ try:
62
+ body = r.text[:500]
63
+ except Exception:
64
+ pass
65
+ status = getattr(r, "status_code", "?")
66
+ if status in (401, 403):
67
+ raise RuntimeError(
68
+ f"{method} {path} failed [{status}] – auth rejected. "
69
+ f"Check BACKEND_TOKEN/HF_TOKEN permissions and that the backend Space is private/readable."
70
+ ) from e
71
+ raise RuntimeError(f"{method} {path} failed [{status}]: {body}") from e
72
+ except requests.RequestException as e:
73
+ raise RuntimeError(f"{method} {path} failed: {e.__class__.__name__}: {e}") from e
74
+ return r
75
 
76
+ # ---- Health ----
77
+ def health():
78
+ try:
79
+ return _json_or_raise(_req("GET", "/health"))
80
+ except Exception:
81
+ try:
82
+ _req("GET", "/")
83
+ return {"ok": True}
84
+ except Exception:
85
+ return {"ok": False}
86
+
87
+ # --- Optional API prefix (e.g., "/api" or "/v1")
88
+ API_PREFIX_ENV = (os.getenv("BACKEND_API_PREFIX") or "").strip().rstrip("/")
89
+
90
+ def _prefixes():
91
+ if API_PREFIX_ENV:
92
+ return ["/" + API_PREFIX_ENV.strip("/"), ""]
93
+ return [""]
94
+
95
+ def _try_candidates(method: str, candidates: list[tuple[str, dict]]):
96
+ tried = []
97
+ for pref in _prefixes():
98
+ for path, kw in candidates:
99
+ url = f"{BACKEND}{pref}{path}"
100
+ tried.append(f"{method} {url}")
101
+ try:
102
+ r = _session.request(method, url, timeout=DEFAULT_TIMEOUT, **kw)
103
+ except requests.RequestException as e:
104
+ continue
105
+ if r.status_code in (401, 403):
106
+ snippet = (r.text or "")[:200]
107
+ raise RuntimeError(f"{method} {path} auth failed [{r.status_code}]: {snippet}")
108
+ if 200 <= r.status_code < 300:
109
+ return _json_or_raise(r)
110
+ raise RuntimeError("No matching endpoint for this operation. Tried:\n- " + "\n- ".join(tried))
111
+
112
+
113
+
114
+
115
+ class _BackendError(Exception):
116
+ pass
117
+
118
+ def _get(url, params=None, timeout=30):
119
+ if not BACKEND_URL:
120
+ raise _BackendError("BACKEND_URL is not set")
121
+ r = requests.get(f"{BACKEND_URL}{url}", params=params or {}, timeout=timeout)
122
+ r.raise_for_status()
123
+ return r.json()
124
+
125
+ def _post(url, json=None, timeout=60):
126
+ if not BACKEND_URL:
127
+ raise _BackendError("BACKEND_URL is not set")
128
+ r = requests.post(f"{BACKEND_URL}{url}", json=json or {}, timeout=timeout)
129
+ r.raise_for_status()
130
+ return r.json()
131
 
132
  def retrieve(query: str, lesson_id: int, level_slug: str = "beginner", k: int = 5) -> str:
133
+ try:
134
+ data = _get("/retrieve", params={"query": query, "lesson_id": lesson_id, "level_slug": level_slug, "k": k})
135
+ return data.get("context", "")
136
+ except Exception as e:
137
+ return f"(retrieval failed: {e})"
138
 
139
+ def chat_ai(query: str, lesson_id: int, level_slug: str, history=None) -> str:
140
  payload = {
141
  "query": query,
142
+ "lesson_id": lesson_id,
143
  "level_slug": level_slug or "beginner",
144
+ "history": history or []
145
  }
146
+ try:
147
+ data = _post("/chat", json=payload, timeout=90)
148
+ return data.get("answer", "")
149
+ except Exception as e:
150
+ return f"(chat failed: {e})"
151
+
152
+
153
+
154
+ # -- Helpers for student_db.py
155
+ def user_stats(student_id: int):
156
+ return _req("GET", f"/students/{student_id}/stats").json()
157
+ def list_assignments_for_student(student_id: int):
158
+ return _req("GET", f"/students/{student_id}/assignments").json()
159
+ def student_quiz_average(student_id: int):
160
+ d = _req("GET", f"/students/{student_id}/quiz_avg").json()
161
+ if isinstance(d, dict):
162
+ for k in ("avg", "average", "score_pct", "score", "value"):
163
+ if k in d:
164
+ v = d[k]
165
+ break
166
+ else:
167
+ v = next((vv for vv in d.values() if isinstance(vv, (int, float, str))), 0)
168
+ else:
169
+ v = d
170
+ try:
171
+ return int(round(float(str(v).strip().rstrip("%"))))
172
+ except Exception:
173
+ return 0
174
+ def recent_lessons_for_student(student_id: int, limit: int = 5):
175
+ return _req("GET", f"/students/{student_id}/recent", params={"limit": limit}).json()
176
+
177
+ # -- Teacher endpoints (backend Space)
178
+ def create_class(teacher_id: int, name: str):
179
+ return _try_candidates("POST", [
180
+ (f"/teachers/{teacher_id}/classes", {"json": {"name": name}}),
181
+ ("/classes", {"json": {"teacher_id": teacher_id, "name": name}}),
182
+ ])
183
+
184
+ def teacher_tiles(teacher_id: int):
185
+ return _json_or_raise(_req("GET", f"/teachers/{teacher_id}/tiles"))
186
+
187
+ def list_classes_by_teacher(teacher_id: int):
188
+ return _try_candidates("GET", [
189
+ (f"/teachers/{teacher_id}/classes", {}),
190
+ ])
191
+
192
+ def class_student_metrics(class_id: int):
193
+ return _try_candidates("GET", [
194
+ (f"/classes/{class_id}/students/metrics", {}),
195
+ (f"/classes/{class_id}/student_metrics", {}),
196
+ (f"/classes/{class_id}/students", {}),
197
+ ])
198
+
199
+ def class_weekly_activity(class_id: int):
200
+ return _try_candidates("GET", [
201
+ (f"/classes/{class_id}/activity/weekly", {}),
202
+ (f"/classes/{class_id}/weekly_activity", {}),
203
+ ])
204
+
205
+ def class_progress_overview(class_id: int):
206
+ return _try_candidates("GET", [
207
+ (f"/classes/{class_id}/progress", {}),
208
+ (f"/classes/{class_id}/progress_overview", {}),
209
+ ])
210
+
211
+ def class_recent_activity(class_id: int, limit=6, days=30):
212
+ return _try_candidates("GET", [
213
+ (f"/classes/{class_id}/activity/recent", {"params": {"limit": limit, "days": days}}),
214
+ (f"/classes/{class_id}/recent_activity", {"params": {"limit": limit, "days": days}}),
215
+ ])
216
+
217
+ def list_students_in_class(class_id: int):
218
+ return _json_or_raise(_req("GET", f"/classes/{class_id}/students"))
219
 
220
+ def level_from_xp(xp: int):
221
+ return _json_or_raise(_req("GET", "/levels/from_xp", params={"xp": xp}))["level"]
 
222
 
223
+ # -- teacherlink.py helpers
224
+ def join_class_by_code(student_id: int, code: str):
225
+ d = _json_or_raise(_req("POST", f"/students/{student_id}/classes/join", json={"code": code}))
226
+ return d.get("class_id", d)
227
 
228
+ def list_classes_for_student(student_id: int):
229
+ return _json_or_raise(_req("GET", f"/students/{student_id}/classes"))
230
+
231
+ def class_content_counts(class_id: int):
232
+ return _try_candidates("GET", [
233
+ (f"/classes/{class_id}/content_counts", {}),
234
+ (f"/classes/{class_id}/counts", {}),
235
+ ])
236
 
237
+ def student_class_progress(student_id: int, class_id: int):
238
+ return _json_or_raise(_req("GET", f"/classes/{class_id}/students/{student_id}/progress"))
 
239
 
240
+ def leave_class(student_id: int, class_id: int):
241
+ _json_or_raise(_req("POST", f"/classes/{class_id}/leave", json={"student_id": student_id}))
242
+ return True
243
 
244
+ def student_assignments_for_class(student_id: int, class_id: int):
245
+ return _json_or_raise(_req("GET", f"/classes/{class_id}/students/{student_id}/assignments"))
246
 
247
+ # ---------- TEACHERS / CLASSES / CONTENT (BACKEND ROUTES THAT EXIST) ----------
248
+ def create_class(teacher_id: int, name: str):
249
+ return _try_candidates("POST", [
250
+ (f"/teachers/{teacher_id}/classes", {"json": {"name": name}}),
251
+ ("/classes", {"json": {"teacher_id": teacher_id, "name": name}}),
252
+ ])
253
 
254
+ def list_classes_by_teacher(teacher_id: int):
255
+ return _try_candidates("GET", [
256
+ (f"/teachers/{teacher_id}/classes", {}),
257
+ ])
258
+
259
+ def list_students_in_class(class_id: int):
260
  return _json_or_raise(_req("GET", f"/classes/{class_id}/students"))
261
 
262
+ def class_content_counts(class_id: int):
263
+ return _try_candidates("GET", [
264
+ (f"/classes/{class_id}/content_counts", {}),
265
+ (f"/classes/{class_id}/counts", {}),
266
+ ])
267
 
268
+ def list_class_assignments(class_id: int):
269
  return _json_or_raise(_req("GET", f"/classes/{class_id}/assignments"))
270
 
271
+ def class_analytics(class_id: int):
272
  return _json_or_raise(_req("GET", f"/classes/{class_id}/analytics"))
273
 
274
+ def teacher_tiles(teacher_id: int):
275
+ return _json_or_raise(_req("GET", f"/teachers/{teacher_id}/tiles"))
276
+
277
+ def class_student_metrics(class_id: int):
278
+ return _try_candidates("GET", [
279
+ (f"/classes/{class_id}/students/metrics", {}),
280
+ (f"/classes/{class_id}/student_metrics", {}),
281
+ (f"/classes/{class_id}/students", {}),
282
+ ])
283
+
284
+ def class_weekly_activity(class_id: int):
285
+ return _try_candidates("GET", [
286
+ (f"/classes/{class_id}/activity/weekly", {}),
287
+ (f"/classes/{class_id}/weekly_activity", {}),
288
+ ])
289
+
290
+ def class_progress_overview(class_id: int):
291
+ return _try_candidates("GET", [
292
+ (f"/classes/{class_id}/progress", {}),
293
+ (f"/classes/{class_id}/progress_overview", {}),
294
+ ])
295
+
296
+ def class_recent_activity(class_id: int, limit=6, days=30):
297
+ return _try_candidates("GET", [
298
+ (f"/classes/{class_id}/activity/recent", {"params": {"limit": limit, "days": days}}),
299
+ (f"/classes/{class_id}/recent_activity", {"params": {"limit": limit, "days": days}}),
300
+ ])
301
 
 
 
 
 
 
302
  # Lessons
303
+ def list_lessons_by_teacher(teacher_id: int):
 
 
304
  return _json_or_raise(_req("GET", f"/teachers/{teacher_id}/lessons"))
305
 
306
+ def create_lesson(teacher_id: int, title: str, description: str,
307
+ subject: str, level: str, sections: list[dict]):
308
+ payload = {
309
+ "title": title,
310
+ "description": description,
311
+ "subject": subject,
312
+ "level": level,
313
+ "sections": sections,
314
+ }
315
+ d = _try_candidates("POST", [
316
+ (f"/teachers/{teacher_id}/lessons", {"json": payload}),
317
+ ("/lessons", {"json": {"teacher_id": teacher_id, **payload}}),
318
+ ])
319
+ return d.get("lesson_id", d.get("id", d))
320
 
321
+ def get_lesson(lesson_id: int):
322
  return _json_or_raise(_req("GET", f"/lessons/{lesson_id}"))
323
 
324
+ def update_lesson(lesson_id: int, teacher_id: int, title: str, description: str,
325
+ subject: str, level: str, sections: list[dict]):
326
+ d = _req("PUT", f"/lessons/{lesson_id}", json={
327
+ "teacher_id": teacher_id,
328
+ "title": title,
329
+ "description": description,
330
+ "subject": subject,
331
+ "level": level,
332
+ "sections": sections
333
+ }).json()
334
  return bool(d.get("ok", True))
335
 
336
+ def delete_lesson(lesson_id: int, teacher_id: int):
337
+ d = _req("DELETE", f"/lessons/{lesson_id}", json={"teacher_id": teacher_id}).json()
338
+ return bool(d.get("ok", True)), d.get("message", "")
 
 
 
339
 
 
340
  # Quizzes
341
+ def list_quizzes_by_teacher(teacher_id: int):
 
 
342
  return _json_or_raise(_req("GET", f"/teachers/{teacher_id}/quizzes"))
343
 
344
+ def create_quiz(lesson_id: int, title: str, items: list[dict], settings: dict):
345
+ d = _req("POST", "/quizzes", json={
346
+ "lesson_id": lesson_id, "title": title, "items": items, "settings": settings
347
+ }).json()
348
+ return d.get("quiz_id", d.get("id", d))
 
 
 
 
 
 
 
 
 
 
 
349
 
350
+ def mark_assignment_started(student_id: int, assignment_id: int):
351
+ return _req("POST",
352
+ f"/assignments/{assignment_id}/start",
353
+ json={"student_id": student_id}).json()
354
 
355
+ def set_assignment_progress(student_id: int, assignment_id: int, current_pos: int, progress: float):
356
+ return _req("PATCH",
357
+ f"/assignments/{assignment_id}/progress",
358
+ json={"student_id": student_id, "current_pos": current_pos, "progress": progress}).json()
359
 
360
+ def get_quiz(quiz_id: int):
361
+ """Fetch a teacher-created quiz (GET /quizzes/{quiz_id}) and return JSON."""
362
+ return _json_or_raise(_req("GET", f"/quizzes/{quiz_id}"))
 
 
 
363
 
364
+ def submit_quiz(*, lesson_id: int | None, level_slug: str | None,
365
+ user_answers: list[dict], original_quiz: list[dict]):
366
+ """
367
+ Grade the quiz on the backend. Returns:
368
+ {"score":{"correct":int,"total":int}, "wrong":[...], "feedback":str}
369
+ """
370
  payload = {
371
+ "lesson_id": int(lesson_id) if lesson_id is not None else None,
 
372
  "level_slug": level_slug,
373
  "user_answers": user_answers,
374
  "original_quiz": original_quiz,
 
375
  }
376
+ return _try_candidates("POST", [
377
+ ("/quiz/submit", {"json": payload}),
378
+ ])
379
+
380
+ def update_quiz(quiz_id: int,
381
+ teacher_id: int, title: str, items: list[dict], settings: dict):
382
+ d = _req("PUT", f"/quizzes/{quiz_id}", json={
383
+ "teacher_id": teacher_id, "title": title, "items": items, "settings": settings
384
+ }).json()
385
+ return bool(d.get("ok", True))
 
 
 
 
 
 
 
386
 
387
+ def delete_quiz(quiz_id: int, teacher_id: int):
388
+ d = _req("DELETE", f"/quizzes/{quiz_id}", json={"teacher_id": teacher_id}).json()
389
+ return bool(d.get("ok", True)), d.get("message", "")
390
 
391
+ def list_assigned_students_for_lesson(lesson_id: int):
392
+ return _json_or_raise(_req("GET", f"/lessons/{lesson_id}/assignees"))
 
 
393
 
394
+ def list_assigned_students_for_quiz(quiz_id: int):
395
+ return _json_or_raise(_req("GET", f"/quizzes/{quiz_id}/assignees"))
 
396
 
397
+ # Assignments
398
+ def assign_to_class(lesson_id: int | None, quiz_id: int | None, class_id: int, teacher_id: int, due_at: str | None = None):
399
+ d = _try_candidates("POST", [
400
+ ("/assign", {"json": {
401
+ "lesson_id": lesson_id, "quiz_id": quiz_id,
402
+ "class_id": class_id, "teacher_id": teacher_id, "due_at": due_at
403
+ }}),
404
+ ("/assignments", {"json": {
405
+ "lesson_id": lesson_id, "quiz_id": quiz_id,
406
+ "class_id": class_id, "teacher_id": teacher_id, "due_at": due_at
407
+ }}),
408
+ ])
409
+ return bool(d.get("ok", True))
410
 
411
+ # ---------- LLM-based quiz generation (backend uses GEN_MODEL) ----------
412
+ def generate_quiz_from_text(content: str, n_questions: int = 5, subject: str = "finance", level: str = "beginner"):
413
+ return _req("POST", "/quiz/generate", json={
414
+ "content": content, "n_questions": n_questions, "subject": subject, "level": level
415
+ }).json()
416
 
417
+ def generate_quiz(*, lesson_id: int | None, level_slug: str | None, lesson_title: str | None):
418
+ payload = {
419
+ "lesson_id": int(lesson_id) if lesson_id is not None else None,
420
+ "level_slug": level_slug,
421
+ "lesson_title": lesson_title,
422
+ }
423
+ logger.debug(f"Generating quiz with POST method and payload: {payload}")
 
 
 
 
 
424
  try:
425
+ resp = _req("POST", "/quiz/auto", json=payload)
426
+ logger.debug(f"Raw response from /quiz/auto: {resp.text}")
427
+ logger.debug(f"Response status code: {resp.status_code}")
428
+ result = _json_or_raise(resp)
429
+ # Ensure result is a list of quiz items, handling Qwen's potential JSON structure
430
+ if isinstance(result, dict) and "items" in result:
431
+ return result["items"]
432
+ return result if isinstance(result, list) else []
433
+ except requests.HTTPError as e:
434
+ logger.error(f"HTTP Error in quiz generation: {e.response.status_code} - {e.response.text}")
435
+ return []
436
+ except RuntimeError as e:
437
+ logger.error(f"Quiz generation failed: {str(e)}")
438
+ return [] # Return empty list to prevent app crash
439
+
440
+ # ---- Legacy agent endpoints (keep) ----
441
+ def start_agent(student_id: int, lesson_id: int, level_slug: str):
442
+ return _json_or_raise(_req("POST", "/agent/start",
443
+ json={"student_id": student_id, "lesson_id": lesson_id, "level_slug": level_slug}))
444
+
445
+ def get_agent_quiz(student_id: int, lesson_id: int, level_slug: str):
446
+ d = _json_or_raise(_req("POST", "/agent/quiz",
447
+ json={"student_id": student_id, "lesson_id": lesson_id, "level_slug": level_slug}))
448
+ return d["items"]
449
+
450
+ def grade_quiz(student_id: int, lesson_id: int, level_slug: str,
451
+ answers: list[str], assignment_id: int | None = None):
452
+ d = _json_or_raise(_req("POST", "/agent/grade",
453
+ json={"student_id": student_id, "lesson_id": lesson_id, "level_slug": level_slug,
454
+ "answers": answers, "assignment_id": assignment_id}))
455
+ return d["score"], d["total"]
456
+
457
+ def next_step(student_id: int, lesson_id: int, level_slug: str,
458
+ answers: list[str], assignment_id: int | None = None):
459
+ return _json_or_raise(_req("POST", "/agent/coach_or_celebrate",
460
+ json={"student_id": student_id, "lesson_id": lesson_id, "level_slug": level_slug,
461
+ "answers": answers, "assignment_id": assignment_id}))
462
+
463
+ # ---- Auth ----
464
+ def login(email: str, password: str):
465
+ return _json_or_raise(_req("POST", "/auth/login", json={"email": email, "password": password}))
466
 
467
+ def signup_student(name: str, email: str, password: str, level_label: str, country_label: str):
468
+ payload_student = {
469
+ "name": name, "email": email, "password": password,
470
+ "level_label": level_label, "country_label": country_label
471
+ }
472
+ return _try_candidates("POST", [
473
+ ("/auth/signup/student", {"json": payload_student}),
474
+ ("/auth/register", {"json": {
475
+ "role": "student", "name": name, "email": email, "password": password,
476
+ "level": level_label, "country": country_label
477
+ }}),
478
+ ])
479
+
480
+ def signup_teacher(title: str, name: str, email: str, password: str):
481
+ payload_teacher = {"title": title, "name": name, "email": email, "password": password}
482
+ return _try_candidates("POST", [
483
+ ("/auth/signup/teacher", {"json": payload_teacher}),
484
+ ("/auth/register", {"json": {
485
+ "role": "teacher", "title": title, "name": name, "email": email, "password": password
486
+ }}),
487
+ ])
488
+
489
+ # ---- New LangGraph-backed endpoints ----
490
+ def fetch_lesson_content(lesson: str, module: str, topic: str):
491
+ r = _json_or_raise(_req("POST", "/lesson",
492
+ json={"lesson": lesson, "module": module, "topic": topic}))
493
+ return r["lesson_content"]
494
+
495
+ def submit_lesson_quiz(lesson: str, module: str, topic: str, responses: dict):
496
+ return _json_or_raise(_req("POST", "/lesson-quiz",
497
+ json={"lesson": lesson, "module": module, "topic": topic, "responses": responses}))
498
+
499
+ def submit_practice_quiz(lesson: str, responses: dict):
500
+ return _json_or_raise(_req("POST", "/practice-quiz",
501
+ json={"lesson": lesson, "responses": responses}))
502
+
503
+ def send_to_chatbot(messages: list[dict]):
504
+ return _json_or_raise(_req("POST", "/chatbot", json={"messages": messages}))
505
+
506
+ # --- Game API helpers ---
507
+ def record_money_match_play(user_id: int, target: int, total: int,
508
+ elapsed_ms: int, matched: bool, gained_xp: int):
509
+ payload = {
510
+ "user_id": user_id, "target": target, "total": total,
511
+ "elapsed_ms": elapsed_ms, "matched": matched, "gained_xp": gained_xp,
512
+ }
513
+ return _try_candidates("POST", [
514
+ ("/games/money_match/record", {"json": payload}),
515
+ ])
516
 
517
+ def record_budget_builder_play(user_id: int, weekly_allowance: int, budget_score: int,
518
+ elapsed_ms: int, allocations: list[dict], gained_xp: int | None):
519
  payload = {
520
+ "user_id": user_id,
521
+ "weekly_allowance": weekly_allowance,
522
+ "budget_score": budget_score,
523
+ "elapsed_ms": elapsed_ms,
524
+ "allocations": allocations,
525
+ "gained_xp": gained_xp,
526
  }
527
+ return _try_candidates("POST", [
528
+ ("/games/budget_builder/record", {"json": payload}),
529
+ ])
530
 
531
+ def record_debt_dilemma_play(user_id: int, loans_cleared: int,
532
+ mistakes: int, elapsed_ms: int, gained_xp: int):
533
  payload = {
534
+ "user_id": user_id,
535
+ "loans_cleared": loans_cleared,
536
+ "mistakes": mistakes,
537
+ "elapsed_ms": elapsed_ms,
538
+ "gained_xp": gained_xp,
539
  }
540
+ return _try_candidates("POST", [
541
+ ("/games/debt_dilemma/record", {"json": payload}),
542
+ ])
543
 
544
+ def record_profit_puzzler_play(user_id: int, puzzles_solved: int, mistakes: int, elapsed_ms: int, gained_xp: int | None = None):
545
+ payload = {"user_id": user_id, "puzzles_solved": puzzles_solved, "mistakes": mistakes, "elapsed_ms": elapsed_ms}
546
+ if gained_xp is not None:
547
+ payload["gained_xp"] = gained_xp
548
+ return _try_candidates("POST", [("/games/profit_puzzler/record", {"json": payload})])
549
 
550
+ # --- Tutor Explanation ---
551
+ def tutor_explain(lesson_id: int, level_slug: str, wrong: list[dict]):
552
+ payload = {
553
+ "lesson_id": lesson_id,
554
+ "level_slug": level_slug,
555
+ "wrong": wrong
556
+ }
557
+ return _json_or_raise(_req("POST", "/tutor/explain", json=payload, timeout=60))