lanna_lalala;- commited on
Commit
769dcb3
·
1 Parent(s): 0ed8333
Files changed (1) hide show
  1. phase/Student_view/lesson.py +108 -117
phase/Student_view/lesson.py CHANGED
@@ -1,7 +1,6 @@
1
  import os
2
  import re
3
  import random
4
- import requests
5
  from typing import List, Dict, Tuple, Optional
6
 
7
  import streamlit as st
@@ -10,8 +9,9 @@ import utils.api as api
10
 
11
  USE_LOCAL_DB = os.getenv("DISABLE_DB", "1") != "1"
12
 
 
13
  def _api_post(path: str, payload: dict):
14
- # try to reuse your utils.api client; gracefully no-op if unavailable
15
  try:
16
  if hasattr(api, "_req"):
17
  return api._req("POST", path, json=payload)
@@ -19,37 +19,53 @@ def _api_post(path: str, payload: dict):
19
  pass
20
  return None
21
 
 
22
  def _get_lesson(lesson_id: int):
23
  """Fetch a lesson from local DB if enabled, otherwise from the backend API."""
24
  if USE_LOCAL_DB and hasattr(dbapi, "get_lesson"):
25
  return dbapi.get_lesson(lesson_id)
26
  return api.get_lesson(lesson_id)
27
 
 
28
  def _save_progress(user_id: int, lesson_id: int, current_pos: int, status: str):
29
  """Best-effort save; don’t crash the UI if the backend route is missing."""
30
  try:
31
  if USE_LOCAL_DB and hasattr(dbapi, "save_progress"):
32
  return dbapi.save_progress(user_id, lesson_id, current_pos, status)
33
- # If you exposed an HTTP route in the backend (see note below), you can call it here.
34
- # Otherwise we silently skip saving when the DB is disabled.
35
  return True
36
  except Exception:
37
- # Keep the lesson page usable even if the save call fails.
38
  return False
39
-
40
-
41
- # ========== Optional direct agent imports (same process) ==========
42
- AGENTS_AVAILABLE = False
43
- try:
44
- from app.agents.schemas import AgentState # pydantic model
45
- from app.agents.nodes import (
46
- node_rag, node_auto_quiz, node_grade, node_chatbot
47
- )
48
- AGENTS_AVAILABLE = True
49
- except Exception:
50
- AGENTS_AVAILABLE = False
51
 
52
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
53
  # --- Load external CSS ---
54
  def load_css(file_name):
55
  try:
@@ -58,11 +74,13 @@ def load_css(file_name):
58
  except FileNotFoundError:
59
  st.warning("⚠️ Stylesheet not found. Please ensure 'assets/styles.css' exists.")
60
 
 
61
  # --- Settings ---
62
  MAX_TOPICS = 5 # show topics 1..5 only, then Summary
63
  MINI_QUIZ_MIN = 2
64
  MINI_QUIZ_MAX = 4
65
 
 
66
  # --- Data structure for lessons ---
67
  lessons_by_level = {
68
  "beginner": [
@@ -301,13 +319,14 @@ lessons_by_level = {
301
  ]
302
  }
303
 
304
- # --- Utility functions ---
305
 
 
306
  def get_display_topics(lesson: dict) -> List[str]:
307
  """Limit to topics 1..5 + Summary placeholder, hiding Play/old Quiz."""
308
  base = (lesson.get("topics") or [])[:MAX_TOPICS]
309
  return base + ["Summary"]
310
 
 
311
  def summarize_first_n_topics(lesson_id: int, n: int = MAX_TOPICS) -> str:
312
  """Very simple, deterministic summary of the first N topics (no LLM)."""
313
  bullets = []
@@ -317,6 +336,7 @@ def summarize_first_n_topics(lesson_id: int, n: int = MAX_TOPICS) -> str:
317
  bullets.append(f"• {first_sentence[0] if first_sentence else ('Key idea from topic ' + str(i))}")
318
  return "<br>".join(bullets) or "No summary available."
319
 
 
320
  # ---------- Mini-quiz generation ----------
321
  _DEFAULT_BANK: List[Dict] = [
322
  {"id": "b1", "question": "Why do people make a budget?",
@@ -333,21 +353,14 @@ _DEFAULT_BANK: List[Dict] = [
333
  "answer_key": "A"},
334
  ]
335
 
 
336
  def _fallback_auto_quiz() -> List[Dict]:
337
  k = random.randint(MINI_QUIZ_MIN, MINI_QUIZ_MAX)
338
  return random.sample(_DEFAULT_BANK, k)
339
 
340
- def _backend_post(path: str, payload: dict, timeout=6) -> dict | None:
341
- try:
342
- r = requests.post(f"{BACKEND_URL}{path}", json=payload, timeout=timeout)
343
- if r.ok:
344
- return r.json()
345
- except Exception:
346
- pass
347
- return None
348
 
349
  def fetch_auto_quiz_from_backend(lesson_title: str, module_id: str, topic_name: str):
350
- # Try new agents path first; fall back to legacy if present; else None
351
  resp = _api_post("/agents/quiz/auto",
352
  {"lesson": lesson_title, "module": module_id, "topic": topic_name}) \
353
  or _api_post("/agent/quiz", {"lesson_id": module_id, "level_slug": "beginner"})
@@ -368,121 +381,77 @@ def fetch_auto_quiz_from_backend(lesson_title: str, module_id: str, topic_name:
368
  })
369
  return out
370
 
 
371
  def grade_and_chat_via_backend(lesson_title: str, module_id: str, topic_name: str,
372
  responses: Dict[str, str]) -> Tuple[int, int, str]:
373
  """
374
- Calls your agent quiz endpoint that matches schemas.QuizRequest/QuizResponse.
375
- Returns (score, total, feedback)
376
  """
377
- resp = _backend_post("/agents/quiz", {
378
  "lesson": lesson_title,
379
  "module": module_id,
380
  "topic": topic_name,
381
  "responses": responses
382
  })
383
- if not resp:
384
- # legacy split endpoints (score only)
385
- grade_only = _backend_post("/agent/grade", {"answers": list(responses.values()),
386
- "lesson_id": module_id, "level_slug": "beginner"}) or {}
387
- score, total = int(grade_only.get("score", 0)), int(grade_only.get("total", len(responses)))
388
- # simple feedback
389
- fb = (_backend_post("/agent/coach_or_celebrate", {"answers": list(responses.values()),
390
- "lesson_id": module_id, "level_slug": "beginner"}) or {}).get("feedback") \
391
- or "Nice work! Review any tricky items and ask me questions."
392
- return score, total, fb
393
-
394
- grade_map = resp.get("grade", {})
395
- score = sum(1 for ok in grade_map.values() if ok)
396
- total = len(grade_map) or max(1, len(responses))
397
- feedback = resp.get("feedback", "Great job!")
398
- return score, total, feedback
399
 
400
- # ---------- Agent integration (in-process) ----------
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
401
  def agent_generate_quiz(lesson: dict) -> List[Dict]:
402
  """
403
- Prefer local agent nodes; fallback to HTTP; then fallback local bank.
404
  """
405
  title = lesson.get("title", f"Lesson {lesson.get('id')}")
406
  module_id = str(lesson.get("id"))
407
  topic_name = "Summary"
408
 
409
- if AGENTS_AVAILABLE:
410
- try:
411
- s = AgentState(lesson=title, module=module_id, topic=topic_name)
412
- # Retrieve content & auto-quiz
413
- s = node_rag(s)
414
- s = node_auto_quiz(s)
415
- qs = s.quiz_questions or []
416
- # normalize
417
- out = []
418
- for i, it in enumerate(qs, start=1):
419
- out.append({
420
- "id": it.get("id") or f"q{i}",
421
- "question": it.get("question",""),
422
- "options": it.get("options", []),
423
- "answer_key": it.get("answer_key","A")
424
- })
425
- if out:
426
- return random.sample(out, min(len(out), random.randint(MINI_QUIZ_MIN, MINI_QUIZ_MAX)))
427
- except Exception:
428
- pass
429
-
430
- # HTTP backend (optional)
431
  items = fetch_auto_quiz_from_backend(title, module_id, topic_name)
432
  if items:
433
  return random.sample(items, min(len(items), random.randint(MINI_QUIZ_MIN, MINI_QUIZ_MAX)))
434
 
435
- # local fallback
436
  return _fallback_auto_quiz()
437
 
 
438
  def agent_grade_and_chat(lesson: dict, items: List[Dict],
439
  answers_by_idx: Dict[int, str]) -> Tuple[int, int, str]:
440
  """
441
- Grade + get chatbot feedback using agents if available,
442
- else HTTP backend, else local grading with friendly feedback.
443
- answers_by_idx: {0:'A',1:'C',...}
444
  """
445
  title = lesson.get("title", f"Lesson {lesson.get('id')}")
446
  module_id = str(lesson.get("id"))
447
  topic_name = "Summary"
448
 
449
- # Build responses keyed by question IDs
450
  responses: Dict[str, str] = {}
451
  for i, it in enumerate(items):
452
  qid = it.get("id") or f"q{i+1}"
453
  responses[qid] = (answers_by_idx.get(i) or "").strip().upper()
454
 
455
- if AGENTS_AVAILABLE:
456
- try:
457
- s = AgentState(
458
- lesson=title, module=module_id, topic=topic_name,
459
- quiz_questions=items, # contains answer_key for extraction if needed
460
- responses=responses
461
- )
462
- # Grade first (uses quiz_questions -> correct_answers if missing)
463
- s = node_grade(s)
464
- # Fetch content so chatbot can reference it
465
- s = node_rag(s)
466
- s = node_chatbot(s)
467
-
468
- grade_map = s.grade or {}
469
- score = sum(1 for ok in grade_map.values() if ok)
470
- total = len(grade_map) or len(items)
471
- feedback = s.feedback or "Great job!"
472
- return score, total, feedback
473
- except Exception:
474
- pass
475
-
476
- # HTTP backend path (optional)
477
  backend_score, backend_total, backend_feedback = grade_and_chat_via_backend(title, module_id, topic_name, responses)
478
  if backend_total:
479
  return backend_score, backend_total, backend_feedback
480
 
481
- # Local fallback grading
482
  def grade_locally(items_: List[Dict], letters: List[str]) -> Tuple[int, int]:
483
- keys = [it.get("answer_key","A").strip().upper() for it in items_]
484
- raw = (letters or []) + [""]*len(keys)
485
- score = sum(1 for a,k in zip(raw, keys) if (a or "").strip().upper() == k)
486
  return score, len(keys)
487
 
488
  letters = [responses[it.get("id") or f"q{i+1}"] for i, it in enumerate(items)]
@@ -497,7 +466,7 @@ def _db_to_general_lesson_shape(L: dict, sections: list) -> dict:
497
  difficulty = {"beginner": "Easy", "intermediate": "Medium", "advanced": "Hard"}.get(level, "Easy")
498
  topics = [(s.get("title") or f"Topic {i+1}") for i, s in enumerate(sections)]
499
 
500
- duration = L.get("duration") or f"{max(6, len(topics)*6)} min"
501
 
502
  return {
503
  "id": int(L["lesson_id"]),
@@ -512,11 +481,13 @@ def _db_to_general_lesson_shape(L: dict, sections: list) -> dict:
512
  "_db_level": level,
513
  }
514
 
 
515
  def get_difficulty_color(difficulty):
516
  """Return color based on difficulty level"""
517
  colors = {"Easy": "#28a745", "Medium": "#ffc107", "Hard": "#dc3545"}
518
  return colors.get(difficulty, "#6c757d")
519
 
 
520
  def get_level_info(level):
521
  level_info = {
522
  "beginner": {"display": "🌱 Beginner", "color": "#28a745", "description": "Build your financial foundation"},
@@ -525,6 +496,7 @@ def get_level_info(level):
525
  }
526
  return level_info.get(level, level_info["beginner"])
527
 
 
528
  # --- Per-user lesson progress (in memory via session_state) ---
529
  def _ensure_progress_state():
530
  if "topic_progress" not in st.session_state:
@@ -532,22 +504,25 @@ def _ensure_progress_state():
532
  if "lesson_completed" not in st.session_state:
533
  st.session_state.lesson_completed = {} # {lesson_id: True/False}
534
 
 
535
  def _mark_topic_seen(lesson_id: int, topic_index: int):
536
  _ensure_progress_state()
537
  current = st.session_state.topic_progress.get(lesson_id, 0)
538
  st.session_state.topic_progress[lesson_id] = max(current, topic_index)
539
 
 
540
  def _is_lesson_completed(lesson_id: int) -> bool:
541
  _ensure_progress_state()
542
  return st.session_state.lesson_completed.get(lesson_id, False)
543
 
 
544
  def _complete_lesson(lesson):
545
  _ensure_progress_state()
546
  st.session_state.lesson_completed[lesson["id"]] = True
547
  lesson["completed"] = True
548
 
549
- #-----
550
 
 
551
  def show_lesson_cards(lessons, user_level):
552
  level_info = get_level_info(user_level)
553
 
@@ -630,6 +605,7 @@ def show_lesson_cards(lessons, user_level):
630
  st.session_state.selected_lesson = lesson["id"]
631
  st.rerun()
632
 
 
633
  def show_lesson_detail(lesson):
634
  units = len(get_display_topics(lesson))
635
  st.markdown(f"""
@@ -672,17 +648,26 @@ def show_lesson_detail(lesson):
672
  st.session_state.selected_lesson = None
673
  st.rerun()
674
 
 
675
  def load_topic_content(lesson_id, topic_index):
676
- """Load topic content for a specific lesson and topic index"""
677
- file_path = os.path.join("phase", "Student_view", "lessons", f"lesson_{lesson_id}", f"topic_{topic_index}.txt")
 
 
 
 
 
 
678
  if os.path.exists(file_path):
679
  try:
680
  with open(file_path, "r", encoding="utf-8") as f:
681
  return f.read()
682
  except Exception as e:
683
  return f"⚠️ Error loading topic content: {e}"
 
684
  return "⚠️ Topic content not available."
685
 
 
686
  def show_lesson_page(lesson, lessons):
687
  # Back link
688
  if st.button("⬅ Back to Lessons", key=f"back_top_{lesson['id']}"):
@@ -772,7 +757,7 @@ def show_lesson_page(lesson, lessons):
772
  # Last page = Summary → trigger the mini-quiz (2–4 questions)
773
  if not _is_lesson_completed(lesson["id"]):
774
  if st.button("✅ Complete Module", key=f"complete_{lesson['id']}", type="primary"):
775
- st.session_state.mini_quiz_items = generate_mini_quiz()
776
  st.session_state.mini_quiz_answers = {}
777
  st.session_state.mini_q_idx = 0
778
  st.session_state.show_mini_quiz = True
@@ -807,15 +792,16 @@ def show_lesson_page(lesson, lessons):
807
 
808
  st.markdown("---")
809
 
810
- #----- Mini-quiz UI (auto handoff to chatbot on submit) -----
811
 
 
812
  def render_mini_quiz_ui(lesson: dict):
813
  st.markdown("### 📝 Mini Quiz")
814
  items = st.session_state.get("mini_quiz_items") or []
815
  if not items:
816
  st.warning("No questions available.")
817
  if st.button("Back"):
818
- st.session_state.show_mini_quiz = False; st.rerun()
 
819
  return
820
 
821
  idx = int(st.session_state.get("mini_q_idx", 0))
@@ -835,10 +821,11 @@ def render_mini_quiz_ui(lesson: dict):
835
  c1, c2 = st.columns(2)
836
  with c1:
837
  if idx > 0 and st.button("⬅ Previous"):
838
- st.session_state.mini_q_idx -= 1; st.rerun()
 
839
  with c2:
840
- if st.button("Next ➡" if idx < len(items)-1 else "Submit Quiz"):
841
- if idx < len(items)-1:
842
  st.session_state.mini_q_idx += 1
843
  st.rerun()
844
  else:
@@ -855,7 +842,7 @@ def render_mini_quiz_ui(lesson: dict):
855
 
856
  # Mark completed & reset lesson state as we’re moving to chat
857
  _complete_lesson(lesson)
858
- for k in ["show_mini_quiz","mini_quiz_items","mini_quiz_answers","mini_q_idx","mini_quiz_result"]:
859
  st.session_state.pop(k, None)
860
 
861
  # === Auto-redirect to Chatbot page with agent feedback ===
@@ -875,10 +862,11 @@ def render_mini_quiz_ui(lesson: dict):
875
  st.experimental_rerun()
876
  return
877
 
878
- def render_assigned_lesson(lesson_id: int, assignment_id: int | None = None):
 
879
  """Render a teacher-assigned lesson from the DB using the SAME structure/CSS as general lessons."""
880
  user = st.session_state.user
881
- user_id = user["user_id"]
882
 
883
  data = _get_lesson(lesson_id) # {"lesson": {...}, "sections":[...] }
884
  if not data or not data.get("lesson"):
@@ -902,6 +890,7 @@ def render_assigned_lesson(lesson_id: int, assignment_id: int | None = None):
902
  else:
903
  show_db_lesson_detail(lesson)
904
 
 
905
  def show_db_lesson_detail(lesson):
906
  st.markdown(f"""
907
  <h1 style="font-size:2.5rem; margin-bottom:0;">{lesson['title']}</h1>
@@ -937,6 +926,7 @@ def show_db_lesson_detail(lesson):
937
  st.session_state.selected_lesson = None
938
  st.rerun()
939
 
 
940
  def show_db_lesson_page(lesson):
941
  # Back link – same as general
942
  if st.button("⬅ Back to Lessons", key=f"db_back_top_{lesson['id']}"):
@@ -951,7 +941,7 @@ def show_db_lesson_page(lesson):
951
  <div style="background: linear-gradient(135deg,#20c997,#17a2b8); padding: 1.5rem; border-radius: 12px; color: white; margin-bottom: 2rem;">
952
  <h2 style="margin:0;">{lesson['title']}</h2>
953
  <p style="margin:.3rem 0;">{lesson['description']}</p>
954
- <div style="margin-top:.5rem;">
955
  <span style="background:#ffffff22;padding:6px 12px;border-radius:6px;margin-right:6px;">⏱ {lesson['duration']}</span>
956
  <span style="background:#ffffff22;padding:6px 12px;border-radius:6px;margin-right:6px;">📘 Module {lesson['id']}</span>
957
  <span style="background:#ffffff22;padding:6px 12px;border-radius:6px;">⭐ {lesson['difficulty']}</span>
@@ -976,7 +966,7 @@ def show_db_lesson_page(lesson):
976
 
977
  st.subheader(f"Topic {st.session_state.current_topic}: {topic_name}")
978
 
979
- body = (sections[idx].get("content") or "⚠️ Topic content not available.")
980
  st.markdown(f"<div class='topic-content'>{body}</div>", unsafe_allow_html=True)
981
 
982
  prev_col, next_col = st.columns([1, 1])
@@ -1025,6 +1015,7 @@ def show_db_lesson_page(lesson):
1025
 
1026
  st.markdown("---")
1027
 
 
1028
  def show_page():
1029
  # Load CSS
1030
  css_path = os.path.join("assets", "styles.css")
 
1
  import os
2
  import re
3
  import random
 
4
  from typing import List, Dict, Tuple, Optional
5
 
6
  import streamlit as st
 
9
 
10
  USE_LOCAL_DB = os.getenv("DISABLE_DB", "1") != "1"
11
 
12
+
13
  def _api_post(path: str, payload: dict):
14
+ """Route all HTTP to the backend through utils.api; no direct URL handling here."""
15
  try:
16
  if hasattr(api, "_req"):
17
  return api._req("POST", path, json=payload)
 
19
  pass
20
  return None
21
 
22
+
23
  def _get_lesson(lesson_id: int):
24
  """Fetch a lesson from local DB if enabled, otherwise from the backend API."""
25
  if USE_LOCAL_DB and hasattr(dbapi, "get_lesson"):
26
  return dbapi.get_lesson(lesson_id)
27
  return api.get_lesson(lesson_id)
28
 
29
+
30
  def _save_progress(user_id: int, lesson_id: int, current_pos: int, status: str):
31
  """Best-effort save; don’t crash the UI if the backend route is missing."""
32
  try:
33
  if USE_LOCAL_DB and hasattr(dbapi, "save_progress"):
34
  return dbapi.save_progress(user_id, lesson_id, current_pos, status)
 
 
35
  return True
36
  except Exception:
 
37
  return False
 
 
 
 
 
 
 
 
 
 
 
 
38
 
39
+
40
+ def _resolve_lesson_title_by_id(lesson_id: int) -> str:
41
+ """Find the lesson title from the in-app catalog; fallback to 'Lesson {id}'."""
42
+ for lvl in lessons_by_level.values():
43
+ for l in lvl:
44
+ try:
45
+ if int(l.get("id")) == int(lesson_id):
46
+ return l.get("title") or f"Lesson {lesson_id}"
47
+ except Exception:
48
+ pass
49
+ return f"Lesson {lesson_id}"
50
+
51
+
52
+ def _fetch_lesson_content_via_backend(lesson_id: int, topic_index: int, *,
53
+ title: Optional[str] = None,
54
+ topic_name: Optional[str] = None) -> Optional[str]:
55
+ """
56
+ Ask the backend lesson agent for the content of a topic.
57
+ Expected shape: { lesson_content | content }
58
+ """
59
+ title = title or _resolve_lesson_title_by_id(lesson_id)
60
+ module_id = str(lesson_id)
61
+ topic = topic_name or f"Topic {int(topic_index)}"
62
+
63
+ resp = _api_post("/agents/lesson", {"lesson": title, "module": module_id, "topic": topic})
64
+ if isinstance(resp, dict):
65
+ return resp.get("lesson_content") or resp.get("content")
66
+ return None
67
+
68
+
69
  # --- Load external CSS ---
70
  def load_css(file_name):
71
  try:
 
74
  except FileNotFoundError:
75
  st.warning("⚠️ Stylesheet not found. Please ensure 'assets/styles.css' exists.")
76
 
77
+
78
  # --- Settings ---
79
  MAX_TOPICS = 5 # show topics 1..5 only, then Summary
80
  MINI_QUIZ_MIN = 2
81
  MINI_QUIZ_MAX = 4
82
 
83
+
84
  # --- Data structure for lessons ---
85
  lessons_by_level = {
86
  "beginner": [
 
319
  ]
320
  }
321
 
 
322
 
323
+ # --- Utility functions ---
324
  def get_display_topics(lesson: dict) -> List[str]:
325
  """Limit to topics 1..5 + Summary placeholder, hiding Play/old Quiz."""
326
  base = (lesson.get("topics") or [])[:MAX_TOPICS]
327
  return base + ["Summary"]
328
 
329
+
330
  def summarize_first_n_topics(lesson_id: int, n: int = MAX_TOPICS) -> str:
331
  """Very simple, deterministic summary of the first N topics (no LLM)."""
332
  bullets = []
 
336
  bullets.append(f"• {first_sentence[0] if first_sentence else ('Key idea from topic ' + str(i))}")
337
  return "<br>".join(bullets) or "No summary available."
338
 
339
+
340
  # ---------- Mini-quiz generation ----------
341
  _DEFAULT_BANK: List[Dict] = [
342
  {"id": "b1", "question": "Why do people make a budget?",
 
353
  "answer_key": "A"},
354
  ]
355
 
356
+
357
  def _fallback_auto_quiz() -> List[Dict]:
358
  k = random.randint(MINI_QUIZ_MIN, MINI_QUIZ_MAX)
359
  return random.sample(_DEFAULT_BANK, k)
360
 
 
 
 
 
 
 
 
 
361
 
362
  def fetch_auto_quiz_from_backend(lesson_title: str, module_id: str, topic_name: str):
363
+ """Prefer new agents route; fallback to legacy if present."""
364
  resp = _api_post("/agents/quiz/auto",
365
  {"lesson": lesson_title, "module": module_id, "topic": topic_name}) \
366
  or _api_post("/agent/quiz", {"lesson_id": module_id, "level_slug": "beginner"})
 
381
  })
382
  return out
383
 
384
+
385
  def grade_and_chat_via_backend(lesson_title: str, module_id: str, topic_name: str,
386
  responses: Dict[str, str]) -> Tuple[int, int, str]:
387
  """
388
+ Calls /agents/quiz (schemas.QuizRequest -> QuizResponse).
389
+ Returns (score, total, feedback).
390
  """
391
+ resp = _api_post("/agents/quiz", {
392
  "lesson": lesson_title,
393
  "module": module_id,
394
  "topic": topic_name,
395
  "responses": responses
396
  })
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
397
 
398
+ if isinstance(resp, dict) and (resp.get("grade") or resp.get("feedback")):
399
+ grade_map = resp.get("grade", {})
400
+ score = sum(1 for ok in grade_map.values() if ok)
401
+ total = len(grade_map) or max(1, len(responses))
402
+ feedback = resp.get("feedback", "Great job!")
403
+ return score, total, feedback
404
+
405
+ # Legacy split fallback
406
+ grade_only = _api_post("/agent/grade", {"answers": list(responses.values()),
407
+ "lesson_id": module_id, "level_slug": "beginner"}) or {}
408
+ score = int(grade_only.get("score", 0))
409
+ total = int(grade_only.get("total", len(responses)))
410
+ fb = (_api_post("/agent/coach_or_celebrate", {"answers": list(responses.values()),
411
+ "lesson_id": module_id, "level_slug": "beginner"}) or {}).get("feedback") \
412
+ or "Nice work! Review any tricky items and ask me questions."
413
+ return score, total, fb
414
+
415
+
416
+ # ---------- Agent integration (HTTP) ----------
417
  def agent_generate_quiz(lesson: dict) -> List[Dict]:
418
  """
419
+ Use backend agents to generate the mini-quiz; fallback to local bank if backend returns nothing.
420
  """
421
  title = lesson.get("title", f"Lesson {lesson.get('id')}")
422
  module_id = str(lesson.get("id"))
423
  topic_name = "Summary"
424
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
425
  items = fetch_auto_quiz_from_backend(title, module_id, topic_name)
426
  if items:
427
  return random.sample(items, min(len(items), random.randint(MINI_QUIZ_MIN, MINI_QUIZ_MAX)))
428
 
 
429
  return _fallback_auto_quiz()
430
 
431
+
432
  def agent_grade_and_chat(lesson: dict, items: List[Dict],
433
  answers_by_idx: Dict[int, str]) -> Tuple[int, int, str]:
434
  """
435
+ Build {qid: 'A'|'B'...} and ask the backend agent to grade + coach.
 
 
436
  """
437
  title = lesson.get("title", f"Lesson {lesson.get('id')}")
438
  module_id = str(lesson.get("id"))
439
  topic_name = "Summary"
440
 
 
441
  responses: Dict[str, str] = {}
442
  for i, it in enumerate(items):
443
  qid = it.get("id") or f"q{i+1}"
444
  responses[qid] = (answers_by_idx.get(i) or "").strip().upper()
445
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
446
  backend_score, backend_total, backend_feedback = grade_and_chat_via_backend(title, module_id, topic_name, responses)
447
  if backend_total:
448
  return backend_score, backend_total, backend_feedback
449
 
450
+ # Local safety net
451
  def grade_locally(items_: List[Dict], letters: List[str]) -> Tuple[int, int]:
452
+ keys = [it.get("answer_key", "A").strip().upper() for it in items_]
453
+ raw = (letters or []) + [""] * len(keys)
454
+ score = sum(1 for a, k in zip(raw, keys) if (a or "").strip().upper() == k)
455
  return score, len(keys)
456
 
457
  letters = [responses[it.get("id") or f"q{i+1}"] for i, it in enumerate(items)]
 
466
  difficulty = {"beginner": "Easy", "intermediate": "Medium", "advanced": "Hard"}.get(level, "Easy")
467
  topics = [(s.get("title") or f"Topic {i+1}") for i, s in enumerate(sections)]
468
 
469
+ duration = L.get("duration") or f"{max(6, len(topics) * 6)} min"
470
 
471
  return {
472
  "id": int(L["lesson_id"]),
 
481
  "_db_level": level,
482
  }
483
 
484
+
485
  def get_difficulty_color(difficulty):
486
  """Return color based on difficulty level"""
487
  colors = {"Easy": "#28a745", "Medium": "#ffc107", "Hard": "#dc3545"}
488
  return colors.get(difficulty, "#6c757d")
489
 
490
+
491
  def get_level_info(level):
492
  level_info = {
493
  "beginner": {"display": "🌱 Beginner", "color": "#28a745", "description": "Build your financial foundation"},
 
496
  }
497
  return level_info.get(level, level_info["beginner"])
498
 
499
+
500
  # --- Per-user lesson progress (in memory via session_state) ---
501
  def _ensure_progress_state():
502
  if "topic_progress" not in st.session_state:
 
504
  if "lesson_completed" not in st.session_state:
505
  st.session_state.lesson_completed = {} # {lesson_id: True/False}
506
 
507
+
508
  def _mark_topic_seen(lesson_id: int, topic_index: int):
509
  _ensure_progress_state()
510
  current = st.session_state.topic_progress.get(lesson_id, 0)
511
  st.session_state.topic_progress[lesson_id] = max(current, topic_index)
512
 
513
+
514
  def _is_lesson_completed(lesson_id: int) -> bool:
515
  _ensure_progress_state()
516
  return st.session_state.lesson_completed.get(lesson_id, False)
517
 
518
+
519
  def _complete_lesson(lesson):
520
  _ensure_progress_state()
521
  st.session_state.lesson_completed[lesson["id"]] = True
522
  lesson["completed"] = True
523
 
 
524
 
525
+ # -----
526
  def show_lesson_cards(lessons, user_level):
527
  level_info = get_level_info(user_level)
528
 
 
605
  st.session_state.selected_lesson = lesson["id"]
606
  st.rerun()
607
 
608
+
609
  def show_lesson_detail(lesson):
610
  units = len(get_display_topics(lesson))
611
  st.markdown(f"""
 
648
  st.session_state.selected_lesson = None
649
  st.rerun()
650
 
651
+
652
  def load_topic_content(lesson_id, topic_index):
653
+ """Fetch topic content from backend agents; fallback to new backend path under app/lessons if present."""
654
+ # primary: backend agents
655
+ text = _fetch_lesson_content_via_backend(lesson_id, topic_index)
656
+ if text:
657
+ return text
658
+
659
+ # fallback: backend’s repo path (moved under app/)
660
+ file_path = os.path.join("app", "lessons", f"lesson_{lesson_id}", f"topic_{topic_index}.txt")
661
  if os.path.exists(file_path):
662
  try:
663
  with open(file_path, "r", encoding="utf-8") as f:
664
  return f.read()
665
  except Exception as e:
666
  return f"⚠️ Error loading topic content: {e}"
667
+
668
  return "⚠️ Topic content not available."
669
 
670
+
671
  def show_lesson_page(lesson, lessons):
672
  # Back link
673
  if st.button("⬅ Back to Lessons", key=f"back_top_{lesson['id']}"):
 
757
  # Last page = Summary → trigger the mini-quiz (2–4 questions)
758
  if not _is_lesson_completed(lesson["id"]):
759
  if st.button("✅ Complete Module", key=f"complete_{lesson['id']}", type="primary"):
760
+ st.session_state.mini_quiz_items = agent_generate_quiz(lesson)
761
  st.session_state.mini_quiz_answers = {}
762
  st.session_state.mini_q_idx = 0
763
  st.session_state.show_mini_quiz = True
 
792
 
793
  st.markdown("---")
794
 
 
795
 
796
+ # ----- Mini-quiz UI (auto handoff to chatbot on submit) -----
797
  def render_mini_quiz_ui(lesson: dict):
798
  st.markdown("### 📝 Mini Quiz")
799
  items = st.session_state.get("mini_quiz_items") or []
800
  if not items:
801
  st.warning("No questions available.")
802
  if st.button("Back"):
803
+ st.session_state.show_mini_quiz = False
804
+ st.rerun()
805
  return
806
 
807
  idx = int(st.session_state.get("mini_q_idx", 0))
 
821
  c1, c2 = st.columns(2)
822
  with c1:
823
  if idx > 0 and st.button("⬅ Previous"):
824
+ st.session_state.mini_q_idx -= 1
825
+ st.rerun()
826
  with c2:
827
+ if st.button("Next ➡" if idx < len(items) - 1 else "Submit Quiz"):
828
+ if idx < len(items) - 1:
829
  st.session_state.mini_q_idx += 1
830
  st.rerun()
831
  else:
 
842
 
843
  # Mark completed & reset lesson state as we’re moving to chat
844
  _complete_lesson(lesson)
845
+ for k in ["show_mini_quiz", "mini_quiz_items", "mini_quiz_answers", "mini_q_idx", "mini_quiz_result"]:
846
  st.session_state.pop(k, None)
847
 
848
  # === Auto-redirect to Chatbot page with agent feedback ===
 
862
  st.experimental_rerun()
863
  return
864
 
865
+
866
+ def render_assigned_lesson(lesson_id: int, assignment_id: Optional[int] = None):
867
  """Render a teacher-assigned lesson from the DB using the SAME structure/CSS as general lessons."""
868
  user = st.session_state.user
869
+ user_id = user["user_id"] # retained for future use
870
 
871
  data = _get_lesson(lesson_id) # {"lesson": {...}, "sections":[...] }
872
  if not data or not data.get("lesson"):
 
890
  else:
891
  show_db_lesson_detail(lesson)
892
 
893
+
894
  def show_db_lesson_detail(lesson):
895
  st.markdown(f"""
896
  <h1 style="font-size:2.5rem; margin-bottom:0;">{lesson['title']}</h1>
 
926
  st.session_state.selected_lesson = None
927
  st.rerun()
928
 
929
+
930
  def show_db_lesson_page(lesson):
931
  # Back link – same as general
932
  if st.button("⬅ Back to Lessons", key=f"db_back_top_{lesson['id']}"):
 
941
  <div style="background: linear-gradient(135deg,#20c997,#17a2b8); padding: 1.5rem; border-radius: 12px; color: white; margin-bottom: 2rem;">
942
  <h2 style="margin:0;">{lesson['title']}</h2>
943
  <p style="margin:.3rem 0;">{lesson['description']}</p>
944
+ <div style="margin-top:.5rem%;">
945
  <span style="background:#ffffff22;padding:6px 12px;border-radius:6px;margin-right:6px;">⏱ {lesson['duration']}</span>
946
  <span style="background:#ffffff22;padding:6px 12px;border-radius:6px;margin-right:6px;">📘 Module {lesson['id']}</span>
947
  <span style="background:#ffffff22;padding:6px 12px;border-radius:6px;">⭐ {lesson['difficulty']}</span>
 
966
 
967
  st.subheader(f"Topic {st.session_state.current_topic}: {topic_name}")
968
 
969
+ body = (sections[idx].get("content") or "⚠️ Topic content not available.") if idx < len(sections) else "⚠️ Topic content not available."
970
  st.markdown(f"<div class='topic-content'>{body}</div>", unsafe_allow_html=True)
971
 
972
  prev_col, next_col = st.columns([1, 1])
 
1015
 
1016
  st.markdown("---")
1017
 
1018
+
1019
  def show_page():
1020
  # Load CSS
1021
  css_path = os.path.join("assets", "styles.css")