Ryadg commited on
Commit
e984a0a
·
1 Parent(s): 5e6ff7d

feat: Off-Brand badge - custom FastAPI+HTML frontend, zero Gradio chrome

Browse files
Files changed (6) hide show
  1. README.md +1 -1
  2. app.py +1 -1
  3. core/questioner.py +16 -4
  4. main.py +125 -0
  5. requirements.txt +5 -0
  6. ui/index.html +1013 -0
README.md CHANGED
@@ -6,7 +6,7 @@ colorTo: blue
6
  sdk: gradio
7
  sdk_version: 6.16.0
8
  python_version: '3.12'
9
- app_file: app.py
10
  pinned: false
11
  ---
12
 
 
6
  sdk: gradio
7
  sdk_version: 6.16.0
8
  python_version: '3.12'
9
+ app_file: main.py
10
  pinned: false
11
  ---
12
 
app.py CHANGED
@@ -237,4 +237,4 @@ with gr.Blocks(title="PaperProf", theme=edu_theme, css=CSS) as demo:
237
  )
238
 
239
  if __name__ == "__main__":
240
- demo.launch()
 
237
  )
238
 
239
  if __name__ == "__main__":
240
+ pass # launching is handled by main.py / HuggingFace Spaces
core/questioner.py CHANGED
@@ -15,13 +15,21 @@ Public API:
15
 
16
  from model.llm import get_llm
17
 
 
 
 
 
 
 
18
  _PROMPT_TEMPLATE = """\
19
  You are a university professor creating exam questions.
20
- Given the following excerpt from a course, write ONE short, focused question that tests understanding of a single concept.
 
 
21
 
22
  Rules:
23
  - ONE question only, on ONE concept
24
- - Maximum 20 words
25
  - No sub-questions, no "and", no compound questions
26
  - Write in {language}
27
  - Output only the question, nothing else
@@ -32,8 +40,12 @@ Excerpt:
32
  Question:"""
33
 
34
 
35
- def generate_question(chunk: str, language: str = "English") -> str:
36
  """Return a single study question generated from *chunk*."""
37
  llm = get_llm()
38
- prompt = _PROMPT_TEMPLATE.format(chunk=chunk.strip(), language=language)
 
 
 
 
39
  return llm.generate(prompt).strip()
 
15
 
16
  from model.llm import get_llm
17
 
18
+ _DIFFICULTY_HINT = {
19
+ "Easy": "Ask for simple factual recall (What is X? Define X.).",
20
+ "Normal": "Ask for conceptual understanding (Explain X. Why does X happen?).",
21
+ "Hard": "Ask for analysis or application (Compare X and Y. How would you apply X to Y?).",
22
+ }
23
+
24
  _PROMPT_TEMPLATE = """\
25
  You are a university professor creating exam questions.
26
+ Given the following excerpt from a course, write ONE focused question.
27
+
28
+ Difficulty — {difficulty_hint}
29
 
30
  Rules:
31
  - ONE question only, on ONE concept
32
+ - Maximum 25 words
33
  - No sub-questions, no "and", no compound questions
34
  - Write in {language}
35
  - Output only the question, nothing else
 
40
  Question:"""
41
 
42
 
43
+ def generate_question(chunk: str, language: str = "English", difficulty: str = "Normal") -> str:
44
  """Return a single study question generated from *chunk*."""
45
  llm = get_llm()
46
+ prompt = _PROMPT_TEMPLATE.format(
47
+ chunk=chunk.strip(),
48
+ language=language,
49
+ difficulty_hint=_DIFFICULTY_HINT.get(difficulty, _DIFFICULTY_HINT["Normal"]),
50
+ )
51
  return llm.generate(prompt).strip()
main.py ADDED
@@ -0,0 +1,125 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ main.py — FastAPI entry point for PaperProf.
3
+
4
+ Serves the custom HTML/CSS/JS frontend at / and exposes the ML pipeline
5
+ as a stateless JSON API at /api/*. The original Gradio interface is mounted
6
+ at /gradio as a fallback.
7
+
8
+ HuggingFace Spaces: README.md has app_file: main.py
9
+ """
10
+ import os
11
+ import tempfile
12
+
13
+ import gradio as gr
14
+ from fastapi import FastAPI, File, HTTPException, UploadFile
15
+ from fastapi.responses import HTMLResponse
16
+ from pydantic import BaseModel
17
+
18
+ from core.parser import extract_text
19
+ from core.chunker import chunk_text
20
+ from core.questioner import generate_question
21
+ from core.evaluator import evaluate_answer
22
+ from app import demo # Gradio demo — mounted at /gradio for ZeroGPU fallback
23
+
24
+ try:
25
+ import spaces
26
+ _GPU = spaces.GPU
27
+ except ImportError:
28
+ def _GPU(duration=60): # no-op decorator for local dev
29
+ def wrap(fn):
30
+ return fn
31
+ return wrap
32
+
33
+ # ─────────────────────────────────────────────────────────────────────────────
34
+ # FastAPI base
35
+ # ─────────────────────────────────────────────────────────────────────────────
36
+
37
+ _base = FastAPI(title="PaperProf", docs_url=None, redoc_url=None)
38
+
39
+
40
+ @_base.get("/", response_class=HTMLResponse)
41
+ async def root():
42
+ with open("ui/index.html", encoding="utf-8") as fh:
43
+ return fh.read()
44
+
45
+
46
+ # ─────────────────────────────────────────────────────────────────────────────
47
+ # /api/load — parse PDF into chunks (CPU, no ZeroGPU needed)
48
+ # ─────────────────────────────────────────────────────────────────────────────
49
+
50
+ @_base.post("/api/load")
51
+ async def api_load(file: UploadFile = File(...)):
52
+ if not (file.filename or "").lower().endswith(".pdf"):
53
+ raise HTTPException(400, "Only PDF files are supported.")
54
+ content = await file.read()
55
+ with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as tmp:
56
+ tmp.write(content)
57
+ tmp_path = tmp.name
58
+ try:
59
+ text = extract_text(tmp_path)
60
+ chunks = chunk_text(text)
61
+ if not chunks:
62
+ raise HTTPException(400, "No text found in PDF (scanned or too short?).")
63
+ return {"chunks": chunks, "count": len(chunks)}
64
+ except HTTPException:
65
+ raise
66
+ except ValueError as exc:
67
+ raise HTTPException(400, str(exc))
68
+ except Exception as exc:
69
+ raise HTTPException(500, f"Unexpected error: {exc}")
70
+ finally:
71
+ os.unlink(tmp_path)
72
+
73
+
74
+ # ─────────────────────────────────────────────────────────────────────────────
75
+ # /api/question — generate a question from a chunk (GPU)
76
+ # ─────────────────────────────────────────────────────────────────────────────
77
+
78
+ class QuestionRequest(BaseModel):
79
+ chunk: str
80
+ language: str = "English"
81
+ difficulty: str = "Normal"
82
+
83
+
84
+ @_GPU(duration=60)
85
+ def _run_generate_question(chunk: str, language: str, difficulty: str) -> str:
86
+ return generate_question(chunk, language=language, difficulty=difficulty)
87
+
88
+
89
+ @_base.post("/api/question")
90
+ def api_question(req: QuestionRequest):
91
+ try:
92
+ return {"question": _run_generate_question(req.chunk, req.language, req.difficulty)}
93
+ except Exception as exc:
94
+ raise HTTPException(500, str(exc))
95
+
96
+
97
+ # ─────────────────────────────────────────────────────────────────────────────
98
+ # /api/evaluate — evaluate student answer (GPU)
99
+ # ─────────────────────────────────────────────────────────────────────────────
100
+
101
+ class EvaluateRequest(BaseModel):
102
+ question: str
103
+ chunk: str
104
+ answer: str
105
+ language: str = "English"
106
+
107
+
108
+ @_GPU(duration=120)
109
+ def _run_evaluate_answer(question: str, chunk: str, answer: str, language: str) -> str:
110
+ return evaluate_answer(question, chunk, answer, language=language)
111
+
112
+
113
+ @_base.post("/api/evaluate")
114
+ def api_evaluate(req: EvaluateRequest):
115
+ try:
116
+ return {"feedback": _run_evaluate_answer(req.question, req.chunk, req.answer, req.language)}
117
+ except Exception as exc:
118
+ raise HTTPException(500, str(exc))
119
+
120
+
121
+ # ─────────────────────────────────────────────────────────────────────────────
122
+ # Mount Gradio at /gradio and export the combined ASGI app
123
+ # ─────────────────────────────────────────────────────────────────────────────
124
+
125
+ app = gr.mount_gradio_app(_base, demo, path="/gradio")
requirements.txt CHANGED
@@ -1,6 +1,11 @@
1
  # Core UI
2
  gradio>=4.36.0
3
 
 
 
 
 
 
4
  # PDF extraction
5
  PyMuPDF>=1.24.0
6
 
 
1
  # Core UI
2
  gradio>=4.36.0
3
 
4
+ # Custom frontend server
5
+ fastapi>=0.100.0
6
+ uvicorn>=0.23.0
7
+ python-multipart>=0.0.6
8
+
9
  # PDF extraction
10
  PyMuPDF>=1.24.0
11
 
ui/index.html ADDED
@@ -0,0 +1,1013 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>PaperProf — AI Study Buddy</title>
7
+ <link rel="preconnect" href="https://fonts.googleapis.com">
8
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&display=swap" rel="stylesheet">
9
+ <style>
10
+ /* ── Reset & base ─────────────────────────────────────────────────────── */
11
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
12
+
13
+ :root {
14
+ --bg: #0A0F1E;
15
+ --surface: rgba(255,255,255,0.04);
16
+ --surface-2: rgba(255,255,255,0.07);
17
+ --border: rgba(255,255,255,0.08);
18
+ --border-glow: rgba(124,58,237,0.5);
19
+ --purple: #7C3AED;
20
+ --purple-l: #A78BFA;
21
+ --cyan: #06B6D4;
22
+ --cyan-l: #67E8F9;
23
+ --green: #10B981;
24
+ --amber: #F59E0B;
25
+ --red: #EF4444;
26
+ --text: #F1F5F9;
27
+ --muted: #94A3B8;
28
+ --dim: #475569;
29
+ --r: 18px;
30
+ --r-sm: 10px;
31
+ }
32
+
33
+ html { scroll-behavior: smooth; }
34
+
35
+ body {
36
+ font-family: 'Inter', system-ui, sans-serif;
37
+ background: var(--bg);
38
+ color: var(--text);
39
+ min-height: 100vh;
40
+ overflow-x: hidden;
41
+ background-image:
42
+ radial-gradient(ellipse at 15% 40%, rgba(124,58,237,0.10) 0%, transparent 55%),
43
+ radial-gradient(ellipse at 85% 15%, rgba(6,182,212,0.08) 0%, transparent 55%),
44
+ radial-gradient(ellipse at 60% 90%, rgba(124,58,237,0.06) 0%, transparent 50%);
45
+ }
46
+
47
+ /* ── Particles canvas ─────────────────────────────────────────────────── */
48
+ #particles {
49
+ position: fixed; inset: 0;
50
+ pointer-events: none; z-index: 0;
51
+ }
52
+
53
+ /* ── Layout ───────────────────────────────────────────────────────────── */
54
+ #app {
55
+ position: relative; z-index: 1;
56
+ max-width: 760px;
57
+ margin: 0 auto;
58
+ padding: 28px 20px 80px;
59
+ display: flex; flex-direction: column; gap: 20px;
60
+ }
61
+
62
+ /* ── Header ───────────────────────────────────────────────────────────── */
63
+ .hero {
64
+ text-align: center;
65
+ padding: 48px 20px 36px;
66
+ animation: fadeUp .6s ease both;
67
+ }
68
+ .hero-icon { font-size: 3.2rem; display: block; margin-bottom: 12px;
69
+ filter: drop-shadow(0 0 16px rgba(124,58,237,.6)); }
70
+ .hero-title {
71
+ font-size: clamp(2.6rem, 8vw, 4.2rem);
72
+ font-weight: 900; line-height: 1;
73
+ background: linear-gradient(135deg, var(--purple-l) 0%, var(--cyan-l) 100%);
74
+ -webkit-background-clip: text; -webkit-text-fill-color: transparent;
75
+ background-clip: text;
76
+ letter-spacing: -1.5px;
77
+ margin-bottom: 10px;
78
+ }
79
+ .hero-sub {
80
+ color: var(--muted); font-size: 1rem; font-weight: 400;
81
+ letter-spacing: .01em;
82
+ }
83
+
84
+ /* ── Card ─────────────────────────────────────────────────────────────── */
85
+ .card {
86
+ background: rgba(10,15,30,0.65);
87
+ backdrop-filter: blur(24px);
88
+ -webkit-backdrop-filter: blur(24px);
89
+ border: 1px solid var(--border);
90
+ border-radius: var(--r);
91
+ padding: 28px;
92
+ box-shadow: 0 8px 32px rgba(0,0,0,.45), 0 1px 0 rgba(255,255,255,.04) inset;
93
+ transition: border-color .25s;
94
+ }
95
+ .card:hover { border-color: rgba(255,255,255,.12); }
96
+
97
+ /* ── Drop zone ────────────────────────────────────────────────────────── */
98
+ .drop-zone {
99
+ border: 2px dashed rgba(124,58,237,.35);
100
+ border-radius: var(--r-sm);
101
+ padding: 40px 24px;
102
+ text-align: center;
103
+ cursor: pointer;
104
+ transition: border-color .2s, box-shadow .2s, background .2s;
105
+ position: relative;
106
+ }
107
+ .drop-zone:hover, .drop-zone.drag-over {
108
+ border-color: var(--cyan);
109
+ box-shadow: 0 0 28px rgba(6,182,212,.15);
110
+ background: rgba(6,182,212,.04);
111
+ }
112
+ .drop-zone.has-file {
113
+ border-color: var(--purple);
114
+ box-shadow: 0 0 28px rgba(124,58,237,.15);
115
+ background: rgba(124,58,237,.04);
116
+ }
117
+ .upload-icon { font-size: 2.4rem; display: block; margin-bottom: 12px;
118
+ transition: transform .2s; }
119
+ .drop-zone:hover .upload-icon { transform: translateY(-4px); }
120
+ .drop-title { font-weight: 600; font-size: 1rem; margin-bottom: 4px; }
121
+ .drop-hint { color: var(--muted); font-size: .85rem; margin-bottom: 16px; }
122
+ .file-name {
123
+ margin-top: 12px; font-size: .9rem; font-weight: 600;
124
+ color: var(--purple-l);
125
+ white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
126
+ max-width: 280px; margin-inline: auto;
127
+ }
128
+
129
+ /* ── Buttons ──────────────────────────────────────────────────────────── */
130
+ button { cursor: pointer; font-family: inherit; font-size: .95rem;
131
+ border: none; outline: none; transition: all .2s; }
132
+
133
+ .btn-primary {
134
+ display: inline-flex; align-items: center; justify-content: center; gap: 8px;
135
+ padding: 12px 28px; border-radius: 50px; font-weight: 700;
136
+ background: linear-gradient(135deg, var(--purple) 0%, var(--cyan) 100%);
137
+ color: #fff;
138
+ box-shadow: 0 4px 20px rgba(124,58,237,.35);
139
+ }
140
+ .btn-primary:hover:not(:disabled) {
141
+ transform: translateY(-1px) scale(1.02);
142
+ box-shadow: 0 6px 28px rgba(124,58,237,.5);
143
+ }
144
+ .btn-primary:active:not(:disabled) { transform: scale(.98); }
145
+ .btn-primary:disabled { opacity: .4; cursor: not-allowed; }
146
+
147
+ .btn-ghost {
148
+ display: inline-flex; align-items: center; justify-content: center; gap: 6px;
149
+ padding: 10px 22px; border-radius: 50px; font-weight: 600;
150
+ background: transparent; color: var(--muted);
151
+ border: 1px solid var(--border);
152
+ }
153
+ .btn-ghost:hover:not(:disabled) {
154
+ background: var(--surface-2); color: var(--text);
155
+ border-color: rgba(255,255,255,.2);
156
+ }
157
+ .btn-ghost:disabled { opacity: .35; cursor: not-allowed; }
158
+
159
+ .btn-browse {
160
+ display: inline-block;
161
+ padding: 9px 22px; border-radius: 50px; font-weight: 600; font-size: .9rem;
162
+ border: 1px solid rgba(124,58,237,.5); color: var(--purple-l);
163
+ background: rgba(124,58,237,.08);
164
+ cursor: pointer; transition: all .2s;
165
+ }
166
+ .btn-browse:hover {
167
+ background: rgba(124,58,237,.18); border-color: var(--purple);
168
+ }
169
+
170
+ /* ── Pill toggles ─────────────────────────────────────────────────────── */
171
+ .toggle-group { display: flex; gap: 6px; }
172
+
173
+ .pill {
174
+ padding: 6px 16px; border-radius: 50px; font-size: .82rem; font-weight: 600;
175
+ border: 1px solid var(--border); color: var(--muted);
176
+ background: transparent; transition: all .18s;
177
+ }
178
+ .pill:hover { background: var(--surface-2); color: var(--text); border-color: rgba(255,255,255,.2); }
179
+ .pill.active {
180
+ background: linear-gradient(135deg, var(--purple), var(--cyan));
181
+ border-color: transparent; color: #fff;
182
+ box-shadow: 0 2px 12px rgba(124,58,237,.4);
183
+ }
184
+
185
+ /* ── Status / alerts ──────────────────────────────────────────────────── */
186
+ .status {
187
+ margin-top: 14px; padding: 11px 16px; border-radius: var(--r-sm);
188
+ font-size: .9rem; font-weight: 500; line-height: 1.4;
189
+ animation: fadeUp .3s ease both;
190
+ }
191
+ .status.info { background: rgba(6,182,212,.12); border: 1px solid rgba(6,182,212,.3); color: var(--cyan-l); }
192
+ .status.ok { background: rgba(16,185,129,.12); border: 1px solid rgba(16,185,129,.3); color: #6EE7B7; }
193
+ .status.error { background: rgba(239,68,68,.12); border: 1px solid rgba(239,68,68,.3); color: #FCA5A5; }
194
+
195
+ /* ── Upload actions row ───────────────────────────────────────────────── */
196
+ .upload-actions { margin-top: 20px; display: flex; justify-content: center; }
197
+
198
+ /* ── Quiz controls row ───────────────────────────────────────────────── */
199
+ .controls-row {
200
+ display: flex; align-items: center; flex-wrap: wrap; gap: 12px;
201
+ margin-bottom: 24px;
202
+ }
203
+ .controls-row .toggle-group:last-of-type { margin-right: auto; }
204
+ .diff-label { font-size: .75rem; color: var(--dim); font-weight: 500;
205
+ letter-spacing: .04em; margin-bottom: 4px; }
206
+
207
+ /* ── Score ring ───────────────────────────────────────────────────────── */
208
+ .score-wrap { position: relative; width: 56px; height: 56px; flex-shrink: 0; }
209
+ .score-ring { width: 56px; height: 56px; }
210
+ .score-label {
211
+ position: absolute; inset: 0;
212
+ display: flex; align-items: center; justify-content: center;
213
+ font-size: .72rem; font-weight: 700; color: var(--text);
214
+ letter-spacing: -.5px;
215
+ }
216
+ #score-arc { transition: stroke-dashoffset .6s cubic-bezier(.4,0,.2,1); }
217
+
218
+ /* ── Question card ────────────────────────────────────────────────────── */
219
+ .q-label {
220
+ font-size: .72rem; font-weight: 700; letter-spacing: .1em;
221
+ text-transform: uppercase; color: var(--purple-l); margin-bottom: 10px;
222
+ }
223
+ .q-text {
224
+ font-size: 1.1rem; font-weight: 600; line-height: 1.55;
225
+ color: var(--text); min-height: 3.2em;
226
+ }
227
+ .q-inner {
228
+ background: rgba(124,58,237,.07); border: 1px solid rgba(124,58,237,.2);
229
+ border-radius: var(--r-sm); padding: 18px 20px; margin-bottom: 20px;
230
+ }
231
+
232
+ /* ── Answer area ──────────────────────────────────────────────────────── */
233
+ .answer-header {
234
+ display: flex; justify-content: space-between; align-items: center;
235
+ margin-bottom: 8px;
236
+ }
237
+ .answer-header label { font-size: .85rem; font-weight: 600; color: var(--muted); }
238
+ .char-count { font-size: .78rem; color: var(--dim); font-variant-numeric: tabular-nums; }
239
+
240
+ textarea.answer-input {
241
+ width: 100%; resize: vertical; min-height: 110px;
242
+ background: rgba(255,255,255,.04); border: 1px solid var(--border);
243
+ border-radius: var(--r-sm); padding: 14px 16px;
244
+ color: var(--text); font-family: inherit; font-size: .95rem; line-height: 1.5;
245
+ outline: none; transition: border-color .2s, box-shadow .2s;
246
+ }
247
+ textarea.answer-input:focus {
248
+ border-color: var(--purple); box-shadow: 0 0 0 3px rgba(124,58,237,.15);
249
+ }
250
+ textarea.answer-input::placeholder { color: var(--dim); }
251
+
252
+ .answer-actions {
253
+ display: flex; justify-content: flex-end; gap: 10px; margin-top: 14px;
254
+ flex-wrap: wrap;
255
+ }
256
+
257
+ /* ── No question prompt ───────────────────────────────────────────────── */
258
+ .empty-state {
259
+ text-align: center; padding: 32px 0;
260
+ color: var(--muted); font-size: .95rem;
261
+ }
262
+ .empty-state p { margin-bottom: 18px; }
263
+
264
+ /* ── End session button ───────────────────────────────────────────────── */
265
+ .end-row { margin-top: 20px; display: flex; justify-content: center; }
266
+
267
+ /* ── Spinner ──────────────────────────────────────────────────────────── */
268
+ .spinner {
269
+ width: 16px; height: 16px; border-radius: 50%;
270
+ border: 2px solid rgba(255,255,255,.25);
271
+ border-top-color: #fff;
272
+ animation: spin .65s linear infinite;
273
+ display: inline-block; flex-shrink: 0;
274
+ }
275
+
276
+ /* ── Feedback card ────────────────────────────────────────────────────── */
277
+ .feedback-header {
278
+ display: flex; align-items: center; gap: 12px; margin-bottom: 18px;
279
+ }
280
+ .verdict-badge {
281
+ padding: 6px 18px; border-radius: 50px;
282
+ font-size: .82rem; font-weight: 800; letter-spacing: .05em;
283
+ text-transform: uppercase; flex-shrink: 0;
284
+ animation: popIn .35s cubic-bezier(.34,1.56,.64,1) both;
285
+ }
286
+ .verdict-badge.correct { background: rgba(16,185,129,.18); border: 1px solid var(--green); color: #6EE7B7; }
287
+ .verdict-badge.partial { background: rgba(245,158,11,.18); border: 1px solid var(--amber); color: #FCD34D; }
288
+ .verdict-badge.incorrect { background: rgba(239,68,68,.18); border: 1px solid var(--red); color: #FCA5A5; }
289
+
290
+ .feedback-body {
291
+ font-size: .95rem; line-height: 1.7; color: var(--text);
292
+ }
293
+ .feedback-body .section { margin-bottom: 12px; }
294
+ .feedback-body .section-num {
295
+ font-size: .75rem; font-weight: 700; letter-spacing: .08em;
296
+ text-transform: uppercase; color: var(--muted); display: block;
297
+ margin-bottom: 4px;
298
+ }
299
+ .feedback-body .section-content { color: var(--text); }
300
+ .feedback-raw { white-space: pre-wrap; }
301
+
302
+ /* ── Collapsible source ───────────────────────────────────────────────── */
303
+ details.source-details { margin-top: 18px; }
304
+ details.source-details summary {
305
+ cursor: pointer; color: var(--dim); font-size: .82rem; font-weight: 600;
306
+ letter-spacing: .03em; list-style: none; user-select: none;
307
+ transition: color .2s;
308
+ }
309
+ details.source-details summary::before { content: '▶ '; font-size: .7em; }
310
+ details.source-details[open] summary::before { content: '▼ '; }
311
+ details.source-details summary:hover { color: var(--muted); }
312
+ .source-text {
313
+ margin-top: 10px; padding: 14px 16px;
314
+ background: rgba(255,255,255,.03); border: 1px solid var(--border);
315
+ border-radius: var(--r-sm);
316
+ font-size: .82rem; line-height: 1.6; color: var(--muted);
317
+ max-height: 180px; overflow-y: auto;
318
+ }
319
+
320
+ /* ── Modal ────────────────────────────────────────────────────────────── */
321
+ .modal-overlay {
322
+ position: fixed; inset: 0; z-index: 100;
323
+ background: rgba(0,0,0,.7); backdrop-filter: blur(8px);
324
+ display: flex; align-items: center; justify-content: center;
325
+ padding: 20px;
326
+ animation: fadeIn .2s ease both;
327
+ }
328
+ .modal-card {
329
+ background: rgba(15,20,40,.95); border: 1px solid rgba(124,58,237,.3);
330
+ border-radius: 24px; padding: 40px 36px;
331
+ max-width: 420px; width: 100%; text-align: center;
332
+ box-shadow: 0 0 60px rgba(124,58,237,.25), 0 20px 60px rgba(0,0,0,.6);
333
+ animation: popIn .4s cubic-bezier(.34,1.56,.64,1) both;
334
+ }
335
+ .modal-icon { font-size: 3.5rem; display: block; margin-bottom: 16px;
336
+ filter: drop-shadow(0 0 12px rgba(124,58,237,.5)); }
337
+ .modal-title { font-size: 1.7rem; font-weight: 800; margin-bottom: 20px;
338
+ background: linear-gradient(135deg, var(--purple-l), var(--cyan-l));
339
+ -webkit-background-clip: text; -webkit-text-fill-color: transparent;
340
+ background-clip: text; }
341
+ .modal-score-big {
342
+ font-size: 3.5rem; font-weight: 900; line-height: 1;
343
+ background: linear-gradient(135deg, var(--purple-l), var(--cyan-l));
344
+ -webkit-background-clip: text; -webkit-text-fill-color: transparent;
345
+ background-clip: text; margin-bottom: 8px;
346
+ }
347
+ .modal-score-label { color: var(--muted); font-size: .9rem; margin-bottom: 20px; }
348
+ .modal-message { color: var(--muted); font-size: .95rem; line-height: 1.5;
349
+ margin-bottom: 28px; }
350
+ .modal-history { text-align: left; margin-bottom: 24px; }
351
+ .modal-history-item {
352
+ display: flex; align-items: flex-start; gap: 10px;
353
+ padding: 8px 0; border-bottom: 1px solid var(--border); font-size: .85rem;
354
+ }
355
+ .modal-history-item:last-child { border-bottom: none; }
356
+ .hist-badge {
357
+ flex-shrink: 0; padding: 2px 10px; border-radius: 50px; font-size: .72rem;
358
+ font-weight: 700; text-transform: uppercase;
359
+ }
360
+ .hist-badge.correct { background: rgba(16,185,129,.2); color: #6EE7B7; }
361
+ .hist-badge.partial { background: rgba(245,158,11,.2); color: #FCD34D; }
362
+ .hist-badge.incorrect { background: rgba(239,68,68,.2); color: #FCA5A5; }
363
+ .hist-q { color: var(--muted); flex: 1; }
364
+
365
+ /* ── Loading overlay (inside card) ───────────────────────────────────── */
366
+ .loading-msg {
367
+ display: flex; align-items: center; justify-content: center; gap: 10px;
368
+ padding: 14px; color: var(--muted); font-size: .9rem;
369
+ }
370
+
371
+ /* ── Animations ───────────────────────────────────────────────────────── */
372
+ @keyframes fadeUp { from { opacity: 0; transform: translateY(18px); } to { opacity: 1; transform: none; } }
373
+ @keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
374
+ @keyframes popIn { from { opacity: 0; transform: scale(.8); } to { opacity: 1; transform: scale(1); } }
375
+ @keyframes spin { to { transform: rotate(360deg); } }
376
+ @keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: .4; } }
377
+
378
+ .hidden { display: none !important; }
379
+ .fade-in-up { animation: fadeUp .4s ease both; }
380
+
381
+ /* ── Responsive ───────────────────────────────────────────────────────── */
382
+ @media (max-width: 520px) {
383
+ #app { padding: 20px 14px 60px; }
384
+ .card { padding: 20px 18px; }
385
+ .hero { padding: 32px 12px 24px; }
386
+ .hero-title { font-size: 2.4rem; }
387
+ .controls-row { gap: 8px; }
388
+ .answer-actions { flex-direction: column; }
389
+ .answer-actions button { width: 100%; justify-content: center; }
390
+ }
391
+ </style>
392
+ </head>
393
+ <body>
394
+ <canvas id="particles"></canvas>
395
+
396
+ <div id="app">
397
+ <!-- Hero -->
398
+ <header class="hero">
399
+ <span class="hero-icon">📄</span>
400
+ <h1 class="hero-title">PaperProf</h1>
401
+ <p class="hero-sub">Upload your course. Get quizzed by AI.</p>
402
+ </header>
403
+
404
+ <!-- ── Upload card ──────────────────────────────────────────────────── -->
405
+ <div class="card fade-in-up" id="upload-card" style="animation-delay:.05s">
406
+ <div class="drop-zone" id="drop-zone">
407
+ <span class="upload-icon" id="upload-icon">📎</span>
408
+ <p class="drop-title" id="drop-title">Drop your PDF here</p>
409
+ <p class="drop-hint">or</p>
410
+ <label class="btn-browse" for="file-input">Browse files</label>
411
+ <input type="file" id="file-input" accept=".pdf" style="display:none">
412
+ <p class="file-name hidden" id="file-name"></p>
413
+ </div>
414
+ <div class="upload-actions">
415
+ <button class="btn-primary" id="load-btn" disabled>
416
+ <span id="load-btn-text">Load PDF</span>
417
+ <span class="spinner hidden" id="load-spinner"></span>
418
+ </button>
419
+ </div>
420
+ <div class="status hidden" id="status-msg"></div>
421
+ </div>
422
+
423
+ <!-- ── Quiz card ────────────────────────────────────────────────────── -->
424
+ <div class="card hidden fade-in-up" id="quiz-card" style="animation-delay:.1s">
425
+
426
+ <!-- Controls row -->
427
+ <div class="controls-row">
428
+ <!-- Language toggle -->
429
+ <div>
430
+ <div class="diff-label">Language</div>
431
+ <div class="toggle-group" id="lang-toggle">
432
+ <button class="pill active" data-val="English">EN</button>
433
+ <button class="pill" data-val="Français">FR</button>
434
+ </div>
435
+ </div>
436
+
437
+ <!-- Difficulty toggle -->
438
+ <div>
439
+ <div class="diff-label">Difficulty</div>
440
+ <div class="toggle-group" id="diff-toggle">
441
+ <button class="pill" data-val="Easy">Easy</button>
442
+ <button class="pill active" data-val="Normal">Normal</button>
443
+ <button class="pill" data-val="Hard">Hard</button>
444
+ </div>
445
+ </div>
446
+
447
+ <!-- Score ring -->
448
+ <div class="score-wrap" style="margin-left:auto" title="Score">
449
+ <svg class="score-ring" viewBox="0 0 56 56">
450
+ <circle cx="28" cy="28" r="22" fill="none"
451
+ stroke="rgba(255,255,255,0.08)" stroke-width="4"/>
452
+ <circle id="score-arc" cx="28" cy="28" r="22" fill="none"
453
+ stroke="url(#scoreGrad)" stroke-width="4" stroke-linecap="round"
454
+ stroke-dasharray="138.2" stroke-dashoffset="138.2"
455
+ transform="rotate(-90 28 28)"/>
456
+ <defs>
457
+ <linearGradient id="scoreGrad" x1="0%" y1="0%" x2="100%" y2="0%">
458
+ <stop offset="0%" stop-color="#7C3AED"/>
459
+ <stop offset="100%" stop-color="#06B6D4"/>
460
+ </linearGradient>
461
+ </defs>
462
+ </svg>
463
+ <div class="score-label" id="score-label">—</div>
464
+ </div>
465
+ </div>
466
+
467
+ <!-- Empty state (before first question) -->
468
+ <div class="empty-state" id="empty-state">
469
+ <p>Ready! Click to generate your first question.</p>
470
+ <button class="btn-primary" id="start-btn">
471
+ New Question
472
+ </button>
473
+ </div>
474
+
475
+ <!-- Question + answer (shown after first question) -->
476
+ <div class="hidden" id="question-area">
477
+ <div class="q-inner">
478
+ <div class="q-label">Question</div>
479
+ <p class="q-text" id="q-text"></p>
480
+ <div class="loading-msg hidden" id="q-loading">
481
+ <span class="spinner"></span>
482
+ <span id="q-loading-msg">Generating question…</span>
483
+ </div>
484
+ </div>
485
+
486
+ <div class="answer-header">
487
+ <label for="answer-input">Your Answer</label>
488
+ <span class="char-count" id="char-count">0 chars</span>
489
+ </div>
490
+ <textarea class="answer-input" id="answer-input"
491
+ placeholder="Type your answer here…" rows="4"></textarea>
492
+ <div class="answer-actions">
493
+ <button class="btn-ghost" id="new-q-btn">↺ New Question</button>
494
+ <button class="btn-primary" id="submit-btn" disabled>
495
+ <span id="submit-btn-text">Submit Answer</span>
496
+ <span class="spinner hidden" id="submit-spinner"></span>
497
+ </button>
498
+ </div>
499
+ </div>
500
+
501
+ <div class="end-row">
502
+ <button class="btn-ghost hidden" id="end-btn" style="font-size:.82rem;padding:7px 18px">
503
+ End Session
504
+ </button>
505
+ </div>
506
+ </div>
507
+
508
+ <!-- ── Feedback card ─────────────────────────────────────────────────── -->
509
+ <div class="card hidden" id="feedback-card">
510
+ <div class="feedback-header">
511
+ <div class="verdict-badge" id="verdict-badge"></div>
512
+ <span style="font-size:.85rem;color:var(--muted);font-weight:600">Feedback</span>
513
+ </div>
514
+ <div class="feedback-body" id="feedback-body"></div>
515
+ <details class="source-details" id="source-details">
516
+ <summary>View source passage</summary>
517
+ <p class="source-text" id="source-text"></p>
518
+ </details>
519
+ </div>
520
+ </div>
521
+
522
+ <!-- ── Session modal ───────────────────────────────────────────────────── -->
523
+ <div class="modal-overlay hidden" id="modal">
524
+ <div class="modal-card">
525
+ <span class="modal-icon">🎓</span>
526
+ <h2 class="modal-title">Session Complete</h2>
527
+ <div class="modal-score-big" id="modal-score-big">—</div>
528
+ <div class="modal-score-label" id="modal-score-label">questions answered</div>
529
+ <p class="modal-message" id="modal-message"></p>
530
+ <div class="modal-history hidden" id="modal-history"></div>
531
+ <button class="btn-primary" id="modal-close" style="width:100%;justify-content:center">
532
+ Study Again
533
+ </button>
534
+ </div>
535
+ </div>
536
+
537
+ <script>
538
+ // ───────────────────────────────────────────────────────────────────────────
539
+ // State
540
+ // ───────────────────────────────────────────────────────────────────────────
541
+ const S = {
542
+ chunks: [],
543
+ currentChunk: '',
544
+ currentQuestion: '',
545
+ correct: 0,
546
+ total: 0,
547
+ language: 'English',
548
+ difficulty: 'Normal',
549
+ history: [], // [{question, verdict}]
550
+ loaded: false,
551
+ selectedFile: null,
552
+ };
553
+
554
+ // ───────────────────────────────────────────────────────────────────────────
555
+ // DOM refs
556
+ // ───────────────────────────────────────────────────────────────────────────
557
+ const $ = id => document.getElementById(id);
558
+
559
+ const dropZone = $('drop-zone');
560
+ const fileInput = $('file-input');
561
+ const fileNameEl = $('file-name');
562
+ const dropTitle = $('drop-title');
563
+ const uploadIcon = $('upload-icon');
564
+ const loadBtn = $('load-btn');
565
+ const loadBtnText = $('load-btn-text');
566
+ const loadSpinner = $('load-spinner');
567
+ const statusEl = $('status-msg');
568
+
569
+ const quizCard = $('quiz-card');
570
+ const emptyState = $('empty-state');
571
+ const questionArea = $('question-area');
572
+ const qText = $('q-text');
573
+ const qLoading = $('q-loading');
574
+ const qLoadingMsg = $('q-loading-msg');
575
+ const answerInput = $('answer-input');
576
+ const charCount = $('char-count');
577
+ const newQBtn = $('new-q-btn');
578
+ const startBtn = $('start-btn');
579
+ const submitBtn = $('submit-btn');
580
+ const submitText = $('submit-btn-text');
581
+ const submitSpinner= $('submit-spinner');
582
+ const endBtn = $('end-btn');
583
+ const scoreArc = $('score-arc');
584
+ const scoreLabel = $('score-label');
585
+
586
+ const feedbackCard = $('feedback-card');
587
+ const verdictBadge = $('verdict-badge');
588
+ const feedbackBody = $('feedback-body');
589
+ const sourceText = $('source-text');
590
+
591
+ const modal = $('modal');
592
+ const modalScoreBig= $('modal-score-big');
593
+ const modalScoreLabel = $('modal-score-label');
594
+ const modalMessage = $('modal-message');
595
+ const modalHistory = $('modal-history');
596
+ const modalClose = $('modal-close');
597
+
598
+ // ───────────────────────────────────────────────────────────────────────────
599
+ // API helpers
600
+ // ───────────────────────────────────────────────────────────────────────────
601
+ async function apiLoad(file) {
602
+ const fd = new FormData();
603
+ fd.append('file', file);
604
+ const r = await fetch('/api/load', { method: 'POST', body: fd });
605
+ const d = await r.json();
606
+ if (!r.ok) throw new Error(d.detail || 'Failed to load PDF');
607
+ return d;
608
+ }
609
+
610
+ async function apiQuestion(chunk, language, difficulty) {
611
+ const r = await fetch('/api/question', {
612
+ method: 'POST',
613
+ headers: { 'Content-Type': 'application/json' },
614
+ body: JSON.stringify({ chunk, language, difficulty }),
615
+ });
616
+ const d = await r.json();
617
+ if (!r.ok) throw new Error(d.detail || 'Failed to generate question');
618
+ return d;
619
+ }
620
+
621
+ async function apiEvaluate(question, chunk, answer, language) {
622
+ const r = await fetch('/api/evaluate', {
623
+ method: 'POST',
624
+ headers: { 'Content-Type': 'application/json' },
625
+ body: JSON.stringify({ question, chunk, answer, language }),
626
+ });
627
+ const d = await r.json();
628
+ if (!r.ok) throw new Error(d.detail || 'Failed to evaluate answer');
629
+ return d;
630
+ }
631
+
632
+ // ───────────────────────────────────────────────────────────────────────────
633
+ // UI helpers
634
+ // ───────────────────────────────────────────────────────────────────────────
635
+ function setStatus(msg, type = 'info') {
636
+ statusEl.textContent = msg;
637
+ statusEl.className = `status ${type}`;
638
+ statusEl.classList.remove('hidden');
639
+ }
640
+ function clearStatus() { statusEl.classList.add('hidden'); }
641
+
642
+ function setLoadingBtn(btn, textEl, spinner, loading, label) {
643
+ btn.disabled = loading;
644
+ textEl.textContent = label || textEl.textContent;
645
+ spinner.classList.toggle('hidden', !loading);
646
+ }
647
+
648
+ function updateScore() {
649
+ const n = S.correct, d = S.total;
650
+ scoreLabel.textContent = d === 0 ? '—' : `${n}/${d}`;
651
+ const pct = d === 0 ? 0 : n / d;
652
+ const circ = 138.2;
653
+ scoreArc.style.strokeDashoffset = circ * (1 - pct);
654
+ // colour: green when >70%, amber when >40%, red otherwise
655
+ const color = pct >= 0.7 ? '#10B981' : pct >= 0.4 ? '#F59E0B' : '#EF4444';
656
+ scoreArc.style.stroke = d === 0
657
+ ? 'url(#scoreGrad)'
658
+ : color;
659
+ }
660
+
661
+ function extractVerdict(text) {
662
+ const lower = text.toLowerCase();
663
+ const lines = lower.split('\n');
664
+ for (const l of lines) {
665
+ if (l.includes('verdict')) {
666
+ if (l.includes('partially') || l.includes('partiellement')) return 'partial';
667
+ if (l.includes('incorrect')) return 'incorrect';
668
+ if (l.includes('correct')) return 'correct';
669
+ }
670
+ }
671
+ // fallback: scan first 200 chars
672
+ if (lower.slice(0,200).includes('incorrect')) return 'incorrect';
673
+ if (lower.slice(0,200).includes('partially') || lower.slice(0,200).includes('partiellement')) return 'partial';
674
+ if (lower.slice(0,200).includes('correct')) return 'correct';
675
+ return 'unknown';
676
+ }
677
+
678
+ function renderFeedback(text) {
679
+ // Try to split into numbered sections, fallback to raw text
680
+ const sectionRe = /^(\d+)\.\s*(.*?)(?=\n\d+\.|$)/gms;
681
+ const sectionLabels = ['', 'Verdict', 'What was good', 'What was missing', 'Model answer'];
682
+ const frLabels = ['', 'Verdict', 'Ce qui était bien', 'Ce qui manquait', 'Réponse modèle'];
683
+ const labels = S.language === 'Français' ? frLabels : sectionLabels;
684
+ const sections = [];
685
+ let m;
686
+ const re = /(\d+)\.\s*([^:\n]*)[::]?\s*([\s\S]*?)(?=\n\d+\.|$)/g;
687
+ while ((m = re.exec(text)) !== null) {
688
+ sections.push({ num: parseInt(m[1]), header: m[2].trim(), body: m[3].trim() });
689
+ }
690
+
691
+ if (sections.length >= 2) {
692
+ feedbackBody.innerHTML = sections.map(s => {
693
+ const label = labels[s.num] || s.header || `Part ${s.num}`;
694
+ return `<div class="section">
695
+ <span class="section-num">${label}</span>
696
+ <div class="section-content">${s.body.replace(/\n/g,'<br>')}</div>
697
+ </div>`;
698
+ }).join('');
699
+ } else {
700
+ // Plain fallback
701
+ feedbackBody.innerHTML = `<div class="feedback-raw">${
702
+ text.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')
703
+ }</div>`;
704
+ }
705
+ }
706
+
707
+ function showFeedback(text, chunk) {
708
+ const verdict = extractVerdict(text);
709
+ verdictBadge.className = `verdict-badge ${verdict === 'unknown' ? '' : verdict}`;
710
+ const labels = {
711
+ correct: S.language === 'Français' ? '✓ Correct' : '✓ Correct',
712
+ partial: S.language === 'Français' ? '~ Partiellement correct' : '~ Partially Correct',
713
+ incorrect: S.language === 'Français' ? '✗ Incorrect' : '✗ Incorrect',
714
+ unknown: '— Unknown',
715
+ };
716
+ verdictBadge.textContent = labels[verdict] || labels.unknown;
717
+
718
+ renderFeedback(text);
719
+ sourceText.textContent = chunk;
720
+ feedbackCard.classList.remove('hidden');
721
+ feedbackCard.classList.add('fade-in-up');
722
+ feedbackCard.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
723
+ return verdict;
724
+ }
725
+
726
+ function showModal() {
727
+ const pct = S.total > 0 ? Math.round((S.correct / S.total) * 100) : 0;
728
+ modalScoreBig.textContent = `${S.correct}/${S.total}`;
729
+ modalScoreLabel.textContent = S.total === 1 ? 'question answered' : 'questions answered';
730
+
731
+ const msgs = pct >= 90
732
+ ? ["Outstanding! You've mastered this material. 🌟", "Incroyable ! Tu maîtrises ce cours. 🌟"]
733
+ : pct >= 70
734
+ ? ["Great work — you have a solid grasp of this content. 💪", "Bon travail — tu maîtrises bien le contenu. 💪"]
735
+ : pct >= 50
736
+ ? ["Good start! Keep practising the tricky sections. 📖", "Bon début ! Continue à pratiquer les parties difficiles. 📖"]
737
+ : ["Keep going — each question makes you stronger. 🔥", "Continue — chaque question te rend plus fort. 🔥"];
738
+ modalMessage.textContent = S.language === 'Français' ? msgs[1] : msgs[0];
739
+
740
+ // History list (last 8 items)
741
+ const recent = S.history.slice(-8);
742
+ if (recent.length > 0) {
743
+ modalHistory.classList.remove('hidden');
744
+ modalHistory.innerHTML = recent.map(h => {
745
+ const vc = h.verdict === 'correct' ? 'correct'
746
+ : h.verdict === 'partial' ? 'partial' : 'incorrect';
747
+ const vl = h.verdict === 'correct' ? '✓'
748
+ : h.verdict === 'partial' ? '~' : '✗';
749
+ return `<div class="modal-history-item">
750
+ <span class="hist-badge ${vc}">${vl}</span>
751
+ <span class="hist-q">${h.question}</span>
752
+ </div>`;
753
+ }).join('');
754
+ }
755
+
756
+ modal.classList.remove('hidden');
757
+ }
758
+
759
+ // ───────────────────────────────────────────────────────────────────────────
760
+ // File handling
761
+ // ───────────────────────────────────────────────────────────────────────────
762
+ function handleFile(file) {
763
+ if (!file || !file.name.toLowerCase().endsWith('.pdf')) {
764
+ setStatus('Please select a PDF file.', 'error');
765
+ return;
766
+ }
767
+ S.selectedFile = file;
768
+ fileNameEl.textContent = '📄 ' + file.name;
769
+ fileNameEl.classList.remove('hidden');
770
+ dropTitle.textContent = 'PDF selected';
771
+ uploadIcon.textContent = '✅';
772
+ dropZone.classList.add('has-file');
773
+ loadBtn.disabled = false;
774
+ clearStatus();
775
+ }
776
+
777
+ dropZone.addEventListener('dragover', e => {
778
+ e.preventDefault();
779
+ dropZone.classList.add('drag-over');
780
+ });
781
+ dropZone.addEventListener('dragleave', () => dropZone.classList.remove('drag-over'));
782
+ dropZone.addEventListener('drop', e => {
783
+ e.preventDefault();
784
+ dropZone.classList.remove('drag-over');
785
+ const file = e.dataTransfer.files[0];
786
+ if (file) { fileInput.files = e.dataTransfer.files; handleFile(file); }
787
+ });
788
+ dropZone.addEventListener('click', () => fileInput.click());
789
+ fileInput.addEventListener('change', () => {
790
+ if (fileInput.files[0]) handleFile(fileInput.files[0]);
791
+ });
792
+
793
+ // ───────────────────────────────────────────────────────────────────────────
794
+ // Load PDF
795
+ // ───────────────────────────────────────────────────────────────────────────
796
+ loadBtn.addEventListener('click', async () => {
797
+ if (!S.selectedFile) return;
798
+ setLoadingBtn(loadBtn, loadBtnText, loadSpinner, true, 'Loading…');
799
+ setStatus('Parsing PDF…', 'info');
800
+ try {
801
+ const data = await apiLoad(S.selectedFile);
802
+ S.chunks = data.chunks;
803
+ S.loaded = true;
804
+ S.correct = 0; S.total = 0; S.history = [];
805
+ updateScore();
806
+ setStatus(`✓ ${data.count} sections extracted — ready to quiz!`, 'ok');
807
+ quizCard.classList.remove('hidden');
808
+ quizCard.scrollIntoView({ behavior: 'smooth', block: 'start' });
809
+ } catch (err) {
810
+ setStatus(err.message, 'error');
811
+ } finally {
812
+ setLoadingBtn(loadBtn, loadBtnText, loadSpinner, false, 'Load PDF');
813
+ }
814
+ });
815
+
816
+ // ───────────────────────────────────────────────────────────────────────────
817
+ // Language / difficulty toggles
818
+ // ───────────────────────────────────────────────────────────────────────────
819
+ function setupToggle(groupId, stateKey) {
820
+ const group = $(groupId);
821
+ group.querySelectorAll('.pill').forEach(btn => {
822
+ btn.addEventListener('click', () => {
823
+ group.querySelectorAll('.pill').forEach(b => b.classList.remove('active'));
824
+ btn.classList.add('active');
825
+ S[stateKey] = btn.dataset.val;
826
+ });
827
+ });
828
+ }
829
+ setupToggle('lang-toggle', 'language');
830
+ setupToggle('diff-toggle', 'difficulty');
831
+
832
+ // ───────────────────────────────────────────────────────────────────────────
833
+ // Generate question
834
+ // ───────────────────────────────────────────────────────────────────────────
835
+ async function fetchQuestion() {
836
+ if (!S.chunks.length) return;
837
+
838
+ // Switch from empty state to question area on first use
839
+ if (emptyState && !emptyState.classList.contains('hidden')) {
840
+ emptyState.classList.add('hidden');
841
+ questionArea.classList.remove('hidden');
842
+ endBtn.classList.remove('hidden');
843
+ }
844
+
845
+ // Show loading inside question box
846
+ qText.textContent = '';
847
+ qLoading.classList.remove('hidden');
848
+ qLoadingMsg.textContent = S.total === 0
849
+ ? 'Generating question (model loading, ~30s first time)…'
850
+ : 'Generating question…';
851
+
852
+ answerInput.value = '';
853
+ charCount.textContent = '0 chars';
854
+ submitBtn.disabled = true;
855
+ feedbackCard.classList.add('hidden');
856
+ newQBtn.disabled = true;
857
+
858
+ try {
859
+ const chunk = S.chunks[Math.floor(Math.random() * S.chunks.length)];
860
+ S.currentChunk = chunk;
861
+ const data = await apiQuestion(chunk, S.language, S.difficulty);
862
+ S.currentQuestion = data.question;
863
+ qText.textContent = data.question;
864
+ } catch (err) {
865
+ qText.textContent = '⚠ ' + err.message;
866
+ } finally {
867
+ qLoading.classList.add('hidden');
868
+ newQBtn.disabled = false;
869
+ }
870
+ }
871
+
872
+ startBtn.addEventListener('click', fetchQuestion);
873
+ newQBtn.addEventListener('click', fetchQuestion);
874
+
875
+ // ───────────────────────────────────────────────────────────────────────────
876
+ // Answer input
877
+ // ───────────────────────────────────────────────────────────────────────────
878
+ answerInput.addEventListener('input', () => {
879
+ const len = answerInput.value.trim().length;
880
+ charCount.textContent = `${answerInput.value.length} chars`;
881
+ submitBtn.disabled = len === 0 || !S.currentQuestion;
882
+ });
883
+
884
+ // ───────────────────────────────────────────────────────────────────────────
885
+ // Submit answer
886
+ // ───────────────────────────────────────────────────────────────────────────
887
+ submitBtn.addEventListener('click', async () => {
888
+ const answer = answerInput.value.trim();
889
+ if (!answer || !S.currentQuestion) return;
890
+
891
+ setLoadingBtn(submitBtn, submitText, submitSpinner, true, 'Evaluating…');
892
+ newQBtn.disabled = true;
893
+
894
+ try {
895
+ const data = await apiEvaluate(
896
+ S.currentQuestion, S.currentChunk, answer, S.language
897
+ );
898
+ S.total++;
899
+ const verdict = showFeedback(data.feedback, S.currentChunk);
900
+ if (verdict === 'correct') S.correct++;
901
+ S.history.push({ question: S.currentQuestion, verdict });
902
+ updateScore();
903
+ } catch (err) {
904
+ feedbackBody.innerHTML = `<span style="color:var(--red)">⚠ ${err.message}</span>`;
905
+ verdictBadge.className = 'verdict-badge';
906
+ verdictBadge.textContent = 'Error';
907
+ feedbackCard.classList.remove('hidden');
908
+ } finally {
909
+ setLoadingBtn(submitBtn, submitText, submitSpinner, false, 'Submit Answer');
910
+ newQBtn.disabled = false;
911
+ }
912
+ });
913
+
914
+ // ───────────────────────────────────────────────────────────────────────────
915
+ // End session
916
+ // ───────────────────────────────────────────────────────────────────────────
917
+ endBtn.addEventListener('click', () => {
918
+ if (S.total === 0) {
919
+ modal.classList.remove('hidden');
920
+ } else {
921
+ showModal();
922
+ }
923
+ });
924
+
925
+ modalClose.addEventListener('click', () => {
926
+ modal.classList.add('hidden');
927
+ // Reset session
928
+ S.correct = 0; S.total = 0; S.history = [];
929
+ S.currentQuestion = ''; S.currentChunk = '';
930
+ updateScore();
931
+ answerInput.value = '';
932
+ charCount.textContent = '0 chars';
933
+ submitBtn.disabled = true;
934
+ feedbackCard.classList.add('hidden');
935
+ emptyState.classList.remove('hidden');
936
+ questionArea.classList.add('hidden');
937
+ endBtn.classList.add('hidden');
938
+ quizCard.scrollIntoView({ behavior: 'smooth', block: 'start' });
939
+ });
940
+
941
+ modal.addEventListener('click', e => {
942
+ if (e.target === modal) modal.classList.add('hidden');
943
+ });
944
+
945
+ // ───────────────────────────────────────────────────────────────────────────
946
+ // Particles
947
+ // ───────────────────────────────────────────────────────────────────────────
948
+ (function initParticles() {
949
+ const canvas = $('particles');
950
+ const ctx = canvas.getContext('2d');
951
+ let W, H, particles;
952
+
953
+ function resize() {
954
+ W = canvas.width = window.innerWidth;
955
+ H = canvas.height = window.innerHeight;
956
+ }
957
+ window.addEventListener('resize', resize);
958
+ resize();
959
+
960
+ function rand(a, b) { return a + Math.random() * (b - a); }
961
+
962
+ function makeParticle() {
963
+ return {
964
+ x: rand(0, W), y: rand(0, H),
965
+ vx: rand(-.25, .25), vy: rand(-.25, .25),
966
+ r: rand(1, 2.2),
967
+ hue: Math.random() > .5 ? '#7C3AED' : '#06B6D4',
968
+ alpha: rand(.15, .55),
969
+ };
970
+ }
971
+
972
+ function init() { particles = Array.from({ length: 65 }, makeParticle); }
973
+ init();
974
+
975
+ function draw() {
976
+ ctx.clearRect(0, 0, W, H);
977
+ for (let i = 0; i < particles.length; i++) {
978
+ const p = particles[i];
979
+ p.x += p.vx; p.y += p.vy;
980
+ if (p.x < -5) p.x = W + 5;
981
+ if (p.x > W + 5) p.x = -5;
982
+ if (p.y < -5) p.y = H + 5;
983
+ if (p.y > H + 5) p.y = -5;
984
+
985
+ // Connections
986
+ for (let j = i + 1; j < particles.length; j++) {
987
+ const q = particles[j];
988
+ const dx = p.x - q.x, dy = p.y - q.y;
989
+ const dist = Math.sqrt(dx*dx + dy*dy);
990
+ if (dist < 110) {
991
+ ctx.beginPath();
992
+ ctx.moveTo(p.x, p.y); ctx.lineTo(q.x, q.y);
993
+ const a = (1 - dist / 110) * 0.12;
994
+ ctx.strokeStyle = `rgba(124,58,237,${a})`;
995
+ ctx.lineWidth = 1;
996
+ ctx.stroke();
997
+ }
998
+ }
999
+
1000
+ // Dot
1001
+ ctx.beginPath();
1002
+ ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2);
1003
+ const hex = p.hue === '#7C3AED' ? '124,58,237' : '6,182,212';
1004
+ ctx.fillStyle = `rgba(${hex},${p.alpha})`;
1005
+ ctx.fill();
1006
+ }
1007
+ requestAnimationFrame(draw);
1008
+ }
1009
+ draw();
1010
+ })();
1011
+ </script>
1012
+ </body>
1013
+ </html>