MathMate / app.py
MIHIR MITHANI
Deploy Streamlit app
0d1528e
Raw History Blame Contribute Delete
16.3 kB
"""
Streamlit app for Hint-Based Math Tutor (Problem 13).
Single Tutor tab with V2 chained pipeline + history sidebar.
"""
import streamlit as st
import json
import os
import sys
import random
from typing import Optional
from datetime import datetime
from dotenv import load_dotenv
# Load .env from the package directory BEFORE importing modules that need it
_here = os.path.dirname(os.path.abspath(__file__))
load_dotenv(os.path.join(_here, ".env"))
sys.path.insert(0, _here)
from pipeline_v2 import (
start_problem,
get_hint,
can_unlock_level3,
analyze_working,
generate_targeted_hint,
get_debug_log,
)
from guardrails import input_gate_code, normalise_numeric_equal
st.set_page_config(page_title="Hint-Based Math Tutor", page_icon="๐Ÿงฎ", layout="wide")
# --- Session State ---
if "tutor_state" not in st.session_state:
st.session_state.tutor_state = {
"problem": "",
"solution": None,
"refusal": None,
"hints_shown": [], # list of {level, hint, follow_up, guard_status, attempts}
"shown_levels": set(),
"attempts_made": 0,
"working_analysis": None,
"targeted_hint": None,
"final_answer": "",
}
if "history" not in st.session_state:
st.session_state.history = [] # list of completed problem sessions
# Bank of 6 extra problems (NOT in eval)
EXTRA_PROBLEMS = [
{
"id": "extra_001",
"text": "A bakery sold 24 cupcakes in the morning and 18 in the afternoon. Each cupcake costs $2.50. How much money did the bakery make?",
"answer": "$105.00"
},
{
"id": "extra_002",
"text": "A rectangular garden is 15 meters long and 10 meters wide. What is its perimeter?",
"answer": "50 meters"
},
{
"id": "extra_003",
"text": "Tom reads 12 pages per day. How many pages does he read in 3 weeks?",
"answer": "252 pages"
},
{
"id": "extra_004",
"text": "A box contains 36 chocolates. If 4 friends share them equally, how many does each get?",
"answer": "9 chocolates"
},
{
"id": "extra_005",
"text": "A car travels 240 km on 15 liters of fuel. How many km per liter does it get?",
"answer": "16 km/L"
},
{
"id": "extra_006",
"text": "Lisa has 3 bags with 8 marbles each. She gives 5 marbles to her brother. How many marbles does she have left?",
"answer": "19 marbles"
},
]
# --- Helper Functions ---
def format_hint_card(level: int, hint_text: str, follow_up: Optional[str], guard_status: str, attempts: int):
"""Render a hint card with guard badge."""
level_names = {1: "๐ŸŽฏ Level 1: Orient", 2: "๐Ÿงญ Level 2: Strategy", 3: "๐Ÿ“ Level 3: Walkthrough"}
badge_colors = {
"passed": "๐ŸŸข",
"fallback": "๐Ÿ”ด",
}
if guard_status.startswith("regenerated"):
badge = f"๐ŸŸก Regenerated {guard_status.split('_')[1]}ร—"
else:
badge = f"{badge_colors.get(guard_status, 'โšช')} {guard_status.capitalize()}"
with st.container(border=True):
st.markdown(f"### {level_names.get(level, f'Level {level}')}")
st.markdown(f"**Guard:** {badge}")
st.markdown(hint_text)
if follow_up:
st.info(f"๐Ÿ’ก {follow_up}")
def reset_tutor():
"""Reset tutor state for a new problem, saving current to history."""
state = st.session_state.tutor_state
if state["problem"] and (state["hints_shown"] or state["working_analysis"] or state["final_answer"]):
# Save to history
history_entry = {
"timestamp": datetime.now().strftime("%H:%M:%S"),
"problem": state["problem"],
"hints": state["hints_shown"].copy(),
"working_analysis": state["working_analysis"],
"targeted_hint": state["targeted_hint"],
"final_answer": state["final_answer"],
"solution_answer": state["solution"].final_answer if state["solution"] else None,
}
st.session_state.history.insert(0, history_entry) # newest first
# Reset tutor state
st.session_state.tutor_state = {
"problem": "",
"solution": None,
"refusal": None,
"hints_shown": [],
"shown_levels": set(),
"attempts_made": 0,
"working_analysis": None,
"targeted_hint": None,
"final_answer": "",
}
def clear_history():
"""Clear the history sidebar."""
st.session_state.history = []
def load_history_problem(index: int):
"""Load a problem from history back into the tutor."""
if 0 <= index < len(st.session_state.history):
entry = st.session_state.history[index]
state = st.session_state.tutor_state
state["problem"] = entry["problem"]
state["hints_shown"] = entry["hints"].copy()
state["shown_levels"] = set(h["level"] for h in entry["hints"])
state["working_analysis"] = entry["working_analysis"]
state["targeted_hint"] = entry["targeted_hint"]
state["final_answer"] = entry["final_answer"]
state["attempts_made"] = len([h for h in entry["hints"] if h["level"] == 3]) + (1 if entry["working_analysis"] else 0)
# Note: solution is not restored (hidden), user would need to click Start Problem again
# --- Sidebar ---
with st.sidebar:
st.header("โš™๏ธ Settings")
# Model selector
model_option = st.selectbox(
"Model",
["MODEL_MAIN (nemotron-3-super)", "MODEL_FAST (nemotron-3-super)"],
index=0,
help="MODEL_MAIN and MODEL_FAST both use nemotron-3-super-120b-a12b via NVIDIA NIM"
)
model_key = "MODEL_MAIN" if "MAIN" in model_option else "MODEL_FAST"
model_name = os.getenv(model_key, "nvidia/nemotron-3-super-120b-a12b")
# Debug toggle
show_debug = st.checkbox("Show Debug Info", value=False)
st.divider()
# --- HISTORY PANEL ---
st.header("๐Ÿ“œ Session History")
if st.button("Clear History", use_container_width=True):
clear_history()
st.rerun()
if st.session_state.history:
for idx, entry in enumerate(st.session_state.history):
with st.expander(f"{entry['timestamp']} โ€” {entry['problem'][:50]}{'...' if len(entry['problem']) > 50 else ''}", expanded=False):
st.caption(f"Problem: {entry['problem']}")
if entry["solution_answer"]:
st.caption(f"Answer: {entry['solution_answer']}")
# Show hints given
for h in entry["hints"]:
level_names = {1: "L1 Orient", 2: "L2 Strategy", 3: "L3 Walkthrough"}
guard_short = h["guard_status"]
if guard_short.startswith("regenerated"):
guard_short = f"regen {guard_short.split('_')[1]}ร—"
st.markdown(f"**{level_names.get(h['level'], f'L{h['level']}')}** ({guard_short})")
st.caption(h["hint"][:120] + ("..." if len(h["hint"]) > 120 else ""))
# Show working analysis
if entry["working_analysis"]:
wa = entry["working_analysis"]
if wa.has_error:
st.warning(f"โš ๏ธ Step {wa.first_wrong_step_index + 1} flagged: {wa.error_type}")
else:
st.success("โœ… No errors")
# Show targeted hint
if entry["targeted_hint"]:
th = entry["targeted_hint"]
st.info(f"Targeted hint: {th['hint'][:100]}...")
# Show final answer
if entry["final_answer"]:
correct = "โœ…" if entry["solution_answer"] and normalise_numeric_equal(entry["final_answer"], entry["solution_answer"]) else "โŒ"
st.markdown(f"**Your answer:** {entry['final_answer']} {correct}")
if st.button("Load This Problem", key=f"load_{idx}", use_container_width=True):
load_history_problem(idx)
st.rerun()
else:
st.caption("No problems solved yet. Start a new problem!")
st.divider()
st.caption("Hint-Based Math Tutor โ€” Problem 13")
# --- MAIN TUTOR TAB ---
def main():
st.title("๐Ÿงฎ Hint-Based Math Tutor")
st.caption("Progressive hints without revealing answers early")
state = st.session_state.tutor_state
# Problem input
problem_text = st.text_area(
"Math Word Problem",
value=state["problem"],
height=100,
placeholder="Enter a math word problem...\n\nExample: Sarah has 15 apples. She gives 4 to her friend and buys 7 more. How many apples does she have now?"
)
col1, col2, col3 = st.columns([1, 1, 1])
with col1:
if st.button("Start Problem", type="primary", use_container_width=True):
if problem_text.strip():
with st.spinner("Starting problem..."):
result = start_problem(problem_text, model=model_name)
state["problem"] = problem_text
state["refusal"] = None
state["solution"] = None
state["hints_shown"] = []
state["shown_levels"] = set()
state["attempts_made"] = 0
state["working_analysis"] = None
state["targeted_hint"] = None
if result["status"] == "ok":
state["solution"] = result.get("solution")
st.success("Problem ready! Click Hint 1 to begin.")
else:
state["refusal"] = result["refusal_message"]
st.error(result["refusal_message"])
st.rerun()
else:
st.error("Please enter a problem")
with col2:
if st.button("Try Random Problem", use_container_width=True):
prob = random.choice(EXTRA_PROBLEMS)
state["problem"] = prob["text"]
st.rerun()
with col3:
if st.button("New Problem", use_container_width=True):
reset_tutor()
st.rerun()
# Show refusal if any
if state["refusal"]:
st.error(f"โ›” {state['refusal']}")
st.stop()
# Show solution in debug
if show_debug and state["solution"]:
with st.expander("๐Ÿ” Hidden Solution (Debug Only)"):
sol = state["solution"]
for step in sol.steps:
st.write(f"- {step.description}: {step.expression} = {step.result}")
st.write(f"**Final Answer:** {sol.final_answer}")
# Hint buttons
if state["solution"]:
st.divider()
st.subheader("Get Hints")
hint_cols = st.columns(3)
# Hint 1
with hint_cols[0]:
hint1_shown = any(h["level"] == 1 for h in state["hints_shown"])
if st.button("Hint 1 (Orient)", disabled=hint1_shown, use_container_width=True):
with st.spinner("Generating Hint 1..."):
prev_hints = [h["hint"] for h in state["hints_shown"]]
result = get_hint(state["problem"], state["solution"], 1, prev_hints, model=model_name)
state["hints_shown"].append({"level": 1, **result})
state["shown_levels"].add(1)
st.rerun()
# Hint 2
with hint_cols[1]:
hint2_shown = any(h["level"] == 2 for h in state["hints_shown"])
disabled = hint2_shown or not any(h["level"] == 1 for h in state["hints_shown"])
if st.button("Hint 2 (Strategy)", disabled=disabled, use_container_width=True):
with st.spinner("Generating Hint 2..."):
prev_hints = [h["hint"] for h in state["hints_shown"]]
result = get_hint(state["problem"], state["solution"], 2, prev_hints, model=model_name)
state["hints_shown"].append({"level": 2, **result})
state["shown_levels"].add(2)
st.rerun()
# Hint 3
with hint_cols[2]:
hint3_shown = any(h["level"] == 3 for h in state["hints_shown"])
can_unlock = can_unlock_level3(list(state["shown_levels"]), state["attempts_made"])
disabled = hint3_shown or not can_unlock
if st.button("Hint 3 (Walkthrough)", disabled=disabled, use_container_width=True):
with st.spinner("Generating Walkthrough..."):
prev_hints = [h["hint"] for h in state["hints_shown"]]
result = get_hint(state["problem"], state["solution"], 3, prev_hints, model=model_name)
state["hints_shown"].append({"level": 3, **result})
state["shown_levels"].add(3)
st.rerun()
if not can_unlock and not hint3_shown:
st.caption("โš ๏ธ Unlock after Level 1 & 2 or an attempt")
# Display hints
if state["hints_shown"]:
st.divider()
st.subheader("Hints Given")
for h in state["hints_shown"]:
format_hint_card(h["level"], h["hint"], h.get("follow_up"), h["guard_status"], h["attempts"])
# Working check
if state["solution"]:
st.divider()
st.subheader("๐Ÿ“ Check Your Working")
working_text = st.text_area(
"Show your working (one step per line)",
height=100,
placeholder="Example:\n15 - 4 = 11\n11 + 7 = 18"
)
col1, col2 = st.columns([1, 2])
with col1:
if st.button("Check My Working", type="secondary", use_container_width=True):
if working_text.strip():
state["attempts_made"] += 1
with st.spinner("Analyzing working..."):
analysis = analyze_working(state["problem"], state["solution"], working_text, model=model_name)
state["working_analysis"] = analysis
# Determine current level for targeted hint
current_level = 1
if any(h["level"] >= 2 for h in state["hints_shown"]):
current_level = 2
targeted = generate_targeted_hint(
state["problem"], state["solution"], analysis, current_level, model=model_name
)
state["targeted_hint"] = targeted
st.rerun()
else:
st.error("Please enter your working")
with col2:
if state["working_analysis"]:
wa = state["working_analysis"]
if wa.has_error:
st.warning(f"โš ๏ธ Step {wa.first_wrong_step_index + 1} flagged: **{wa.error_type}**")
else:
st.success("โœ… No errors detected")
# Show targeted hint
if state["targeted_hint"]:
th = state["targeted_hint"]
format_hint_card(
1 if "Orient" in str(th.get("follow_up", "")) else 2,
th["hint"],
th.get("follow_up"),
th["guard_status"],
th["attempts"]
)
# Final answer
if state["solution"]:
st.divider()
st.subheader("๐ŸŽฏ Your Final Answer")
final_answer = st.text_input("Enter your final answer", value=state["final_answer"])
state["final_answer"] = final_answer
if st.button("Check Answer", use_container_width=True):
if final_answer.strip():
correct = normalise_numeric_equal(final_answer, state["solution"].final_answer)
if correct:
st.success("โœ… Correct!")
else:
st.error(f"โŒ Incorrect. The answer is: {state['solution'].final_answer}")
else:
st.error("Please enter an answer")
# Debug log
if show_debug:
st.divider()
st.subheader("๐Ÿ› Debug Log")
for entry in get_debug_log():
st.json(entry)
if __name__ == "__main__":
main()