Spaces:
Sleeping
Sleeping
| """ | |
| 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 | |
| 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() | |