Kerikim commited on
Commit
88fdb8a
·
1 Parent(s): 869dff4

elkay: api.py, chatbot

Browse files
Files changed (2) hide show
  1. phase/Student_view/chatbot.py +29 -103
  2. utils/api.py +246 -418
phase/Student_view/chatbot.py CHANGED
@@ -1,104 +1,28 @@
1
  # phase/Student_view/chatbot.py
2
  import streamlit as st
3
- import datetime, os, traceback
4
- from huggingface_hub import InferenceClient
5
-
6
- HF_TOKEN = os.getenv("HF_TOKEN")
7
- GEN_MODEL = os.getenv("GEN_MODEL", "TinyLlama/TinyLlama-1.1B-Chat-v1.0") # <- default TinyLlama
8
-
9
- if not HF_TOKEN:
10
- st.error("⚠️ HF_TOKEN is not set. In your Space, add a Secret named HF_TOKEN.")
11
- else:
12
- client = InferenceClient(model=GEN_MODEL, token=HF_TOKEN, timeout=60)
13
 
14
  TUTOR_PROMPT = (
15
  "You are a kind Jamaican primary-school finance tutor. "
16
  "Keep answers short, friendly, and age-appropriate. "
17
- "Teach step-by-step with tiny examples. Avoid giving personal financial advice."
 
 
18
  )
19
 
20
-
21
-
22
- # -------------------------------
23
- # History helpers
24
- # -------------------------------
25
- def _format_history_for_flan(messages: list[dict]) -> str:
26
- """Format history for text-generation style models."""
27
- lines = []
28
- for m in messages:
29
- txt = (m.get("text") or "").strip()
30
- if not txt:
31
- continue
32
- lines.append(("Tutor" if m.get("sender") == "assistant" else "User") + f": {txt}")
33
- return "\n".join(lines)
34
-
35
- def _history_as_chat_messages(messages: list[dict]) -> list[dict]:
36
- """Convert history to chat-completion style messages."""
37
  msgs = [{"role": "system", "content": TUTOR_PROMPT}]
38
  for m in messages:
39
  txt = (m.get("text") or "").strip()
40
  if not txt:
41
  continue
42
- role = "assistant" if m.get("sender") == "assistant" else "user"
43
  msgs.append({"role": role, "content": txt})
44
  return msgs
45
 
46
- def _extract_chat_text(chat_resp) -> str:
47
- """Extract text from HF chat response."""
48
- try:
49
- return chat_resp.choices[0].message["content"] if isinstance(
50
- chat_resp.choices[0].message, dict
51
- ) else chat_resp.choices[0].message.content
52
- except Exception:
53
- try:
54
- return chat_resp["choices"][0]["message"]["content"]
55
- except Exception:
56
- return str(chat_resp)
57
-
58
- # -------------------------------
59
- # Reply logic
60
- # -------------------------------
61
- def _reply_with_hf():
62
- if "client" not in globals():
63
- raise RuntimeError("HF client not initialized")
64
-
65
- try:
66
- # 1) Prefer chat API
67
- msgs = _history_as_chat_messages(st.session_state.get("messages", []))
68
- chat = client.chat.completions.create(
69
- model=GEN_MODEL,
70
- messages=msgs,
71
- max_tokens=300, # give enough room
72
- temperature=0.2,
73
- top_p=0.9,
74
- )
75
- return _extract_chat_text(chat).strip()
76
-
77
- except ValueError as ve:
78
- # 2) Fallback to text-generation if chat unsupported
79
- if "Supported task: text-generation" in str(ve):
80
- convo = _format_history_for_flan(st.session_state.get("messages", []))
81
- tg_prompt = f"{TUTOR_PROMPT}\n\n{convo}\n\nTutor:"
82
- resp = client.text_generation(
83
- tg_prompt,
84
- max_new_tokens=300,
85
- temperature=0.2,
86
- top_p=0.9,
87
- repetition_penalty=1.1,
88
- return_full_text=True,
89
- stream=False,
90
- )
91
- return (resp.get("generated_text") if isinstance(resp, dict) else resp).strip()
92
-
93
- raise # rethrow anything else
94
-
95
- except Exception as e:
96
- err_text = ''.join(traceback.format_exception_only(type(e), e)).strip()
97
- raise RuntimeError(f"Hugging Face API Error: {err_text}")
98
-
99
- # -------------------------------
100
- # Session message helper
101
- # -------------------------------
102
  def add_message(text: str, sender: str):
103
  if "messages" not in st.session_state:
104
  st.session_state.messages = []
@@ -114,20 +38,16 @@ def add_message(text: str, sender: str):
114
  def _coerce_ts(ts):
115
  if isinstance(ts, datetime.datetime):
116
  return ts
117
- if isinstance(ts, (int, float)):
118
- try:
119
  return datetime.datetime.fromtimestamp(ts)
120
- except Exception:
121
- return None
122
- if isinstance(ts, str):
123
- # Try ISO 8601 first; fall back to float epoch
124
- try:
125
- return datetime.datetime.fromisoformat(ts)
126
- except Exception:
127
  try:
128
- return datetime.datetime.fromtimestamp(float(ts))
129
  except Exception:
130
- return None
 
 
131
  return None
132
 
133
  def _normalize_messages():
@@ -141,10 +61,17 @@ def _normalize_messages():
141
  normed.append({**m, "text": text, "sender": sender, "timestamp": ts})
142
  st.session_state.messages = normed
143
 
 
 
 
 
 
 
 
 
 
 
144
 
145
- # -------------------------------
146
- # Streamlit page
147
- # -------------------------------
148
  def show_page():
149
  st.title("🤖 AI Financial Tutor")
150
  st.caption("Get personalized help with your financial questions")
@@ -160,7 +87,7 @@ def show_page():
160
  st.session_state.is_typing = False
161
 
162
  _normalize_messages()
163
-
164
  chat_container = st.container()
165
  with chat_container:
166
  for msg in st.session_state.messages:
@@ -174,7 +101,6 @@ def show_page():
174
  )
175
  st.markdown(bubble, unsafe_allow_html=True)
176
 
177
-
178
  if st.session_state.is_typing:
179
  st.markdown("🤖 _FinanceBot is typing..._")
180
 
@@ -188,7 +114,7 @@ def show_page():
188
  "How do I start investing?"
189
  ]
190
  for i, q in enumerate(quick):
191
- if cols[i % 2].button(q):
192
  add_message(q, "user")
193
  st.session_state.is_typing = True
194
  st.rerun()
@@ -202,7 +128,7 @@ def show_page():
202
  if st.session_state.is_typing:
203
  try:
204
  with st.spinner("FinanceBot is thinking..."):
205
- bot_reply = _reply_with_hf()
206
  add_message(bot_reply, "assistant")
207
  except Exception as e:
208
  add_message(f"⚠️ Error: {e}", "assistant")
 
1
  # phase/Student_view/chatbot.py
2
  import streamlit as st
3
+ import datetime, traceback
4
+ from typing import List, Dict
5
+ import utils.api as api # <-- use backend
 
 
 
 
 
 
 
6
 
7
  TUTOR_PROMPT = (
8
  "You are a kind Jamaican primary-school finance tutor. "
9
  "Keep answers short, friendly, and age-appropriate. "
10
+ "Teach step-by-step with tiny examples. Avoid giving personal financial advice. "
11
+ "Ensure the explanations are based on the Jamaican context and currency (JMD). "
12
+ "If you don't know the answer, just say you don't know."
13
  )
14
 
15
+ def _history_for_backend(messages: List[Dict]) -> List[Dict[str, str]]:
16
+ # Convert local history to backend chat format
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
17
  msgs = [{"role": "system", "content": TUTOR_PROMPT}]
18
  for m in messages:
19
  txt = (m.get("text") or "").strip()
20
  if not txt:
21
  continue
22
+ role = "assistant" if (m.get("sender") == "assistant") else "user"
23
  msgs.append({"role": role, "content": txt})
24
  return msgs
25
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
26
  def add_message(text: str, sender: str):
27
  if "messages" not in st.session_state:
28
  st.session_state.messages = []
 
38
  def _coerce_ts(ts):
39
  if isinstance(ts, datetime.datetime):
40
  return ts
41
+ try:
42
+ if isinstance(ts, (int, float)):
43
  return datetime.datetime.fromtimestamp(ts)
44
+ if isinstance(ts, str):
 
 
 
 
 
 
45
  try:
46
+ return datetime.datetime.fromisoformat(ts)
47
  except Exception:
48
+ return datetime.datetime.fromtimestamp(float(ts))
49
+ except Exception:
50
+ return None
51
  return None
52
 
53
  def _normalize_messages():
 
61
  normed.append({**m, "text": text, "sender": sender, "timestamp": ts})
62
  st.session_state.messages = normed
63
 
64
+ def _reply_with_backend():
65
+ # Identify context — if your app tracks current lesson/level in session, use it here
66
+ lesson_id = st.session_state.get("current_lesson_id", 0)
67
+ level_slug = st.session_state.get("current_level_slug", "beginner")
68
+
69
+ user_query = st.session_state.messages[-1]["text"]
70
+ history = _history_for_backend(st.session_state.get("messages", []))
71
+
72
+ # Let backend do retrieval + chat generation
73
+ return api.chat_ai(user_query, lesson_id=lesson_id, level_slug=level_slug, history=history)
74
 
 
 
 
75
  def show_page():
76
  st.title("🤖 AI Financial Tutor")
77
  st.caption("Get personalized help with your financial questions")
 
87
  st.session_state.is_typing = False
88
 
89
  _normalize_messages()
90
+
91
  chat_container = st.container()
92
  with chat_container:
93
  for msg in st.session_state.messages:
 
101
  )
102
  st.markdown(bubble, unsafe_allow_html=True)
103
 
 
104
  if st.session_state.is_typing:
105
  st.markdown("🤖 _FinanceBot is typing..._")
106
 
 
114
  "How do I start investing?"
115
  ]
116
  for i, q in enumerate(quick):
117
+ if cols[i % 2].button(q, key=f"quick_{i}"):
118
  add_message(q, "user")
119
  st.session_state.is_typing = True
120
  st.rerun()
 
128
  if st.session_state.is_typing:
129
  try:
130
  with st.spinner("FinanceBot is thinking..."):
131
+ bot_reply = _reply_with_backend()
132
  add_message(bot_reply, "assistant")
133
  except Exception as e:
134
  add_message(f"⚠️ Error: {e}", "assistant")
utils/api.py CHANGED
@@ -1,20 +1,29 @@
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,
20
  read=3,
@@ -22,9 +31,8 @@ retry = Retry(
22
  status_forcelist=(429, 500, 502, 503, 504),
23
  allowed_methods=frozenset(["GET", "POST", "PUT", "PATCH", "DELETE"]),
24
  )
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,482 +40,302 @@ _session.headers.update({
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:
46
  snippet = (resp.text or "")[:300]
47
- raise RuntimeError(f"Expected JSON but got {ctype or 'unknown'}:\n{snippet}")
48
 
49
- def _req(method: str, path: str, **kw):
50
  if not path.startswith("/"):
51
  path = "/" + path
52
  url = f"{BACKEND}{path}"
53
  kw.setdefault("timeout", DEFAULT_TIMEOUT)
54
- try:
55
- r = _session.request(method, url, **kw)
56
- r.raise_for_status()
57
- except requests.HTTPError as e:
58
- body = ""
59
- try:
60
- body = r.text[:500]
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. "
67
- f"Check BACKEND_TOKEN/HF_TOKEN permissions and that the backend Space is private/readable."
68
- ) from e
69
- raise RuntimeError(f"{method} {path} failed [{status}]: {body}") from e
70
- except requests.RequestException as e:
71
- raise RuntimeError(f"{method} {path} failed: {e.__class__.__name__}: {e}") from e
72
- return r
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}
82
- except Exception:
83
- return {"ok": False}
84
-
85
- # --- Optional API prefix (e.g., "/api" or "/v1")
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:
97
- url = f"{BACKEND}{pref}{path}"
98
- tried.append(f"{method} {url}")
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
111
- def user_stats(student_id: int):
112
- return _req("GET", f"/students/{student_id}/stats").json()
113
- def list_assignments_for_student(student_id: int):
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
130
- def recent_lessons_for_student(student_id: int, limit: int = 5):
131
- return _req("GET", f"/students/{student_id}/recent", params={"limit": limit}).json()
132
-
133
- # -- Teacher endpoints (backend Space)
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
-
140
- def teacher_tiles(teacher_id: int):
141
- return _json_or_raise(_req("GET", f"/teachers/{teacher_id}/tiles"))
142
-
143
- def list_classes_by_teacher(teacher_id: int):
144
- return _try_candidates("GET", [
145
- (f"/teachers/{teacher_id}/classes", {}),
146
- ])
147
-
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):
156
- return _try_candidates("GET", [
157
- (f"/classes/{class_id}/activity/weekly", {}),
158
- (f"/classes/{class_id}/weekly_activity", {}),
159
- ])
160
-
161
- def class_progress_overview(class_id: int):
162
- return _try_candidates("GET", [
163
- (f"/classes/{class_id}/progress", {}),
164
- (f"/classes/{class_id}/progress_overview", {}),
165
- ])
166
-
167
- def class_recent_activity(class_id: int, limit=6, days=30):
168
- return _try_candidates("GET", [
169
- (f"/classes/{class_id}/activity/recent", {"params": {"limit": limit, "days": days}}),
170
- (f"/classes/{class_id}/recent_activity", {"params": {"limit": limit, "days": days}}),
171
- ])
172
-
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):
185
- return _json_or_raise(_req("GET", f"/students/{student_id}/classes"))
 
 
 
 
186
 
187
- def class_content_counts(class_id: int):
188
- return _try_candidates("GET", [
189
- (f"/classes/{class_id}/content_counts", {}),
190
- (f"/classes/{class_id}/counts", {}),
191
- ])
 
 
 
 
192
 
193
- def student_class_progress(student_id: int, class_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
 
200
- def student_assignments_for_class(student_id: int, class_id: int):
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
 
210
- def list_classes_by_teacher(teacher_id: int):
211
- return _try_candidates("GET", [
212
- (f"/teachers/{teacher_id}/classes", {}),
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):
219
- return _try_candidates("GET", [
220
- (f"/classes/{class_id}/content_counts", {}),
221
- (f"/classes/{class_id}/counts", {}),
222
- ])
223
 
224
- def list_class_assignments(class_id: int):
225
  return _json_or_raise(_req("GET", f"/classes/{class_id}/assignments"))
226
 
227
- def class_analytics(class_id: int):
228
  return _json_or_raise(_req("GET", f"/classes/{class_id}/analytics"))
229
 
230
- def teacher_tiles(teacher_id: int):
231
- return _json_or_raise(_req("GET", f"/teachers/{teacher_id}/tiles"))
232
-
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):
241
- return _try_candidates("GET", [
242
- (f"/classes/{class_id}/activity/weekly", {}),
243
- (f"/classes/{class_id}/weekly_activity", {}),
244
- ])
245
-
246
- def class_progress_overview(class_id: int):
247
- return _try_candidates("GET", [
248
- (f"/classes/{class_id}/progress", {}),
249
- (f"/classes/{class_id}/progress_overview", {}),
250
- ])
251
-
252
- def class_recent_activity(class_id: int, limit=6, days=30):
253
- return _try_candidates("GET", [
254
- (f"/classes/{class_id}/activity/recent", {"params": {"limit": limit, "days": days}}),
255
- (f"/classes/{class_id}/recent_activity", {"params": {"limit": limit, "days": days}}),
256
- ])
257
 
 
 
 
 
 
258
  # Lessons
259
- def list_lessons_by_teacher(teacher_id: int):
 
 
260
  return _json_or_raise(_req("GET", f"/teachers/{teacher_id}/lessons"))
261
 
262
- def create_lesson(teacher_id: int, title: str, description: str,
263
- subject: str, level: str, sections: list[dict]):
264
- payload = {
265
- "title": title,
266
- "description": description,
267
- "subject": subject,
268
- "level": level,
269
- "sections": sections,
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))
276
 
277
- def get_lesson(lesson_id: int):
278
  return _json_or_raise(_req("GET", f"/lessons/{lesson_id}"))
279
 
280
- def update_lesson(lesson_id: int, teacher_id: int, title: str, description: str,
281
- subject: str, level: str, sections: list[dict]):
282
- d = _req("PUT", f"/lessons/{lesson_id}", json={
283
- "teacher_id": teacher_id,
284
- "title": title,
285
- "description": description,
286
- "subject": subject,
287
- "level": level,
288
- "sections": sections
289
- }).json()
290
  return bool(d.get("ok", True))
291
 
292
- def delete_lesson(lesson_id: int, teacher_id: int):
293
- d = _req("DELETE", f"/lessons/{lesson_id}", json={"teacher_id": teacher_id}).json()
294
- return bool(d.get("ok", True)), d.get("message", "")
295
 
 
 
 
 
296
  # Quizzes
297
- def list_quizzes_by_teacher(teacher_id: int):
 
 
298
  return _json_or_raise(_req("GET", f"/teachers/{teacher_id}/quizzes"))
299
 
300
- def create_quiz(lesson_id: int, title: str, items: list[dict], settings: dict):
301
- d = _req("POST", "/quizzes", json={
302
- "lesson_id": lesson_id, "title": title, "items": items, "settings": settings
303
- }).json()
304
- return d.get("quiz_id", d.get("id", d))
305
 
306
- def mark_assignment_started(student_id: int, assignment_id: int):
307
- return _req("POST",
308
- f"/assignments/{assignment_id}/start",
309
- json={"student_id": student_id}).json()
310
 
311
- def set_assignment_progress(student_id: int, assignment_id: int, current_pos: int, progress: float):
312
- return _req("PATCH",
313
- f"/assignments/{assignment_id}/progress",
314
- json={"student_id": student_id, "current_pos": current_pos, "progress": progress}).json()
 
315
 
316
- def get_quiz(quiz_id: int):
317
- """Fetch a teacher-created quiz (GET /quizzes/{quiz_id}) and return JSON."""
318
- return _json_or_raise(_req("GET", f"/quizzes/{quiz_id}"))
 
 
 
 
 
 
 
 
 
 
 
 
319
 
320
- def submit_quiz(*, lesson_id: int | None, level_slug: str | None,
321
- user_answers: list[dict], original_quiz: list[dict]):
322
- """
323
- Grade the quiz on the backend. Returns:
324
- {"score":{"correct":int,"total":int}, "wrong":[...], "feedback":str}
325
- """
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,
337
- teacher_id: int, title: str, items: list[dict], settings: dict):
338
- d = _req("PUT", f"/quizzes/{quiz_id}", json={
339
- "teacher_id": teacher_id, "title": title, "items": items, "settings": settings
340
- }).json()
341
- return bool(d.get("ok", True))
342
 
343
- def delete_quiz(quiz_id: int, teacher_id: int):
344
- d = _req("DELETE", f"/quizzes/{quiz_id}", json={"teacher_id": teacher_id}).json()
345
- return bool(d.get("ok", True)), d.get("message", "")
346
 
347
- def list_assigned_students_for_lesson(lesson_id: int):
348
- return _json_or_raise(_req("GET", f"/lessons/{lesson_id}/assignees"))
 
 
349
 
350
- def list_assigned_students_for_quiz(quiz_id: int):
351
- return _json_or_raise(_req("GET", f"/quizzes/{quiz_id}/assignees"))
 
352
 
353
- # Assignments
354
- def assign_to_class(lesson_id: int | None, quiz_id: int | None, class_id: int, teacher_id: int, due_at: str | None = None):
355
- d = _try_candidates("POST", [
356
- ("/assign", {"json": {
357
- "lesson_id": lesson_id, "quiz_id": quiz_id,
358
- "class_id": class_id, "teacher_id": teacher_id, "due_at": due_at
359
- }}),
360
- ("/assignments", {"json": {
361
- "lesson_id": lesson_id, "quiz_id": quiz_id,
362
- "class_id": class_id, "teacher_id": teacher_id, "due_at": due_at
363
- }}),
364
- ])
365
- return bool(d.get("ok", True))
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()
372
 
373
- def generate_quiz(*, lesson_id: int | None, level_slug: str | None, lesson_title: str | None):
374
- payload = {
375
- "lesson_id": int(lesson_id) if lesson_id is not None else None,
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
- logger.debug(f"Response status code: {resp.status_code}")
384
- result = _json_or_raise(resp)
385
- # Ensure result is a list of quiz items, handling Qwen's potential JSON structure
386
- if isinstance(result, dict) and "items" in result:
387
- return result["items"]
388
- return result if isinstance(result, list) else []
389
- except requests.HTTPError as e:
390
- logger.error(f"HTTP Error in quiz generation: {e.response.status_code} - {e.response.text}")
391
- return []
392
- except RuntimeError as e:
393
- logger.error(f"Quiz generation failed: {str(e)}")
394
- return [] # Return empty list to prevent app crash
395
-
396
- # ---- Legacy agent endpoints (keep) ----
397
- def start_agent(student_id: int, lesson_id: int, level_slug: str):
398
- return _json_or_raise(_req("POST", "/agent/start",
399
- json={"student_id": student_id, "lesson_id": lesson_id, "level_slug": level_slug}))
400
-
401
- def get_agent_quiz(student_id: int, lesson_id: int, level_slug: str):
402
- d = _json_or_raise(_req("POST", "/agent/quiz",
403
- json={"student_id": student_id, "lesson_id": lesson_id, "level_slug": level_slug}))
404
- return d["items"]
405
-
406
- def grade_quiz(student_id: int, lesson_id: int, level_slug: str,
407
- answers: list[str], assignment_id: int | None = None):
408
- d = _json_or_raise(_req("POST", "/agent/grade",
409
- json={"student_id": student_id, "lesson_id": lesson_id, "level_slug": level_slug,
410
- "answers": answers, "assignment_id": assignment_id}))
411
- return d["score"], d["total"]
412
-
413
- def next_step(student_id: int, lesson_id: int, level_slug: str,
414
- answers: list[str], assignment_id: int | None = None):
415
- return _json_or_raise(_req("POST", "/agent/coach_or_celebrate",
416
- json={"student_id": student_id, "lesson_id": lesson_id, "level_slug": level_slug,
417
- "answers": answers, "assignment_id": assignment_id}))
418
-
419
- # ---- Auth ----
420
- def login(email: str, password: str):
421
- return _json_or_raise(_req("POST", "/auth/login", json={"email": email, "password": password}))
422
 
423
- def signup_student(name: str, email: str, password: str, level_label: str, country_label: str):
424
- payload_student = {
425
- "name": name, "email": email, "password": password,
426
- "level_label": level_label, "country_label": country_label
427
- }
428
- return _try_candidates("POST", [
429
- ("/auth/signup/student", {"json": payload_student}),
430
- ("/auth/register", {"json": {
431
- "role": "student", "name": name, "email": email, "password": password,
432
- "level": level_label, "country": country_label
433
- }}),
434
- ])
435
-
436
- def signup_teacher(title: str, name: str, email: str, password: str):
437
- payload_teacher = {"title": title, "name": name, "email": email, "password": password}
438
- return _try_candidates("POST", [
439
- ("/auth/signup/teacher", {"json": payload_teacher}),
440
- ("/auth/register", {"json": {
441
- "role": "teacher", "title": title, "name": name, "email": email, "password": password
442
- }}),
443
- ])
444
-
445
- # ---- New LangGraph-backed endpoints ----
446
- def fetch_lesson_content(lesson: str, module: str, topic: str):
447
- r = _json_or_raise(_req("POST", "/lesson",
448
- json={"lesson": lesson, "module": module, "topic": topic}))
449
- return r["lesson_content"]
450
-
451
- def submit_lesson_quiz(lesson: str, module: str, topic: str, responses: dict):
452
- return _json_or_raise(_req("POST", "/lesson-quiz",
453
- json={"lesson": lesson, "module": module, "topic": topic, "responses": responses}))
454
-
455
- def submit_practice_quiz(lesson: str, responses: dict):
456
- return _json_or_raise(_req("POST", "/practice-quiz",
457
- json={"lesson": lesson, "responses": responses}))
458
-
459
- def send_to_chatbot(messages: list[dict]):
460
- return _json_or_raise(_req("POST", "/chatbot", json={"messages": messages}))
461
-
462
- # --- Game API helpers ---
463
- def record_money_match_play(user_id: int, target: int, total: int,
464
- elapsed_ms: int, matched: bool, gained_xp: int):
465
- payload = {
466
- "user_id": user_id, "target": target, "total": total,
467
- "elapsed_ms": elapsed_ms, "matched": matched, "gained_xp": gained_xp,
468
- }
469
- return _try_candidates("POST", [
470
- ("/games/money_match/record", {"json": payload}),
471
- ])
472
 
473
- def record_budget_builder_play(user_id: int, weekly_allowance: int, budget_score: int,
474
- elapsed_ms: int, allocations: list[dict], gained_xp: int | None):
475
- payload = {
476
- "user_id": user_id,
477
- "weekly_allowance": weekly_allowance,
478
- "budget_score": budget_score,
479
- "elapsed_ms": elapsed_ms,
480
- "allocations": allocations,
481
- "gained_xp": gained_xp,
482
- }
483
- return _try_candidates("POST", [
484
- ("/games/budget_builder/record", {"json": payload}),
485
- ])
 
 
 
 
 
486
 
487
- def record_debt_dilemma_play(user_id: int, loans_cleared: int,
488
- mistakes: int, elapsed_ms: int, gained_xp: int):
489
  payload = {
490
- "user_id": user_id,
491
- "loans_cleared": loans_cleared,
492
- "mistakes": mistakes,
493
- "elapsed_ms": elapsed_ms,
494
- "gained_xp": gained_xp,
495
  }
496
- return _try_candidates("POST", [
497
- ("/games/debt_dilemma/record", {"json": payload}),
498
- ])
499
-
500
- def record_profit_puzzler_play(user_id: int, puzzles_solved: int, mistakes: int, elapsed_ms: int, gained_xp: int | None = None):
501
- payload = {"user_id": user_id, "puzzles_solved": puzzles_solved, "mistakes": mistakes, "elapsed_ms": elapsed_ms}
502
  if gained_xp is not None:
503
- payload["gained_xp"] = gained_xp
504
- return _try_candidates("POST", [("/games/profit_puzzler/record", {"json": payload})])
505
 
506
- # --- Tutor Explanation ---
507
- def tutor_explain(lesson_id: int, level_slug: str, wrong: list[dict]):
508
  payload = {
509
- "lesson_id": lesson_id,
510
- "level_slug": level_slug,
511
- "wrong": wrong
512
  }
513
- return _json_or_raise(_req("POST", "/tutor/explain", json=payload, timeout=60))
 
 
 
 
 
 
 
 
 
 
 
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
  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
  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))