Download phase/Student_view/lesson.py from FinEDProj/Frontened: direct link, hf CLI and curl.
- Browser
- Download file 18.4 kB
-
https://huggingface.co/spaces/FinEDProj/Frontened/resolve/main/phase/Student_view/lesson.py
- Command line
-
hf download hf://spaces/FinEDProj/Frontened/phase/Student_view/lesson.py
-
curl -L -o lesson.py https://huggingface.co/spaces/FinEDProj/Frontened/resolve/main/phase/Student_view/lesson.py
18.4 kB
| 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 | |
| # --------------------------------------------- | |
| 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() | |