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