PaperProf / app.py
Mehdi
fix: pre-translate non-English chunks to English before LLM inference
67da08d
Raw
History Blame Contribute Delete
48.8 kB
"""
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
@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: <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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')+'</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)