lanna_lalala;- commited on
Commit
3bd2e1a
·
1 Parent(s): c0b5bc1

update lesson.py -alanna

Browse files
Files changed (1) hide show
  1. phase/Student_view/lesson.py +284 -312
phase/Student_view/lesson.py CHANGED
@@ -1,41 +1,68 @@
 
1
  import os
2
  import re
3
  import random
4
  import requests
5
  from typing import List, Dict, Tuple, Optional
6
 
7
-
8
  import streamlit as st
9
- from utils import db as dbapi
10
 
 
 
 
11
 
12
- # ========== Optional direct agent imports (same process) ==========
13
- AGENTS_AVAILABLE = False
14
- try:
15
- from app.agents.schemas import AgentState # pydantic model
16
- from app.agents.nodes import (
17
- node_rag, node_auto_quiz, node_grade, node_chatbot
 
18
  )
19
- AGENTS_AVAILABLE = True
20
- except Exception:
21
- AGENTS_AVAILABLE = False
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
22
 
23
-
24
- # --- Load external CSS ---
25
- def load_css(file_name):
26
  try:
27
- with open(file_name, 'r') as f:
28
  st.markdown(f"<style>{f.read()}</style>", unsafe_allow_html=True)
29
  except FileNotFoundError:
30
  st.warning("⚠️ Stylesheet not found. Please ensure 'assets/styles.css' exists.")
31
 
32
- # --- Settings ---
 
 
 
33
  MAX_TOPICS = 5 # show topics 1..5 only, then Summary
34
  MINI_QUIZ_MIN = 2
35
  MINI_QUIZ_MAX = 4
36
 
37
- # --- Data structure for lessons ---
38
- {
 
 
 
39
  "beginner": [
40
  {
41
  "id": 1,
@@ -53,8 +80,8 @@ MINI_QUIZ_MAX = 4
53
  "Spending Wisely",
54
  "Play: Money Match",
55
  "Quiz",
56
- "Summary: My Money Plan"
57
- ]
58
  },
59
  {
60
  "id": 2,
@@ -72,8 +99,8 @@ MINI_QUIZ_MAX = 4
72
  "Making Choices",
73
  "Play: Budget Builder",
74
  "Quiz",
75
- "Summary: My First Budget"
76
- ]
77
  },
78
  {
79
  "id": 3,
@@ -91,8 +118,8 @@ MINI_QUIZ_MAX = 4
91
  "Money Safety",
92
  "Play: Piggy Bank Challenge",
93
  "Quiz",
94
- "Summary: Money Journal"
95
- ]
96
  },
97
  {
98
  "id": 4,
@@ -110,9 +137,9 @@ MINI_QUIZ_MAX = 4
110
  "Advertising Basics",
111
  "Play: Smart Shopper",
112
  "Quiz",
113
- "Summary: My Business Plan"
114
- ]
115
- }
116
  ],
117
  "intermediate": [
118
  {
@@ -131,8 +158,8 @@ MINI_QUIZ_MAX = 4
131
  "Tracking Spending",
132
  "Play: Budget Tracker",
133
  "Quiz",
134
- "Summary: My Monthly Budget"
135
- ]
136
  },
137
  {
138
  "id": 6,
@@ -150,8 +177,8 @@ MINI_QUIZ_MAX = 4
150
  "Loss Prevention",
151
  "Play: Profit Planner",
152
  "Quiz",
153
- "Summary: My Profit Plan"
154
- ]
155
  },
156
  {
157
  "id": 7,
@@ -169,8 +196,8 @@ MINI_QUIZ_MAX = 4
169
  "Banking Safety",
170
  "Play: Bank Manager",
171
  "Quiz",
172
- "Summary: My Bank Plan"
173
- ]
174
  },
175
  {
176
  "id": 8,
@@ -188,9 +215,9 @@ MINI_QUIZ_MAX = 4
188
  "Economic Choices",
189
  "Play: Market Simulator",
190
  "Quiz",
191
- "Summary: My Market Plan"
192
- ]
193
- }
194
  ],
195
  "advanced": [
196
  {
@@ -209,8 +236,8 @@ MINI_QUIZ_MAX = 4
209
  "Financial Goals",
210
  "Play: Financial Planner",
211
  "Quiz",
212
- "Summary: My Financial Plan"
213
- ]
214
  },
215
  {
216
  "id": 10,
@@ -228,8 +255,8 @@ MINI_QUIZ_MAX = 4
228
  "Scaling a Business",
229
  "Play: Business Tycoon",
230
  "Quiz",
231
- "Summary: My Business Growth Plan"
232
- ]
233
  },
234
  {
235
  "id": 11,
@@ -247,8 +274,8 @@ MINI_QUIZ_MAX = 4
247
  "Investment Risks",
248
  "Play: Investment Game",
249
  "Quiz",
250
- "Summary: My Investment Plan"
251
- ]
252
  },
253
  {
254
  "id": 12,
@@ -266,29 +293,68 @@ MINI_QUIZ_MAX = 4
266
  "Economic Impact",
267
  "Play: Economy Builder",
268
  "Quiz",
269
- "Summary: My Economic Model"
270
- ]
271
- }
272
- ]
273
  }
274
 
275
- # --- Utility functions ---
 
 
276
 
277
  def get_display_topics(lesson: dict) -> List[str]:
278
  """Limit to topics 1..5 + Summary placeholder, hiding Play/old Quiz."""
279
  base = (lesson.get("topics") or [])[:MAX_TOPICS]
280
  return base + ["Summary"]
281
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
282
  def summarize_first_n_topics(lesson_id: int, n: int = MAX_TOPICS) -> str:
283
- """Very simple, deterministic summary of the first N topics (no LLM)."""
284
- bullets = []
285
- for i in range(1, n + 1):
286
- text = load_topic_content(lesson_id, i)
287
- first_sentence = re.split(r'(?<=[.!?])\s+', (text or "").strip())[:1]
288
- bullets.append(f"• {first_sentence[0] if first_sentence else ('Key idea from topic ' + str(i))}")
289
- return "<br>".join(bullets) or "No summary available."
290
-
291
- # ---------- Mini-quiz generation ----------
292
  _DEFAULT_BANK: List[Dict] = [
293
  {"id": "b1", "question": "Why do people make a budget?",
294
  "options": ["To plan money and make choices", "To waste money", "To hide money", "To lose track"],
@@ -308,107 +374,78 @@ def _fallback_auto_quiz() -> List[Dict]:
308
  k = random.randint(MINI_QUIZ_MIN, MINI_QUIZ_MAX)
309
  return random.sample(_DEFAULT_BANK, k)
310
 
311
- def _backend_post(path: str, payload: dict, timeout=6) -> dict | None:
312
- try:
313
- r = requests.post(f"{BACKEND_URL}{path}", json=payload, timeout=timeout)
314
- if r.ok:
315
- return r.json()
316
- except Exception:
317
- pass
318
- return None
319
-
320
- def fetch_auto_quiz_from_backend() -> List[Dict] | None:
321
- # Uses your legacy stub: /agent/quiz returns {"items":[{question,options,answer_key,points}...]}
322
- resp = _backend_post("/agents/quiz/auto",
323
- {"lesson": lesson_title, "module": module_id, "topic": topic_name}) \
324
- or _backend_post("/agent/quiz", {"lesson_id": module_id, "level_slug": "beginner"})
325
- items = None
326
- if resp:
327
- items = resp.get("questions") or resp.get("items")
328
- if items and isinstance(items, list):
329
- out = []
330
- for i, it in enumerate(items, start=1):
331
- out.append({
332
- "id": it.get("id") or f"auto_{i}",
333
- "question": it.get("question",""),
334
- "options": it.get("options", []),
335
- "answer_key": it.get("answer_key","A")
336
- })
337
- return out
338
- return None
339
 
340
  def grade_and_chat_via_backend(lesson_title: str, module_id: str, topic_name: str,
341
  responses: Dict[str, str]) -> Tuple[int, int, str]:
342
  """
343
- Calls your agent quiz endpoint that matches schemas.QuizRequest/QuizResponse.
344
- Returns (score, total, feedback)
 
345
  """
346
- resp = _backend_post("/agents/quiz", {
347
- "lesson": lesson_title,
348
- "module": module_id,
349
- "topic": topic_name,
350
- "responses": responses
351
  })
352
- if not resp:
353
- # legacy split endpoints (score only)
354
- grade_only = _backend_post("/agent/grade", {"answers": list(responses.values()),
355
- "lesson_id": module_id, "level_slug": "beginner"}) or {}
356
- score, total = int(grade_only.get("score", 0)), int(grade_only.get("total", len(responses)))
357
- # simple feedback
358
- fb = (_backend_post("/agent/coach_or_celebrate", {"answers": list(responses.values()),
359
- "lesson_id": module_id, "level_slug": "beginner"}) or {}).get("feedback") \
360
- or "Nice work! Review any tricky items and ask me questions."
361
- return score, total, fb
362
-
363
- grade_map = resp.get("grade", {})
364
- score = sum(1 for ok in grade_map.values() if ok)
365
- total = len(grade_map) or max(1, len(responses))
366
- feedback = resp.get("feedback", "Great job!")
 
 
 
 
 
 
367
  return score, total, feedback
368
 
369
- # ---------- Agent integration (in-process) ----------
370
  def agent_generate_quiz(lesson: dict) -> List[Dict]:
371
  """
372
- Prefer local agent nodes; fallback to HTTP; then fallback local bank.
 
373
  """
374
  title = lesson.get("title", f"Lesson {lesson.get('id')}")
375
  module_id = str(lesson.get("id"))
376
  topic_name = "Summary"
377
 
378
- if AGENTS_AVAILABLE:
379
- try:
380
- s = AgentState(lesson=title, module=module_id, topic=topic_name)
381
- # Retrieve content & auto-quiz
382
- s = node_rag(s)
383
- s = node_auto_quiz(s)
384
- qs = s.quiz_questions or []
385
- # normalize
386
- out = []
387
- for i, it in enumerate(qs, start=1):
388
- out.append({
389
- "id": it.get("id") or f"q{i}",
390
- "question": it.get("question",""),
391
- "options": it.get("options", []),
392
- "answer_key": it.get("answer_key","A")
393
- })
394
- if out:
395
- return random.sample(out, min(len(out), random.randint(MINI_QUIZ_MIN, MINI_QUIZ_MAX)))
396
- except Exception:
397
- pass
398
-
399
- # HTTP backend (optional)
400
  items = fetch_auto_quiz_from_backend(title, module_id, topic_name)
401
  if items:
402
  return random.sample(items, min(len(items), random.randint(MINI_QUIZ_MIN, MINI_QUIZ_MAX)))
403
-
404
- # local fallback
405
  return _fallback_auto_quiz()
406
 
407
  def agent_grade_and_chat(lesson: dict, items: List[Dict],
408
  answers_by_idx: Dict[int, str]) -> Tuple[int, int, str]:
409
  """
410
- Grade + get chatbot feedback using agents if available,
411
- else HTTP backend, else local grading with friendly feedback.
412
  answers_by_idx: {0:'A',1:'C',...}
413
  """
414
  title = lesson.get("title", f"Lesson {lesson.get('id')}")
@@ -421,85 +458,31 @@ def agent_grade_and_chat(lesson: dict, items: List[Dict],
421
  qid = it.get("id") or f"q{i+1}"
422
  responses[qid] = (answers_by_idx.get(i) or "").strip().upper()
423
 
424
- if AGENTS_AVAILABLE:
425
- try:
426
- s = AgentState(
427
- lesson=title, module=module_id, topic=topic_name,
428
- quiz_questions=items, # contains answer_key for extraction if needed
429
- responses=responses
430
- )
431
- # Grade first (uses quiz_questions -> correct_answers if missing)
432
- s = node_grade(s)
433
- # Fetch content so chatbot can reference it
434
- s = node_rag(s)
435
- s = node_chatbot(s)
436
-
437
- grade_map = s.grade or {}
438
- score = sum(1 for ok in grade_map.values() if ok)
439
- total = len(grade_map) or len(items)
440
- feedback = s.feedback or "Great job!"
441
- return score, total, feedback
442
- except Exception:
443
- pass
444
-
445
- # HTTP backend path (optional)
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 fallback grading
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)]
458
- score, total = grade_locally(items, letters)
459
- feedback = "Nice work! I can walk you through any tricky ones—ask me in the chat."
460
- return score, total, feedback
461
 
 
 
 
462
 
463
- def _db_to_general_lesson_shape(L: dict, sections: list) -> dict:
464
- """Convert a DB lesson+sections into the same dict shape used by general lessons."""
465
- level = (L.get("level") or "beginner").lower()
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"]),
473
- "title": L.get("title") or "Untitled",
474
- "description": L.get("description") or "",
475
- "duration": duration,
476
- "completed": False,
477
- "locked": False,
478
- "difficulty": difficulty,
479
- "topics": topics,
480
- "_db_sections": sections,
481
- "_db_level": level,
482
- }
483
-
484
- def get_difficulty_color(difficulty):
485
- """Return color based on difficulty level"""
486
- colors = {"Easy": "#28a745", "Medium": "#ffc107", "Hard": "#dc3545"}
487
- return colors.get(difficulty, "#6c757d")
488
-
489
- def get_level_info(level):
490
- level_info = {
491
- "beginner": {"display": "🌱 Beginner", "color": "#28a745", "description": "Build your financial foundation"},
492
- "intermediate": {"display": "🚀 Intermediate", "color": "#007bff", "description": "Grow your financial knowledge"},
493
- "advanced": {"display": "🎓 Advanced", "color": "#6f42c1", "description": "Master advanced strategies"},
494
- }
495
- return level_info.get(level, level_info["beginner"])
496
-
497
- # --- Per-user lesson progress (in memory via session_state) ---
498
  def _ensure_progress_state():
499
  if "topic_progress" not in st.session_state:
500
- st.session_state.topic_progress = {} # {lesson_id: max_topic_index_seen}
501
  if "lesson_completed" not in st.session_state:
502
- st.session_state.lesson_completed = {} # {lesson_id: True/False}
503
 
504
  def _mark_topic_seen(lesson_id: int, topic_index: int):
505
  _ensure_progress_state()
@@ -510,14 +493,16 @@ def _is_lesson_completed(lesson_id: int) -> bool:
510
  _ensure_progress_state()
511
  return st.session_state.lesson_completed.get(lesson_id, False)
512
 
513
- def _complete_lesson(lesson):
514
  _ensure_progress_state()
515
  st.session_state.lesson_completed[lesson["id"]] = True
516
  lesson["completed"] = True
517
 
518
- #-----
 
 
519
 
520
- def show_lesson_cards(lessons, user_level):
521
  level_info = get_level_info(user_level)
522
 
523
  # Level header
@@ -530,51 +515,34 @@ def show_lesson_cards(lessons, user_level):
530
 
531
  # Stats
532
  total_lessons = len(lessons)
533
- completed_lessons = sum(1 for lesson in lessons if lesson["completed"])
534
  progress_pct = int((completed_lessons / total_lessons) * 100) if total_lessons else 0
535
 
536
  col1, col2, col3, col4 = st.columns(4)
537
- with col1:
538
- st.metric("📚 Total Lessons", total_lessons)
539
- with col2:
540
- st.metric("✅ Completed", completed_lessons)
541
- with col3:
542
- st.metric("📈 Progress", f"{progress_pct}%")
543
  with col4:
544
- available_lessons = sum(1 for lesson in lessons if not lesson["locked"])
545
  st.metric("🔓 Available", available_lessons)
546
 
547
  st.markdown("---")
548
 
549
- # Display lessons in a 3-column grid like Microsoft Learn
550
  cols = st.columns(3)
551
  for i, lesson in enumerate(lessons):
552
  lesson["completed"] = lesson.get("completed") or _is_lesson_completed(lesson["id"])
553
-
554
  with cols[i % 3]:
555
- if lesson["locked"]:
556
- icon_html = '🔒'
557
- card_opacity = "0.6"
558
- elif lesson["completed"]:
559
- icon_html = '✓'
560
- card_opacity = "1"
561
  else:
562
- icon_html = '📖'
563
- card_opacity = "1"
564
 
565
  card_html = f"""
566
  <div class="lesson-card" style="
567
- background: white;
568
- border: 1px solid #e1e5e9;
569
- border-radius: 8px;
570
- padding: 24px;
571
- margin-bottom: 15px;
572
- opacity: {card_opacity};
573
- box-shadow: 0 2px 4px rgba(0,0,0,0.1);
574
- height: 250px;
575
- transition: all 0.3s ease;
576
- cursor: pointer;
577
- ">
578
  <div style="display: flex; justify-content: space-between; align-items: flex-start;">
579
  <h3 style="margin: 0; font-size: 18px; font-weight: 600; color: #323130; line-height: 1.3;">{lesson['title']}</h3>
580
  <div style="color: #28a745; font-size: 24px;">{icon_html}</div>
@@ -588,9 +556,9 @@ def show_lesson_cards(lessons, user_level):
588
  """
589
  st.markdown(card_html, unsafe_allow_html=True)
590
 
591
- if lesson["locked"]:
592
  st.button("🔒 Locked", key=f"lesson_locked_{lesson['id']}", disabled=True, use_container_width=True)
593
- elif lesson["completed"]:
594
  if st.button("✅ Review", key=f"lesson_review_{lesson['id']}", use_container_width=True, type="secondary"):
595
  st.session_state.selected_lesson = lesson["id"]
596
  st.rerun()
@@ -599,7 +567,7 @@ def show_lesson_cards(lessons, user_level):
599
  st.session_state.selected_lesson = lesson["id"]
600
  st.rerun()
601
 
602
- def show_lesson_detail(lesson):
603
  units = len(get_display_topics(lesson))
604
  st.markdown(f"""
605
  <h1 style="font-size:2.5rem; margin-bottom:0;">{lesson['title']}</h1>
@@ -607,7 +575,6 @@ def show_lesson_detail(lesson):
607
  <hr style="margin:1rem 0;"/>
608
  """, unsafe_allow_html=True)
609
 
610
- # Learning objectives section
611
  st.subheader("Learning objectives")
612
  st.markdown("<p style='color:#888; margin-bottom:0.5rem;'>In this module, you'll</p>", unsafe_allow_html=True)
613
  st.markdown(
@@ -617,7 +584,6 @@ def show_lesson_detail(lesson):
617
  "- Understanding simple financial transactions"
618
  )
619
 
620
- # Start button
621
  if st.button("▶ Start", key=f"start_{lesson['id']}", type="primary"):
622
  st.session_state.start_lesson = True
623
  st.session_state.current_topic = 1
@@ -628,32 +594,39 @@ def show_lesson_detail(lesson):
628
 
629
  st.markdown("---")
630
 
631
- # Topics list (with clickable links)
632
  st.subheader("Topics")
633
- display_topics = get_display_topics(lesson)
634
- for i, t in enumerate(display_topics, start=1):
635
  st.markdown(f"- [{t}](#topic-{i})")
636
 
637
  st.markdown("---")
638
 
639
- # Back button
640
  if st.button("⬅ Back to Lessons", key=f"back_{lesson['id']}"):
641
  st.session_state.selected_lesson = None
642
  st.rerun()
643
 
644
- def load_topic_content(lesson_id, topic_index):
645
- """Load topic content for a specific lesson and topic index"""
646
- file_path = os.path.join("phase", "Student_view", "lessons", f"lesson_{lesson_id}", f"topic_{topic_index}.txt")
647
- if os.path.exists(file_path):
648
- try:
649
- with open(file_path, "r", encoding="utf-8") as f:
650
- return f.read()
651
- except Exception as e:
652
- return f"⚠️ Error loading topic content: {e}"
653
- return "⚠️ Topic content not available."
654
-
655
- def show_lesson_page(lesson, lessons):
656
- # Back link
 
 
 
 
 
 
 
 
 
 
657
  if st.button("⬅ Back to Lessons", key=f"back_top_{lesson['id']}"):
658
  st.session_state.selected_lesson = None
659
  st.session_state.current_topic = 1
@@ -661,7 +634,6 @@ def show_lesson_page(lesson, lessons):
661
  st.session_state.start_lesson = False
662
  st.rerun()
663
 
664
- # Header
665
  st.markdown(f"""
666
  <div style="background: linear-gradient(135deg,#20c997,#17a2b8); padding: 1.5rem; border-radius: 12px; color: white; margin-bottom: 2rem;">
667
  <h2 style="margin:0;">{lesson['title']}</h2>
@@ -674,10 +646,9 @@ def show_lesson_page(lesson, lessons):
674
  </div>
675
  """, unsafe_allow_html=True)
676
 
677
- # Two-column layout
678
  col1, col2 = st.columns([2, 1])
679
 
680
- # --- LEFT COLUMN: Lesson content ---
681
  with col1:
682
  # If the mini-quiz is active, render it and return early
683
  if st.session_state.get("show_mini_quiz"):
@@ -688,17 +659,14 @@ def show_lesson_page(lesson, lessons):
688
  if "current_topic" not in st.session_state:
689
  st.session_state.current_topic = 1
690
 
691
- # Clamp to valid range in case topics changed
692
  st.session_state.current_topic = max(1, min(st.session_state.current_topic, len(topics)))
693
  topic_idx = st.session_state.current_topic - 1
694
  topic_name = topics[topic_idx]
695
 
696
- # Track progress (max topic reached)
697
  _mark_topic_seen(lesson["id"], st.session_state.current_topic)
698
 
699
  st.subheader(f"Topic {st.session_state.current_topic}: {topic_name}")
700
 
701
- # --- Special handling for Play / Quiz / Summary / Normal ---
702
  if topic_name.lower().startswith("play"):
703
  st.info("���� This is a game-based activity to reinforce learning.")
704
  st.write("👉 Instructions on how to play go here.")
@@ -706,21 +674,14 @@ def show_lesson_page(lesson, lessons):
706
  st.markdown('<a href="http://your-game-url.com" target="_blank">Open Game in New Tab</a>', unsafe_allow_html=True)
707
 
708
  elif "quiz" in topic_name.lower():
709
- # kept only for compatibility; normally hidden by get_display_topics
710
  st.info("📝 Quick quiz (legacy). This is superseded by the auto mini-quiz at completion.")
711
 
712
- elif topic_name.lower() == "summary":
713
- st.info("🧾 Module Summary")
714
- summary_html = summarize_first_n_topics(lesson["id"], MAX_TOPICS)
715
- st.markdown(f"<div class='topic-content'>{summary_html}</div>", unsafe_allow_html=True)
716
-
717
  else:
718
- content = load_topic_content(lesson["id"], st.session_state.current_topic)
719
- st.markdown(f"<div class='topic-content'>{content}</div>", unsafe_allow_html=True)
720
 
721
- # Topic navigation buttons
722
  prev_col, next_col = st.columns([1, 1])
723
-
724
  with prev_col:
725
  st.markdown("<div class='topic-nav-btn prev-btn'>", unsafe_allow_html=True)
726
  if st.session_state.current_topic > 1:
@@ -732,16 +693,15 @@ def show_lesson_page(lesson, lessons):
732
  with next_col:
733
  st.markdown("<div class='topic-nav-btn next-btn'>", unsafe_allow_html=True)
734
  is_last_topic = (st.session_state.current_topic >= len(topics))
735
-
736
  if not is_last_topic:
737
  if st.button("Next Topic ➡", key=f"next_topic_{lesson['id']}", type="primary"):
738
  st.session_state.current_topic += 1
739
  st.rerun()
740
  else:
741
- # Last page = Summary → trigger the mini-quiz (2–4 questions)
742
  if not _is_lesson_completed(lesson["id"]):
743
  if st.button("✅ Complete Module", key=f"complete_{lesson['id']}", type="primary"):
744
- st.session_state.mini_quiz_items = generate_mini_quiz()
745
  st.session_state.mini_quiz_answers = {}
746
  st.session_state.mini_q_idx = 0
747
  st.session_state.show_mini_quiz = True
@@ -750,7 +710,7 @@ def show_lesson_page(lesson, lessons):
750
  st.button("✅ Completed", key=f"completed_{lesson['id']}", disabled=True)
751
  st.markdown("</div>", unsafe_allow_html=True)
752
 
753
- # --- RIGHT COLUMN: Progress + Lesson navigation ---
754
  with col2:
755
  st.subheader("📊 Progress")
756
  _ensure_progress_state()
@@ -761,13 +721,11 @@ def show_lesson_page(lesson, lessons):
761
 
762
  st.subheader("➡ Lesson Navigation")
763
  idx = next((i for i, l in enumerate(lessons) if l["id"] == lesson["id"]), 0)
764
-
765
  if idx > 0:
766
  if st.button("⬅ Previous Lesson", key=f"prev_lesson_{lesson['id']}"):
767
  st.session_state.selected_lesson = lessons[idx - 1]["id"]
768
  st.session_state.current_topic = 1
769
  st.rerun()
770
-
771
  if idx < len(lessons) - 1:
772
  if st.button("Next Lesson ➡", key=f"next_lesson_{lesson['id']}"):
773
  st.session_state.selected_lesson = lessons[idx + 1]["id"]
@@ -776,7 +734,9 @@ def show_lesson_page(lesson, lessons):
776
 
777
  st.markdown("---")
778
 
779
- #----- Mini-quiz UI (auto handoff to chatbot on submit) -----
 
 
780
 
781
  def render_mini_quiz_ui(lesson: dict):
782
  st.markdown("### 📝 Mini Quiz")
@@ -784,7 +744,8 @@ def render_mini_quiz_ui(lesson: dict):
784
  if not items:
785
  st.warning("No questions available.")
786
  if st.button("Back"):
787
- st.session_state.show_mini_quiz = False; st.rerun()
 
788
  return
789
 
790
  idx = int(st.session_state.get("mini_q_idx", 0))
@@ -804,14 +765,15 @@ def render_mini_quiz_ui(lesson: dict):
804
  c1, c2 = st.columns(2)
805
  with c1:
806
  if idx > 0 and st.button("⬅ Previous"):
807
- st.session_state.mini_q_idx -= 1; st.rerun()
 
808
  with c2:
809
  if st.button("Next ➡" if idx < len(items)-1 else "Submit Quiz"):
810
  if idx < len(items)-1:
811
  st.session_state.mini_q_idx += 1
812
  st.rerun()
813
  else:
814
- # Build letters (A/B/C/...) from chosen options
815
  answers_by_idx: Dict[int, str] = {}
816
  for i, it in enumerate(items):
817
  sel_text = st.session_state.mini_quiz_answers.get(i, "")
@@ -819,20 +781,17 @@ def render_mini_quiz_ui(lesson: dict):
819
  letter = chr(65 + ops.index(sel_text)) if sel_text in ops else ""
820
  answers_by_idx[i] = letter
821
 
822
- # === Agent grade + chatbot feedback ===
823
  score, total, feedback = agent_grade_and_chat(lesson, items, answers_by_idx)
824
 
825
- # Mark completed & reset lesson state as we’re moving to chat
826
  _complete_lesson(lesson)
827
- for k in ["show_mini_quiz","mini_quiz_items","mini_quiz_answers","mini_q_idx","mini_quiz_result"]:
828
  st.session_state.pop(k, None)
829
 
830
- # === Auto-redirect to Chatbot page with agent feedback ===
831
  st.session_state.current_page = "Chatbot"
832
  if "messages" not in st.session_state:
833
  st.session_state.messages = []
834
 
835
- # Provide a concise results summary + agent feedback as assistant
836
  percent = int(round(100 * score / max(1, total)))
837
  summary = f"Mini-Quiz complete — Score: {percent}% ({score}/{total})."
838
  st.session_state.messages.append({
@@ -841,13 +800,34 @@ def render_mini_quiz_ui(lesson: dict):
841
  "sender": "assistant"
842
  })
843
  st.session_state.is_typing = False
844
- st.experimental_rerun()
845
  return
846
 
847
- def render_assigned_lesson(lesson_id: int, assignment_id: int | None = None):
848
- """Render a teacher-assigned lesson from the DB using the SAME structure/CSS as general lessons."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
849
  user = st.session_state.user
850
- user_id = user["user_id"]
851
 
852
  data = dbapi.get_lesson(lesson_id) # {"lesson": {...}, "sections":[...] }
853
  if not data or not data.get("lesson"):
@@ -871,7 +851,7 @@ def render_assigned_lesson(lesson_id: int, assignment_id: int | None = None):
871
  else:
872
  show_db_lesson_detail(lesson)
873
 
874
- def show_db_lesson_detail(lesson):
875
  st.markdown(f"""
876
  <h1 style="font-size:2.5rem; margin-bottom:0;">{lesson['title']}</h1>
877
  <p style="color: #666; margin-top:0;">{lesson['duration']} &nbsp;•&nbsp; Module &nbsp;•&nbsp; {len(lesson.get("topics", []))} units</p>
@@ -895,19 +875,16 @@ def show_db_lesson_detail(lesson):
895
  st.rerun()
896
 
897
  st.markdown("---")
898
-
899
  st.subheader("Topics")
900
  for i, t in enumerate(lesson.get("topics", []), start=1):
901
  st.markdown(f"- [{t}](#topic-{i})")
902
-
903
  st.markdown("---")
904
 
905
  if st.button("⬅ Back to Lessons", key=f"db_back_detail_{lesson['id']}"):
906
  st.session_state.selected_lesson = None
907
  st.rerun()
908
 
909
- def show_db_lesson_page(lesson):
910
- # Back link – same as general
911
  if st.button("⬅ Back to Lessons", key=f"db_back_top_{lesson['id']}"):
912
  st.session_state.selected_lesson = None
913
  st.session_state.current_topic = 1
@@ -915,7 +892,6 @@ def show_db_lesson_page(lesson):
915
  st.session_state.start_lesson = False
916
  st.rerun()
917
 
918
- # Header – same gradient & chips
919
  st.markdown(f"""
920
  <div style="background: linear-gradient(135deg,#20c997,#17a2b8); padding: 1.5rem; border-radius: 12px; color: white; margin-bottom: 2rem;">
921
  <h2 style="margin:0;">{lesson['title']}</h2>
@@ -928,10 +904,8 @@ def show_db_lesson_page(lesson):
928
  </div>
929
  """, unsafe_allow_html=True)
930
 
931
- # Layout
932
  col1, col2 = st.columns([2, 1])
933
 
934
- # LEFT: topic content (pulled from DB sections)
935
  with col1:
936
  topics = lesson.get("topics", [])
937
  sections = lesson.get("_db_sections", [])
@@ -944,7 +918,6 @@ def show_db_lesson_page(lesson):
944
  _mark_topic_seen(lesson["id"], st.session_state.current_topic)
945
 
946
  st.subheader(f"Topic {st.session_state.current_topic}: {topic_name}")
947
-
948
  body = (sections[idx].get("content") or "⚠️ Topic content not available.")
949
  st.markdown(f"<div class='topic-content'>{body}</div>", unsafe_allow_html=True)
950
 
@@ -977,7 +950,6 @@ def show_db_lesson_page(lesson):
977
  st.button("✅ Completed", key=f"db_completed_{lesson['id']}", disabled=True)
978
  st.markdown("</div>", unsafe_allow_html=True)
979
 
980
- # RIGHT: progress + lesson navigation – same UI
981
  with col2:
982
  st.subheader("📊 Progress")
983
  _ensure_progress_state()
@@ -994,14 +966,17 @@ def show_db_lesson_page(lesson):
994
 
995
  st.markdown("---")
996
 
 
 
 
 
997
  def show_page():
998
- # Load CSS
999
  css_path = os.path.join("assets", "styles.css")
1000
  load_css(css_path)
1001
-
1002
  st.markdown("""<style> .markdown { font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; line-height: 1.6;} </style>""", unsafe_allow_html=True)
1003
 
1004
- # Check login
1005
  if not st.session_state.get("user"):
1006
  st.error("❌ Please login first to access lessons.")
1007
  st.info("💡 Create an account to track your progress and unlock advanced content!")
@@ -1010,35 +985,32 @@ def show_page():
1010
  user = st.session_state.user
1011
  user_level = user.get("level", "beginner").lower()
1012
 
1013
- # Deep link from Teacher Link: if a selected_lesson isn't in the catalog, render it from the DB instead.
1014
  deep_lesson_id = st.session_state.get("selected_lesson")
1015
  deep_assignment_id = st.session_state.get("selected_assignment")
1016
 
1017
- # Try to resolve in catalog first
1018
  lessons = lessons_by_level.get(user_level, [])
1019
  catalog_ids = {l["id"] for l in lessons}
1020
 
 
1021
  if deep_lesson_id and deep_lesson_id not in catalog_ids:
1022
- # 👉 This is a teacher-assigned(DB) lesson. Open it directly.
1023
  render_assigned_lesson(int(deep_lesson_id), deep_assignment_id)
1024
  return
1025
 
1026
- # Initialize session state
1027
  if "selected_lesson" not in st.session_state:
1028
  st.session_state.selected_lesson = None
1029
  if "start_lesson" not in st.session_state:
1030
  st.session_state.start_lesson = False
1031
 
1032
- # If a lesson is selected
1033
  if st.session_state.selected_lesson:
1034
  lesson = next((l for l in lessons if l["id"] == st.session_state.selected_lesson), None)
1035
  if lesson:
1036
- if st.session_state.start_lesson: # 🚀 Jump straight into topics
1037
  show_lesson_page(lesson, lessons)
1038
  else:
1039
  show_lesson_detail(lesson)
1040
  else:
1041
  st.warning("⚠️ Lesson not found")
1042
  else:
1043
- # Otherwise → show lesson cards
1044
  show_lesson_cards(lessons, user_level)
 
1
+ # frontend/lesson.py
2
  import os
3
  import re
4
  import random
5
  import requests
6
  from typing import List, Dict, Tuple, Optional
7
 
 
8
  import streamlit as st
9
+ from utils import db as dbapi # still used for teacher-assigned(DB) lessons
10
 
11
+ # ===============================
12
+ # Backend integration (HF Spaces)
13
+ # ===============================
14
 
15
+ def _resolve_backend_url() -> str:
16
+ """
17
+ Resolve the backend base URL in a Spaces-friendly way.
18
+ Priority: environment -> st.secrets -> localhost (dev).
19
+ """
20
+ url = os.getenv("BACKEND_URL", "").strip() or (
21
+ getattr(st, "secrets", {}).get("BACKEND_URL", "").strip() if hasattr(st, "secrets") else ""
22
  )
23
+ if not url:
24
+ url = "http://localhost:8000" # dev fallback
25
+ return url.rstrip("/")
26
+
27
+ BACKEND_URL = _resolve_backend_url()
28
+
29
+ def _backend_post(path: str, payload: dict, timeout: float = 8.0) -> Optional[dict]:
30
+ """
31
+ POST to backend and return JSON (or None on error).
32
+ CORS is already allowed server-side; Streamlit calls from the server.
33
+ """
34
+ try:
35
+ r = requests.post(f"{BACKEND_URL}{path}", json=payload, timeout=timeout)
36
+ if r.ok:
37
+ return r.json()
38
+ except Exception:
39
+ pass
40
+ return None
41
+
42
+ # ===============================
43
+ # Visuals
44
+ # ===============================
45
 
46
+ def load_css(file_name: str):
 
 
47
  try:
48
+ with open(file_name, 'r', encoding="utf-8") as f:
49
  st.markdown(f"<style>{f.read()}</style>", unsafe_allow_html=True)
50
  except FileNotFoundError:
51
  st.warning("⚠️ Stylesheet not found. Please ensure 'assets/styles.css' exists.")
52
 
53
+ # ===============================
54
+ # Settings
55
+ # ===============================
56
+
57
  MAX_TOPICS = 5 # show topics 1..5 only, then Summary
58
  MINI_QUIZ_MIN = 2
59
  MINI_QUIZ_MAX = 4
60
 
61
+ # ===============================
62
+ # Catalog (moved into a variable)
63
+ # ===============================
64
+
65
+ lessons_by_level: Dict[str, List[dict]] = {
66
  "beginner": [
67
  {
68
  "id": 1,
 
80
  "Spending Wisely",
81
  "Play: Money Match",
82
  "Quiz",
83
+ "Summary: My Money Plan",
84
+ ],
85
  },
86
  {
87
  "id": 2,
 
99
  "Making Choices",
100
  "Play: Budget Builder",
101
  "Quiz",
102
+ "Summary: My First Budget",
103
+ ],
104
  },
105
  {
106
  "id": 3,
 
118
  "Money Safety",
119
  "Play: Piggy Bank Challenge",
120
  "Quiz",
121
+ "Summary: Money Journal",
122
+ ],
123
  },
124
  {
125
  "id": 4,
 
137
  "Advertising Basics",
138
  "Play: Smart Shopper",
139
  "Quiz",
140
+ "Summary: My Business Plan",
141
+ ],
142
+ },
143
  ],
144
  "intermediate": [
145
  {
 
158
  "Tracking Spending",
159
  "Play: Budget Tracker",
160
  "Quiz",
161
+ "Summary: My Monthly Budget",
162
+ ],
163
  },
164
  {
165
  "id": 6,
 
177
  "Loss Prevention",
178
  "Play: Profit Planner",
179
  "Quiz",
180
+ "Summary: My Profit Plan",
181
+ ],
182
  },
183
  {
184
  "id": 7,
 
196
  "Banking Safety",
197
  "Play: Bank Manager",
198
  "Quiz",
199
+ "Summary: My Bank Plan",
200
+ ],
201
  },
202
  {
203
  "id": 8,
 
215
  "Economic Choices",
216
  "Play: Market Simulator",
217
  "Quiz",
218
+ "Summary: My Market Plan",
219
+ ],
220
+ },
221
  ],
222
  "advanced": [
223
  {
 
236
  "Financial Goals",
237
  "Play: Financial Planner",
238
  "Quiz",
239
+ "Summary: My Financial Plan",
240
+ ],
241
  },
242
  {
243
  "id": 10,
 
255
  "Scaling a Business",
256
  "Play: Business Tycoon",
257
  "Quiz",
258
+ "Summary: My Business Growth Plan",
259
+ ],
260
  },
261
  {
262
  "id": 11,
 
274
  "Investment Risks",
275
  "Play: Investment Game",
276
  "Quiz",
277
+ "Summary: My Investment Plan",
278
+ ],
279
  },
280
  {
281
  "id": 12,
 
293
  "Economic Impact",
294
  "Play: Economy Builder",
295
  "Quiz",
296
+ "Summary: My Economic Model",
297
+ ],
298
+ },
299
+ ],
300
  }
301
 
302
+ # ===============================
303
+ # Utility helpers
304
+ # ===============================
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 get_difficulty_color(difficulty: str) -> str:
312
+ return {"Easy": "#28a745", "Medium": "#ffc107", "Hard": "#dc3545"}.get(difficulty, "#6c757d")
313
+
314
+ def get_level_info(level: str) -> dict:
315
+ level_info = {
316
+ "beginner": {"display": "🌱 Beginner", "color": "#28a745", "description": "Build your financial foundation"},
317
+ "intermediate": {"display": "🚀 Intermediate", "color": "#007bff", "description": "Grow your financial knowledge"},
318
+ "advanced": {"display": "🎓 Advanced", "color": "#6f42c1", "description": "Master advanced strategies"},
319
+ }
320
+ return level_info.get(level, level_info["beginner"])
321
+
322
+ # ===============================
323
+ # Topic content (from backend)
324
+ # ===============================
325
+
326
+ def _fetch_topic_content_via_backend(lesson_title: str, module_id: str, topic_name: str) -> Optional[str]:
327
+ """
328
+ Uses the new backend /lesson endpoint:
329
+ POST /lesson {lesson, module, topic} -> {lesson_content}
330
+ """
331
+ resp = _backend_post("/lesson", {"lesson": lesson_title, "module": module_id, "topic": topic_name})
332
+ if resp and isinstance(resp, dict):
333
+ content = resp.get("lesson_content")
334
+ if isinstance(content, str) and content.strip():
335
+ return content.strip()
336
+ return None
337
+ # (endpoint contract: /lesson and /lesson-quiz are defined in backend main.py) :contentReference[oaicite:0]{index=0}
338
+
339
+ def load_topic_content(lesson_id: int, topic_index: int) -> str:
340
+ """
341
+ Frontend no longer reads local files. We call the backend for each topic.
342
+ """
343
+ # Resolve the lesson & topic name from the catalog for the current user level.
344
+ # We don't know the level here; the caller passes the concrete lesson already, so
345
+ # this function will usually be called via show_lesson_page which has the lesson dict.
346
+ # To keep this utility, we'll compute names dynamically in the caller and pass through here.
347
+ return "⚠️ Topic loader expects to be called from show_lesson_page with the resolved names."
348
+
349
  def summarize_first_n_topics(lesson_id: int, n: int = MAX_TOPICS) -> str:
350
+ """Deterministic summary: first sentence from topics 1..N (no LLM)."""
351
+ # NOTE: We delegate actual fetch in show_lesson_page where we know the lesson dict.
352
+ return "Summary will appear after you navigate topics."
353
+
354
+ # ===============================
355
+ # Mini-quiz helpers
356
+ # ===============================
357
+
 
358
  _DEFAULT_BANK: List[Dict] = [
359
  {"id": "b1", "question": "Why do people make a budget?",
360
  "options": ["To plan money and make choices", "To waste money", "To hide money", "To lose track"],
 
374
  k = random.randint(MINI_QUIZ_MIN, MINI_QUIZ_MAX)
375
  return random.sample(_DEFAULT_BANK, k)
376
 
377
+ def fetch_auto_quiz_from_backend(lesson_title: str, module_id: str, topic_name: str) -> Optional[List[Dict]]:
378
+ """
379
+ Use the kept legacy stub to fetch items:
380
+ POST /agent/quiz -> {"items":[{question,options,answer_key,points}...]}
381
+ """
382
+ resp = _backend_post("/agent/quiz", {"student_id": 0, "lesson_id": int(module_id), "level_slug": "beginner"})
383
+ # Legacy shape still present in backend for compatibility. :contentReference[oaicite:1]{index=1}
384
+ items = (resp or {}).get("items")
385
+ if not isinstance(items, list):
386
+ return None
387
+ out = []
388
+ for i, it in enumerate(items, start=1):
389
+ out.append({
390
+ "id": it.get("id") or f"auto_{i}",
391
+ "question": it.get("question", ""),
392
+ "options": it.get("options", []) or [],
393
+ "answer_key": it.get("answer_key", "A"),
394
+ })
395
+ return out
 
 
 
 
 
 
 
 
 
396
 
397
  def grade_and_chat_via_backend(lesson_title: str, module_id: str, topic_name: str,
398
  responses: Dict[str, str]) -> Tuple[int, int, str]:
399
  """
400
+ Preferred grading path:
401
+ POST /lesson-quiz {lesson,module,topic,responses} -> {grade, feedback, next_steps}
402
+ Fallback to legacy /agent/grade + /agent/coach_or_celebrate.
403
  """
404
+ # New combined endpoint (LangGraph-backed). :contentReference[oaicite:2]{index=2}
405
+ resp = _backend_post("/lesson-quiz", {
406
+ "lesson": lesson_title, "module": module_id, "topic": topic_name, "responses": responses
 
 
407
  })
408
+ if resp and isinstance(resp, dict) and isinstance(resp.get("grade"), dict):
409
+ grade_map = resp["grade"]
410
+ score = sum(1 for ok in grade_map.values() if ok)
411
+ total = len(grade_map) or max(1, len(responses))
412
+ feedback = resp.get("feedback", "Great job!")
413
+ return score, total, feedback
414
+
415
+ # Legacy fallbacks kept server-side for compatibility. :contentReference[oaicite:3]{index=3}
416
+ grade_only = _backend_post("/agent/grade", {
417
+ "answers": list(responses.values()),
418
+ "lesson_id": int(module_id),
419
+ "level_slug": "beginner"
420
+ }) or {}
421
+ score, total = int(grade_only.get("score", 0)), int(grade_only.get("total", len(responses)))
422
+
423
+ coach = _backend_post("/agent/coach_or_celebrate", {
424
+ "answers": list(responses.values()),
425
+ "lesson_id": int(module_id),
426
+ "level_slug": "beginner"
427
+ }) or {}
428
+ feedback = coach.get("feedback") or coach.get("message") or "Nice work! Review any tricky items and ask me questions."
429
  return score, total, feedback
430
 
 
431
  def agent_generate_quiz(lesson: dict) -> List[Dict]:
432
  """
433
+ 1) Try backend /agent/quiz for items.
434
+ 2) Fallback local bank.
435
  """
436
  title = lesson.get("title", f"Lesson {lesson.get('id')}")
437
  module_id = str(lesson.get("id"))
438
  topic_name = "Summary"
439
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
440
  items = fetch_auto_quiz_from_backend(title, module_id, topic_name)
441
  if items:
442
  return random.sample(items, min(len(items), random.randint(MINI_QUIZ_MIN, MINI_QUIZ_MAX)))
 
 
443
  return _fallback_auto_quiz()
444
 
445
  def agent_grade_and_chat(lesson: dict, items: List[Dict],
446
  answers_by_idx: Dict[int, str]) -> Tuple[int, int, str]:
447
  """
448
+ Grade + get chatbot feedback via backend (/lesson-quiz), else fallback local.
 
449
  answers_by_idx: {0:'A',1:'C',...}
450
  """
451
  title = lesson.get("title", f"Lesson {lesson.get('id')}")
 
458
  qid = it.get("id") or f"q{i+1}"
459
  responses[qid] = (answers_by_idx.get(i) or "").strip().upper()
460
 
461
+ score, total, feedback = grade_and_chat_via_backend(title, module_id, topic_name, responses)
462
+ if total:
463
+ return score, total, feedback
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
464
 
465
  # Local fallback grading
466
  def grade_locally(items_: List[Dict], letters: List[str]) -> Tuple[int, int]:
467
+ keys = [it.get("answer_key", "A").strip().upper() for it in items_]
468
+ raw = (letters or []) + [""] * len(keys)
469
+ sc = sum(1 for a, k in zip(raw, keys) if (a or "").strip().upper() == k)
470
+ return sc, len(keys)
471
 
472
  letters = [responses[it.get("id") or f"q{i+1}"] for i, it in enumerate(items)]
473
+ local_score, local_total = grade_locally(items, letters)
474
+ local_feedback = "Nice work! I can walk you through any tricky ones—ask me in the chat."
475
+ return local_score, local_total, local_feedback
476
 
477
+ # ===============================
478
+ # Session progress helpers
479
+ # ===============================
480
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
481
  def _ensure_progress_state():
482
  if "topic_progress" not in st.session_state:
483
+ st.session_state.topic_progress = {}
484
  if "lesson_completed" not in st.session_state:
485
+ st.session_state.lesson_completed = {}
486
 
487
  def _mark_topic_seen(lesson_id: int, topic_index: int):
488
  _ensure_progress_state()
 
493
  _ensure_progress_state()
494
  return st.session_state.lesson_completed.get(lesson_id, False)
495
 
496
+ def _complete_lesson(lesson: dict):
497
  _ensure_progress_state()
498
  st.session_state.lesson_completed[lesson["id"]] = True
499
  lesson["completed"] = True
500
 
501
+ # ===============================
502
+ # Cards & detail
503
+ # ===============================
504
 
505
+ def show_lesson_cards(lessons: List[dict], user_level: str):
506
  level_info = get_level_info(user_level)
507
 
508
  # Level header
 
515
 
516
  # Stats
517
  total_lessons = len(lessons)
518
+ completed_lessons = sum(1 for lesson in lessons if lesson.get("completed"))
519
  progress_pct = int((completed_lessons / total_lessons) * 100) if total_lessons else 0
520
 
521
  col1, col2, col3, col4 = st.columns(4)
522
+ with col1: st.metric("📚 Total Lessons", total_lessons)
523
+ with col2: st.metric("✅ Completed", completed_lessons)
524
+ with col3: st.metric("📈 Progress", f"{progress_pct}%")
 
 
 
525
  with col4:
526
+ available_lessons = sum(1 for lesson in lessons if not lesson.get("locked"))
527
  st.metric("🔓 Available", available_lessons)
528
 
529
  st.markdown("---")
530
 
 
531
  cols = st.columns(3)
532
  for i, lesson in enumerate(lessons):
533
  lesson["completed"] = lesson.get("completed") or _is_lesson_completed(lesson["id"])
 
534
  with cols[i % 3]:
535
+ if lesson.get("locked"):
536
+ icon_html, card_opacity = '🔒', "0.6"
537
+ elif lesson.get("completed"):
538
+ icon_html, card_opacity = '✓', "1"
 
 
539
  else:
540
+ icon_html, card_opacity = '📖', "1"
 
541
 
542
  card_html = f"""
543
  <div class="lesson-card" style="
544
+ background: white; border: 1px solid #e1e5e9; border-radius: 8px; padding: 24px; margin-bottom: 15px;
545
+ opacity: {card_opacity}; box-shadow: 0 2px 4px rgba(0,0,0,0.1); height: 250px; transition: all 0.3s ease; cursor: pointer;">
 
 
 
 
 
 
 
 
 
546
  <div style="display: flex; justify-content: space-between; align-items: flex-start;">
547
  <h3 style="margin: 0; font-size: 18px; font-weight: 600; color: #323130; line-height: 1.3;">{lesson['title']}</h3>
548
  <div style="color: #28a745; font-size: 24px;">{icon_html}</div>
 
556
  """
557
  st.markdown(card_html, unsafe_allow_html=True)
558
 
559
+ if lesson.get("locked"):
560
  st.button("🔒 Locked", key=f"lesson_locked_{lesson['id']}", disabled=True, use_container_width=True)
561
+ elif lesson.get("completed"):
562
  if st.button("✅ Review", key=f"lesson_review_{lesson['id']}", use_container_width=True, type="secondary"):
563
  st.session_state.selected_lesson = lesson["id"]
564
  st.rerun()
 
567
  st.session_state.selected_lesson = lesson["id"]
568
  st.rerun()
569
 
570
+ def show_lesson_detail(lesson: dict):
571
  units = len(get_display_topics(lesson))
572
  st.markdown(f"""
573
  <h1 style="font-size:2.5rem; margin-bottom:0;">{lesson['title']}</h1>
 
575
  <hr style="margin:1rem 0;"/>
576
  """, unsafe_allow_html=True)
577
 
 
578
  st.subheader("Learning objectives")
579
  st.markdown("<p style='color:#888; margin-bottom:0.5rem;'>In this module, you'll</p>", unsafe_allow_html=True)
580
  st.markdown(
 
584
  "- Understanding simple financial transactions"
585
  )
586
 
 
587
  if st.button("▶ Start", key=f"start_{lesson['id']}", type="primary"):
588
  st.session_state.start_lesson = True
589
  st.session_state.current_topic = 1
 
594
 
595
  st.markdown("---")
596
 
 
597
  st.subheader("Topics")
598
+ for i, t in enumerate(get_display_topics(lesson), start=1):
 
599
  st.markdown(f"- [{t}](#topic-{i})")
600
 
601
  st.markdown("---")
602
 
 
603
  if st.button("⬅ Back to Lessons", key=f"back_{lesson['id']}"):
604
  st.session_state.selected_lesson = None
605
  st.rerun()
606
 
607
+ # ===============================
608
+ # Lesson page (now calls backend)
609
+ # ===============================
610
+
611
+ def _topic_text_from_backend(current_lesson: dict, topic_index: int) -> str:
612
+ topics = get_display_topics(current_lesson)
613
+ topic_index = max(1, min(topic_index, len(topics)))
614
+ topic_name = topics[topic_index - 1]
615
+ title = current_lesson.get("title", f"Lesson {current_lesson.get('id')}")
616
+ module_id = str(current_lesson.get("id"))
617
+ if topic_name.lower() == "summary":
618
+ # Build summary from first N topics (pull each from backend)
619
+ bullets = []
620
+ for i in range(1, min(MAX_TOPICS, len(topics)) + 1):
621
+ tn = topics[i - 1]
622
+ txt = _fetch_topic_content_via_backend(title, module_id, tn) or ""
623
+ first_sentence = re.split(r'(?<=[.!?])\s+', txt.strip())[:1]
624
+ bullets.append(f"• {first_sentence[0] if first_sentence else ('Key idea from topic ' + str(i))}")
625
+ return "<br>".join(bullets) or "No summary available."
626
+ # Normal topic
627
+ return _fetch_topic_content_via_backend(title, module_id, topic_name) or "⚠️ Topic content not available."
628
+
629
+ def show_lesson_page(lesson: dict, lessons: List[dict]):
630
  if st.button("⬅ Back to Lessons", key=f"back_top_{lesson['id']}"):
631
  st.session_state.selected_lesson = None
632
  st.session_state.current_topic = 1
 
634
  st.session_state.start_lesson = False
635
  st.rerun()
636
 
 
637
  st.markdown(f"""
638
  <div style="background: linear-gradient(135deg,#20c997,#17a2b8); padding: 1.5rem; border-radius: 12px; color: white; margin-bottom: 2rem;">
639
  <h2 style="margin:0;">{lesson['title']}</h2>
 
646
  </div>
647
  """, unsafe_allow_html=True)
648
 
 
649
  col1, col2 = st.columns([2, 1])
650
 
651
+ # LEFT: content / quiz
652
  with col1:
653
  # If the mini-quiz is active, render it and return early
654
  if st.session_state.get("show_mini_quiz"):
 
659
  if "current_topic" not in st.session_state:
660
  st.session_state.current_topic = 1
661
 
 
662
  st.session_state.current_topic = max(1, min(st.session_state.current_topic, len(topics)))
663
  topic_idx = st.session_state.current_topic - 1
664
  topic_name = topics[topic_idx]
665
 
 
666
  _mark_topic_seen(lesson["id"], st.session_state.current_topic)
667
 
668
  st.subheader(f"Topic {st.session_state.current_topic}: {topic_name}")
669
 
 
670
  if topic_name.lower().startswith("play"):
671
  st.info("���� This is a game-based activity to reinforce learning.")
672
  st.write("👉 Instructions on how to play go here.")
 
674
  st.markdown('<a href="http://your-game-url.com" target="_blank">Open Game in New Tab</a>', unsafe_allow_html=True)
675
 
676
  elif "quiz" in topic_name.lower():
 
677
  st.info("📝 Quick quiz (legacy). This is superseded by the auto mini-quiz at completion.")
678
 
 
 
 
 
 
679
  else:
680
+ content_html = _topic_text_from_backend(lesson, st.session_state.current_topic)
681
+ st.markdown(f"<div class='topic-content'>{content_html}</div>", unsafe_allow_html=True)
682
 
683
+ # Topic nav
684
  prev_col, next_col = st.columns([1, 1])
 
685
  with prev_col:
686
  st.markdown("<div class='topic-nav-btn prev-btn'>", unsafe_allow_html=True)
687
  if st.session_state.current_topic > 1:
 
693
  with next_col:
694
  st.markdown("<div class='topic-nav-btn next-btn'>", unsafe_allow_html=True)
695
  is_last_topic = (st.session_state.current_topic >= len(topics))
 
696
  if not is_last_topic:
697
  if st.button("Next Topic ➡", key=f"next_topic_{lesson['id']}", type="primary"):
698
  st.session_state.current_topic += 1
699
  st.rerun()
700
  else:
701
+ # Last page (Summary) → trigger the mini-quiz (2–4 questions)
702
  if not _is_lesson_completed(lesson["id"]):
703
  if st.button("✅ Complete Module", key=f"complete_{lesson['id']}", type="primary"):
704
+ st.session_state.mini_quiz_items = agent_generate_quiz(lesson) # FIX: use agent_generate_quiz
705
  st.session_state.mini_quiz_answers = {}
706
  st.session_state.mini_q_idx = 0
707
  st.session_state.show_mini_quiz = True
 
710
  st.button("✅ Completed", key=f"completed_{lesson['id']}", disabled=True)
711
  st.markdown("</div>", unsafe_allow_html=True)
712
 
713
+ # RIGHT: progress + lesson navigation
714
  with col2:
715
  st.subheader("📊 Progress")
716
  _ensure_progress_state()
 
721
 
722
  st.subheader("➡ Lesson Navigation")
723
  idx = next((i for i, l in enumerate(lessons) if l["id"] == lesson["id"]), 0)
 
724
  if idx > 0:
725
  if st.button("⬅ Previous Lesson", key=f"prev_lesson_{lesson['id']}"):
726
  st.session_state.selected_lesson = lessons[idx - 1]["id"]
727
  st.session_state.current_topic = 1
728
  st.rerun()
 
729
  if idx < len(lessons) - 1:
730
  if st.button("Next Lesson ➡", key=f"next_lesson_{lesson['id']}"):
731
  st.session_state.selected_lesson = lessons[idx + 1]["id"]
 
734
 
735
  st.markdown("---")
736
 
737
+ # ===============================
738
+ # Mini-quiz UI
739
+ # ===============================
740
 
741
  def render_mini_quiz_ui(lesson: dict):
742
  st.markdown("### 📝 Mini Quiz")
 
744
  if not items:
745
  st.warning("No questions available.")
746
  if st.button("Back"):
747
+ st.session_state.show_mini_quiz = False
748
+ st.rerun()
749
  return
750
 
751
  idx = int(st.session_state.get("mini_q_idx", 0))
 
765
  c1, c2 = st.columns(2)
766
  with c1:
767
  if idx > 0 and st.button("⬅ Previous"):
768
+ st.session_state.mini_q_idx -= 1
769
+ st.rerun()
770
  with c2:
771
  if st.button("Next ➡" if idx < len(items)-1 else "Submit Quiz"):
772
  if idx < len(items)-1:
773
  st.session_state.mini_q_idx += 1
774
  st.rerun()
775
  else:
776
+ # Map selected option text -> letter (A/B/C/…)
777
  answers_by_idx: Dict[int, str] = {}
778
  for i, it in enumerate(items):
779
  sel_text = st.session_state.mini_quiz_answers.get(i, "")
 
781
  letter = chr(65 + ops.index(sel_text)) if sel_text in ops else ""
782
  answers_by_idx[i] = letter
783
 
 
784
  score, total, feedback = agent_grade_and_chat(lesson, items, answers_by_idx)
785
 
 
786
  _complete_lesson(lesson)
787
+ for k in ["show_mini_quiz", "mini_quiz_items", "mini_quiz_answers", "mini_q_idx", "mini_quiz_result"]:
788
  st.session_state.pop(k, None)
789
 
790
+ # Redirect to Chatbot page with feedback message
791
  st.session_state.current_page = "Chatbot"
792
  if "messages" not in st.session_state:
793
  st.session_state.messages = []
794
 
 
795
  percent = int(round(100 * score / max(1, total)))
796
  summary = f"Mini-Quiz complete — Score: {percent}% ({score}/{total})."
797
  st.session_state.messages.append({
 
800
  "sender": "assistant"
801
  })
802
  st.session_state.is_typing = False
803
+ st.rerun()
804
  return
805
 
806
+ # ===============================
807
+ # Teacher-assigned (DB) lessons
808
+ # ===============================
809
+
810
+ def _db_to_general_lesson_shape(L: dict, sections: list) -> dict:
811
+ level = (L.get("level") or "beginner").lower()
812
+ difficulty = {"beginner": "Easy", "intermediate": "Medium", "advanced": "Hard"}.get(level, "Easy")
813
+ topics = [(s.get("title") or f"Topic {i+1}") for i, s in enumerate(sections)]
814
+ duration = L.get("duration") or f"{max(6, len(topics) * 6)} min"
815
+ return {
816
+ "id": int(L["lesson_id"]),
817
+ "title": L.get("title") or "Untitled",
818
+ "description": L.get("description") or "",
819
+ "duration": duration,
820
+ "completed": False,
821
+ "locked": False,
822
+ "difficulty": difficulty,
823
+ "topics": topics,
824
+ "_db_sections": sections,
825
+ "_db_level": level,
826
+ }
827
+
828
+ def render_assigned_lesson(lesson_id: int, assignment_id: Optional[int] = None):
829
  user = st.session_state.user
830
+ _ = user["user_id"] # not used here yet
831
 
832
  data = dbapi.get_lesson(lesson_id) # {"lesson": {...}, "sections":[...] }
833
  if not data or not data.get("lesson"):
 
851
  else:
852
  show_db_lesson_detail(lesson)
853
 
854
+ def show_db_lesson_detail(lesson: dict):
855
  st.markdown(f"""
856
  <h1 style="font-size:2.5rem; margin-bottom:0;">{lesson['title']}</h1>
857
  <p style="color: #666; margin-top:0;">{lesson['duration']} &nbsp;•&nbsp; Module &nbsp;•&nbsp; {len(lesson.get("topics", []))} units</p>
 
875
  st.rerun()
876
 
877
  st.markdown("---")
 
878
  st.subheader("Topics")
879
  for i, t in enumerate(lesson.get("topics", []), start=1):
880
  st.markdown(f"- [{t}](#topic-{i})")
 
881
  st.markdown("---")
882
 
883
  if st.button("⬅ Back to Lessons", key=f"db_back_detail_{lesson['id']}"):
884
  st.session_state.selected_lesson = None
885
  st.rerun()
886
 
887
+ def show_db_lesson_page(lesson: dict):
 
888
  if st.button("⬅ Back to Lessons", key=f"db_back_top_{lesson['id']}"):
889
  st.session_state.selected_lesson = None
890
  st.session_state.current_topic = 1
 
892
  st.session_state.start_lesson = False
893
  st.rerun()
894
 
 
895
  st.markdown(f"""
896
  <div style="background: linear-gradient(135deg,#20c997,#17a2b8); padding: 1.5rem; border-radius: 12px; color: white; margin-bottom: 2rem;">
897
  <h2 style="margin:0;">{lesson['title']}</h2>
 
904
  </div>
905
  """, unsafe_allow_html=True)
906
 
 
907
  col1, col2 = st.columns([2, 1])
908
 
 
909
  with col1:
910
  topics = lesson.get("topics", [])
911
  sections = lesson.get("_db_sections", [])
 
918
  _mark_topic_seen(lesson["id"], st.session_state.current_topic)
919
 
920
  st.subheader(f"Topic {st.session_state.current_topic}: {topic_name}")
 
921
  body = (sections[idx].get("content") or "⚠️ Topic content not available.")
922
  st.markdown(f"<div class='topic-content'>{body}</div>", unsafe_allow_html=True)
923
 
 
950
  st.button("✅ Completed", key=f"db_completed_{lesson['id']}", disabled=True)
951
  st.markdown("</div>", unsafe_allow_html=True)
952
 
 
953
  with col2:
954
  st.subheader("📊 Progress")
955
  _ensure_progress_state()
 
966
 
967
  st.markdown("---")
968
 
969
+ # ===============================
970
+ # Page entry
971
+ # ===============================
972
+
973
  def show_page():
974
+ # CSS
975
  css_path = os.path.join("assets", "styles.css")
976
  load_css(css_path)
 
977
  st.markdown("""<style> .markdown { font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; line-height: 1.6;} </style>""", unsafe_allow_html=True)
978
 
979
+ # Login gate
980
  if not st.session_state.get("user"):
981
  st.error("❌ Please login first to access lessons.")
982
  st.info("💡 Create an account to track your progress and unlock advanced content!")
 
985
  user = st.session_state.user
986
  user_level = user.get("level", "beginner").lower()
987
 
 
988
  deep_lesson_id = st.session_state.get("selected_lesson")
989
  deep_assignment_id = st.session_state.get("selected_assignment")
990
 
 
991
  lessons = lessons_by_level.get(user_level, [])
992
  catalog_ids = {l["id"] for l in lessons}
993
 
994
+ # If it's a teacher-assigned(DB) lesson that isn't in the catalog:
995
  if deep_lesson_id and deep_lesson_id not in catalog_ids:
 
996
  render_assigned_lesson(int(deep_lesson_id), deep_assignment_id)
997
  return
998
 
999
+ # Init state
1000
  if "selected_lesson" not in st.session_state:
1001
  st.session_state.selected_lesson = None
1002
  if "start_lesson" not in st.session_state:
1003
  st.session_state.start_lesson = False
1004
 
1005
+ # Routing
1006
  if st.session_state.selected_lesson:
1007
  lesson = next((l for l in lessons if l["id"] == st.session_state.selected_lesson), None)
1008
  if lesson:
1009
+ if st.session_state.start_lesson:
1010
  show_lesson_page(lesson, lessons)
1011
  else:
1012
  show_lesson_detail(lesson)
1013
  else:
1014
  st.warning("⚠️ Lesson not found")
1015
  else:
 
1016
  show_lesson_cards(lessons, user_level)