camcalderon777's picture
Deploy Streamlit tutorial builder
5428664 verified
Raw
History Blame Contribute Delete
15.3 kB
"""
Reusable tutorial builder for interactive tutorials (e.g. Monetary Economics EC3014).
ONE Streamlit app serves every topic. A tutorial is just a JSON file in
./tutorials/ -- drop in a new file and it appears automatically. No code
changes are needed to add a topic, so teachers/colleagues can create any number
of tutorials WITHOUT coding. This replaces the need for a separate
topicXquestions.qmd / Hugging Face Space per topic.
Two clearly separated views (chosen in the sidebar):
* Teacher mode -- build/edit a tutorial and save it as JSON (optionally
persisted to a Hugging Face Dataset so it survives restarts).
* Student mode -- work through a tutorial, get AI feedback, export answers.
Tutorial JSON schema:
{
"topic_number": "2",
"topic_name": "Classical Theory of Money",
"title": "Topic 2 Questions",
"intro": "markdown shown above the questions",
"readings": "markdown list of readings",
"questions": [
{"title": "...", "prompt": "markdown + $LaTeX$", "suggested_answer": "markdown"}
]
}
Optional environment variables (set as Hugging Face Space secrets):
GROQ_API_KEY[, GROQ_API_KEY_FALLBACK_1, GROQ_API_KEY_FALLBACK_2] -> enables AI feedback
HF_TOKEN -> enables persistence
HF_DATASET_REPO (e.g. "your-username/ec3014-tutorials") -> where tutorials persist
"""
import json
import os
import re
from datetime import datetime
from pathlib import Path
import streamlit as st
try:
from dotenv import load_dotenv
load_dotenv()
except ImportError:
pass
TUTORIALS_DIR = Path(__file__).parent / "tutorials"
TUTORIALS_DIR.mkdir(exist_ok=True)
FEEDBACK_MODEL = "llama-3.3-70b-versatile"
# --------------------------------------------------------------------------- #
# Persistence: local folder + optional Hugging Face Dataset
# --------------------------------------------------------------------------- #
def _hf_config() -> tuple[str, str] | None:
"""Return (token, repo_id) if HF persistence is configured, else None."""
token = os.environ.get("HF_TOKEN", "").strip()
repo = os.environ.get("HF_DATASET_REPO", "").strip()
return (token, repo) if token and repo else None
@st.cache_resource(show_spinner=False)
def sync_from_hub() -> str:
"""Pull any tutorials stored in the configured HF Dataset into the local
folder. Cached so it runs once per server start. Safe no-op if unconfigured."""
cfg = _hf_config()
if cfg is None:
return "local-only"
token, repo = cfg
try:
from huggingface_hub import HfApi, hf_hub_download
api = HfApi(token=token)
files = api.list_repo_files(repo_id=repo, repo_type="dataset")
for fname in files:
if fname.endswith(".json"):
src = hf_hub_download(
repo_id=repo, filename=fname, repo_type="dataset", token=token
)
(TUTORIALS_DIR / Path(fname).name).write_bytes(Path(src).read_bytes())
return "synced"
except Exception as exc: # noqa: BLE001 - surface, never crash the app
return f"sync-error: {exc}"
def push_to_hub(path: Path) -> str:
"""Upload one tutorial JSON to the HF Dataset. Returns a status message."""
cfg = _hf_config()
if cfg is None:
return "Persistence not configured (set HF_TOKEN and HF_DATASET_REPO secrets)."
token, repo = cfg
try:
from huggingface_hub import HfApi
api = HfApi(token=token)
api.create_repo(repo_id=repo, repo_type="dataset", exist_ok=True)
api.upload_file(
path_or_fileobj=str(path),
path_in_repo=path.name,
repo_id=repo,
repo_type="dataset",
commit_message=f"Add/update tutorial {path.name}",
)
return f"Persisted to Hugging Face Dataset `{repo}`."
except Exception as exc: # noqa: BLE001
return f"Could not persist to Hub: {exc}"
# --------------------------------------------------------------------------- #
# Data access
# --------------------------------------------------------------------------- #
def slugify(text: str) -> str:
slug = re.sub(r"[^a-z0-9]+", "-", str(text).strip().lower()).strip("-")
return slug or "tutorial"
def list_tutorials() -> dict[str, Path]:
out: dict[str, Path] = {}
for path in sorted(TUTORIALS_DIR.glob("*.json")):
try:
data = json.loads(path.read_text(encoding="utf-8"))
label = data.get("title") or path.stem
num = data.get("topic_number")
if num:
label = f"Topic {num}: {data.get('topic_name', label)}"
except (json.JSONDecodeError, OSError):
label = f"{path.stem} (unreadable)"
out[label] = path
return out
def load_tutorial(path: Path) -> dict:
return json.loads(path.read_text(encoding="utf-8"))
def save_tutorial(data: dict) -> Path:
stem = slugify(f"topic{data.get('topic_number', '')}-{data.get('topic_name', data.get('title', ''))}")
path = TUTORIALS_DIR / f"{stem}.json"
path.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
return path
# --------------------------------------------------------------------------- #
# AI feedback (Groq) -- generalised across topics, hints not answers
# --------------------------------------------------------------------------- #
def get_groq_api_keys() -> list[str]:
"""Return configured Groq API keys in priority order (mirrors other Spaces)."""
keys: list[str] = []
for env_name in ["GROQ_API_KEY", "GROQ_API_KEY_FALLBACK_1", "GROQ_API_KEY_FALLBACK_2"]:
value = os.environ.get(env_name, "").strip()
if value and value not in keys:
keys.append(value)
return keys
def feedback_available() -> bool:
return bool(get_groq_api_keys())
def build_feedback_prompt(tutorial: dict, question: dict, student_answer: str) -> str:
topic = tutorial.get("topic_name") or tutorial.get("title", "this topic")
return f"""You are an expert tutor giving feedback on a question from "{topic}".
Your goal is to help the student improve through HINTS and guidance, NOT to give
the complete answer.
QUESTION:
{question.get('prompt', '')}
STUDENT'S ANSWER:
{student_answer}
INDICATIVE ANSWER (for your reference only - DO NOT reproduce it directly):
{question.get('suggested_answer', '(none provided)')}
INSTRUCTIONS:
1. Acknowledge what the student got right.
2. Where the answer is incomplete or wrong, give hints that nudge toward the
correct reasoning (definitions, setup, the missing causal step) without
handing over the solution.
3. For multi-part questions, address each part briefly.
4. Encourage economic intuition, not just mechanics.
5. Keep it concise (200-250 words), encouraging and constructive.
Provide your feedback now:"""
def get_ai_feedback(tutorial: dict, question: dict, student_answer: str) -> str:
if not student_answer.strip():
return "Please write an answer first, then request feedback."
api_keys = get_groq_api_keys()
if not api_keys:
return "AI feedback is not enabled for this Space (no GROQ_API_KEY set)."
try:
from groq import Groq
except ImportError:
return "The `groq` package is not installed. Add it to requirements.txt."
prompt = build_feedback_prompt(tutorial, question, student_answer)
last_err = ""
for key in api_keys: # rotate through keys on failure (e.g. rate limit)
try:
client = Groq(api_key=key)
resp = client.chat.completions.create(
messages=[{"role": "user", "content": prompt}],
model=FEEDBACK_MODEL,
)
return resp.choices[0].message.content
except Exception as exc: # noqa: BLE001
last_err = str(exc)
continue
return f"Could not get feedback (all keys failed). Last error: {last_err}"
# --------------------------------------------------------------------------- #
# Teacher mode
# --------------------------------------------------------------------------- #
def teacher_view() -> None:
st.header("👩‍🏫 Teacher mode — build a tutorial")
st.caption(
"Fill in the fields (or load an existing tutorial to edit), then **Save**. "
"Math uses LaTeX: `$x$` inline, `$$x$$` for display. No coding required."
)
seed: dict = {}
existing = list_tutorials()
with st.expander("Start from an existing tutorial (optional)"):
col_a, col_b = st.columns(2)
with col_a:
pick = st.selectbox("Load saved tutorial", ["— none —", *existing.keys()])
if pick != "— none —" and st.button("Load for editing"):
st.session_state["teacher_seed"] = load_tutorial(existing[pick])
with col_b:
up = st.file_uploader("…or upload a tutorial JSON", type="json")
if up is not None:
st.session_state["teacher_seed"] = json.loads(up.read().decode("utf-8"))
seed = st.session_state.get("teacher_seed", {})
col1, col2 = st.columns(2)
topic_number = col1.text_input("Topic number", value=seed.get("topic_number", ""))
topic_name = col2.text_input("Topic name", value=seed.get("topic_name", ""))
title = st.text_input(
"Tutorial title",
value=seed.get("title", f"Topic {topic_number} Questions" if topic_number else ""),
)
intro = st.text_area("Intro / instructions (markdown)", value=seed.get("intro", ""), height=120)
readings = st.text_area(
"Readings (markdown — paste a list, or upload below)",
value=seed.get("readings", ""),
height=140,
)
up_readings = st.file_uploader("…or upload readings (.md/.txt)", type=["md", "txt"], key="rd")
if up_readings is not None:
readings = up_readings.read().decode("utf-8")
st.subheader("Questions")
seed_qs = seed.get("questions", [])
n = st.number_input(
"How many questions?", min_value=1, max_value=30, value=max(1, len(seed_qs)), step=1
)
questions = []
for i in range(int(n)):
sq = seed_qs[i] if i < len(seed_qs) else {}
with st.expander(f"Question {i + 1}", expanded=i == 0):
q_title = st.text_input("Short title", value=sq.get("title", ""), key=f"qt{i}")
prompt = st.text_area(
"Question (markdown + LaTeX)", value=sq.get("prompt", ""), height=160, key=f"qp{i}"
)
answer = st.text_area(
"Suggested / indicative answer (markdown + LaTeX)",
value=sq.get("suggested_answer", ""),
height=160,
key=f"qa{i}",
)
questions.append({"title": q_title, "prompt": prompt, "suggested_answer": answer})
data = {
"topic_number": topic_number.strip(),
"topic_name": topic_name.strip(),
"title": title.strip() or "Untitled tutorial",
"intro": intro,
"readings": readings,
"questions": questions,
}
st.divider()
c1, c2 = st.columns(2)
if c1.button("💾 Save tutorial", type="primary"):
path = save_tutorial(data)
st.success(f"Saved locally → `{path.name}`")
st.info(push_to_hub(path))
st.cache_resource.clear() # so Student mode sees the new/updated file
c2.download_button(
"⬇️ Download JSON",
data=json.dumps(data, indent=2, ensure_ascii=False),
file_name=f"{slugify('topic' + topic_number + '-' + topic_name)}.json",
mime="application/json",
)
with st.expander("Preview the JSON that will be saved"):
st.code(json.dumps(data, indent=2, ensure_ascii=False), language="json")
# --------------------------------------------------------------------------- #
# Student mode
# --------------------------------------------------------------------------- #
def student_view() -> None:
tutorials = list_tutorials()
if not tutorials:
st.info("No tutorials yet. Switch to **Teacher mode** to create one.")
return
label = st.sidebar.selectbox("Choose a tutorial", list(tutorials.keys()))
data = load_tutorial(tutorials[label])
st.title(data.get("title", "Tutorial"))
if data.get("topic_name"):
st.caption(f"Topic {data.get('topic_number', '')}{data['topic_name']}")
if data.get("intro"):
st.markdown(data["intro"])
if data.get("readings"):
with st.expander("📚 Readings", expanded=True):
st.markdown(data["readings"])
ai_on = feedback_available()
if not ai_on:
st.sidebar.caption("ℹ️ AI feedback disabled (no GROQ_API_KEY set).")
st.divider()
answers: dict[str, str] = {}
for i, q in enumerate(data.get("questions", []), start=1):
heading = f"Question {i}" + (f": {q['title']}" if q.get("title") else "")
st.subheader(heading)
st.markdown(q.get("prompt", ""))
ans = st.text_area(
"Your answer",
key=f"student_ans_{i}",
height=180,
placeholder="Write your own answer first, before revealing the suggested answer…",
)
answers[heading] = ans
fb_key = f"feedback_{i}"
if ai_on and st.button("🤖 Get AI feedback", key=f"fb_btn_{i}"):
with st.spinner("Asking the AI tutor…"):
st.session_state[fb_key] = get_ai_feedback(data, q, ans)
if st.session_state.get(fb_key):
st.info(st.session_state[fb_key])
st.caption(
"⚠️ AI can be wrong or hallucinate. Verify against lecture notes and "
"bring your answer to your tutor — the tutor is your primary feedback source."
)
with st.expander("💡 Reveal suggested answer"):
st.markdown(q.get("suggested_answer", "_No suggested answer provided._"))
st.divider()
# Export student responses (and any AI feedback received) as markdown.
st.sidebar.divider()
st.sidebar.subheader("Export your answers")
lines = [f"# {data.get('title', 'Tutorial')}", ""]
for i, (heading, ans) in enumerate(answers.items(), start=1):
lines += [f"## {heading}", "", ans or "_(no answer)_", ""]
fb = st.session_state.get(f"feedback_{i}")
if fb:
lines += ["**AI feedback:**", "", fb, ""]
md = "\n".join(lines)
st.sidebar.download_button(
"⬇️ Download my answers (.md)",
data=md,
file_name=f"{slugify(data.get('title', 'answers'))}-responses-{datetime.now():%Y%m%d}.md",
mime="text/markdown",
)
# --------------------------------------------------------------------------- #
# Entry point
# --------------------------------------------------------------------------- #
def main() -> None:
st.set_page_config(page_title="Tutorial Builder", page_icon="📘", layout="centered")
sync_from_hub() # pull persisted tutorials once per server start (no-op if unconfigured)
mode = st.sidebar.radio("Mode", ["Student", "Teacher"], index=0)
st.sidebar.divider()
if mode == "Teacher":
teacher_view()
else:
student_view()
if __name__ == "__main__":
main()