study-partner / app.py
nz-nz's picture
Deploy Recall study-partner app (stub-mode demo)
7563305 verified
Raw
History Blame Contribute Delete
8.98 kB
"""
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())