| import streamlit as st |
| from typing import List, Dict, Any, Optional, Tuple |
| import re |
|
|
| |
| |
| |
| from utils import api as backend_api |
|
|
| |
| |
| |
| _SS_DEFAULTS = { |
| "level": "beginner", |
| "module_id": None, |
| "topic_idx": 0, |
| "mode": "catalog", |
| "topics_cache": {}, |
| "quiz_data": None, |
| "quiz_answers": {}, |
| "quiz_result": None, |
| "chatbot_feedback": None, |
| } |
|
|
|
|
| def _ensure_state(): |
| for k, v in _SS_DEFAULTS.items(): |
| if k not in st.session_state: |
| st.session_state[k] = v |
|
|
|
|
| |
| |
| |
| |
| MODULES_META: Dict[str, List[Dict[str, Any]]] = { |
| "beginner": [ |
| { |
| "id": 1, |
| "title": "Understanding Money", |
| "description": "Learn the basics of what money is, its uses, and how to manage it.", |
| "duration": "20 min", |
| "completed": False, |
| "locked": False, |
| "difficulty": "Easy", |
| "topics": [ |
| "What is Money?", |
| "Needs vs. Wants", |
| "Earning Money", |
| "Saving Money", |
| "Spending Wisely", |
| "Play: Money Match", |
| "Quiz", |
| "Summary: My Money Plan" |
| ] |
| }, |
| { |
| "id": 2, |
| "title": "Basic Budgeting", |
| "description": "Start building the habit of planning and managing money through budgeting.", |
| "duration": "20 min", |
| "completed": False, |
| "locked": False, |
| "difficulty": "Easy", |
| "topics": [ |
| "What is a Budget?", |
| "Income and Expenses", |
| "Profit and Loss", |
| "Saving Goals", |
| "Making Choices", |
| "Play: Budget Builder", |
| "Quiz", |
| "Summary: My First Budget" |
| ] |
| }, |
| { |
| "id": 3, |
| "title": "Money in Action", |
| "description": "Learn how money is used in everyday transactions and its role in society.", |
| "duration": "20 min", |
| "completed": False, |
| "locked": False, |
| "difficulty": "Easy", |
| "topics": [ |
| "Paying for Things", |
| "Keeping Track of Money", |
| "What Are Taxes?", |
| "Giving and Sharing", |
| "Money Safety", |
| "Play: Piggy Bank Challenge", |
| "Quiz", |
| "Summary: Money Journal" |
| ] |
| }, |
| { |
| "id": 4, |
| "title": "Simple Business Ideas", |
| "description": "Explore the basics of starting a small business and earning profit.", |
| "duration": "20 min", |
| "completed": False, |
| "locked": False, |
| "difficulty": "Easy", |
| "topics": [ |
| "What is a Business?", |
| "Costs in a Business", |
| "Revenue in a Business", |
| "Profit in a Business", |
| "Advertising Basics", |
| "Play: Smart Shopper", |
| "Quiz", |
| "Summary: My Business Plan" |
| ] |
| } |
| ], |
| "intermediate": [], |
| "advanced": [], |
| } |
|
|
|
|
| |
|
|
| def _topic_plan(level: str, module_id: int): |
| """ |
| Returns a list of (title, backend_ordinal) after filtering: |
| - drop any 'Play:' topic |
| - drop 'Quiz' |
| - keep first five + the Summary (6 total) |
| backend_ordinal is the 1-based index in the original metadata (so backend files line up). |
| """ |
| mod = next(m for m in MODULES_META[level] if m["id"] == module_id) |
| raw = (mod.get("topics") or mod.get("topic_labels") or []) |
| plan = [] |
| for i, t in enumerate(raw, start=1): |
| tl = t.strip().lower() |
| if tl == "quiz" or tl.startswith("play:"): |
| continue |
| plan.append((t, i)) |
|
|
| |
| if len(plan) > 6: |
| |
| summary_pos = next((idx for idx, (title, _) in enumerate(plan) |
| if title.strip().lower().startswith("summary")), None) |
| if summary_pos is not None: |
| plan = plan[:5] + [plan[summary_pos]] |
| else: |
| plan = plan[:6] |
| return plan |
|
|
| def _topic_titles(level: str, module_id: int): |
| return [t for (t, _) in _topic_plan(level, module_id)] |
|
|
|
|
| |
| |
| |
| @st.cache_data(show_spinner=False, ttl=300) |
| def _fetch_topic_from_backend(level: str, module_id: int, topic_idx: int) -> Tuple[str, str]: |
| """ |
| Calls the backend to read the topic text from the lessons folder. |
| Returns (title, content). Title uses the configured UI label as a fallback. |
| """ |
| plan = _topic_plan(level, module_id) |
| title, backend_ordinal = plan[topic_idx] |
| |
| lesson_label = MODULES_META[level][[m["id"] for m in MODULES_META[level]].index(module_id)]["title"] |
| module_label = str(module_id) |
| topic_label = str(backend_ordinal) |
|
|
| payload = {"lesson": lesson_label, "module": module_label, "topic": topic_label} |
|
|
| |
| |
| try: |
| data = backend_api._try_candidates( |
| "POST", |
| [ |
| ("/agents/lesson", {"json": payload}), |
| ("/lesson", {"json": payload}), |
| ], |
| ) |
| content = (data.get("lesson_content") or "").strip() |
| except Exception as e: |
| content = f"(Failed to fetch lesson content: {e})" |
|
|
| |
| title = _topic_titles(level, module_id)[topic_idx] |
| return title, content |
|
|
|
|
| def _extract_takeaways(text: str, max_items: int = 5) -> List[str]: |
| """Heuristic key-takeaway extractor from raw lesson text.""" |
| if not text: |
| return [] |
|
|
| |
| m = re.search(r"(?mi)^\s*(Key\s*Takeaways?|Summary)\s*[:\n]+(.*)$", text, re.DOTALL) |
| if m: |
| body = m.group(2) |
| lines = [ln.strip(" •-*–\t") for ln in body.splitlines() if ln.strip()] |
| items = [ln for ln in lines if len(ln) > 3][:max_items] |
| if items: |
| return items |
|
|
| |
| bullets = [ |
| ln.strip(" •-*–\t") |
| for ln in text.splitlines() |
| if ln.strip().startswith(("-", "•", "*", "–")) and len(ln.strip()) > 3 |
| ] |
| if bullets: |
| return bullets[:max_items] |
|
|
| |
| sents = re.split(r"(?<=[.!?])\s+", text.strip()) |
| return [s for s in sents if len(s) > 20][:min(max_items, 3)] |
|
|
|
|
| def _start_quiz(level: str, module_id: int) -> Optional[List[Dict[str, Any]]]: |
| """Ask backend to generate a 5-question mini quiz for this module.""" |
| module_conf = next(m for m in MODULES_META[level] if m["id"] == module_id) |
| try: |
| quiz = backend_api.generate_quiz( |
| lesson_id=module_id, |
| level_slug=level, |
| lesson_title=module_conf["title"], |
| ) |
| if isinstance(quiz, list) and quiz: |
| return quiz |
| return None |
| except Exception as e: |
| st.error(f"Could not generate quiz: {e}") |
| return None |
|
|
|
|
| def _submit_quiz(level: str, module_id: int, original_quiz: List[Dict[str, Any]], answers_map: Dict[int, str]) -> Optional[Dict[str, Any]]: |
| """Submit answers and get score + tutor feedback.""" |
| user_answers = [] |
| for i, q in enumerate(original_quiz): |
| |
| user_answers.append({ |
| "question": q.get("question", f"Q{i+1}"), |
| "answer": answers_map.get(i, ""), |
| }) |
| try: |
| result = backend_api.submit_quiz( |
| lesson_id=module_id, |
| level_slug=level, |
| user_answers=user_answers, |
| original_quiz=original_quiz, |
| ) |
| return result |
| except Exception as e: |
| st.error(f"Could not submit quiz: {e}") |
| return None |
|
|
|
|
| |
| |
| |
| def _render_catalog(): |
| st.header("Financial Education") |
| st.caption("Build your financial knowledge with structured paths for every skill level.") |
|
|
| level = st.session_state.get("level", _SS_DEFAULTS["level"]) |
|
|
| cols = st.columns(3) |
| for i, mod in enumerate(MODULES_META[level]): |
| with cols[i % 3]: |
| st.subheader(mod["title"]) |
| if mod.get("description"): |
| st.caption(mod["description"]) |
| st.caption(f"Duration: {mod.get('duration','—')} · Difficulty: {mod.get('difficulty','—')}") |
| with st.expander("Topics include"): |
| for t, _ord in _topic_plan(level, mod["id"]): |
| st.write("• ", t) |
| if st.button("Start Learning", key=f"start_{level}_{mod['id']}"): |
| st.session_state.module_id = mod["id"] |
| st.session_state.topic_idx = 0 |
| st.session_state.mode = "lesson" |
| st.rerun() |
|
|
|
|
|
|
| def _get_topics(level: str, module_id: int) -> List[Tuple[str, str]]: |
| cache_key = (level, module_id) |
| if cache_key in st.session_state.topics_cache: |
| return st.session_state.topics_cache[cache_key] |
|
|
| plan = _topic_plan(level, module_id) |
| out = [] |
| misses = 0 |
| for idx in range(len(plan)): |
| title, text = _fetch_topic_from_backend(level, module_id, idx) |
| if text and not text.startswith("(Failed"): |
| out.append((title, text)) |
| misses = 0 |
| else: |
| misses += 1 |
| if misses >= 2: |
| break |
|
|
| st.session_state.topics_cache[cache_key] = out |
| return out |
|
|
|
|
|
|
| def _render_lesson(): |
| level = st.session_state.level |
| module_id = st.session_state.module_id |
| if module_id is None: |
| st.session_state.mode = "catalog" |
| st.rerun() |
|
|
| mod = next(m for m in MODULES_META[level] if m["id"] == module_id) |
|
|
| st.markdown(f"### {mod['title']}") |
| if mod.get("description"): |
| st.caption(mod["description"]) |
|
|
| topics = _get_topics(level, module_id) |
|
|
| if not topics: |
| st.info("No topics found for this module yet. Please check back later.") |
| if st.button("Back to Learning Paths"): |
| st.session_state.mode = "catalog" |
| st.session_state.module_id = None |
| st.rerun() |
| return |
|
|
| with st.container(border=True): |
| progress = (st.session_state.topic_idx + 1) / max(1, len(topics)) |
| st.progress(progress, text=f"Unit {st.session_state.topic_idx + 1} of {len(topics)}") |
|
|
| t_title, t_text = topics[st.session_state.topic_idx] |
|
|
| |
| if t_title.strip().lower() == "quiz": |
| with st.spinner("Generating quiz…"): |
| quiz = _start_quiz(level, module_id) |
| if quiz: |
| st.session_state.quiz_data = quiz |
| st.session_state.quiz_answers = {} |
| st.session_state.mode = "quiz" |
| st.rerun() |
| else: |
| st.error("Quiz could not be generated. Please try again or skip.") |
| return |
|
|
| st.subheader(t_title) |
| if t_text: |
| st.write(t_text) |
| takeaways = _extract_takeaways(t_text) |
| if takeaways: |
| st.markdown("#### Key Takeaways") |
| for it in takeaways: |
| st.write("✅ ", it) |
| else: |
| st.info("Content coming soon.") |
|
|
| col1, col2, col3 = st.columns([1, 1, 1]) |
| with col1: |
| if st.button("← Previous", disabled=st.session_state.topic_idx == 0): |
| st.session_state.topic_idx -= 1 |
| st.rerun() |
| with col2: |
| if st.button("Back to Modules"): |
| st.session_state.mode = "catalog" |
| st.session_state.module_id = None |
| st.rerun() |
| with col3: |
| is_last = st.session_state.topic_idx >= len(topics) - 1 |
| if is_last: |
| |
| if st.button("Take Lesson Quiz →"): |
| with st.spinner("Generating quiz…"): |
| quiz = _start_quiz(level, module_id) |
| if quiz: |
| st.session_state.quiz_data = quiz |
| st.session_state.quiz_answers = {} |
| st.session_state.mode = "quiz" |
| else: |
| st.error("Quiz could not be generated. Please try again.") |
| st.rerun() |
| else: |
| if st.button("Next →"): |
| st.session_state.topic_idx += 1 |
| st.rerun() |
|
|
| with st.expander("Module Units", expanded=False): |
| for i, (tt, _) in enumerate(topics): |
| label = f"{i+1}. {tt}" |
| st.button(label, key=f"jump_{i}", on_click=lambda j=i: st.session_state.update({"topic_idx": j}) or st.rerun()) |
|
|
|
|
|
|
| def _letter_for(i: int) -> str: |
| return chr(ord("A") + i) |
|
|
|
|
| def _render_quiz(): |
| quiz: List[Dict[str, Any]] = st.session_state.quiz_data or [] |
| if not quiz: |
| st.session_state.mode = "lesson" |
| st.rerun() |
|
|
| st.markdown("### Lesson Quiz") |
|
|
| |
| for q_idx, q in enumerate(quiz): |
| st.markdown(f"**Q{q_idx+1}. {q.get('question','').strip()}**") |
| opts = q.get("options") or [] |
| |
| labels = [f"{_letter_for(i)}. {opt}" for i, opt in enumerate(opts)] |
| |
| def _on_select(): |
| letter = _letter_for(labels.index(st.session_state[f"ans_{q_idx}"])) |
| st.session_state.quiz_answers[q_idx] = letter |
| st.radio( |
| "", |
| labels, |
| index=(labels.index(f"{st.session_state.quiz_answers.get(q_idx,'')}.") if st.session_state.quiz_answers.get(q_idx) else None), |
| key=f"ans_{q_idx}", |
| on_change=_on_select, |
| ) |
| st.divider() |
|
|
| |
| all_answered = len(st.session_state.quiz_answers) == len(quiz) |
| if st.button("Submit Quiz", disabled=not all_answered): |
| with st.spinner("Grading…"): |
| result = _submit_quiz( |
| st.session_state.level, |
| st.session_state.module_id, |
| quiz, |
| st.session_state.quiz_answers, |
| ) |
| if result: |
| st.session_state.quiz_result = result |
| st.session_state.chatbot_feedback = result.get("feedback") |
| st.session_state.mode = "results" |
| st.rerun() |
|
|
|
|
| def _render_results(): |
| result = st.session_state.quiz_result or {} |
| score = result.get("score", {}) |
| correct = score.get("correct", 0) |
| total = score.get("total", 0) |
|
|
| st.success(f"Quiz Complete! You scored {correct} / {total}.") |
|
|
| wrong = result.get("wrong", []) |
| if wrong: |
| with st.expander("Review your answers"): |
| for w in wrong: |
| st.markdown(f"**{w.get('question','')}**") |
| st.write(f"Your answer: {w.get('your_answer','')}") |
| st.write(f"Correct answer: {w.get('correct_answer','')}") |
| st.divider() |
|
|
| fb = st.session_state.chatbot_feedback |
| if fb: |
| st.markdown("#### Tutor Explanation") |
| st.write(fb) |
|
|
| level = st.session_state.level |
| module_id = st.session_state.module_id |
| planned = next((m.get("topics", []) for m in MODULES_META[level] if m["id"] == module_id), []) |
| try: |
| quiz_index = [t.strip().lower() for t in planned].index("quiz") |
| except ValueError: |
| quiz_index = None |
|
|
| c1, c2, c3 = st.columns([1, 1, 1]) |
| with c1: |
| if st.button("Back to Modules"): |
| st.session_state.mode = "catalog" |
| st.session_state.module_id = None |
| st.rerun() |
| with c2: |
| if st.button("Ask the Chatbot →"): |
| st.session_state.current_page = "Chatbot" |
| st.session_state.chatbot_prefill = fb |
| st.rerun() |
| with c3: |
| if quiz_index is not None and quiz_index + 1 < len(planned): |
| if st.button("Continue Lesson →"): |
| st.session_state.mode = "lesson" |
| st.session_state.topic_idx = quiz_index + 1 |
| st.rerun() |
|
|
|
|
| |
| |
| |
|
|
| def render(): |
| _ensure_state() |
|
|
| |
| st.caption("Learning Path · " + st.session_state.level.capitalize()) |
|
|
| mode = st.session_state.mode |
| if mode == "catalog": |
| _render_catalog() |
| elif mode == "lesson": |
| _render_lesson() |
| elif mode == "quiz": |
| _render_quiz() |
| elif mode == "results": |
| _render_results() |
| else: |
| st.session_state.mode = "catalog" |
| _render_catalog() |
|
|
|
|
| |
| show_page = render |
|
|
|
|
| if __name__ == "__main__": |
| |
| st.set_page_config(page_title="Lesson", page_icon="📘", layout="centered") |
| render() |
|
|