Spaces:
Running on Zero
Running on Zero
| """ | |
| PaperProf — Main Gradio entry point. | |
| """ | |
| import warnings | |
| warnings.filterwarnings("ignore") | |
| try: | |
| import os | |
| import random | |
| import pathlib | |
| import threading | |
| import gradio as gr | |
| import spaces | |
| from core.parser import extract_text | |
| from core.chunker import chunk_text | |
| from core.questioner import generate_question, generate_mcq | |
| from core.evaluator import evaluate_answer | |
| from core.image_gen import generate_concept_image | |
| from model.llm import get_llm | |
| print("✅ All imports successful") | |
| except Exception as e: | |
| import traceback | |
| print(f"❌ Import error: {e}") | |
| traceback.print_exc() | |
| raise | |
| RUNTIME = os.environ.get("PAPERPROF_RUNTIME", "transformers").lower() | |
| def gpu_if_needed(duration): | |
| """@spaces.GPU only for the transformers runtime — llama.cpp runs on CPU | |
| and must not be capped by (or waste) a ZeroGPU window.""" | |
| def wrap(fn): | |
| return spaces.GPU(duration=duration)(fn) if RUNTIME != "llamacpp" else fn | |
| return wrap | |
| if RUNTIME == "llamacpp": | |
| # Load the GGUF into RAM at startup so the first question only pays | |
| # generation time, not the ~60s model load. | |
| threading.Thread(target=get_llm, daemon=True).start() | |
| CSS = """ | |
| /* ── Base ──────────────────────────────────────────────────────────────────── */ | |
| footer { display: none !important; } | |
| /* Only hide Gradio's own header chrome — NOT our custom <header class="hero"> */ | |
| .gradio-container > header, | |
| .app > header { display: none !important; } | |
| * { box-sizing: border-box; } | |
| body { background: #0A0F1E !important; margin: 0 !important; } | |
| /* ── Gradio shell ───────────────────────────────────────────────────────────── */ | |
| .gradio-container { | |
| padding: 0 !important; | |
| margin: 0 !important; | |
| max-width: 100% !important; | |
| background: #0A0F1E !important; | |
| min-height: 0 !important; | |
| } | |
| .main { padding: 0 !important; } | |
| .gap { gap: 0 !important; } | |
| .contain { padding: 0 !important; } | |
| #component-0 { padding: 0 !important; } | |
| /* ── Upload row: centred column, card look ──────────────────────────────────── */ | |
| #upload-row { | |
| max-width: 760px !important; | |
| margin: 0 auto !important; | |
| padding: 20px !important; | |
| flex-direction: column !important; | |
| gap: 12px !important; | |
| background: rgba(10,15,30,0.65) !important; | |
| border: 1px solid rgba(255,255,255,0.08) !important; | |
| border-radius: 18px !important; | |
| backdrop-filter: blur(24px) !important; | |
| -webkit-backdrop-filter: blur(24px) !important; | |
| box-shadow: 0 8px 32px rgba(0,0,0,0.45) !important; | |
| } | |
| /* ── Rows are moved off-screen but NOT display:none so Gradio attaches event | |
| handlers; height:0 + overflow:visible let their textboxes bleed into the | |
| page as a dark full-width bar, so park them off-viewport instead. ── */ | |
| #hidden-row-status, | |
| #hidden-row-question { | |
| position: fixed !important; | |
| top: -9999px !important; | |
| left: -9999px !important; | |
| width: 1px !important; | |
| height: 1px !important; | |
| min-height: 0 !important; | |
| overflow: hidden !important; | |
| opacity: 0 !important; | |
| pointer-events: none !important; | |
| margin: 0 !important; | |
| padding: 0 !important; | |
| border: none !important; | |
| gap: 0 !important; | |
| } | |
| /* ── Force hero text to be visible (defeat Gradio color resets) ─────────────── */ | |
| .hero-title { | |
| color: #A78BFA !important; | |
| background: linear-gradient(135deg, #A78BFA 0%, #67E8F9 100%) !important; | |
| -webkit-background-clip: text !important; | |
| -webkit-text-fill-color: transparent !important; | |
| background-clip: text !important; | |
| } | |
| .hero-sub { color: #94A3B8 !important; } | |
| .hero-icon { color: initial !important; font-size: 3.2rem !important; display: block !important; } | |
| /* ── Upload zone card ───────────────────────────────────────────────────────── */ | |
| /* Gradio's own block/file wrappers paint a beige fill on dark theme — make | |
| everything inside the upload row transparent so the glass card shows. */ | |
| #upload-row .block, | |
| #upload-row .file, | |
| #upload-row .gradio-file, | |
| #upload-row .wrap, | |
| #upload-row [data-testid="block"] { | |
| background: transparent !important; | |
| border-color: transparent !important; | |
| } | |
| [data-testid="upload-zone"], | |
| .file-drop-zone, | |
| .upload-container { | |
| background: rgba(255,255,255,0.03) !important; | |
| border: 2px dashed rgba(124,58,237,0.4) !important; | |
| border-radius: 14px !important; | |
| min-height: 130px !important; | |
| color: rgba(255,255,255,0.65) !important; | |
| text-align: center !important; | |
| transition: border-color .2s, box-shadow .2s, background .2s !important; | |
| backdrop-filter: blur(12px) !important; | |
| -webkit-backdrop-filter: blur(12px) !important; | |
| } | |
| [data-testid="upload-zone"]:hover, | |
| .file-drop-zone:hover { | |
| border-color: #06B6D4 !important; | |
| background: rgba(6,182,212,0.04) !important; | |
| box-shadow: 0 0 28px rgba(6,182,212,0.15) !important; | |
| } | |
| [data-testid="upload-zone"] *, | |
| .file-drop-zone * { color: rgba(255,255,255,0.7) !important; } | |
| /* ── File preview after selection ──────────────────────────────────────────── */ | |
| .file-preview-holder, | |
| .file-name-wrapper, | |
| .file-preview { | |
| background: rgba(124,58,237,0.07) !important; | |
| border: 1px solid rgba(124,58,237,0.2) !important; | |
| border-radius: 10px !important; | |
| color: #A78BFA !important; | |
| } | |
| .file-preview * { color: #A78BFA !important; } | |
| /* ── Hide all Gradio component labels ──────────────────────────────────────── */ | |
| .block > label, | |
| .block > .form > label, | |
| .label-wrap, | |
| .block label span { display: none !important; } | |
| /* ── Load PDF button — full-width gradient pill ─────────────────────────────── */ | |
| #hidden-load-btn { width: 100% !important; margin-top: 12px !important; } | |
| #hidden-load-btn button { | |
| width: 100% !important; | |
| background: linear-gradient(135deg, #7C3AED, #06B6D4) !important; | |
| border-radius: 50px !important; | |
| color: #fff !important; | |
| font-weight: 700 !important; | |
| font-size: 1.1rem !important; | |
| letter-spacing: 0.5px !important; | |
| border: none !important; | |
| padding: 16px !important; | |
| box-shadow: 0 4px 20px rgba(124,58,237,0.35) !important; | |
| transition: box-shadow .2s, transform .2s !important; | |
| } | |
| #hidden-load-btn button:hover { | |
| box-shadow: 0 6px 28px rgba(124,58,237,0.55) !important; | |
| transform: translateY(-1px) !important; | |
| } | |
| /* ── Post-load: upload area recedes ────────────────────────────────────────── */ | |
| .upload-receded { | |
| opacity: 0.45 !important; | |
| transform: scale(0.98) !important; | |
| transition: opacity .4s ease, transform .4s ease !important; | |
| pointer-events: none !important; | |
| } | |
| /* ── Standalone hidden components (no row wrapper) ──────────────────────────── */ | |
| #hidden-feedback-output, | |
| #hidden-answer-input, | |
| #hidden-submit-btn, | |
| #hidden-question-btn, | |
| #hidden-question-trigger, | |
| #hidden-answer-trigger, | |
| #hidden-chunk-output, | |
| #hidden-mcq-trigger, | |
| #hidden-mcq-output, | |
| #hidden-language-box, | |
| #hidden-session-topics, | |
| #hidden-generate-session-image-btn { | |
| position: fixed !important; | |
| top: -9999px !important; | |
| left: -9999px !important; | |
| width: 1px !important; | |
| height: 1px !important; | |
| overflow: hidden !important; | |
| opacity: 0 !important; | |
| pointer-events: none !important; | |
| } | |
| /* ── Hidden concept image component ─────────────────────────────────────────── */ | |
| #hidden-concept-image { | |
| position: fixed !important; | |
| top: -9999px !important; | |
| opacity: 0 !important; | |
| } | |
| /* ── Empty .form wrappers ─────────────────────────────────────────────────────── | |
| Gradio groups consecutive textboxes into bordered .form containers; ours are | |
| all parked off-screen, leaving 3 empty bars at the page bottom — hide them. */ | |
| .form:has(> #hidden-answer-input), | |
| .form:has(> #hidden-feedback-output), | |
| .form:has(> #hidden-question-trigger), | |
| .form:has(> #hidden-session-topics) { | |
| position: fixed !important; | |
| top: -9999px !important; | |
| left: -9999px !important; | |
| width: 1px !important; | |
| height: 1px !important; | |
| border: none !important; | |
| } | |
| /* fallback for browsers without :has — at least make the bars invisible */ | |
| .gradio-container .form { | |
| background: transparent !important; | |
| border-color: transparent !important; | |
| box-shadow: none !important; | |
| } | |
| /* ── Load PDF button — defeat Gradio's orange primary override ──────────────── */ | |
| .gr-button-primary, | |
| button.primary, | |
| #hidden-load-btn button, | |
| #hidden-load-btn .gr-button { | |
| background: linear-gradient(135deg, #7C3AED, #06B6D4) !important; | |
| background-color: #7C3AED !important; | |
| } | |
| """ | |
| import pathlib | |
| CUSTOM_HTML = pathlib.Path("ui/index.html").read_text() | |
| # --------------------------------------------------------------------------- | |
| # Internationalisation | |
| # --------------------------------------------------------------------------- | |
| UI = { | |
| "English": { | |
| "load_btn": "Load PDF", | |
| "new_q_btn": "New Question", | |
| "submit_btn": "Submit Answer", | |
| "pdf_label": "📎 Course PDF", | |
| "status_label": "Status", | |
| "question_label": "❓ Question", | |
| "answer_label": "✏️ Your Answer", | |
| "answer_ph": "Write your answer here…", | |
| "feedback_label": "💬 Feedback", | |
| "no_file": "No file selected.", | |
| "no_chunks": "PDF loaded but no text found (scanned or too short?).", | |
| "loaded": lambda n: f"📚 {n} sections extracted — ready to generate questions!", | |
| "no_pdf": "⚠️ Please upload a PDF first.", | |
| "no_question": "⚠️ Generate a question first.", | |
| "no_answer": "⚠️ Please write an answer before submitting.", | |
| }, | |
| "Français": { | |
| "load_btn": "Charger le PDF", | |
| "new_q_btn": "Nouvelle question", | |
| "submit_btn": "Valider la réponse", | |
| "pdf_label": "📎 Cours PDF", | |
| "status_label": "Statut", | |
| "question_label": "❓ Question", | |
| "answer_label": "✏️ Ta réponse", | |
| "answer_ph": "Écris ta réponse ici…", | |
| "feedback_label": "💬 Feedback", | |
| "no_file": "Aucun fichier sélectionné.", | |
| "no_chunks": "PDF chargé mais aucun texte trouvé (scanné ou trop court ?).", | |
| "loaded": lambda n: f"📚 {n} sections extraites — prêt à générer des questions !", | |
| "no_pdf": "⚠️ Charge d'abord un PDF.", | |
| "no_question": "⚠️ Génère d'abord une question.", | |
| "no_answer": "⚠️ Écris une réponse avant de valider.", | |
| }, | |
| } | |
| # --------------------------------------------------------------------------- | |
| # State helpers | |
| # --------------------------------------------------------------------------- | |
| def _parse_verdict(feedback: str) -> bool: | |
| for line in feedback.splitlines(): | |
| low = line.lower() | |
| if "verdict" in low: | |
| return "correct" in low and "partially" not in low and "incorrect" not in low | |
| return False | |
| def _score_label(correct: int, total: int) -> str: | |
| return f"✅ {correct} / {total}" if total > 0 else "—" | |
| def update_ui_language(language): | |
| s = UI[language] | |
| return ( | |
| gr.update(value=s["load_btn"]), | |
| gr.update(value=s["new_q_btn"]), | |
| gr.update(value=s["submit_btn"]), | |
| gr.update(label=s["pdf_label"]), | |
| gr.update(label=s["status_label"]), | |
| gr.update(label=s["question_label"]), | |
| gr.update(label=s["answer_label"], placeholder=s["answer_ph"]), | |
| gr.update(label=s["feedback_label"]), | |
| ) | |
| def load_pdf(pdf_file, language): | |
| print(f"load_pdf called with: {pdf_file}") | |
| s = UI[language] | |
| if pdf_file is None: | |
| return [], s["no_file"], 0, 0 | |
| try: | |
| text = extract_text(pdf_file.name) | |
| chunks = chunk_text(text) | |
| print(f"Extracted chunks: {len(chunks)}") | |
| except ValueError as e: | |
| print(f"load_pdf ValueError: {e}") | |
| return [], f"Erreur : {e}", 0, 0 | |
| except Exception as e: | |
| print(f"load_pdf Exception: {e}") | |
| return [], f"Erreur inattendue : {e}", 0, 0 | |
| if not chunks: | |
| return [], s["no_chunks"], 0, 0 | |
| return chunks, s["loaded"](len(chunks)), 0, 0 | |
| def new_question(chunks, language): | |
| print(f"[new_question] called — {len(chunks)} chunks, lang={language}") | |
| s = UI[language] | |
| if not chunks: | |
| print("[new_question] no chunks, returning early") | |
| return s["no_pdf"], "", s["no_pdf"], "" | |
| try: | |
| chunk = random.choice(chunks) | |
| print(f"[new_question] chunk selected ({len(chunk)} chars), calling LLM…") | |
| question = generate_question(chunk, language=language) | |
| print(f"[new_question] question generated: {question[:80]!r}") | |
| sentences = [s.strip() for s in chunk.replace('\n', ' ').split('.') if s.strip()] | |
| preview = '. '.join(sentences[:3]) + ('.' if sentences else '') | |
| if len(preview) > 400: | |
| preview = preview[:400].rsplit(' ', 1)[0] + '…' | |
| return question, chunk, "", preview | |
| except Exception as e: | |
| import traceback; traceback.print_exc() | |
| err = f"Erreur : {e}" | |
| return err, "", err, "" | |
| def new_mcq(chunks, language): | |
| import json as _json | |
| print(f"[new_mcq] called — {len(chunks)} chunks, lang={language}") | |
| s = UI[language] | |
| if not chunks: | |
| return s["no_pdf"], "", _json.dumps({}), "" | |
| try: | |
| chunk = random.choice(chunks) | |
| print(f"[new_mcq] chunk selected ({len(chunk)} chars), calling LLM…") | |
| mcq = generate_mcq(chunk, language=language) | |
| print(f"[new_mcq] MCQ generated: {mcq.get('question','?')[:60]!r}") | |
| sentences = [x.strip() for x in chunk.replace('\n', ' ').split('.') if x.strip()] | |
| preview = '. '.join(sentences[:3]) + ('.' if sentences else '') | |
| if len(preview) > 400: | |
| preview = preview[:400].rsplit(' ', 1)[0] + '…' | |
| return mcq.get("question", ""), chunk, _json.dumps(mcq), preview | |
| except Exception as e: | |
| import traceback; traceback.print_exc() | |
| err = f"Erreur : {e}" | |
| return err, "", _json.dumps({"error": err}), "" | |
| def submit_answer(question, chunk, student_answer, correct, total, language): | |
| s = UI[language] | |
| if not question: | |
| return s["no_question"], correct, total, _score_label(correct, total) | |
| if not student_answer.strip(): | |
| return s["no_answer"], correct, total, _score_label(correct, total) | |
| try: | |
| feedback = evaluate_answer(question, chunk, student_answer, language=language) | |
| total += 1 | |
| if _parse_verdict(feedback): | |
| correct += 1 | |
| return feedback, correct, total, _score_label(correct, total) | |
| except Exception as e: | |
| return f"Erreur : {e}", correct, total, _score_label(correct, total) | |
| def generate_image_fn(topics): | |
| if not topics or not topics.strip(): | |
| return None | |
| try: | |
| prompt = f"Visual summary of a study session covering: {topics.strip()}" | |
| print(f"[generate_image_fn] generating session illustration for: {prompt[:120]!r}") | |
| return generate_concept_image(prompt) | |
| except Exception as e: | |
| import traceback; traceback.print_exc() | |
| print(f"[generate_image_fn] failed: {e}") | |
| return None | |
| # --------------------------------------------------------------------------- | |
| # UI | |
| # --------------------------------------------------------------------------- | |
| BRIDGE_JS = """() => { | |
| // ── State ────────────────────────────────────────────────────────────────── | |
| const S = { | |
| correct: 0, total: 0, history: [], | |
| currentQuestion: '', | |
| quizRevealed: false, | |
| lastStatus: '', | |
| waitingQ: false, prevQuestion: '', | |
| waitingFB: false, prevFeedback: '', | |
| mode: 'open', | |
| mcqData: null, | |
| waitingMCQ: false, prevMCQ: '', | |
| imgPoll: null, imgRequested: false, | |
| }; | |
| const $ = id => document.getElementById(id); | |
| // ── Status bar ───────────────────────────────────────────────────────────── | |
| function ensureStatusBar() { | |
| if ($('gradio-status-bar')) return; | |
| const anchor = $('quiz-card') || $('app'); | |
| if (!anchor) return; | |
| const bar = document.createElement('div'); | |
| bar.id = 'gradio-status-bar'; | |
| bar.style.cssText = 'display:none;max-width:760px;margin:8px auto 0;padding:12px 20px;border-radius:10px;font-size:.9rem;font-weight:500;font-family:Inter,system-ui,sans-serif;transition:all .3s ease;'; | |
| anchor.parentNode.insertBefore(bar, anchor); | |
| } | |
| function applyStatus(text) { | |
| if (text === S.lastStatus) return; | |
| S.lastStatus = text; | |
| ensureStatusBar(); | |
| const bar = $('gradio-status-bar'); | |
| if (!bar) return; | |
| bar.textContent = text; | |
| bar.style.display = 'block'; | |
| const isErr = text.includes('\\u26a0') || text.toLowerCase().includes('erreur'); | |
| const isOK = text.includes('sections') || text.includes('extracted') || text.includes('extraites'); | |
| if (isErr) { | |
| bar.style.cssText += ';background:rgba(239,68,68,.12);border:1px solid rgba(239,68,68,.3);color:#FCA5A5'; | |
| } else if (isOK) { | |
| bar.style.cssText += ';background:rgba(16,185,129,.12);border:1px solid rgba(16,185,129,.3);color:#6EE7B7'; | |
| if (!S.quizRevealed) { S.quizRevealed = true; revealQuiz(); } | |
| } else { | |
| bar.style.cssText += ';background:rgba(6,182,212,.12);border:1px solid rgba(6,182,212,.3);color:#67E8F9'; | |
| } | |
| } | |
| function revealQuiz() { | |
| const qc = $('quiz-card'); | |
| if (qc) { qc.classList.remove('hidden'); setTimeout(() => qc.scrollIntoView({behavior:'smooth',block:'start'}), 400); } | |
| const ur = document.querySelector('#upload-row'); | |
| if (ur) { ur.style.cssText += ';opacity:0;height:0;overflow:hidden;margin:0;padding:0;pointer-events:none;transition:all .4s ease'; } | |
| } | |
| // ── Helpers ──────────────────────────────────────────────────────────────── | |
| function setGradioTA(sel, val) { | |
| const el = document.querySelector(sel); | |
| if (!el) return false; | |
| Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype,'value').set.call(el, val); | |
| el.dispatchEvent(new Event('input', {bubbles:true})); | |
| el.dispatchEvent(new Event('change', {bubbles:true})); | |
| return true; | |
| } | |
| function clickGradioBtn(sel) { | |
| const el = document.querySelector(sel); | |
| if (!el) return false; | |
| const btn = el.tagName === 'BUTTON' ? el : el.querySelector('button'); | |
| if (btn) { btn.click(); return true; } return false; | |
| } | |
| // ── Hero: professor eye tracking ─────────────────────────────────────────── | |
| // (Lives here, not in index.html: <script> tags injected via gr.HTML's | |
| // innerHTML are never executed by browsers.) | |
| function initHeroProfessor() { | |
| const prof = $('professor'); | |
| if (!prof || prof._pp) return; | |
| prof._pp = true; | |
| const pupils = prof.querySelectorAll('.prof-pupil'); | |
| let tx = 0, ty = 0, cx = 0, cy = 0; | |
| document.addEventListener('mousemove', e => { | |
| const r = prof.getBoundingClientRect(); | |
| // eyes sit in the head, around the top third of the character | |
| const ox = r.left + r.width / 2, oy = r.top + r.height * 0.32; | |
| const dx = e.clientX - ox, dy = e.clientY - oy; | |
| const d = Math.min(4, Math.hypot(dx, dy) / 40); // max 4px displacement | |
| const a = Math.atan2(dy, dx); | |
| tx = Math.cos(a) * d; ty = Math.sin(a) * d; | |
| }); | |
| (function track() { | |
| cx += (tx - cx) * 0.18; cy += (ty - cy) * 0.18; | |
| const t = 'translate(' + cx.toFixed(2) + 'px,' + cy.toFixed(2) + 'px)'; | |
| pupils.forEach(p => { p.style.transform = t; }); | |
| requestAnimationFrame(track); | |
| })(); | |
| } | |
| // ── Library wings: span the whole page, repeat content to the bottom ────── | |
| // position:fixed breaks under Gradio's ancestor transforms, so the wings are | |
| // re-parented onto <body>, stretched to the page height, and their .lib-unit | |
| // content is cloned as many times as needed to fill it. | |
| function syncLibraryWings() { | |
| const wings = document.querySelectorAll('.library'); | |
| if (!wings.length) return; | |
| wings.forEach(w => { if (w.parentElement !== document.body) document.body.appendChild(w); }); | |
| const gc = document.querySelector('.gradio-container'); | |
| const H = Math.max(gc ? gc.scrollHeight : 0, window.innerHeight); | |
| wings.forEach(w => { | |
| if (parseInt(w.style.height, 10) !== H) w.style.height = H + 'px'; | |
| const first = w.querySelector('.lib-unit'); | |
| if (!first) return; | |
| const per = first.offsetHeight || 950; | |
| const need = Math.max(1, Math.ceil(H / per)); | |
| let units = w.querySelectorAll('.lib-unit'); | |
| for (let i = units.length; i < need; i++) w.appendChild(first.cloneNode(true)); | |
| units = w.querySelectorAll('.lib-unit'); | |
| for (let i = units.length - 1; i >= need; i--) units[i].remove(); | |
| }); | |
| } | |
| // ── Force the Gradio upload zone to English (it localises to the browser) ── | |
| function enforceEnglishUpload() { | |
| const row = document.querySelector('#upload-row'); | |
| if (!row || !/D\\u00e9posez|Cliquez pour|Glissez/.test(row.textContent)) return; | |
| const tw = document.createTreeWalker(row, NodeFilter.SHOW_TEXT); | |
| let n; | |
| while ((n = tw.nextNode())) { | |
| let t = n.nodeValue; | |
| t = t.replace(/(D\\u00e9posez|Glissez-d\\u00e9posez) le fichier ici/g, 'Drop your PDF here'); | |
| t = t.replace(/-\\s*ou\\s*-/g, '- or -'); | |
| t = t.replace(/Cliquez pour t\\u00e9l\\u00e9(charger|verser) un fichier/g, 'Click to upload'); | |
| if (t !== n.nodeValue) n.nodeValue = t; | |
| } | |
| } | |
| // ── Background particles (canvas is in gr.HTML markup; animation here) ──── | |
| function initParticles() { | |
| const canvas = $('particles'); | |
| if (!canvas || canvas._pp) return; | |
| canvas._pp = true; | |
| const ctx = canvas.getContext('2d'); | |
| let W, H; | |
| const rand = (a, b) => a + Math.random() * (b - a); | |
| function resize() { W = canvas.width = window.innerWidth; H = canvas.height = window.innerHeight; } | |
| window.addEventListener('resize', resize); | |
| resize(); | |
| const parts = Array.from({length: 65}, () => ({ | |
| x: rand(0, W), y: rand(0, H), | |
| vx: rand(-.25, .25), vy: rand(-.25, .25), | |
| r: rand(1, 2.2), | |
| purple: Math.random() > .5, | |
| alpha: rand(.15, .55), | |
| })); | |
| (function draw() { | |
| ctx.clearRect(0, 0, W, H); | |
| for (let i = 0; i < parts.length; i++) { | |
| const p = parts[i]; | |
| p.x += p.vx; p.y += p.vy; | |
| if (p.x < -5) p.x = W + 5; if (p.x > W + 5) p.x = -5; | |
| if (p.y < -5) p.y = H + 5; if (p.y > H + 5) p.y = -5; | |
| for (let j = i + 1; j < parts.length; j++) { | |
| const q = parts[j]; | |
| const dx = p.x - q.x, dy = p.y - q.y; | |
| const dist = Math.hypot(dx, dy); | |
| if (dist < 110) { | |
| ctx.beginPath(); | |
| ctx.moveTo(p.x, p.y); ctx.lineTo(q.x, q.y); | |
| ctx.strokeStyle = 'rgba(124,58,237,' + ((1 - dist / 110) * 0.12) + ')'; | |
| ctx.lineWidth = 1; | |
| ctx.stroke(); | |
| } | |
| } | |
| ctx.beginPath(); | |
| ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2); | |
| ctx.fillStyle = 'rgba(' + (p.purple ? '124,58,237' : '6,182,212') + ',' + p.alpha + ')'; | |
| ctx.fill(); | |
| } | |
| requestAnimationFrame(draw); | |
| })(); | |
| } | |
| // ── Score ring ───────────────────────────────────────────────────────────── | |
| function updateScore() { | |
| const arc = $('score-arc'); | |
| const label = $('score-label'); | |
| if (!arc || !label) return; | |
| const n = S.correct, d = S.total; | |
| label.textContent = d === 0 ? '—' : n + '/' + d; | |
| arc.style.strokeDashoffset = 138.2 * (1 - (d ? n/d : 0)); | |
| arc.style.stroke = d === 0 ? 'url(#scoreGrad)' : (n/d >= .7 ? '#10B981' : n/d >= .4 ? '#F59E0B' : '#EF4444'); | |
| } | |
| // ── Feedback rendering ───────────────────────────────────────────────────── | |
| function extractVerdict(t) { | |
| const lo = t.toLowerCase(), lines = lo.split('\\n'); | |
| for (const l of lines) { | |
| if (l.includes('verdict')) { | |
| if (l.includes('partially')) return 'partial'; | |
| if (l.includes('incorrect')) return 'incorrect'; | |
| if (l.includes('correct')) return 'correct'; | |
| } | |
| } | |
| const h = lo.slice(0,200); | |
| return h.includes('incorrect') ? 'incorrect' : h.includes('partially') ? 'partial' : h.includes('correct') ? 'correct' : 'unknown'; | |
| } | |
| function showFeedback(text) { | |
| const badge = $('verdict-badge'), fc = $('feedback-card'), body = $('feedback-body'); | |
| const verdict = extractVerdict(text); | |
| if (badge) { | |
| badge.className = 'verdict-badge ' + (verdict === 'unknown' ? '' : verdict); | |
| badge.textContent = verdict === 'correct' ? '✓ Correct' : verdict === 'partial' ? '~ Partially Correct' : verdict === 'incorrect' ? '✗ Incorrect' : '— Unknown'; | |
| } | |
| if (body) { | |
| const labels = ['','Verdict','What was good','What was missing','Model answer']; | |
| const sections = [], re = /(\\d+)\\.\\s*([^:\\n]*)[::]?\\s*([\\s\\S]*?)(?=\\n\\d+\\.|$)/g; | |
| let m; | |
| while ((m = re.exec(text)) !== null) sections.push({num:+m[1], body:m[3].trim().replace(/^\\d+\\.\\s*/,'')}); | |
| body.innerHTML = sections.length >= 2 | |
| ? sections.map(s => '<div class="section"><span class="section-num">'+(labels[s.num]||'Part '+s.num)+'</span><div class="section-content">'+s.body.replace(/\\n/g,'<br>')+'</div></div>').join('') | |
| : '<div class="feedback-raw">'+text.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>')+'</div>'; | |
| } | |
| const chunkEl = document.querySelector('#hidden-chunk-output textarea'); | |
| const srcEl = document.getElementById('source-text'); | |
| if (srcEl && chunkEl) srcEl.textContent = chunkEl.value; | |
| if (fc) { fc.classList.remove('hidden'); fc.classList.add('fade-in-up'); setTimeout(() => fc.scrollIntoView({behavior:'smooth',block:'nearest'}),100); } | |
| return verdict; | |
| } | |
| // ── Session reward image (generic encouraging illustration) ─────────────── | |
| // Pre-generated in the background as soon as the first question shows, so | |
| // the End Session modal can display it instantly. | |
| function requestSessionImage() { | |
| if (S.imgRequested) return; | |
| if (!setGradioTA('#hidden-session-topics textarea', 'study session')) return; | |
| S.imgRequested = true; | |
| // Small delay so Gradio registers the input before the click | |
| setTimeout(() => clickGradioBtn('#hidden-generate-session-image-btn'), 200); | |
| } | |
| function stopSessionImagePoll() { | |
| if (S.imgPoll) { clearInterval(S.imgPoll); S.imgPoll = null; } | |
| $('modal-image-loading')?.classList.add('hidden'); | |
| } | |
| function showSessionImage() { | |
| const sec = $('modal-session-image'), loading = $('modal-image-loading'), img = $('session-image'); | |
| if (!sec || !loading || !img) return; | |
| stopSessionImagePoll(); | |
| sec.classList.add('hidden'); sec.classList.remove('fade-in-up'); | |
| const ready = document.querySelector('#hidden-concept-image img'); | |
| if (ready && ready.src) { // preloaded — show instantly | |
| img.src = ready.src; | |
| sec.classList.remove('hidden'); | |
| return; | |
| } | |
| requestSessionImage(); // fallback in case the preload never fired | |
| loading.classList.remove('hidden'); | |
| const started = Date.now(); | |
| S.imgPoll = setInterval(() => { | |
| const el = document.querySelector('#hidden-concept-image img'); | |
| if (el && el.src) { | |
| stopSessionImagePoll(); | |
| img.src = el.src; | |
| sec.classList.remove('hidden'); | |
| void sec.offsetWidth; // restart fade-in animation | |
| sec.classList.add('fade-in-up'); | |
| } else if (Date.now() - started > 120000) { | |
| stopSessionImagePoll(); // give up quietly after 2 min | |
| } | |
| }, 500); | |
| } | |
| // ── Question display ─────────────────────────────────────────────────────── | |
| function showQuestion(text) { | |
| S.currentQuestion = text; S.waitingQ = false; | |
| const es = $('empty-state'), qa = $('question-area'), qt = $('q-text'), ql = $('q-loading'); | |
| const eb = $('end-btn'), fc = $('feedback-card'), ai = $('answer-input'), cc = $('char-count'); | |
| const sb = $('submit-btn'), nq = $('new-q-btn'); | |
| if (es) es.classList.add('hidden'); | |
| if (qa) qa.classList.remove('hidden'); | |
| if (eb) eb.classList.remove('hidden'); | |
| if (ql) ql.classList.add('hidden'); | |
| if (qt) qt.textContent = text; | |
| if (fc) fc.classList.add('hidden'); | |
| if (ai) { ai.value = ''; ai.disabled = false; } | |
| if (cc) cc.textContent = '0 chars'; | |
| if (sb) sb.disabled = true; | |
| if (nq) nq.disabled = false; | |
| $('mcq-options')?.classList.add('hidden'); | |
| $('answer-wrapper')?.classList.remove('hidden'); | |
| requestSessionImage(); // generate the reward image while the user answers | |
| } | |
| // ── Language toggle ──────────────────────────────────────────────────────── | |
| function setLanguage(lang) { | |
| setGradioTA('#hidden-language-box textarea', lang); | |
| $('lang-fr-btn')?.classList.toggle('active', lang === 'Français'); | |
| $('lang-en-btn')?.classList.toggle('active', lang === 'English'); | |
| } | |
| // ── Mode toggle ──────────────────────────────────────────────────────────── | |
| function setMode(m) { | |
| S.mode = m; | |
| $('mode-open-btn')?.classList.toggle('selected', m === 'open'); | |
| $('mode-mcq-btn')?.classList.toggle('selected', m === 'mcq'); | |
| S.currentQuestion = ''; S.mcqData = null; | |
| $('empty-state')?.classList.remove('hidden'); | |
| $('question-area')?.classList.add('hidden'); | |
| $('feedback-card')?.classList.add('hidden'); | |
| } | |
| // ── MCQ display ──────────────────────────────────────────────────────────── | |
| function showMCQ(data) { | |
| S.mcqData = data; S.waitingMCQ = false; S.mcqAnswered = false; | |
| const qt = $('q-text'), ql = $('q-loading'), nq = $('new-q-btn'); | |
| const es = $('empty-state'), qa = $('question-area'), eb = $('end-btn'); | |
| if (es) es.classList.add('hidden'); | |
| if (qa) qa.classList.remove('hidden'); | |
| if (eb) eb.classList.remove('hidden'); | |
| if (ql) ql.classList.add('hidden'); | |
| if (qt) qt.textContent = data.question || ''; | |
| if (nq) nq.disabled = false; | |
| for (const l of ['A','B','C','D']) { | |
| const txt = $('mcq-text-'+l.toLowerCase()); | |
| const btn = $('mcq-'+l.toLowerCase()); | |
| if (txt) txt.textContent = (data.choices && data.choices[l]) || ''; | |
| if (btn) { | |
| btn.className = 'mcq-btn'; btn.disabled = false; | |
| btn.onclick = ((letter) => () => handleMCQAnswer(letter))(l); | |
| } | |
| } | |
| $('mcq-options')?.classList.remove('hidden'); | |
| requestSessionImage(); // generate the reward image while the user answers | |
| $('mcq-explanations')?.classList.add('hidden'); | |
| $('answer-wrapper')?.classList.add('hidden'); | |
| $('feedback-card')?.classList.add('hidden'); | |
| } | |
| // ── MCQ answer handler (client-side — no LLM call needed) ───────────────── | |
| function handleMCQAnswer(letter) { | |
| const data = S.mcqData; | |
| if (!data || !data.correct || S.waitingMCQ || S.mcqAnswered) return; | |
| S.mcqAnswered = true; | |
| const correct = data.correct; | |
| const isCorrect = letter === correct; | |
| for (const l of ['A','B','C','D']) { | |
| const btn = $('mcq-'+l.toLowerCase()); | |
| if (!btn) continue; | |
| btn.disabled = true; | |
| if (l === correct) btn.classList.add('mcq-correct'); | |
| else if (l === letter) btn.classList.add('mcq-wrong'); | |
| else btn.classList.add('mcq-neutral'); | |
| } | |
| const expEl = $('mcq-explanations'); | |
| if (expEl && data.explanations) { | |
| expEl.innerHTML = ['A','B','C','D'].map(l => { | |
| const exp = data.explanations[l]; if (!exp) return ''; | |
| const cls = l===correct ? 'exp-correct' : l===letter ? 'exp-wrong' : 'exp-neutral'; | |
| return '<div class="mcq-exp '+cls+'"><span class="mcq-exp-letter">'+l+'</span><span class="mcq-exp-text">'+exp+'</span></div>'; | |
| }).join(''); | |
| expEl.classList.remove('hidden'); | |
| } | |
| S.total++; if (isCorrect) S.correct++; | |
| S.currentQuestion = data.question || ''; | |
| S.history.push({question: S.currentQuestion, verdict: isCorrect ? 'correct' : 'incorrect'}); | |
| updateScore(); | |
| const chunkEl = document.querySelector('#hidden-chunk-output textarea'); | |
| const srcEl = document.getElementById('source-text'); | |
| if (srcEl && chunkEl) srcEl.textContent = chunkEl.value; | |
| const badge=$('verdict-badge'), fc=$('feedback-card'), body=$('feedback-body'); | |
| if (badge) { | |
| badge.className = 'verdict-badge '+(isCorrect?'correct':'incorrect'); | |
| badge.textContent = isCorrect ? '✓ Correct' : '✗ Incorrect'; | |
| } | |
| if (body) body.innerHTML = ''; | |
| if (fc) { fc.classList.remove('hidden'); fc.classList.add('fade-in-up'); setTimeout(()=>fc.scrollIntoView({behavior:'smooth',block:'nearest'}),100); } | |
| } | |
| // ── Trigger actions via hidden Gradio components ─────────────────────────── | |
| function fetchQuestion() { | |
| if (S.waitingQ || S.waitingMCQ) return; | |
| S.waitingQStart = Date.now(); | |
| const ql = $('q-loading'), qm = $('q-loading-msg'), nq = $('new-q-btn'), fc = $('feedback-card'); | |
| const es = $('empty-state'), qa = $('question-area'), eb = $('end-btn'); | |
| if (ql) ql.classList.remove('hidden'); | |
| if (qm) qm.textContent = S.total === 0 ? 'Model loading (~30–60s first time)…' : (S.mode==='mcq' ? 'Generating MCQ…' : 'Generating question…'); | |
| if (nq) nq.disabled = true; | |
| if (fc) fc.classList.add('hidden'); | |
| if (es) es.classList.add('hidden'); | |
| if (qa) qa.classList.remove('hidden'); | |
| if (eb) eb.classList.remove('hidden'); | |
| if (S.mode === 'mcq') { | |
| S.waitingMCQ = true; | |
| S.prevMCQ = document.querySelector('#hidden-mcq-output textarea')?.value || ''; | |
| const clicked = setGradioTA('#hidden-mcq-trigger textarea', Date.now().toString()); | |
| if (!clicked) applyStatus('⚠️ Could not reach Gradio backend — try reloading the page.'); | |
| } else { | |
| S.waitingQ = true; | |
| S.prevQuestion = document.querySelector('#hidden-question-output textarea')?.value || ''; | |
| const clicked = setGradioTA('#hidden-question-trigger textarea', Date.now().toString()); | |
| if (!clicked) applyStatus('⚠️ Could not reach Gradio backend — try reloading the page.'); | |
| } | |
| } | |
| function doSubmit() { | |
| const ai = $('answer-input'); | |
| const answer = ai?.value?.trim(); | |
| if (!answer || !S.currentQuestion || S.waitingFB) return; | |
| S.waitingFB = true; | |
| S.prevFeedback = document.querySelector('#hidden-feedback-output textarea')?.value || ''; | |
| const sb = $('submit-btn'), st = $('submit-btn-text'), ss = $('submit-spinner'), nq = $('new-q-btn'); | |
| if (sb) sb.disabled = true; | |
| if (st) st.textContent = 'Evaluating…'; | |
| if (ss) ss.classList.remove('hidden'); | |
| if (nq) nq.disabled = true; | |
| setGradioTA('#hidden-answer-input textarea', answer); | |
| setTimeout(() => setGradioTA('#hidden-answer-trigger textarea', Date.now().toString()), 200); | |
| } | |
| // ── Modal ────────────────────────────────────────────────────────────────── | |
| function showModal() { | |
| const modal = $('modal'); | |
| if (!modal) return; | |
| const pct = S.total > 0 ? Math.round(S.correct/S.total*100) : 0; | |
| const msb = $('modal-score-big'), msl = $('modal-score-label'), mm = $('modal-message'), mh = $('modal-history'); | |
| if (msb) msb.textContent = S.correct+'/'+S.total; | |
| if (msl) msl.textContent = S.total === 1 ? 'question answered' : 'questions answered'; | |
| if (mm) mm.textContent = pct>=90 ? "Outstanding! You've mastered this material. \\uD83C\\uDF1F" | |
| : pct>=70 ? "Great work — solid grasp of the content. \\uD83D\\uDCAA" | |
| : pct>=50 ? "Good start! Keep practising the tricky sections. \\uD83D\\uDCD6" | |
| : "Keep going — each question makes you stronger. \\uD83D\\uDD25"; | |
| if (mh && S.history.length > 0) { | |
| mh.classList.remove('hidden'); | |
| mh.innerHTML = S.history.slice(-8).map(h => { | |
| const vc = h.verdict==='correct'?'correct':h.verdict==='partial'?'partial':'incorrect'; | |
| return '<div class="modal-history-item"><span class="hist-badge '+vc+'">'+(vc==='correct'?'✓':vc==='partial'?'~':'✗')+'</span><span class="hist-q">'+h.question+'</span></div>'; | |
| }).join(''); | |
| } | |
| showSessionImage(); | |
| modal.classList.remove('hidden'); | |
| } | |
| // ── Wire HTML buttons ────────────────────────────────────────────────────── | |
| function wireButtons() { | |
| const pairs = [ | |
| ['start-btn', () => fetchQuestion()], | |
| ['new-q-btn', () => fetchQuestion()], | |
| ['submit-btn', () => doSubmit()], | |
| ['mode-open-btn',() => setMode('open')], | |
| ['mode-mcq-btn', () => setMode('mcq')], | |
| ['lang-fr-btn', () => setLanguage('Français')], | |
| ['lang-en-btn', () => setLanguage('English')], | |
| ['end-btn', () => { if (S.total===0) { const m=$('modal'); if(m) m.classList.remove('hidden'); } else showModal(); }], | |
| ['modal-close', () => { | |
| const modal=$('modal'); if(modal) modal.classList.add('hidden'); | |
| S.correct=0; S.total=0; S.history=[]; S.currentQuestion=''; S.mcqData=null; | |
| updateScore(); | |
| const ai=$('answer-input'), cc=$('char-count'), sb=$('submit-btn'), fc=$('feedback-card'); | |
| const es=$('empty-state'), qa=$('question-area'), eb=$('end-btn'), qc=$('quiz-card'); | |
| if(ai){ai.value='';} if(cc) cc.textContent='0 chars'; if(sb) sb.disabled=true; | |
| if(fc) fc.classList.add('hidden'); if(es) es.classList.remove('hidden'); | |
| if(qa) qa.classList.add('hidden'); if(eb) eb.classList.add('hidden'); | |
| stopSessionImagePoll(); | |
| $('modal-session-image')?.classList.add('hidden'); | |
| if(qc) qc.scrollIntoView({behavior:'smooth',block:'start'}); | |
| }], | |
| ]; | |
| for (const [id, fn] of pairs) { | |
| const el = $(id); | |
| if (el && !el._pp) { el._pp = true; el.addEventListener('click', fn); } | |
| } | |
| const modal = $('modal'); | |
| if (modal && !modal._pp) { modal._pp = true; modal.addEventListener('click', e => { if(e.target===modal) { modal.classList.add('hidden'); stopSessionImagePoll(); } }); } | |
| const ai = $('answer-input'), sb = $('submit-btn'), cc = $('char-count'); | |
| if (ai && !ai._pp) { | |
| ai._pp = true; | |
| ai.addEventListener('input', () => { | |
| if(cc) cc.textContent = ai.value.length+' chars'; | |
| if(sb) sb.disabled = ai.value.trim().length===0 || !S.currentQuestion; | |
| }); | |
| } | |
| } | |
| // ── Main polling loop ────────────────────────────────────────────────────── | |
| setInterval(function() { | |
| wireButtons(); | |
| initHeroProfessor(); | |
| initParticles(); | |
| syncLibraryWings(); | |
| enforceEnglishUpload(); | |
| const statusEl = document.querySelector('#hidden-status-output textarea'); | |
| if (statusEl && statusEl.value) applyStatus(statusEl.value); | |
| // Question output — picked up even after a timeout (CPU runtime can be | |
| // slower than the spinner's patience; a late result is still a result). | |
| { | |
| const qEl = document.querySelector('#hidden-question-output textarea'); | |
| if (qEl && qEl.value && qEl.value !== S.prevQuestion) { | |
| const val = qEl.value; | |
| S.prevQuestion = val; | |
| const isErr = val.startsWith('Erreur') || val.startsWith('⚠️'); | |
| if (isErr) { | |
| S.waitingQ = false; | |
| const ql=$('q-loading'), nq=$('new-q-btn'); | |
| if(ql) ql.classList.add('hidden'); | |
| if(nq) nq.disabled=false; | |
| applyStatus(val); | |
| } else if (S.mode === 'open' && (S.waitingQ || S.quizRevealed)) { | |
| showQuestion(val); | |
| } | |
| } | |
| } | |
| if (S.waitingQ) { | |
| const elapsed = Math.round((Date.now() - S.waitingQStart) / 1000); | |
| const qm = $('q-loading-msg'); | |
| if (qm) { | |
| if (elapsed < 15) qm.textContent = 'Generating question…'; | |
| else if (elapsed < 90) qm.textContent = 'Model working… (' + elapsed + 's)'; | |
| else if (elapsed < 240) qm.textContent = 'Still working… (' + elapsed + 's) — CPU runtime can take a few minutes'; | |
| else qm.textContent = 'Very slow… (' + elapsed + 's) — check Space logs if this persists'; | |
| } | |
| // Hard timeout: 5 min — unlock UI (a late result will still display) | |
| if (elapsed > 300) { | |
| S.waitingQ = false; | |
| const ql=$('q-loading'), nq=$('new-q-btn'); | |
| if(ql) ql.classList.add('hidden'); | |
| if(nq) nq.disabled=false; | |
| applyStatus('⚠️ Request timed out (' + elapsed + 's). The question will appear if it finishes — or try again.'); | |
| } | |
| } | |
| // MCQ output — same late-pickup logic as questions. | |
| { | |
| const mcqEl = document.querySelector('#hidden-mcq-output textarea'); | |
| if (mcqEl && mcqEl.value && mcqEl.value !== S.prevMCQ) { | |
| S.prevMCQ = mcqEl.value; | |
| try { | |
| const data = JSON.parse(mcqEl.value); | |
| if (data && data.question && S.mode === 'mcq' && (S.waitingMCQ || S.quizRevealed)) showMCQ(data); | |
| else if (S.waitingMCQ) { S.waitingMCQ=false; $('q-loading')?.classList.add('hidden'); $('new-q-btn').disabled=false; applyStatus('⚠️ MCQ generation failed.'); } | |
| } catch(e) { if (S.waitingMCQ) { S.waitingMCQ=false; $('q-loading')?.classList.add('hidden'); $('new-q-btn').disabled=false; } } | |
| } | |
| } | |
| if (S.waitingMCQ) { | |
| const elapsed = Math.round((Date.now() - S.waitingQStart) / 1000); | |
| const qm = $('q-loading-msg'); | |
| if (qm) { | |
| if (elapsed<15) qm.textContent='Generating MCQ…'; | |
| else if (elapsed<90) qm.textContent='Model working… ('+elapsed+'s)'; | |
| else if (elapsed<240) qm.textContent='Still working… ('+elapsed+'s) — CPU runtime can take a few minutes'; | |
| else qm.textContent='Very slow… ('+elapsed+'s) — check Space logs'; | |
| } | |
| if (elapsed>300) { | |
| S.waitingMCQ=false; | |
| $('q-loading')?.classList.add('hidden'); $('new-q-btn').disabled=false; | |
| applyStatus('⚠️ MCQ timed out ('+elapsed+'s). It will appear if it finishes — or try again.'); | |
| } | |
| } | |
| if (S.waitingFB) { | |
| const fbEl = document.querySelector('#hidden-feedback-output textarea'); | |
| if (fbEl && fbEl.value && fbEl.value !== S.prevFeedback) { | |
| const text = fbEl.value; | |
| S.prevFeedback = text; S.waitingFB = false; | |
| const sb=$('submit-btn'), st=$('submit-btn-text'), ss=$('submit-spinner'), nq=$('new-q-btn'); | |
| if(st) st.textContent='Submit Answer'; if(ss) ss.classList.add('hidden'); | |
| if(sb) sb.disabled=false; if(nq) nq.disabled=false; | |
| const verdict = showFeedback(text); | |
| S.total++; | |
| if (verdict==='correct') S.correct++; | |
| S.history.push({question:S.currentQuestion, verdict}); | |
| updateScore(); | |
| } | |
| } | |
| }, 300); | |
| }""" | |
| print("✅ Starting Gradio app...") | |
| with gr.Blocks(title="PaperProf", css=CSS) as demo: | |
| gr.HTML(CUSTOM_HTML) | |
| demo.load(None, js=BRIDGE_JS) | |
| chunks_state = gr.State([]) | |
| chunk_state = gr.State("") | |
| correct_state = gr.State(0) | |
| total_state = gr.State(0) | |
| with gr.Row(elem_id="upload-row"): | |
| pdf_input = gr.File(label="📎 Cours PDF", file_types=[".pdf"], scale=3) | |
| load_btn = gr.Button("Load PDF", variant="primary", scale=2, elem_id="hidden-load-btn") | |
| language_selector = gr.Radio(["English", "Français"], value="English", label="🌐 Language", visible=False) | |
| with gr.Row(elem_id="hidden-row-status"): | |
| load_status = gr.Textbox(label="Status", interactive=False, scale=4, elem_id="hidden-status-output") | |
| score_box = gr.Textbox(label="Score", value="—", interactive=False, scale=1, elem_id="score-box", visible=False) | |
| with gr.Row(elem_id="hidden-row-question"): | |
| question_box = gr.Textbox(label="❓ Question", interactive=False, lines=3, scale=3, elem_id="hidden-question-output") | |
| new_q_btn = gr.Button("New Question", variant="secondary", scale=1, elem_id="hidden-question-btn") | |
| answer_box = gr.Textbox(label="✏️ Your Answer", lines=4, placeholder="Write your answer here…", elem_id="hidden-answer-input") | |
| submit_btn = gr.Button("Submit Answer", variant="primary", elem_id="hidden-submit-btn") | |
| feedback_box = gr.Textbox(label="💬 Feedback", interactive=False, lines=7, elem_id="hidden-feedback-output") | |
| concept_image = gr.Image(label="concept", type="pil", interactive=False, visible=True, elem_id="hidden-concept-image") | |
| question_trigger = gr.Textbox(value="0", label="qtrig", elem_id="hidden-question-trigger") | |
| answer_trigger = gr.Textbox(value="0", label="atrig", elem_id="hidden-answer-trigger") | |
| chunk_display = gr.Textbox(value="", label="chunk", elem_id="hidden-chunk-output") | |
| mcq_trigger = gr.Textbox(value="0", label="mcqtrig", elem_id="hidden-mcq-trigger") | |
| mcq_output = gr.Textbox(value="", label="mcqout", elem_id="hidden-mcq-output") | |
| language_box = gr.Textbox(value="English", label="lang", elem_id="hidden-language-box") | |
| session_topics = gr.Textbox(value="", label="topics", elem_id="hidden-session-topics") | |
| session_img_btn = gr.Button("gen session image", elem_id="hidden-generate-session-image-btn") | |
| load_btn.click( | |
| load_pdf, | |
| inputs=[pdf_input, language_selector], | |
| outputs=[chunks_state, load_status, correct_state, total_state], | |
| ).then(lambda: "—", outputs=[score_box]) | |
| question_trigger.change( | |
| new_question, | |
| inputs=[chunks_state, language_box], | |
| outputs=[question_box, chunk_state, feedback_box, chunk_display], | |
| ) | |
| answer_trigger.change( | |
| submit_answer, | |
| inputs=[question_box, chunk_state, answer_box, correct_state, total_state, language_box], | |
| outputs=[feedback_box, correct_state, total_state, score_box], | |
| ) | |
| session_img_btn.click( | |
| generate_image_fn, | |
| inputs=[session_topics], | |
| outputs=[concept_image], | |
| ) | |
| mcq_trigger.change( | |
| new_mcq, | |
| inputs=[chunks_state, language_box], | |
| outputs=[question_box, chunk_state, mcq_output, chunk_display], | |
| ) | |
| print("✅ Gradio demo defined successfully") | |
| print("✅ Launching demo...") | |
| demo.launch(server_name="0.0.0.0", server_port=7860) | |