lanna_lalala;-
updating
bdae069
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()