SlideScholar / app.py
kshamaasuresh's picture
Update app.py
74fd2d4 verified
Raw
History Blame Contribute Delete
24.4 kB
"""
SlideScholar β€” app.py
HuggingFace Spaces deployment (CPU basic β€” free tier).
"""
import os, re, json
from pathlib import Path
from typing import List, Dict
import numpy as np
import faiss
import torch
import gradio_client.utils as _gcu
_orig = _gcu.json_schema_to_python_type
def _safe(schema, defs=None):
if not isinstance(schema, dict): return "Any"
try: return _orig(schema)
except Exception: return "Any"
_gcu.json_schema_to_python_type = _safe
import gradio as gr
from sentence_transformers import SentenceTransformer
HF_TOKEN = os.environ.get("HF_TOKEN", "")
CHUNKS_PATH = Path("chunks.json")
FAISS_PATH = Path("slidescholar.faiss")
MISTRAL_ID = "mistralai/Mistral-7B-Instruct-v0.2"
_db = None
_PLACEHOLDER = "SSANSWER"
# ══════════════════════════════════════════════════════════════════════════════
# VDB
# ══════════════════════════════════════════════════════════════════════════════
class vdb:
def __init__(self, model_name="intfloat/e5-large-v2"):
self.device = "cuda" if torch.cuda.is_available() else "cpu"
self.model = SentenceTransformer(model_name, device=self.device)
self.dim = 1024
self.index = None
self.chunks: List[Dict] = []
def _embed(self, texts, prefix):
return self.model.encode(
[f"{prefix}{t}" for t in texts],
batch_size=32, normalize_embeddings=True,
convert_to_numpy=True, show_progress_bar=False,
).astype(np.float32)
def load(self, faiss_path, chunks):
self.index = faiss.read_index(faiss_path)
self.chunks = chunks
def search(self, query, top_k=8):
if self.index is None: raise RuntimeError("Index not loaded.")
q = self._embed([query], "query: ")
distances, indices = self.index.search(q, top_k)
return [
{"score": float(d), "chunk": self.chunks[i]}
for d, i in zip(distances[0], indices[0])
if i != -1 and i < len(self.chunks)
]
def add_texts(self, texts, metadatas):
self.chunks.extend([{"text": t, "metadata": m} for t, m in zip(texts, metadatas)])
emb = self._embed(texts, "passage: ")
if self.index is None: self.index = faiss.IndexFlatIP(self.dim)
self.index.add(emb)
def _load_index():
global _db
if _db is not None: return _db
if not CHUNKS_PATH.exists(): raise FileNotFoundError("chunks.json not found.")
if not FAISS_PATH.exists(): raise FileNotFoundError("slidescholar.faiss not found.")
with open(CHUNKS_PATH) as f: chunks = json.load(f)
_db = vdb()
_db.load(str(FAISS_PATH), chunks)
print(f"Index loaded β€” {_db.index.ntotal} vectors")
return _db
# ══════════════════════════════════════════════════════════════════════════════
# GENERATION
# ══════════════════════════════════════════════════════════════════════════════
def _generate(prompt, max_new_tokens=700, temperature=0.1):
if not HF_TOKEN:
raise RuntimeError("HF_TOKEN secret not set.")
from huggingface_hub import InferenceClient
client = InferenceClient(model=MISTRAL_ID, token=HF_TOKEN)
response = client.chat_completion(
messages=[{"role": "user", "content": prompt}],
max_tokens=max_new_tokens,
temperature=temperature,
)
return response.choices[0].message.content.strip()
# ══════════════════════════════════════════════════════════════════════════════
# RAG HELPERS
# ══════════════════════════════════════════════════════════════════════════════
def _fmt_context(results, max_chars=900):
parts = []
for i, r in enumerate(results):
m = r["chunk"]["metadata"]
parts.append(
f"[Source {i+1}: {m.get('name','?')} β€” Slide {m.get('slide','?')}]\n"
f"{r['chunk']['text'][:max_chars]}"
)
return "\n\n" + ("\n\n" + "─"*40 + "\n\n").join(parts)
def _fmt_sources(results):
lines = ["---", "**πŸ“š Sources used:**"]
for r in results:
m = r["chunk"]["metadata"]
lines.append(f"- **{m.get('name','?')}** β€” Slide {m.get('slide','?')} *(score: {r['score']:.2f})*")
return "\n".join(lines)
def _strip_bracket_hints(text):
"""Remove lines that are purely [placeholder hints] the model echoed back."""
lines = text.split("\n")
cleaned = [l for l in lines if not re.match(r"^\s*\[.*\]\s*$", l)]
return re.sub(r"\n{3,}", "\n\n", "\n".join(cleaned)).strip()
# ══════════════════════════════════════════════════════════════════════════════
# EXAM FORMATTER
# ══════════════════════════════════════════════════════════════════════════════
def _fix_exam_format(text):
text = text.replace("**", "")
text = re.sub(r"\[([ABCD])\]\s*", r"\1) ", text)
text = re.sub(r"\(([ABCD])\)\s*", r"\1) ", text)
text = re.sub(r"([ABCD])\)([^\s)])", r"\1) \2", text)
text = re.sub(
r"[βœ…βœ“]?\s*\[?Answer:?\s*([ABCD])[\])]?\s*",
lambda m: f" {_PLACEHOLDER}_{m.group(1)} ", text
)
text = re.sub(r"([^\n]) *([ABCD]\) )", r"\1\n\2", text)
text = re.sub(r"^([ABCD])\) ", r"**\1)** ", text, flags=re.MULTILINE)
text = re.sub(rf"\s*{_PLACEHOLDER}_([ABCD])\s*", r"\n\nβœ… **Answer: \1)** ", text)
text = re.sub(r" {2,}", " ", text)
text = re.sub(r"\n{3,}", "\n\n", text)
return text.strip()
# ══════════════════════════════════════════════════════════════════════════════
# PROMPTS β€” context FIRST, instructions LAST
# ══════════════════════════════════════════════════════════════════════════════
def _study_prompt(context, query):
return (
"You are an AI tutor helping students study for exams.\n"
"STRICT RULES:\n"
"1. Use ONLY the lecture slide content below. Do not add outside knowledge.\n"
"2. Copy key terms, formulas, and definitions verbatim from the slides.\n"
"3. Cite every point with [Source N].\n"
"4. If something is not covered, say 'Not covered in provided slides.'\n\n"
f"Context:\n{context}\n\n"
f"Question: {query}\n\n"
"Output (use these exact headers):\n"
"## Key Concepts\n"
"## Definitions\n"
"## Important Formulas\n"
"## Common Exam Topics\n"
"## Summary"
)
def _flashcard_prompt(context, query, batch=1):
extra = "" if batch == 1 else (
f"Generate 5 MORE flashcards covering DIFFERENT aspects of {query} not yet covered.\n"
)
return (
f"Use ONLY these lecture slides to make 5 flashcard Q&A pairs about \"{query}\".\n"
f"{extra}\n"
f"LECTURE SLIDES:\n{context}\n\n"
"Write exactly 5 cards in this format. No other text.\n\n"
"Q: first question\n"
"A: first answer (under 20 words)\n"
"Source: slide reference\n\n"
"Q: second question\n"
"A: second answer (under 20 words)\n"
"Source: slide reference\n\n"
"Q: third question\n"
"A: third answer (under 20 words)\n"
"Source: slide reference\n\n"
"Q: fourth question\n"
"A: fourth answer (under 20 words)\n"
"Source: slide reference\n\n"
"Q: fifth question\n"
"A: fifth answer (under 20 words)\n"
"Source: slide reference\n\n"
f"Now write 5 real flashcards from the slides about \"{query}\":\n\n"
"Q:"
)
def _exam_prompt(context, query):
return (
"You are a professor creating a practice exam from lecture slides only.\n"
"Do not use outside knowledge.\n\n"
f"LECTURE SLIDES:\n{context}\n\n"
f"TOPIC: {query}\n\n"
"Create a practice exam with EXACTLY this structure:\n\n"
f"## Practice Exam: {query}\n\n"
"### Multiple Choice (5 questions)\n\n"
"For each MCQ:\n"
"**Question N.** [Difficulty] Question text?\n"
"**A)** Option\n**B)** Option\n**C)** Option\n**D)** Option\n"
"βœ… **Answer: X)** One-sentence explanation. [Source N]\n\n"
"---\n\n"
"### Short Answer (3 questions)\n\n"
"**Question N.** [Difficulty] Question text?\n"
"**Model Answer:** Full answer in 2-3 sentences. [Source N]\n\n"
"---\n\n"
"### Answer Key\n"
"1-? 2-? 3-? 4-? 5-?\n\n"
"Now write the full exam. Start with Question 1:"
)
def _eli5_prompt(context, query):
return (
"You are a friendly tutor. Use ONLY the lecture slides to explain a concept simply.\n\n"
f"LECTURE SLIDES:\n{context}\n\n"
f"CONCEPT: {query}\n\n"
"Write your explanation with these four sections:\n\n"
"## Simple Explanation\n\n"
"**The core idea in one sentence:**\n\n"
"**Real-world analogy:**\n\n"
"**How it works, step by step:**\n"
"1.\n2.\n3.\n\n"
"**Why it matters:**\n\n"
"Begin now:"
)
def _gap_prompt(context, question, student_ans, correct_ans):
return (
"A student got an exam question wrong. Use ONLY these lecture slides to explain why.\n\n"
f"LECTURE SLIDES:\n{context}\n\n"
f"EXAM QUESTION: {question}\n"
f"STUDENT ANSWERED: {student_ans}\n"
f"CORRECT ANSWER: {correct_ans}\n\n"
"Write a re-explanation with these four sections:\n\n"
"## Why the correct answer is right\n"
"Cite the relevant slide using [Source N].\n\n"
"## Why the student answer was wrong\n"
"Be specific and constructive.\n\n"
"## Key concept to remember\n"
"State it in one sentence.\n\n"
"## Memory aid\n"
"Give a simple analogy or mnemonic.\n\n"
"Begin now:"
)
# ══════════════════════════════════════════════════════════════════════════════
# FLASHCARD PARSER β€” Q:/A:/Source: line format
# ══════════════════════════════════════════════════════════════════════════════
def _parse_flashcards(raw):
# Model starts mid-card-1 since prompt ends with "Q:"
if not raw.strip().upper().startswith("Q:"):
raw = "Q:" + raw
pat_q = re.compile(r"^Q:\s*(.+)", re.MULTILINE)
pat_a = re.compile(r"^A:\s*(.+)", re.MULTILINE)
pat_s = re.compile(r"^Source:\s*(.+)", re.MULTILINE | re.IGNORECASE)
SKIP_Q = {"first question", "second question", "third question", "fourth question",
"fifth question", "write the question here", "[question]", "question here"}
SKIP_A = {"first answer (under 20 words)", "second answer (under 20 words)",
"third answer (under 20 words)", "fourth answer (under 20 words)",
"fifth answer (under 20 words)", "write a short answer here", "[answer]"}
cards = []
blocks = re.split(r"(?=^Q:)", raw, flags=re.MULTILINE)
for block in blocks:
block = block.strip()
if not block:
continue
q_m = pat_q.match(block)
a_m = pat_a.search(block)
s_m = pat_s.search(block)
if not (q_m and a_m):
continue
q = q_m.group(1).strip().rstrip("?") + ("?" if not q_m.group(1).strip().endswith("?") else "")
a = a_m.group(1).strip()
if q.lower().rstrip("?") in SKIP_Q or a.lower() in SKIP_A:
continue
cards.append({
"q": q,
"a": a,
"source": s_m.group(1).strip() if s_m else "",
})
if not cards:
raise ValueError("No flashcards parsed from output")
return cards
def _render_flashcards(cards, query):
lines = [f"## πŸƒ Flashcards: *{query}*", f"*{len(cards)} cards generated*", ""]
for i, card in enumerate(cards, 1):
q = card.get("q", "").strip()
a = card.get("a", "").strip()
s = card.get("source", "").strip()
lines += ["---", f"**Q{i}.** {q}", "", f"> {a}"]
if s: lines += ["> ", f"> *πŸ“– {s}*"]
lines.append("")
return "\n".join(lines)
# ══════════════════════════════════════════════════════════════════════════════
# TAB HANDLERS
# ══════════════════════════════════════════════════════════════════════════════
def handle_study_guide(query, k):
if not query.strip(): return "Please enter a topic or question.", ""
try:
db = _load_index()
results = db.search(query, top_k=int(k))
output = _generate(_study_prompt(_fmt_context(results, max_chars=800), query), max_new_tokens=900)
return output, _fmt_sources(results)
except Exception as e:
return f"❌ {e}", ""
def handle_flashcards(query, k):
if not query.strip(): return "Please enter a topic.", ""
try:
db = _load_index()
top_k = min(int(k), 4)
results = db.search(query, top_k=top_k)
ctx = _fmt_context(results, max_chars=500)
# Two batches of 5 β€” more reliable than asking for 10 at once
all_cards = []
for batch in [1, 2]:
try:
raw = _generate(_flashcard_prompt(ctx, query, batch=batch),
max_new_tokens=550, temperature=0.2)
cards = _parse_flashcards(raw)
all_cards.extend(cards)
except Exception:
pass # show whatever we have if a batch fails
if not all_cards:
return "❌ Could not generate flashcards β€” try a more specific topic.", _fmt_sources(results)
display = _render_flashcards(all_cards[:10], query)
if len(all_cards) < 10:
display += f"\n\n*Note: {len(all_cards)} cards generated*"
return display, _fmt_sources(results)
except Exception as e:
return f"❌ {e}", ""
def handle_exam(query, k):
if not query.strip(): return "Please enter a topic.", ""
try:
db = _load_index()
top_k = min(int(k), 5)
results = db.search(query, top_k=top_k)
raw = _generate(_exam_prompt(_fmt_context(results, max_chars=550), query),
max_new_tokens=900, temperature=0.2)
return _fix_exam_format(raw), _fmt_sources(results)
except Exception as e:
return f"❌ {e}", ""
def handle_eli5(query, k):
if not query.strip(): return "Please enter a concept.", ""
try:
db = _load_index()
top_k = min(int(k), 5)
results = db.search(query, top_k=top_k)
output = _strip_bracket_hints(
_generate(_eli5_prompt(_fmt_context(results, max_chars=600), query),
max_new_tokens=600, temperature=0.1)
)
return output, _fmt_sources(results)
except Exception as e:
return f"❌ {e}", ""
def handle_gap(question, student_ans, correct_ans, k):
if not all([question.strip(), student_ans.strip(), correct_ans.strip()]):
return "Please fill in all three fields.", ""
try:
db = _load_index()
top_k = min(int(k), 5)
results = db.search(f"{question} {correct_ans}", top_k=top_k)
output = _strip_bracket_hints(
_generate(_gap_prompt(_fmt_context(results, max_chars=550), question, student_ans, correct_ans),
max_new_tokens=600, temperature=0.1)
)
return output, _fmt_sources(results)
except Exception as e:
return f"❌ {e}", ""
def handle_upload(files):
if not files: return "No files selected."
try:
import pdfplumber
except ImportError:
return "pdfplumber not installed."
try:
db = _load_index()
total, names = 0, []
for file in files:
path = Path(file if isinstance(file, str) else file.name)
texts, metas = [], []
with pdfplumber.open(str(path)) as pdf:
for i, page in enumerate(pdf.pages):
raw = (page.extract_text() or "").strip()
if raw:
texts.append(raw)
metas.append({
"name": path.stem, "slide": i+1, "lecture_num": None,
"source": str(path), "filename": path.name,
"filetype": "pdf", "is_scanned": False,
"char_count": len(raw), "chunk_id": f"upload_{path.stem}_{i+1}",
})
db.add_texts(texts, metas)
total += len(texts)
names.append(f"{path.name} ({len(texts)} slides)")
return (
f"βœ… Added {total} slides from {len(files)} file(s):\n"
+ "\n".join(f" β€’ {n}" for n in names)
+ f"\n\nTotal index size: {len(db.chunks)} chunks"
)
except Exception as e:
return f"❌ Upload failed: {e}"
# ══════════════════════════════════════════════════════════════════════════════
# GRADIO UI
# ══════════════════════════════════════════════════════════════════════════════
CSS = """
.tab-nav button { font-size: 15px !important; padding: 10px 18px !important; }
.sources-box { border-left: 4px solid #0D9488; padding: 12px 16px;
border-radius: 6px; font-size: 13px; margin-top: 8px; }
.output-box { min-height: 280px; }
footer { display: none !important; }
"""
HEADER = """
# πŸ“š SlideScholar
### AI Study Assistant β€” STATGR5293 Β· GenAI Using LLMs Β· Spring 2026
Powered by **Mistral-7B-Instruct** + **FAISS** retrieval over your actual lecture slides.
All outputs are grounded in course content β€” not generic AI responses.
> ⏱️ First generation may take 30–60s while the model warms up on HuggingFace servers.
"""
def build_app():
with gr.Blocks(css=CSS, title="SlideScholar") as app:
gr.Markdown(HEADER)
with gr.Row():
k_slider = gr.Slider(minimum=3, maximum=15, value=8, step=1,
label="Slides to retrieve (k)",
info="More slides = richer context but slower.")
with gr.Tab("πŸ“ Study Guide"):
gr.Markdown("Get structured notes with citations grounded in your lecture slides.")
sg_query = gr.Textbox(label="Topic or question",
placeholder="e.g. attention mechanism and transformers", lines=2)
sg_btn = gr.Button("Generate Study Guide", variant="primary")
sg_output = gr.Markdown(elem_classes=["output-box"])
sg_sources = gr.Markdown(elem_classes=["sources-box"])
sg_btn.click(handle_study_guide, [sg_query, k_slider], [sg_output, sg_sources])
with gr.Tab("πŸƒ Flashcards"):
gr.Markdown("Generate Q&A flashcard pairs from your slides.")
fc_query = gr.Textbox(label="Topic",
placeholder="e.g. gradient descent and optimization", lines=2)
fc_btn = gr.Button("Generate Flashcards", variant="primary")
fc_output = gr.Markdown(elem_classes=["output-box"])
fc_sources = gr.Markdown(elem_classes=["sources-box"])
fc_btn.click(handle_flashcards, [fc_query, k_slider], [fc_output, fc_sources])
with gr.Tab("πŸ“‹ Practice Exam"):
gr.Markdown("Generate a practice exam: 5 MCQs + 3 short-answer questions with answer key.")
pe_query = gr.Textbox(label="Topic",
placeholder="e.g. transformer architecture and self-attention", lines=2)
pe_btn = gr.Button("Generate Exam", variant="primary")
pe_output = gr.Markdown(elem_classes=["output-box"])
pe_sources = gr.Markdown(elem_classes=["sources-box"])
pe_btn.click(handle_exam, [pe_query, k_slider], [pe_output, pe_sources])
with gr.Tab("πŸ’‘ ELI5"):
gr.Markdown("Explain a complex concept in simple terms using your lecture slides.")
e5_query = gr.Textbox(label="Concept",
placeholder="e.g. what is the attention mechanism?", lines=2)
e5_btn = gr.Button("Explain Simply", variant="primary")
e5_output = gr.Markdown(elem_classes=["output-box"])
e5_sources = gr.Markdown(elem_classes=["sources-box"])
e5_btn.click(handle_eli5, [e5_query, k_slider], [e5_output, e5_sources])
with gr.Tab("🎯 Gap Analysis"):
gr.Markdown("Got a question wrong? Enter the question, your answer, and the correct answer.")
gap_q = gr.Textbox(label="Exam question", lines=2)
gap_s = gr.Textbox(label="Your answer", lines=2)
gap_c = gr.Textbox(label="Correct answer", lines=2)
gap_btn = gr.Button("Explain My Mistake", variant="primary")
gap_out = gr.Markdown(elem_classes=["output-box"])
gap_src = gr.Markdown(elem_classes=["sources-box"])
gap_btn.click(handle_gap, [gap_q, gap_s, gap_c, k_slider], [gap_out, gap_src])
with gr.Tab("πŸ“‚ Upload Slides"):
gr.Markdown(
"Upload additional PDF lecture slides to extend the knowledge base.\n\n"
"> **Note:** Text is extracted directly β€” no vision model on Spaces."
)
upload_files = gr.File(label="Upload PDF files", file_count="multiple",
file_types=[".pdf"], type="filepath")
upload_btn = gr.Button("Add to Index", variant="primary")
upload_status = gr.Textbox(label="Status", interactive=False, lines=5)
upload_btn.click(handle_upload, [upload_files], [upload_status])
gr.Markdown("---\n*SlideScholar Β· STATGR5293 Β· GenAI Using LLMs Β· Spring 2026 Β· Columbia University*")
return app
if __name__ == "__main__":
print("Pre-loading index...")
try:
_load_index()
print(f"Index ready β€” {_db.index.ntotal} vectors")
except Exception as e:
print(f"Warning: {e}")
build_app().launch(server_name="0.0.0.0", server_port=7860, show_api=False)