Raminnit commited on
Commit
5851ef6
Β·
verified Β·
1 Parent(s): 9ec5706

Upload 2 files

Browse files
Files changed (2) hide show
  1. app.py +596 -0
  2. requirements.txt +27 -0
app.py ADDED
@@ -0,0 +1,596 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ The Lecture Whisperer β€” app.py
3
+ A Hugging Face Space that transcribes lectures, extracts slide content,
4
+ syncs them together, and generates mock quizzes.
5
+
6
+ Uses the HF Inference API to keep Space RAM low.
7
+ """
8
+
9
+ import os
10
+ import re
11
+ import json
12
+ import time
13
+ import tempfile
14
+ import textwrap
15
+ from pathlib import Path
16
+ from typing import Optional
17
+
18
+ import gradio as gr
19
+ import requests
20
+ from pdf2image import convert_from_path
21
+ from PIL import Image
22
+ import base64
23
+ from io import BytesIO
24
+
25
+ # ---------------------------------------------------------------------------
26
+ # Configuration
27
+ # ---------------------------------------------------------------------------
28
+
29
+ HF_TOKEN = os.environ.get("HF_TOKEN", "") # Set as a Space secret
30
+
31
+ HEADERS = {"Authorization": f"Bearer {HF_TOKEN}"}
32
+
33
+ # Inference API endpoints
34
+ WHISPER_API = "https://api-inference.huggingface.co/models/openai/whisper-large-v3"
35
+ QWEN_API = "https://api-inference.huggingface.co/models/Qwen/Qwen2-VL-7B-Instruct"
36
+ LLAMA_API = "https://api-inference.huggingface.co/models/meta-llama/Meta-Llama-3-8B-Instruct"
37
+
38
+ # ---------------------------------------------------------------------------
39
+ # Helpers
40
+ # ---------------------------------------------------------------------------
41
+
42
+ def pil_to_base64(img: Image.Image, fmt: str = "PNG") -> str:
43
+ buf = BytesIO()
44
+ img.save(buf, format=fmt)
45
+ return base64.b64encode(buf.getvalue()).decode()
46
+
47
+
48
+ def hf_post(url: str, payload: dict, retries: int = 3, wait: int = 20) -> dict:
49
+ """POST to a HF Inference endpoint with simple retry / loading handling."""
50
+ for attempt in range(retries):
51
+ resp = requests.post(url, headers=HEADERS, json=payload, timeout=120)
52
+ if resp.status_code == 503:
53
+ data = resp.json()
54
+ if "estimated_time" in data:
55
+ secs = min(int(data["estimated_time"]) + 5, 60)
56
+ else:
57
+ secs = wait
58
+ time.sleep(secs)
59
+ continue
60
+ resp.raise_for_status()
61
+ return resp.json()
62
+ raise RuntimeError(f"HF API unavailable after {retries} retries: {url}")
63
+
64
+
65
+ # ---------------------------------------------------------------------------
66
+ # 1. Audio β†’ Transcription with timestamps
67
+ # ---------------------------------------------------------------------------
68
+
69
+ def transcribe_audio(audio_path: str) -> list[dict]:
70
+ """
71
+ Returns a list of segments:
72
+ [{"start": float, "end": float, "text": str}, ...]
73
+ """
74
+ if not audio_path:
75
+ return []
76
+
77
+ with open(audio_path, "rb") as f:
78
+ audio_bytes = f.read()
79
+
80
+ # Whisper via Inference API (binary upload)
81
+ resp = requests.post(
82
+ WHISPER_API,
83
+ headers={**HEADERS, "Content-Type": "audio/mpeg"},
84
+ data=audio_bytes,
85
+ params={"return_timestamps": "true"},
86
+ timeout=300,
87
+ )
88
+ resp.raise_for_status()
89
+ result = resp.json()
90
+
91
+ # Normalise output shape
92
+ segments = []
93
+ if "chunks" in result:
94
+ for chunk in result["chunks"]:
95
+ ts = chunk.get("timestamp", [0, 0]) or [0, 0]
96
+ segments.append({
97
+ "start": ts[0] if ts[0] is not None else 0,
98
+ "end": ts[1] if ts[1] is not None else 0,
99
+ "text": chunk.get("text", "").strip(),
100
+ })
101
+ elif "text" in result:
102
+ # Fallback: single block, no timestamps
103
+ segments.append({"start": 0.0, "end": 0.0, "text": result["text"].strip()})
104
+
105
+ return segments
106
+
107
+
108
+ def format_transcript(segments: list[dict]) -> str:
109
+ lines = []
110
+ for seg in segments:
111
+ ts = f"[{int(seg['start'])//60:02d}:{int(seg['start'])%60:02d}]"
112
+ lines.append(f"{ts} {seg['text']}")
113
+ return "\n".join(lines)
114
+
115
+
116
+ # ---------------------------------------------------------------------------
117
+ # 2. PDF β†’ slide images β†’ text extraction
118
+ # ---------------------------------------------------------------------------
119
+
120
+ def pdf_to_images(pdf_path: str) -> list[Image.Image]:
121
+ """Convert each PDF page to a PIL Image."""
122
+ if not pdf_path:
123
+ return []
124
+ images = convert_from_path(pdf_path, dpi=150)
125
+ return images
126
+
127
+
128
+ def extract_slide_text(img: Image.Image, slide_num: int) -> str:
129
+ """Ask Qwen2-VL to extract key text/concepts from a slide image."""
130
+ b64 = pil_to_base64(img)
131
+ payload = {
132
+ "inputs": {
133
+ "image": b64,
134
+ "question": (
135
+ "You are an academic assistant. "
136
+ "Extract ALL key text, equations, definitions, bullet points, "
137
+ "and concepts visible on this lecture slide. "
138
+ "Be thorough and concise. Output plain text only."
139
+ ),
140
+ }
141
+ }
142
+ try:
143
+ result = hf_post(QWEN_API, payload)
144
+ if isinstance(result, list):
145
+ return result[0].get("generated_text", "").strip()
146
+ if isinstance(result, dict):
147
+ return result.get("generated_text", "").strip()
148
+ return str(result)
149
+ except Exception as e:
150
+ return f"[Slide {slide_num}: extraction error β€” {e}]"
151
+
152
+
153
+ # ---------------------------------------------------------------------------
154
+ # 3. Sync Logic
155
+ # ---------------------------------------------------------------------------
156
+
157
+ def build_keyword_index(slide_texts: list[str]) -> list[set]:
158
+ """Build a simple keyword set per slide (lowercase words, len > 3)."""
159
+ stop = {
160
+ "this", "that", "with", "from", "have", "will", "been",
161
+ "they", "their", "there", "were", "what", "when", "which",
162
+ "also", "into", "over", "more", "some", "such", "than",
163
+ "then", "these", "those", "after", "about", "would",
164
+ }
165
+ index = []
166
+ for text in slide_texts:
167
+ words = re.findall(r"[a-z]{4,}", text.lower())
168
+ index.append({w for w in words if w not in stop})
169
+ return index
170
+
171
+
172
+ def sync_transcript_to_slides(
173
+ segments: list[dict],
174
+ slide_texts: list[str],
175
+ ) -> list[dict]:
176
+ """
177
+ For each transcript segment, find the most likely slide.
178
+ Returns enriched segments with a `slide_idx` key (0-based, or -1 if no match).
179
+ """
180
+ if not slide_texts or not segments:
181
+ return segments
182
+
183
+ slide_kw_index = build_keyword_index(slide_texts)
184
+ enriched = []
185
+
186
+ for seg in segments:
187
+ seg_words = set(re.findall(r"[a-z]{4,}", seg["text"].lower()))
188
+ best_slide, best_score = -1, 0
189
+ for idx, kw_set in enumerate(slide_kw_index):
190
+ score = len(seg_words & kw_set)
191
+ if score > best_score:
192
+ best_score, best_slide = score, idx
193
+ seg = dict(seg)
194
+ seg["slide_idx"] = best_slide if best_score > 0 else -1
195
+ seg["match_score"] = best_score
196
+ enriched.append(seg)
197
+
198
+ return enriched
199
+
200
+
201
+ def format_sync_report(synced: list[dict]) -> str:
202
+ lines = ["## Transcript ↔ Slide Sync Report\n"]
203
+ for seg in synced:
204
+ ts = f"[{int(seg['start'])//60:02d}:{int(seg['start'])%60:02d}]"
205
+ slide_ref = (
206
+ f"β†’ **Slide {seg['slide_idx']+1}** (score: {seg['match_score']})"
207
+ if seg["slide_idx"] >= 0
208
+ else "β†’ no slide match"
209
+ )
210
+ lines.append(f"`{ts}` {seg['text']}\n {slide_ref}\n")
211
+ return "\n".join(lines)
212
+
213
+
214
+ # ---------------------------------------------------------------------------
215
+ # 4. Mock Quiz Generation
216
+ # ---------------------------------------------------------------------------
217
+
218
+ def generate_quiz(transcript_text: str) -> str:
219
+ """Ask Llama-3-8B to write 5-10 MCQs based strictly on the transcript."""
220
+ if not transcript_text.strip():
221
+ return "No transcript available. Please process audio first."
222
+
223
+ # Truncate to avoid token overflow
224
+ clipped = transcript_text[:6000]
225
+ prompt = textwrap.dedent(f"""
226
+ <|begin_of_text|>
227
+ <|start_header_id|>system<|end_header_id|>
228
+ You are an expert educator. Generate a multiple-choice quiz based ONLY on
229
+ the lecture transcript provided. Do not add external knowledge.
230
+ Format each question as:
231
+ Q1. <question>
232
+ A) <option> B) <option> C) <option> D) <option>
233
+ Answer: <letter>
234
+ <|eot_id|>
235
+ <|start_header_id|>user<|end_header_id|>
236
+ Lecture transcript:
237
+ {clipped}
238
+
239
+ Create 7 multiple-choice questions with 4 options each. Include the correct answer.
240
+ <|eot_id|>
241
+ <|start_header_id|>assistant<|end_header_id|>
242
+ """).strip()
243
+
244
+ payload = {
245
+ "inputs": prompt,
246
+ "parameters": {
247
+ "max_new_tokens": 1200,
248
+ "temperature": 0.4,
249
+ "do_sample": True,
250
+ },
251
+ }
252
+ result = hf_post(LLAMA_API, payload)
253
+ if isinstance(result, list):
254
+ return result[0].get("generated_text", "").split("<|start_header_id|>assistant<|end_header_id|>")[-1].strip()
255
+ return str(result)
256
+
257
+
258
+ # ---------------------------------------------------------------------------
259
+ # 5. Chatbot Q&A
260
+ # ---------------------------------------------------------------------------
261
+
262
+ def answer_question(
263
+ message: str,
264
+ history: list,
265
+ transcript_text: str,
266
+ slide_texts_json: str,
267
+ ) -> tuple[list, list]:
268
+ """RAG-lite: stuff relevant context into Llama prompt."""
269
+ slide_texts = json.loads(slide_texts_json) if slide_texts_json else []
270
+ context_parts = []
271
+
272
+ if transcript_text:
273
+ # Grab sentences containing keywords from the question
274
+ question_words = set(re.findall(r"[a-z]{4,}", message.lower()))
275
+ relevant_lines = [
276
+ line for line in transcript_text.split("\n")
277
+ if question_words & set(re.findall(r"[a-z]{4,}", line.lower()))
278
+ ]
279
+ context_parts.append("**Transcript excerpts:**\n" + "\n".join(relevant_lines[:15]))
280
+
281
+ if slide_texts:
282
+ kw_index = build_keyword_index(slide_texts)
283
+ question_words = set(re.findall(r"[a-z]{4,}", message.lower()))
284
+ for i, (text, kw_set) in enumerate(zip(slide_texts, kw_index)):
285
+ if question_words & kw_set:
286
+ context_parts.append(f"**Slide {i+1}:**\n{text[:400]}")
287
+ if len(context_parts) >= 5:
288
+ break
289
+
290
+ context = "\n\n".join(context_parts) if context_parts else "No specific context found."
291
+
292
+ history_str = ""
293
+ for h in history[-4:]:
294
+ history_str += f"User: {h[0]}\nAssistant: {h[1]}\n"
295
+
296
+ prompt = textwrap.dedent(f"""
297
+ <|begin_of_text|>
298
+ <|start_header_id|>system<|end_header_id|>
299
+ You are a helpful teaching assistant. Answer questions about the lecture
300
+ using ONLY the provided context. If unsure, say so.
301
+ <|eot_id|>
302
+ <|start_header_id|>user<|end_header_id|>
303
+ Context:
304
+ {context}
305
+
306
+ Conversation so far:
307
+ {history_str}
308
+ Question: {message}
309
+ <|eot_id|>
310
+ <|start_header_id|>assistant<|end_header_id|>
311
+ """).strip()
312
+
313
+ payload = {
314
+ "inputs": prompt,
315
+ "parameters": {"max_new_tokens": 512, "temperature": 0.3, "do_sample": True},
316
+ }
317
+ result = hf_post(LLAMA_API, payload)
318
+ if isinstance(result, list):
319
+ answer = result[0].get("generated_text", "").split("<|start_header_id|>assistant<|end_header_id|>")[-1].strip()
320
+ else:
321
+ answer = str(result)
322
+
323
+ history.append((message, answer))
324
+ return history, history
325
+
326
+
327
+ # ---------------------------------------------------------------------------
328
+ # 6. Main Processing Pipeline
329
+ # ---------------------------------------------------------------------------
330
+
331
+ def process_inputs(audio_file, pdf_file, progress=gr.Progress()):
332
+ """
333
+ Master pipeline called by the 'Process' button.
334
+ Returns:
335
+ transcript_text (str) β€” for display / state
336
+ sync_report (str) β€” markdown
337
+ slide_images (list[PIL]) β€” for gallery
338
+ slide_texts_json (str) β€” JSON string for state
339
+ status (str) β€” status message
340
+ """
341
+ if audio_file is None and pdf_file is None:
342
+ return "", "Please upload at least one file.", [], "[]", "⚠️ No files uploaded."
343
+
344
+ transcript_text = ""
345
+ segments: list[dict] = []
346
+ slide_images: list[Image.Image] = []
347
+ slide_texts: list[str] = []
348
+
349
+ # ---- Transcription ----
350
+ if audio_file is not None:
351
+ progress(0.1, desc="πŸŽ™οΈ Transcribing audio (this may take a while)…")
352
+ try:
353
+ segments = transcribe_audio(audio_file)
354
+ transcript_text = format_transcript(segments)
355
+ except Exception as e:
356
+ transcript_text = f"Transcription error: {e}"
357
+
358
+ # ---- PDF β†’ Images β†’ Text ----
359
+ if pdf_file is not None:
360
+ progress(0.4, desc="πŸ“„ Converting PDF pages…")
361
+ try:
362
+ slide_images = pdf_to_images(pdf_file)
363
+ except Exception as e:
364
+ return transcript_text, f"PDF error: {e}", [], "[]", f"❌ PDF error: {e}"
365
+
366
+ for i, img in enumerate(slide_images):
367
+ progress(0.5 + 0.4 * (i / max(len(slide_images), 1)),
368
+ desc=f"πŸ” Analysing slide {i+1}/{len(slide_images)}…")
369
+ slide_texts.append(extract_slide_text(img, i + 1))
370
+
371
+ # ---- Sync ----
372
+ progress(0.92, desc="πŸ”— Syncing transcript to slides…")
373
+ sync_report = ""
374
+ if segments and slide_texts:
375
+ synced = sync_transcript_to_slides(segments, slide_texts)
376
+ sync_report = format_sync_report(synced)
377
+ elif segments:
378
+ sync_report = "No slides uploaded β€” sync skipped.\n\n" + format_transcript(segments)
379
+ elif slide_texts:
380
+ sync_report = "No audio uploaded β€” showing extracted slide texts.\n\n"
381
+ for i, t in enumerate(slide_texts):
382
+ sync_report += f"**Slide {i+1}:**\n{t}\n\n"
383
+
384
+ slide_texts_json = json.dumps(slide_texts)
385
+ status = "βœ… Processing complete!"
386
+ progress(1.0, desc=status)
387
+
388
+ return transcript_text, sync_report, slide_images, slide_texts_json, status
389
+
390
+
391
+ # ---------------------------------------------------------------------------
392
+ # 7. Gradio UI
393
+ # ---------------------------------------------------------------------------
394
+
395
+ THEME = gr.themes.Soft(
396
+ primary_hue="violet",
397
+ secondary_hue="indigo",
398
+ neutral_hue="slate",
399
+ font=[gr.themes.GoogleFont("DM Sans"), "ui-sans-serif", "sans-serif"],
400
+ font_mono=[gr.themes.GoogleFont("JetBrains Mono"), "monospace"],
401
+ ).set(
402
+ button_primary_background_fill="*primary_500",
403
+ button_primary_background_fill_hover="*primary_600",
404
+ )
405
+
406
+ CSS = """
407
+ #header {
408
+ text-align: center;
409
+ padding: 1.5rem 0 1rem;
410
+ background: linear-gradient(135deg, #4f46e5 0%, #7c3aed 50%, #a21caf 100%);
411
+ border-radius: 12px;
412
+ margin-bottom: 1rem;
413
+ color: white;
414
+ }
415
+ #header h1 { font-size: 2.4rem; margin: 0; font-weight: 800; letter-spacing: -1px; }
416
+ #header p { margin: 0.25rem 0 0; opacity: 0.85; font-size: 1rem; }
417
+ .status-box { font-weight: 600; border-left: 4px solid #7c3aed; padding-left: 0.75rem; }
418
+ .quiz-output { font-family: 'JetBrains Mono', monospace; font-size: 0.85rem; }
419
+ """
420
+
421
+ def build_ui():
422
+ with gr.Blocks(title="The Lecture Whisperer") as demo:
423
+
424
+ # ── Shared state ────────────────────────────────────────────────────
425
+ state_transcript = gr.State("")
426
+ state_slide_texts = gr.State("[]")
427
+
428
+ # ── Header ──────────────────────────────────────────────────────────
429
+ gr.HTML("""
430
+ <div id="header">
431
+ <h1>πŸŽ“ The Lecture Whisperer</h1>
432
+ <p>Transcribe Β· Analyse Β· Sync Β· Quiz β€” your lectures, supercharged by AI</p>
433
+ </div>
434
+ """)
435
+
436
+ # ── Tabs ─────────────────────────────────────────────────────────────
437
+ with gr.Tabs():
438
+
439
+ # ================================================================
440
+ # TAB 1 β€” Upload & Process
441
+ # ================================================================
442
+ with gr.TabItem("πŸ“€ Upload & Process"):
443
+ gr.Markdown(
444
+ "Upload your lecture audio and/or slide PDF. "
445
+ "Hit **Process** to run the full pipeline."
446
+ )
447
+ with gr.Row():
448
+ with gr.Column(scale=1):
449
+ audio_input = gr.Audio(
450
+ label="πŸŽ™οΈ Lecture Audio (MP3 / WAV)",
451
+ type="filepath",
452
+ )
453
+ pdf_input = gr.File(
454
+ label="πŸ“‘ Lecture Slides (PDF)",
455
+ file_types=[".pdf"],
456
+ type="filepath",
457
+ )
458
+ process_btn = gr.Button(
459
+ "⚑ Process Lecture", variant="primary", size="lg"
460
+ )
461
+ status_output = gr.Textbox(
462
+ label="Status",
463
+ interactive=False,
464
+ elem_classes=["status-box"],
465
+ )
466
+ with gr.Column(scale=2):
467
+ sync_output = gr.Markdown(
468
+ label="Sync Report",
469
+ value="*Sync report will appear here after processing…*",
470
+ )
471
+
472
+ process_btn.click(
473
+ fn=process_inputs,
474
+ inputs=[audio_input, pdf_input],
475
+ outputs=[
476
+ state_transcript,
477
+ sync_output,
478
+ gr.State(), # slide_images placeholder β€” see below
479
+ state_slide_texts,
480
+ status_output,
481
+ ],
482
+ )
483
+
484
+ # ================================================================
485
+ # TAB 2 β€” Dashboard (Chatbot + Gallery)
486
+ # ================================================================
487
+ with gr.TabItem("πŸ“Š Dashboard"):
488
+ with gr.Row():
489
+ # ── Left: Chatbot ────────────────────────────────────────
490
+ with gr.Column(scale=1):
491
+ gr.Markdown("### πŸ’¬ Ask About the Lecture")
492
+ chatbot = gr.Chatbot(
493
+ label="Lecture Assistant",
494
+ height=480,
495
+ )
496
+ with gr.Row():
497
+ chat_input = gr.Textbox(
498
+ placeholder="e.g. What did the professor say about Newton's laws?",
499
+ label="Your question",
500
+ scale=4,
501
+ )
502
+ chat_btn = gr.Button("Send", variant="primary", scale=1)
503
+
504
+ chat_state = gr.State([])
505
+
506
+ def _chat(msg, history, transcript, slide_texts_json):
507
+ return answer_question(msg, history, transcript, slide_texts_json)
508
+
509
+ chat_btn.click(
510
+ fn=_chat,
511
+ inputs=[chat_input, chat_state, state_transcript, state_slide_texts],
512
+ outputs=[chatbot, chat_state],
513
+ )
514
+ chat_input.submit(
515
+ fn=_chat,
516
+ inputs=[chat_input, chat_state, state_transcript, state_slide_texts],
517
+ outputs=[chatbot, chat_state],
518
+ )
519
+
520
+ # ── Right: Slide Gallery ─────────────────────────────────
521
+ with gr.Column(scale=1):
522
+ gr.Markdown("### πŸ–ΌοΈ Slide Gallery")
523
+ slide_gallery = gr.Gallery(
524
+ label="Lecture Slides",
525
+ columns=2,
526
+ height=520,
527
+ object_fit="contain",
528
+ )
529
+ refresh_gallery_btn = gr.Button(
530
+ "πŸ”„ Refresh Gallery", variant="secondary"
531
+ )
532
+
533
+ # Wire up gallery refresh
534
+ # We need to store images in state from the process step
535
+ state_images = gr.State([])
536
+
537
+ def _refresh_gallery(imgs):
538
+ return imgs
539
+
540
+ refresh_gallery_btn.click(
541
+ fn=_refresh_gallery,
542
+ inputs=[state_images],
543
+ outputs=[slide_gallery],
544
+ )
545
+
546
+ # Patch process_btn outputs to also update gallery state
547
+ # We redo the click with the full output list here:
548
+ process_btn.click(
549
+ fn=process_inputs,
550
+ inputs=[audio_input, pdf_input],
551
+ outputs=[
552
+ state_transcript,
553
+ sync_output,
554
+ state_images,
555
+ state_slide_texts,
556
+ status_output,
557
+ ],
558
+ )
559
+
560
+ # ================================================================
561
+ # TAB 3 β€” Mock Quiz
562
+ # ================================================================
563
+ with gr.TabItem("πŸ“ Mock Quiz"):
564
+ gr.Markdown(
565
+ "Generate a multiple-choice quiz based strictly on the "
566
+ "lecture transcript. Requires audio to be processed first."
567
+ )
568
+ quiz_btn = gr.Button(
569
+ "🧠 Generate Mock Quiz", variant="primary", size="lg"
570
+ )
571
+ quiz_output = gr.Markdown(
572
+ value="*Quiz will appear here…*",
573
+ elem_classes=["quiz-output"],
574
+ )
575
+
576
+ def _gen_quiz(transcript):
577
+ if not transcript.strip():
578
+ return "⚠️ No transcript found. Please upload and process audio first."
579
+ return generate_quiz(transcript)
580
+
581
+ quiz_btn.click(
582
+ fn=_gen_quiz,
583
+ inputs=[state_transcript],
584
+ outputs=[quiz_output],
585
+ )
586
+
587
+ return demo
588
+
589
+
590
+ # ---------------------------------------------------------------------------
591
+ # Entry point
592
+ # ---------------------------------------------------------------------------
593
+
594
+ if __name__ == "__main__":
595
+ app = build_ui()
596
+ app.launch(share=False, theme=THEME, css=CSS)
requirements.txt ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ── Gradio UI ──────────────────────────────────────────────────────────────
2
+ gradio>=6.0.0
3
+
4
+ # ── PDF handling ───────────────────────────────────────────────────────────
5
+ pdf2image>=1.17.0
6
+ # pdf2image requires poppler. On HF Spaces (Debian) add to packages.txt:
7
+ # poppler-utils
8
+
9
+ # ── Image utilities ────────────────────────────────────────────────────────
10
+ Pillow>=10.0.0
11
+
12
+ # ── HTTP requests (HF Inference API) ──────────────────────────────────────
13
+ requests>=2.31.0
14
+
15
+ # ── Optional: run Whisper locally if you have enough GPU RAM ───────────────
16
+ # transformers>=4.40.0
17
+ # torch>=2.2.0
18
+ # accelerate>=0.30.0
19
+ # faster-whisper>=1.0.0 # lighter alternative to transformers Whisper
20
+
21
+ # ── Optional: run Qwen2-VL locally ────────────────────────────────────────
22
+ # transformers>=4.40.0
23
+ # qwen-vl-utils>=0.0.2
24
+
25
+ # ── Optional: run Llama-3 locally ─────────────────────────────────────────
26
+ # transformers>=4.40.0
27
+ # bitsandbytes>=0.43.0 # for 4-bit / 8-bit quantisation