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