import streamlit as st from typing import List, Dict, Any, Optional, Tuple import re # Internal API client (already used across the app) # Uses BACKEND_URL/BACKEND_TOKEN env vars and has retry logic # See utils/api.py for details from utils import api as backend_api # --------------------------------------------- # Page state helpers # --------------------------------------------- _SS_DEFAULTS = { "level": "beginner", # beginner | intermediate | advanced "module_id": None, # int (1-based) "topic_idx": 0, # 0-based within module "mode": "catalog", # catalog | lesson | quiz | results "topics_cache": {}, # {(level, module_id): [(title, text), ...]} "quiz_data": None, # original quiz payload (list[dict]) "quiz_answers": {}, # q_index -> "A"|"B"|"C"|"D" "quiz_result": None, # backend result dict "chatbot_feedback": None, # str } def _ensure_state(): for k, v in _SS_DEFAULTS.items(): if k not in st.session_state: st.session_state[k] = v # --------------------------------------------- # Content metadata (UI only) # --------------------------------------------- # These titles mirror the React version you shared, so the experience feels the same. 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": [], } # Helper to read topic titles regardless of whether metadata uses `topics` or `topic_labels` 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)) # Ensure at most 6 topics: first five + Summary if present if len(plan) > 6: # Prefer keeping a 'Summary' entry last if it exists 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)] # --------------------------------------------- # Backend integrations # --------------------------------------------- @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] # 1-based ordinal in original list # this keeps your filesystem mapping correct: topic_{backend_ordinal}.txt 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 tolerant endpoint discovery using the same client session + retries # Prefer the documented /agents/lesson; fall back to /lesson with/without common prefixes 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})" # Use UI label as title (we render a breadcrumb with it) 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 [] # Prefer explicit sections 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 # Otherwise, harvest bullet-y looking lines bullets = [ ln.strip(" •-*–\t") for ln in text.splitlines() if ln.strip().startswith(("-", "•", "*", "–")) and len(ln.strip()) > 3 ] if bullets: return bullets[:max_items] # Fallback: first few sentences 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, # int id works; backend uses it for retrieval bucketing level_slug=level, # "beginner" | "intermediate" | "advanced" 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): # Expect letters A-D; default to "" 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 # --------------------------------------------- # UI building blocks # --------------------------------------------- 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)): # exactly the filtered six 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] # Special Quiz placeholder 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 no explicit Quiz topic, allow generating quiz at end 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") # Render each question as a block (single page quiz) for q_idx, q in enumerate(quiz): st.markdown(f"**Q{q_idx+1}. {q.get('question','').strip()}**") opts = q.get("options") or [] # Build labels like "A. option" labels = [f"{_letter_for(i)}. {opt}" for i, opt in enumerate(opts)] # Keep the stored answer as the LETTER, not the full text 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() # Submit 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() # --------------------------------------------- # Public entry point(s) # --------------------------------------------- def render(): _ensure_state() # Breadcrumb 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() # Some parts of the app import pages and call a conventional `show()` show_page = render if __name__ == "__main__": # Allow standalone run for local testing st.set_page_config(page_title="Lesson", page_icon="📘", layout="centered") render()