""" 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 # ---- Actions (UI <-> modules glue) ----------------------------------------- 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']}_" ) # ---- UI --------------------------------------------------------------------- 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.*") # Upload view 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("") # Study view 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") # Recap view with gr.Group(visible=False) as recap_view: recap_md = gr.Markdown("") resume_btn = gr.Button("↩️ Keep studying") # Debug panel — visible after each upload so you can verify extraction & chunks with gr.Accordion("🔍 Debug: extraction & chunks", open=False, visible=False) as debug_accordion: debug_data = gr.JSON(label="Content pipeline output") # Every handler that can hit the model shows a spinner (show_progress="full") # and disables its button while working, so a slow call never reads as a dead # or double-clickable screen. 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]) # Difficulty dial — regenerate the current card harder/easier (NAH-32). 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__": # Gradio 6 moved `theme` from the Blocks constructor to launch(). demo.launch(theme=gr.themes.Soft())