lanna_lalala;- commited on
Commit ·
3bd2e1a
1
Parent(s): c0b5bc1
update lesson.py -alanna
Browse files- 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 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
|
|
|
| 18 |
)
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
#
|
|
|
|
|
|
|
|
|
|
| 33 |
MAX_TOPICS = 5 # show topics 1..5 only, then Summary
|
| 34 |
MINI_QUIZ_MIN = 2
|
| 35 |
MINI_QUIZ_MAX = 4
|
| 36 |
|
| 37 |
-
#
|
| 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 |
-
#
|
|
|
|
|
|
|
| 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 |
-
"""
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
| 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
|
| 312 |
-
|
| 313 |
-
|
| 314 |
-
|
| 315 |
-
|
| 316 |
-
|
| 317 |
-
|
| 318 |
-
|
| 319 |
-
|
| 320 |
-
|
| 321 |
-
|
| 322 |
-
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
|
| 328 |
-
|
| 329 |
-
|
| 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 |
-
|
| 344 |
-
|
|
|
|
| 345 |
"""
|
| 346 |
-
|
| 347 |
-
|
| 348 |
-
"module": module_id,
|
| 349 |
-
"topic": topic_name,
|
| 350 |
-
"responses": responses
|
| 351 |
})
|
| 352 |
-
if
|
| 353 |
-
|
| 354 |
-
|
| 355 |
-
|
| 356 |
-
|
| 357 |
-
|
| 358 |
-
|
| 359 |
-
|
| 360 |
-
|
| 361 |
-
|
| 362 |
-
|
| 363 |
-
|
| 364 |
-
|
| 365 |
-
total =
|
| 366 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 367 |
return score, total, feedback
|
| 368 |
|
| 369 |
-
# ---------- Agent integration (in-process) ----------
|
| 370 |
def agent_generate_quiz(lesson: dict) -> List[Dict]:
|
| 371 |
"""
|
| 372 |
-
|
|
|
|
| 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
|
| 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 |
-
|
| 425 |
-
|
| 426 |
-
|
| 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 |
-
|
| 455 |
-
return
|
| 456 |
|
| 457 |
letters = [responses[it.get("id") or f"q{i+1}"] for i, it in enumerate(items)]
|
| 458 |
-
|
| 459 |
-
|
| 460 |
-
return
|
| 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 = {}
|
| 501 |
if "lesson_completed" not in st.session_state:
|
| 502 |
-
st.session_state.lesson_completed = {}
|
| 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
|
| 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 |
-
|
| 539 |
-
with
|
| 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
|
| 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
|
| 556 |
-
icon_html = '🔒'
|
| 557 |
-
|
| 558 |
-
|
| 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 |
-
|
| 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
|
| 592 |
st.button("🔒 Locked", key=f"lesson_locked_{lesson['id']}", disabled=True, use_container_width=True)
|
| 593 |
-
elif lesson
|
| 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 |
-
|
| 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 |
-
|
| 645 |
-
|
| 646 |
-
|
| 647 |
-
|
| 648 |
-
|
| 649 |
-
|
| 650 |
-
|
| 651 |
-
|
| 652 |
-
|
| 653 |
-
|
| 654 |
-
|
| 655 |
-
|
| 656 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
#
|
| 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 |
-
|
| 719 |
-
st.markdown(f"<div class='topic-content'>{
|
| 720 |
|
| 721 |
-
# Topic
|
| 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
|
| 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 =
|
| 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 |
-
#
|
| 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 |
-
#
|
|
|
|
|
|
|
| 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
|
|
|
|
| 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
|
|
|
|
| 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 |
-
#
|
| 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 |
-
#
|
| 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.
|
| 845 |
return
|
| 846 |
|
| 847 |
-
|
| 848 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 849 |
user = st.session_state.user
|
| 850 |
-
|
| 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']} • Module • {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 |
-
#
|
| 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 |
-
#
|
| 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 |
-
#
|
| 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 |
-
#
|
| 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:
|
| 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']} • Module • {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)
|