""" 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
*/ .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 @gpu_if_needed(duration=180) 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, "" @gpu_if_needed(duration=180) 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}), "" @gpu_if_needed(duration=120) 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) @spaces.GPU(duration=120) 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: