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

elkay: api.py, chatbot

Browse files
Files changed (2) hide show
  1. phase/Student_view/chatbot.py +101 -67
  2. utils/api.py +199 -401
phase/Student_view/chatbot.py CHANGED
@@ -1,44 +1,29 @@
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 = []
29
- st.session_state.messages.append(
30
- {
31
- "id": str(datetime.datetime.now().timestamp()),
32
- "text": text,
33
- "sender": sender,
34
- "timestamp": datetime.datetime.now()
35
- }
36
- )
37
 
 
 
 
 
 
 
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):
@@ -47,8 +32,9 @@ def _coerce_ts(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():
54
  msgs = st.session_state.get("messages", [])
@@ -58,80 +44,128 @@ def _normalize_messages():
58
  text = (m.get("text") or "").strip()
59
  sender = m.get("sender") or "user"
60
  ts = _coerce_ts(m.get("timestamp")) or now
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")
 
 
 
 
 
 
 
 
 
 
78
 
 
79
  if "messages" not in st.session_state:
80
  st.session_state.messages = [{
81
- "id": "1",
82
- "text": "Hi! I'm your AI Financial Tutor. What would you like to learn today?",
83
  "sender": "assistant",
84
- "timestamp": datetime.datetime.now()
85
  }]
86
  if "is_typing" not in st.session_state:
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:
94
- time_str = msg["timestamp"].strftime("%H:%M") if hasattr(msg["timestamp"], "strftime") else datetime.datetime.now().strftime("%H:%M")
95
- bubble = (
96
- f"<div style='background-color:#e0e0e0; color:black; padding:10px; border-radius:12px; max-width:70%; margin-bottom:5px;'>"
97
- f"{msg.get('text','')}<br><sub>{time_str}</sub></div>"
98
- if msg.get("sender") == "assistant" else
99
- f"<div style='background-color:#4CAF50; color:white; padding:10px; border-radius:12px; max-width:70%; margin-left:auto; margin-bottom:5px;'>"
100
- f"{msg.get('text','')}<br><sub>{time_str}</sub></div>"
101
- )
 
 
 
 
 
102
  st.markdown(bubble, unsafe_allow_html=True)
103
 
104
  if st.session_state.is_typing:
105
- st.markdown("🤖 _FinanceBot is typing..._")
106
 
 
107
  if len(st.session_state.messages) == 1:
108
  st.markdown("Try asking about:")
109
  cols = st.columns(2)
110
- quick = [
111
  "How does compound interest work?",
112
  "How much should I save for emergencies?",
113
- "What's a good budgeting strategy?",
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()
121
 
122
- user_input = st.chat_input("Ask me anything about personal finance...")
 
123
  if user_input:
124
  add_message(user_input, "user")
125
  st.session_state.is_typing = True
126
  st.rerun()
127
 
 
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")
135
  finally:
136
  st.session_state.is_typing = False
137
  st.rerun()
 
1
  # phase/Student_view/chatbot.py
2
  import streamlit as st
3
+ import datetime
4
+ import traceback
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ # --- Make sure we can import phase/api.py no matter where Streamlit runs from ---
9
+ ROOT = Path(__file__).resolve().parents[1] # .../phase
10
+ if str(ROOT) not in sys.path:
11
+ sys.path.insert(0, str(ROOT))
12
+
13
+ import api as backend # uses BACKEND_URL + optional BACKEND_TOKEN
 
 
 
 
 
 
 
 
 
 
 
14
 
 
 
 
 
 
 
 
 
 
 
 
15
 
16
+ TUTOR_WELCOME = "Hi! I'm your AI Financial Tutor. What would you like to learn today?"
17
+
18
+
19
+ # -------------------------------
20
+ # Session helpers
21
+ # -------------------------------
22
  def _coerce_ts(ts):
23
  if isinstance(ts, datetime.datetime):
24
  return ts
25
  try:
26
+ # try epoch
27
  if isinstance(ts, (int, float)):
28
  return datetime.datetime.fromtimestamp(ts)
29
  if isinstance(ts, str):
 
32
  except Exception:
33
  return datetime.datetime.fromtimestamp(float(ts))
34
  except Exception:
35
+ pass
36
+ return datetime.datetime.now()
37
+
38
 
39
  def _normalize_messages():
40
  msgs = st.session_state.get("messages", [])
 
44
  text = (m.get("text") or "").strip()
45
  sender = m.get("sender") or "user"
46
  ts = _coerce_ts(m.get("timestamp")) or now
47
+ if text:
48
+ normed.append({**m, "text": text, "sender": sender, "timestamp": ts})
49
  st.session_state.messages = normed
50
 
 
 
 
 
51
 
52
+ def add_message(text: str, sender: str):
53
+ if "messages" not in st.session_state:
54
+ st.session_state.messages = []
55
+ st.session_state.messages.append(
56
+ {
57
+ "id": str(datetime.datetime.now().timestamp()),
58
+ "text": text,
59
+ "sender": sender,
60
+ "timestamp": datetime.datetime.now(),
61
+ }
62
+ )
63
+
64
+
65
+ def _history_for_backend():
66
+ """
67
+ Convert local message format -> backend ChatRequest.history
68
+ Keeps only the last ~10 turns to keep prompts lean.
69
+ """
70
+ hist = []
71
+ for m in st.session_state.get("messages", [])[-20:]:
72
+ role = "assistant" if m.get("sender") == "assistant" else "user"
73
+ hist.append({"role": role, "content": m.get("text", "")})
74
+ return hist
75
 
 
 
76
 
77
+ # -------------------------------
78
+ # Streamlit page
79
+ # -------------------------------
80
  def show_page():
81
  st.title("🤖 AI Financial Tutor")
82
+ st.caption("Backed by your local TinyLlama on the backend")
83
+
84
+ # Health ping (optional, but helpful)
85
+ col_a, col_b = st.columns(2)
86
+ with col_a:
87
+ try:
88
+ h = backend.health()
89
+ ok = h.get("status") == "ok"
90
+ st.success(f"Backend: {'OK' if ok else 'Not ready'}")
91
+ except Exception as e:
92
+ st.error(f"Backend health failed: {e}")
93
 
94
+ # First-time session state
95
  if "messages" not in st.session_state:
96
  st.session_state.messages = [{
97
+ "id": "welcome",
98
+ "text": TUTOR_WELCOME,
99
  "sender": "assistant",
100
+ "timestamp": datetime.datetime.now(),
101
  }]
102
  if "is_typing" not in st.session_state:
103
  st.session_state.is_typing = False
104
 
105
  _normalize_messages()
106
 
107
+ # Chat history bubbles
108
  chat_container = st.container()
109
  with chat_container:
110
  for msg in st.session_state.messages:
111
+ time_str = msg["timestamp"].strftime("%H:%M")
112
+ if msg.get("sender") == "assistant":
113
+ bubble = (
114
+ f"<div style='background:#e0e0e0;color:#000;padding:10px;border-radius:12px;"
115
+ f"max-width:70%;margin-bottom:6px;'>{msg.get('text','')}<br>"
116
+ f"<sub>{time_str}</sub></div>"
117
+ )
118
+ else:
119
+ bubble = (
120
+ f"<div style='background:#4CAF50;color:#fff;padding:10px;border-radius:12px;"
121
+ f"max-width:70%;margin-left:auto;margin-bottom:6px;'>{msg.get('text','')}<br>"
122
+ f"<sub>{time_str}</sub></div>"
123
+ )
124
  st.markdown(bubble, unsafe_allow_html=True)
125
 
126
  if st.session_state.is_typing:
127
+ st.markdown("🤖 _Tutor is typing..._")
128
 
129
+ # Quick starters
130
  if len(st.session_state.messages) == 1:
131
  st.markdown("Try asking about:")
132
  cols = st.columns(2)
133
+ for i, q in enumerate([
134
  "How does compound interest work?",
135
  "How much should I save for emergencies?",
136
+ "What's a simple budgeting rule?",
137
+ "How do I start investing safely?",
138
+ ]):
139
+ if cols[i % 2].button(q):
 
140
  add_message(q, "user")
141
  st.session_state.is_typing = True
142
  st.rerun()
143
 
144
+ # Input
145
+ user_input = st.chat_input("Ask me anything about personal finance…")
146
  if user_input:
147
  add_message(user_input, "user")
148
  st.session_state.is_typing = True
149
  st.rerun()
150
 
151
+ # Produce reply (via backend /chat)
152
  if st.session_state.is_typing:
153
  try:
154
+ with st.spinner("Thinking…"):
155
+ history = _history_for_backend()
156
+ # If your app tracks lesson_id/level in session, pass them here; else use defaults.
157
+ lesson_id = st.session_state.get("current_lesson_id", 0)
158
+ level = st.session_state.get("current_level_slug", "beginner")
159
+
160
+ bot_reply = backend.chat_ai(
161
+ query=st.session_state.messages[-1]["text"],
162
+ lesson_id=lesson_id,
163
+ level_slug=level,
164
+ history=history,
165
+ ) or "I couldn't generate a response just now."
166
  add_message(bot_reply, "assistant")
167
  except Exception as e:
168
+ add_message(f"⚠️ Error: {''.join(traceback.format_exception_only(type(e), e)).strip()}", "assistant")
169
  finally:
170
  st.session_state.is_typing = False
171
  st.rerun()
utils/api.py CHANGED
@@ -1,4 +1,8 @@
1
- import os, json, requests, logging
 
 
 
 
2
  from urllib3.util.retry import Retry
3
  from requests.adapters import HTTPAdapter
4
 
@@ -7,15 +11,10 @@ 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,
@@ -26,7 +25,6 @@ retry = Retry(
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,12 +32,17 @@ _session.headers.update({
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:
@@ -51,29 +54,34 @@ def _json_or_raise(resp: requests.Response):
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"))
@@ -83,187 +91,42 @@ def health():
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"))
@@ -271,51 +134,36 @@ def list_class_assignments(class_id: int):
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):
@@ -323,96 +171,46 @@ def get_lesson(lesson_id: int):
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 = {
@@ -420,138 +218,138 @@ def generate_quiz(*, lesson_id: int | None, level_slug: str | None, lesson_title
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))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # phase/api.py
2
+ import os
3
+ import json
4
+ import logging
5
+ import requests
6
  from urllib3.util.retry import Retry
7
  from requests.adapters import HTTPAdapter
8
 
 
11
  if not BACKEND:
12
  raise RuntimeError("BACKEND_URL is not set in Space secrets.")
13
 
 
 
14
  TOKEN = (os.getenv("BACKEND_TOKEN") or os.getenv("HF_TOKEN") or "").strip()
 
 
15
  DEFAULT_TIMEOUT = int(os.getenv("BACKEND_TIMEOUT", "60"))
16
 
17
  _session = requests.Session()
 
18
  retry = Retry(
19
  total=3,
20
  connect=3,
 
25
  )
26
  _session.mount("https://", HTTPAdapter(max_retries=retry))
27
  _session.mount("http://", HTTPAdapter(max_retries=retry))
 
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
+ logging.basicConfig(level=logging.INFO)
 
36
  logger = logging.getLogger(__name__)
37
 
38
+ # --- Optional API prefix (e.g., "/api")
39
+ API_PREFIX_ENV = (os.getenv("BACKEND_API_PREFIX") or "").strip().rstrip("/")
40
+ def _prefixes():
41
+ return ["/" + API_PREFIX_ENV.strip("/"), ""] if API_PREFIX_ENV else [""]
42
+
43
+ # ---- Core helpers ----
44
  def _json_or_raise(resp: requests.Response):
45
+ ctype = (resp.headers.get("content-type") or "").lower()
46
  if "application/json" in ctype:
47
  return resp.json()
48
  try:
 
54
  def _req(method: str, path: str, **kw):
55
  if not path.startswith("/"):
56
  path = "/" + path
57
+ for pref in _prefixes():
58
+ url = f"{BACKEND}{pref}{path}"
 
 
 
 
 
59
  try:
60
+ r = _session.request(method, url, timeout=DEFAULT_TIMEOUT, **kw)
61
+ r.raise_for_status()
62
+ return r
63
+ except requests.HTTPError as e:
64
+ status = getattr(e.response, "status_code", "?")
65
+ # try next prefix on 404
66
+ if status == 404:
67
+ continue
68
+ body = ""
69
+ try:
70
+ body = e.response.text[:500]
71
+ except Exception:
72
+ pass
73
+ if status in (401, 403):
74
+ raise RuntimeError(
75
+ f"{method} {url} failed [{status}] – auth rejected. "
76
+ f"Check BACKEND_TOKEN/HF_TOKEN and backend visibility."
77
+ ) from e
78
+ raise RuntimeError(f"{method} {url} failed [{status}]: {body}") from e
79
+ except requests.RequestException as e:
80
+ # try next prefix
81
+ continue
82
+ raise RuntimeError(f"No matching endpoint for {method} {path} with prefixes {list(_prefixes())}")
83
+
84
+ # ---- Public helpers ----
85
  def health():
86
  try:
87
  return _json_or_raise(_req("GET", "/health"))
 
91
  return {"ok": True}
92
  except Exception:
93
  return {"ok": False}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
94
 
95
+ # ---------- Auth ----------
96
+ def login(email: str, password: str):
97
+ return _json_or_raise(_req("POST", "/auth/login", json={"email": email, "password": password}))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
98
 
99
+ def signup_student(name: str, email: str, password: str, level_label: str, country_label: str):
100
  payload = {
101
+ "name": name, "email": email, "password": password,
102
+ "level_label": level_label, "country_label": country_label
 
 
103
  }
104
+ return _json_or_raise(_req("POST", "/auth/signup/student", json=payload))
 
 
 
 
 
105
 
106
+ def signup_teacher(title: str, name: str, email: str, password: str):
107
+ payload = {"title": title, "name": name, "email": email, "password": password}
108
+ return _json_or_raise(_req("POST", "/auth/signup/teacher", json=payload))
109
 
110
+ # ---------- Classes ----------
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
111
  def create_class(teacher_id: int, name: str):
112
+ return _json_or_raise(_req("POST", f"/teachers/{teacher_id}/classes", json={"name": name}))
 
 
 
 
 
 
113
 
114
  def list_classes_by_teacher(teacher_id: int):
115
+ return _json_or_raise(_req("GET", f"/teachers/{teacher_id}/classes"))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
116
 
 
 
 
 
 
 
 
 
 
 
 
 
 
117
  def join_class_by_code(student_id: int, code: str):
118
  d = _json_or_raise(_req("POST", f"/students/{student_id}/classes/join", json={"code": code}))
119
  return d.get("class_id", d)
120
 
 
 
 
 
 
 
 
 
 
 
 
 
121
  def leave_class(student_id: int, class_id: int):
122
  _json_or_raise(_req("POST", f"/classes/{class_id}/leave", json={"student_id": student_id}))
123
  return True
124
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
125
  def list_students_in_class(class_id: int):
126
  return _json_or_raise(_req("GET", f"/classes/{class_id}/students"))
127
 
128
  def class_content_counts(class_id: int):
129
+ return _json_or_raise(_req("GET", f"/classes/{class_id}/content_counts"))
 
 
 
130
 
131
  def list_class_assignments(class_id: int):
132
  return _json_or_raise(_req("GET", f"/classes/{class_id}/assignments"))
 
134
  def class_analytics(class_id: int):
135
  return _json_or_raise(_req("GET", f"/classes/{class_id}/analytics"))
136
 
 
 
 
137
  def class_student_metrics(class_id: int):
138
+ return _json_or_raise(_req("GET", f"/classes/{class_id}/students/metrics"))
 
 
 
 
139
 
140
  def class_weekly_activity(class_id: int):
141
+ return _json_or_raise(_req("GET", f"/classes/{class_id}/activity/weekly"))
 
 
 
142
 
143
  def class_progress_overview(class_id: int):
144
+ return _json_or_raise(_req("GET", f"/classes/{class_id}/progress"))
 
 
 
145
 
146
  def class_recent_activity(class_id: int, limit=6, days=30):
147
+ return _json_or_raise(_req("GET", f"/classes/{class_id}/activity/recent",
148
+ params={"limit": limit, "days": days}))
149
+
150
+ def student_class_progress(student_id: int, class_id: int):
151
+ return _json_or_raise(_req("GET", f"/classes/{class_id}/students/{student_id}/progress"))
152
+
153
+ def student_assignments_for_class(student_id: int, class_id: int):
154
+ return _json_or_raise(_req("GET", f"/classes/{class_id}/students/{student_id}/assignments"))
155
 
156
+ # ---------- Lessons ----------
157
  def list_lessons_by_teacher(teacher_id: int):
158
  return _json_or_raise(_req("GET", f"/teachers/{teacher_id}/lessons"))
159
 
160
  def create_lesson(teacher_id: int, title: str, description: str,
161
  subject: str, level: str, sections: list[dict]):
162
  payload = {
163
+ "title": title, "description": description,
164
+ "subject": subject, "level": level, "sections": sections,
 
 
 
165
  }
166
+ d = _json_or_raise(_req("POST", f"/teachers/{teacher_id}/lessons", json=payload))
 
 
 
167
  return d.get("lesson_id", d.get("id", d))
168
 
169
  def get_lesson(lesson_id: int):
 
171
 
172
  def update_lesson(lesson_id: int, teacher_id: int, title: str, description: str,
173
  subject: str, level: str, sections: list[dict]):
174
+ d = _json_or_raise(_req("PUT", f"/lessons/{lesson_id}", json={
175
+ "teacher_id": teacher_id, "title": title, "description": description,
176
+ "subject": subject, "level": level, "sections": sections
177
+ }))
 
 
 
 
178
  return bool(d.get("ok", True))
179
 
180
  def delete_lesson(lesson_id: int, teacher_id: int):
181
+ d = _json_or_raise(_req("DELETE", f"/lessons/{lesson_id}", json={"teacher_id": teacher_id}))
182
  return bool(d.get("ok", True)), d.get("message", "")
183
 
184
+ def list_assigned_students_for_lesson(lesson_id: int):
185
+ return _json_or_raise(_req("GET", f"/lessons/{lesson_id}/assignees"))
186
+
187
+ # ---------- Quizzes ----------
188
  def list_quizzes_by_teacher(teacher_id: int):
189
  return _json_or_raise(_req("GET", f"/teachers/{teacher_id}/quizzes"))
190
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
191
  def get_quiz(quiz_id: int):
 
192
  return _json_or_raise(_req("GET", f"/quizzes/{quiz_id}"))
193
 
194
+ def create_quiz(lesson_id: int, title: str, items: list[dict], settings: dict):
195
+ d = _json_or_raise(_req("POST", "/quizzes", json={
196
+ "lesson_id": lesson_id, "title": title, "items": items, "settings": settings
197
+ }))
198
+ return d.get("quiz_id", d.get("id", d))
 
 
 
 
 
 
 
 
 
 
199
 
200
+ def update_quiz(quiz_id: int, teacher_id: int, title: str, items: list[dict], settings: dict):
201
+ d = _json_or_raise(_req("PUT", f"/quizzes/{quiz_id}", json={
 
202
  "teacher_id": teacher_id, "title": title, "items": items, "settings": settings
203
+ }))
204
  return bool(d.get("ok", True))
205
 
206
  def delete_quiz(quiz_id: int, teacher_id: int):
207
+ d = _json_or_raise(_req("DELETE", f"/quizzes/{quiz_id}", json={"teacher_id": teacher_id}))
208
  return bool(d.get("ok", True)), d.get("message", "")
209
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
210
  def generate_quiz_from_text(content: str, n_questions: int = 5, subject: str = "finance", level: str = "beginner"):
211
+ return _json_or_raise(_req("POST", "/quiz/generate", json={
212
  "content": content, "n_questions": n_questions, "subject": subject, "level": level
213
+ }))
214
 
215
  def generate_quiz(*, lesson_id: int | None, level_slug: str | None, lesson_title: str | None):
216
  payload = {
 
218
  "level_slug": level_slug,
219
  "lesson_title": lesson_title,
220
  }
221
+ resp = _req("POST", "/quiz/auto", json=payload)
222
+ result = _json_or_raise(resp)
223
+ if isinstance(result, dict) and "items" in result:
224
+ return result["items"]
225
+ return result if isinstance(result, list) else []
226
+
227
+ def submit_quiz(*, student_id: int, lesson_id: int, level_slug: str,
228
+ user_answers: list[dict], original_quiz: list[dict], assignment_id: int | None = None):
229
+ payload = {
230
+ "student_id": student_id,
231
+ "lesson_id": lesson_id,
232
+ "level_slug": level_slug,
233
+ "user_answers": user_answers,
234
+ "original_quiz": original_quiz,
235
+ "assignment_id": assignment_id,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
236
  }
237
+ return _json_or_raise(_req("POST", "/quiz/grade", json=payload))
 
 
 
 
 
 
238
 
239
+ # ---------- Tutor explain ----------
240
+ def tutor_explain(lesson_id: int, level_slug: str, wrong: list[dict]):
241
+ payload = {"lesson_id": lesson_id, "level_slug": level_slug, "wrong": wrong}
242
+ return _json_or_raise(_req("POST", "/tutor/explain", json=payload, timeout=60))
 
 
 
 
 
 
 
 
 
 
243
 
244
+ # ---------- Student dashboard ----------
245
+ def user_stats(student_id: int):
246
+ return _json_or_raise(_req("GET", f"/students/{student_id}/stats"))
247
 
248
+ def list_assignments_for_student(student_id: int):
249
+ return _json_or_raise(_req("GET", f"/students/{student_id}/assignments"))
250
+
251
+ def student_quiz_average(student_id: int):
252
+ d = _json_or_raise(_req("GET", f"/students/{student_id}/quiz_avg"))
253
+ # backend returns {"avg_pct": ...}
254
+ for k in ("avg_pct", "avg", "average", "score_pct", "score", "value"):
255
+ if k in d:
256
+ try:
257
+ return int(round(float(str(d[k]).strip().rstrip("%"))))
258
+ except Exception:
259
+ break
260
+ return 0
261
+
262
+ def recent_lessons_for_student(student_id: int, limit: int = 5):
263
+ return _json_or_raise(_req("GET", f"/students/{student_id}/recent", params={"limit": limit}))
264
+
265
+ def list_classes_for_student(student_id: int):
266
+ return _json_or_raise(_req("GET", f"/students/{student_id}/classes"))
267
 
268
+ def level_from_xp(xp: int):
269
+ return _json_or_raise(_req("GET", "/level-from-xp", params={"xp": xp}))["level"]
270
 
271
+ # ---------- Games ----------
272
  def record_money_match_play(user_id: int, target: int, total: int,
273
+ elapsed_ms: int, matched: bool, gained_xp: int | None = None):
274
  payload = {
275
+ "user_id": user_id,
276
+ "target": target,
277
+ "total": total,
278
+ "elapsed_ms": elapsed_ms,
279
+ "matched": bool(matched),
280
+ "gained_xp": gained_xp,
281
  }
282
+ return _json_or_raise(_req("POST", "/games/money_match/record", json=payload))
 
 
283
 
284
+ def record_budget_builder_play(user_id: int, weekly_allowance: int,
285
+ allocations: list[dict], gained_xp: int | None = None):
286
  payload = {
287
  "user_id": user_id,
288
  "weekly_allowance": weekly_allowance,
 
 
289
  "allocations": allocations,
290
  "gained_xp": gained_xp,
291
  }
292
+ return _json_or_raise(_req("POST", "/games/budget_builder/record", json=payload))
 
 
293
 
294
+ def record_debt_dilemma_play(user_id: int, *, level: int, round_no: int,
295
+ wallet: int, health: int, happiness: int, credit_score: int,
296
+ event_json: dict, outcome: str, gained_xp: int | None = None):
297
  payload = {
298
  "user_id": user_id,
299
+ "level": level,
300
+ "round_no": round_no,
301
+ "wallet": wallet,
302
+ "health": health,
303
+ "happiness": happiness,
304
+ "credit_score": credit_score,
305
+ "event_json": event_json,
306
+ "outcome": outcome,
307
  "gained_xp": gained_xp,
308
  }
309
+ return _json_or_raise(_req("POST", "/games/debt_dilemma/record", json=payload))
 
 
310
 
311
+ def record_profit_puzzler_play(user_id: int, scenario_id: str, title: str,
312
+ units: int, price: int, cost: int,
313
+ user_answer: float, actual_profit: float, is_correct: bool,
314
+ gained_xp: int | None = None):
315
+ payload = {
316
+ "user_id": user_id, "scenario_id": scenario_id, "title": title,
317
+ "units": units, "price": price, "cost": cost,
318
+ "user_answer": user_answer, "actual_profit": actual_profit,
319
+ "is_correct": bool(is_correct), "gained_xp": gained_xp,
320
+ }
321
+ return _json_or_raise(_req("POST", "/games/profit_puzzler/record", json=payload))
322
 
323
+ # ---------- RAG passthrough ----------
324
+ def retrieve(query: str, lesson_id: int, level_slug: str = "beginner", k: int = 5) -> str:
325
+ try:
326
+ d = _json_or_raise(_req("GET", "/retrieve",
327
+ params={"query": query, "lesson_id": lesson_id, "level_slug": level_slug, "k": k}))
328
+ return d.get("context", "")
329
+ except Exception as e:
330
+ return f"(retrieval failed: {e})"
331
+
332
+ def chat_ai(query: str, lesson_id: int, level_slug: str, history=None) -> str:
333
  payload = {
334
+ "query": query,
335
+ "lesson_id": int(lesson_id) if lesson_id is not None else 0,
336
+ "level_slug": level_slug or "beginner",
337
+ "history": history or [],
338
  }
339
+ try:
340
+ d = _json_or_raise(_req("POST", "/chat", json=payload, timeout=90))
341
+ return d.get("answer", "")
342
+ except Exception as e:
343
+ return f"(chat failed: {e})"
344
+
345
+ # ---------- LangGraph-backed ----------
346
+ def fetch_lesson_content(lesson: str, module: str, topic: str):
347
+ d = _json_or_raise(_req("POST", "/lesson", json={"lesson": lesson, "module": module, "topic": topic}))
348
+ return d.get("lesson_content", "")
349
+
350
+ def submit_lesson_quiz(lesson: str, module: str, topic: str, responses: dict):
351
+ return _json_or_raise(_req("POST", "/lesson-quiz",
352
+ json={"lesson": lesson, "module": module, "topic": topic, "responses": responses}))
353
+
354
+ def submit_practice_quiz(lesson: str, responses: dict):
355
+ return _json_or_raise(_req("POST", "/practice-quiz", json={"lesson": lesson, "responses": responses}))