| """ |
| Recall — Gradio app. OWNER: Arturo (Module C) |
| |
| Three views (Upload / Study / Recap) over a single Session held in gr.State. |
| Wired to content_pipeline + learning_engine. Runs against stubs immediately: |
| |
| pip install -r requirements.txt |
| python app.py # opens at http://127.0.0.1:7860 in stub mode |
| |
| Flip RECALL_STUB=0 once the real model is wired: |
| RECALL_STUB=0 python app.py |
| """ |
| from __future__ import annotations |
|
|
| import gradio as gr |
|
|
| import content_pipeline as cp |
| import learning_engine as le |
|
|
|
|
| |
|
|
| def start_session(file, pasted_text): |
| if pasted_text and pasted_text.strip(): |
| text = pasted_text.strip() |
| else: |
| try: |
| text = cp.extract_text(file) |
| except cp.ExtractionError as e: |
| return (None, gr.update(visible=True), gr.update(visible=False), |
| f"⚠️ {e}", "", "", gr.update(visible=False), None) |
| if not text: |
| return (None, gr.update(visible=True), gr.update(visible=False), |
| "⚠️ No text found. Upload a PDF or paste some notes.", "", "", |
| gr.update(visible=False), None) |
| chunks = cp.chunk_text(text) |
| debug = { |
| "total_chars": len(text), |
| "extracted_text_preview": text[:800] + ("…" if len(text) > 800 else ""), |
| "chunk_count": len(chunks), |
| "chunks": [ |
| { |
| "index": i, |
| "chars": len(c), |
| "tokens_approx": len(c) // 4, |
| "text": c, |
| } |
| for i, c in enumerate(chunks) |
| ], |
| } |
| try: |
| deck = cp.generate_deck(text) |
| except Exception as e: |
| return (None, gr.update(visible=True), gr.update(visible=False), |
| f"⚠️ generate_deck failed ({type(e).__name__}: {e}). " |
| "Extraction & chunks are shown below.", |
| "", "", gr.update(visible=True), debug) |
| if not deck: |
| return (None, gr.update(visible=True), gr.update(visible=False), |
| "⚠️ Couldn't generate questions from that. Try different material.", |
| "", "", gr.update(visible=True), debug) |
| session = le.init_session(deck) |
| card = le.next_card(session) |
| return (session, gr.update(visible=False), gr.update(visible=True), |
| "", _progress(session), card["question"] if card else "", |
| gr.update(visible=True), debug) |
|
|
|
|
| def submit_answer(session, user_answer): |
| if not session: |
| return session, "", "", "", gr.update() |
|
|
| grade, fups = le.grade_and_adapt(session, user_answer or "") |
| if grade is None: |
| return session, "🎉 Deck complete!", "", "", gr.update(visible=False) |
|
|
| followup_note = "" |
| if fups: |
| followup_note = ("🎯 Added " + str(len(fups)) + |
| " new question(s) targeting what you missed.") |
|
|
| verdict = ("✅ " if grade["correct"] else "❌ ") + grade["explanation"] |
| return session, verdict, followup_note, _progress(session), gr.update() |
|
|
|
|
| def change_difficulty(session, direction): |
| """Difficulty dial (NAH-32): rewrite the current card harder/easier on the |
| same concept and re-show it. No-op if there's no current card.""" |
| if not session: |
| return session, "", "" |
| card = le.next_card(session) |
| if card is None: |
| return session, "", "" |
| new = cp.regenerate(card, direction) |
| session = le.replace_card(session, card["id"], new) |
| label = "harder" if direction == "harder" else "easier" |
| return session, new["question"], f"🎚️ Rewrote this question to be {label}." |
|
|
|
|
| def show_next(session): |
| card = le.next_card(session) |
| if card is None: |
| return session, "", gr.update(visible=False), gr.update(visible=True), _render_recap(session) |
| return session, card["question"], gr.update(), gr.update(visible=False), "" |
|
|
|
|
| def finish_session(session): |
| """End the session on demand and show the recap (NAH-35). The spaced- |
| repetition queue never empties on its own, so this is the user's way out.""" |
| if not session: |
| return gr.update(), gr.update(), "" |
| return gr.update(visible=False), gr.update(visible=True), _render_recap(session) |
|
|
|
|
| def resume_study(session): |
| """Return from the recap to keep studying (so recap isn't a dead end).""" |
| card = le.next_card(session) |
| return (gr.update(visible=True), gr.update(visible=False), |
| card["question"] if card else "") |
|
|
|
|
| def _progress(session): |
| total = len(session["deck"]) |
| answered = len(session["history"]) |
| current = min(answered + 1, total) |
| remaining = len(session["queue"]) |
| return (f"**Card {current} of {total}** · ✅ {answered} answered" |
| f" · 🔥 Streak: {session['streak']} · {remaining} left in queue") |
|
|
|
|
| def _render_recap(session): |
| r = le.recap(session) |
| return ( |
| f"### Session recap\n\n" |
| f"**Answered:** {r['answered']} · **🔥 Streak:** {r['streak']}\n\n" |
| f"**Mastered:** {', '.join(r['mastered']) or '—'}\n\n" |
| f"**Still weak:** {', '.join(r['weak_topics']) or '—'}\n\n" |
| f"_{r['reflection']}_" |
| ) |
|
|
|
|
| |
|
|
| with gr.Blocks(title="Recall — your AI study partner") as demo: |
| session = gr.State(None) |
| gr.Markdown("# 📚 Recall\n*Upload your material. Get quizzed. It adapts to what you miss.*") |
|
|
| |
| with gr.Group(visible=True) as upload_view: |
| gr.Markdown("### 1 · Add your study material") |
| file_in = gr.File(label="Upload a PDF or .txt", file_types=[".pdf", ".txt"]) |
| text_in = gr.Textbox(label="…or paste notes", lines=4, |
| placeholder="Paste any text to study from") |
| start_btn = gr.Button("Generate deck & start", variant="primary") |
| upload_msg = gr.Markdown("") |
|
|
| |
| with gr.Group(visible=False) as study_view: |
| progress = gr.Markdown("") |
| question = gr.Markdown("") |
| answer_in = gr.Textbox(label="Your answer", lines=2) |
| with gr.Row(): |
| easier_btn = gr.Button("😌 Make it easier", size="sm") |
| harder_btn = gr.Button("🔥 Make it harder", size="sm") |
| submit_btn = gr.Button("Submit", variant="primary") |
| verdict = gr.Markdown("") |
| followup = gr.Markdown("") |
| with gr.Row(): |
| next_btn = gr.Button("Next question →", variant="primary") |
| finish_btn = gr.Button("🏁 Finish & see recap") |
|
|
| |
| with gr.Group(visible=False) as recap_view: |
| recap_md = gr.Markdown("") |
| resume_btn = gr.Button("↩️ Keep studying") |
|
|
| |
| with gr.Accordion("🔍 Debug: extraction & chunks", open=False, visible=False) as debug_accordion: |
| debug_data = gr.JSON(label="Content pipeline output") |
|
|
| |
| |
| |
| start_btn.click( |
| lambda: gr.update(value="⏳ Generating deck…", interactive=False), None, start_btn, |
| ).then( |
| start_session, [file_in, text_in], |
| [session, upload_view, study_view, upload_msg, progress, question, |
| debug_accordion, debug_data], |
| show_progress="full", |
| ).then( |
| lambda: gr.update(value="Generate deck & start", interactive=True), None, start_btn, |
| ) |
|
|
| submit_btn.click( |
| lambda: gr.update(value="⏳ Grading…", interactive=False), None, submit_btn, |
| ).then( |
| submit_answer, [session, answer_in], |
| [session, verdict, followup, progress, next_btn], |
| show_progress="full", |
| ).then( |
| lambda: gr.update(value="Submit", interactive=True), None, submit_btn, |
| ) |
|
|
| next_btn.click( |
| show_next, [session], |
| [session, question, study_view, recap_view, recap_md], |
| show_progress="full", |
| ).then(lambda: ("", "", ""), None, [answer_in, verdict, followup]) |
|
|
| finish_btn.click( |
| finish_session, [session], |
| [study_view, recap_view, recap_md], |
| show_progress="full", |
| ) |
| resume_btn.click( |
| resume_study, [session], |
| [study_view, recap_view, question], |
| ).then(lambda: ("", "", ""), None, [answer_in, verdict, followup]) |
|
|
| |
| easier_btn.click( |
| lambda s: change_difficulty(s, "easier"), [session], |
| [session, question, followup], show_progress="full", |
| ) |
| harder_btn.click( |
| lambda s: change_difficulty(s, "harder"), [session], |
| [session, question, followup], show_progress="full", |
| ) |
|
|
|
|
| if __name__ == "__main__": |
| |
| demo.launch(theme=gr.themes.Soft()) |
|
|