Wckd314 commited on
Commit
fd1afd0
·
verified ·
1 Parent(s): 9dc8ead

Upload 7 files

Browse files
static/index.html ADDED
@@ -0,0 +1,142 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+
4
+ <head>
5
+ <meta charset="UTF-8">
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
7
+ <title>Pundit Feynman — Research Paper to Code</title>
8
+ <link rel="stylesheet" href="/style.css">
9
+ <link
10
+ href="https://fonts.googleapis.com/css2?family=Playfair+Display:wght@400;600;700&family=JetBrains+Mono:wght@400;500&display=swap"
11
+ rel="stylesheet">
12
+ </head>
13
+
14
+ <body>
15
+ <!-- Left Panel: Upload & Status -->
16
+ <aside class="left-panel" id="left-panel">
17
+ <div class="panel-inner">
18
+ <header>
19
+ <h1>Pundit Feynman</h1>
20
+ <p class="tagline">Upload a research paper.<br>Learn it the Feynman way.</p>
21
+ <button id="visualize-btn" class="header-visualize hidden" style="display: none !important;">🎨
22
+ Visualize Concept</button>
23
+ </header>
24
+
25
+ <!-- Upload State -->
26
+ <div id="upload-section">
27
+ <div id="drop-zone" class="drop-zone">
28
+ <svg class="upload-icon" width="32" height="32" viewBox="0 0 24 24" fill="none"
29
+ stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
30
+ <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
31
+ <polyline points="17 8 12 3 7 8"></polyline>
32
+ <line x1="12" y1="3" x2="12" y2="15"></line>
33
+ </svg>
34
+ <p class="drop-text">Drop your PDF here</p>
35
+ <span class="drop-hint">or click to browse</span>
36
+ <input type="file" id="file-input" accept="application/pdf" hidden>
37
+ </div>
38
+
39
+ <!-- Divider -->
40
+ <div class="divider">
41
+ <span>or paste arXiv link</span>
42
+ </div>
43
+
44
+ <!-- arXiv URL Input -->
45
+ <div class="arxiv-input-row">
46
+ <input type="text" id="arxiv-input" class="arxiv-input"
47
+ placeholder="https://arxiv.org/abs/2401.12345">
48
+ <button id="arxiv-btn" class="btn btn-primary arxiv-btn">Go →</button>
49
+ </div>
50
+ </div>
51
+
52
+ <!-- Extraction Progress -->
53
+ <div id="extract-status" class="status-box hidden">
54
+ <div class="spinner"></div>
55
+ <p class="status-label" id="extract-label">Analyzing paper…</p>
56
+ <p class="status-sub">This may take a few minutes for long papers.</p>
57
+ </div>
58
+
59
+ <!-- Stream Active Indicator -->
60
+ <div id="stream-status" class="status-box hidden">
61
+ <div class="pulse-dot"></div>
62
+ <p class="status-label">Generating code live…</p>
63
+ <p class="status-sub">Watch the output in the code viewer →</p>
64
+ </div>
65
+
66
+ <!-- Done -->
67
+ <div id="done-section" class="status-box hidden">
68
+ <p class="done-check">✓</p>
69
+ <p class="status-label">Generation complete</p>
70
+ <div class="btn-row">
71
+ <a id="download-btn" class="btn btn-primary">⬇ Download .ipynb</a>
72
+ <button id="reset-btn" class="btn btn-secondary">↻ New Paper</button>
73
+ </div>
74
+ </div>
75
+
76
+ <!-- Error -->
77
+ <div id="error-section" class="status-box hidden">
78
+ <p class="error-x">✕</p>
79
+ <p class="status-label">Something went wrong</p>
80
+ <p class="status-sub" id="error-text"></p>
81
+ <button id="error-reset-btn" class="btn btn-secondary">↻ Try Again</button>
82
+ </div>
83
+
84
+ <footer>
85
+ <p>Powered by <strong>NVIDIA NIM</strong></p>
86
+ <div class="feedback-footer">
87
+ <p>please give feedback, so that i can make it better</p>
88
+ <a href="https://mail.google.com/mail/?view=cm&to=Avijitshil52460@gmail.com&su=Pundit%20Feynman%20Feedback"
89
+ target="_blank" class="feedback-link">Avijitshil52460@gmail.com</a>
90
+ </div>
91
+ </footer>
92
+ </div>
93
+ </aside>
94
+
95
+ <!-- Right Panel: Live Code Viewer -->
96
+ <main class="right-panel" id="right-panel">
97
+ <div class="code-header">
98
+ <span class="code-title">Code Output</span>
99
+ <span class="code-badge" id="code-badge">waiting</span>
100
+ </div>
101
+ <pre class="code-viewer"
102
+ id="code-viewer"><code id="code-output">// Upload a paper to see the generated code here…</code></pre>
103
+ </main>
104
+
105
+ <script src="/script.js"></script>
106
+
107
+ <!-- Floating Image Window (Hidden) -->
108
+ <div id="image-float" class="float-window hidden" style="display: none !important;">
109
+ <div class="float-header" id="float-header">
110
+ <span class="float-title">🎨 Concept Illustration</span>
111
+ <div class="float-actions">
112
+ <button id="float-download" class="float-btn" title="Download PNG">
113
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
114
+ stroke-linecap="round" stroke-linejoin="round">
115
+ <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
116
+ <polyline points="7 10 12 15 17 10" />
117
+ <line x1="12" y1="15" x2="12" y2="3" />
118
+ </svg>
119
+ </button>
120
+ <button id="float-minimize" class="float-btn" title="Minimize">─</button>
121
+ <button id="float-close" class="float-btn" title="Close">✕</button>
122
+ </div>
123
+ </div>
124
+ <div class="float-body" id="float-body">
125
+ <div class="float-spinner" id="float-spinner">
126
+ <div class="paint-brush-container">
127
+ <div class="brush">🖌️</div>
128
+ <div class="shimmer-line"></div>
129
+ </div>
130
+ <p id="visualize-status">FLUX is painting your concept…</p>
131
+ </div>
132
+ <img id="float-image" class="float-image hidden" alt="Concept Illustration" />
133
+ </div>
134
+ </div>
135
+
136
+ <!-- Minimized Pill (Hidden) -->
137
+ <div id="image-pill" class="float-pill hidden" style="display: none !important;">
138
+ <span>🎨 Illustration</span>
139
+ </div>
140
+ </body>
141
+
142
+ </html>
static/script.js ADDED
@@ -0,0 +1,346 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // ── DOM Refs ──
2
+ const dropZone = document.getElementById('drop-zone');
3
+ const fileInput = document.getElementById('file-input');
4
+ const uploadSection = document.getElementById('upload-section');
5
+ const extractStatus = document.getElementById('extract-status');
6
+ const extractLabel = document.getElementById('extract-label');
7
+ const streamStatus = document.getElementById('stream-status');
8
+ const doneSection = document.getElementById('done-section');
9
+ const errorSection = document.getElementById('error-section');
10
+ const errorText = document.getElementById('error-text');
11
+ const downloadBtn = document.getElementById('download-btn');
12
+ const resetBtn = document.getElementById('reset-btn');
13
+ const errorResetBtn = document.getElementById('error-reset-btn');
14
+ const codeOutput = document.getElementById('code-output');
15
+ const codeViewer = document.getElementById('code-viewer');
16
+ const codeBadge = document.getElementById('code-badge');
17
+ const arxivInput = document.getElementById('arxiv-input');
18
+ const arxivBtn = document.getElementById('arxiv-btn');
19
+ const visualizeBtn = document.getElementById('visualize-btn');
20
+ const imageFloat = document.getElementById('image-float');
21
+ const imagePill = document.getElementById('image-pill');
22
+ const floatHeader = document.getElementById('float-header');
23
+ const floatImage = document.getElementById('float-image');
24
+ const floatSpinner = document.getElementById('float-spinner');
25
+ const floatDownload = document.getElementById('float-download');
26
+ const floatMinimize = document.getElementById('float-minimize');
27
+ console.log('🚀 Pundit Feynman Script Loaded [v2.1]');
28
+ console.log('🎨 Visualize Button found:', !!visualizeBtn);
29
+
30
+ window.onerror = function (msg, url, lineNo, columnNo, error) {
31
+ alert(`JS Error: ${msg}\nLine: ${lineNo}\nCheck browser console!`);
32
+ return false;
33
+ };
34
+
35
+ // Test backend connectivity
36
+ fetch('/api/ping').then(r => r.json()).then(d => console.log('🏓 Backend connectivity:', d.status)).catch(e => console.error('❌ Backend UNREACHABLE:', e));
37
+
38
+ // ── Visual Illustration State ──
39
+ let currentJobId = null;
40
+ window._debugJobId = () => currentJobId; // Access via console: window._debugJobId()
41
+
42
+ // ── State Manager ──
43
+ function showSection(section) {
44
+ [uploadSection, extractStatus, streamStatus, doneSection, errorSection]
45
+ .forEach(el => el.classList.add('hidden'));
46
+ if (section) section.classList.remove('hidden');
47
+ }
48
+
49
+ // ── Drag & Drop ──
50
+ dropZone.addEventListener('click', () => fileInput.click());
51
+
52
+ dropZone.addEventListener('dragover', (e) => {
53
+ e.preventDefault();
54
+ dropZone.classList.add('drag-over');
55
+ });
56
+
57
+ dropZone.addEventListener('dragleave', () => dropZone.classList.remove('drag-over'));
58
+
59
+ dropZone.addEventListener('drop', (e) => {
60
+ e.preventDefault();
61
+ dropZone.classList.remove('drag-over');
62
+ if (e.dataTransfer.files.length > 0) handleUpload(e.dataTransfer.files[0]);
63
+ });
64
+
65
+ fileInput.addEventListener('change', (e) => {
66
+ if (e.target.files.length > 0) handleUpload(e.target.files[0]);
67
+ });
68
+
69
+ // ── arXiv URL Handler ──
70
+ arxivBtn.addEventListener('click', () => handleArxiv());
71
+ arxivInput.addEventListener('keydown', (e) => {
72
+ if (e.key === 'Enter') handleArxiv();
73
+ });
74
+
75
+ async function handleArxiv() {
76
+ const url = arxivInput.value.trim();
77
+ if (!url) return;
78
+ if (!url.includes('arxiv.org')) {
79
+ alert('Please enter a valid arXiv URL (e.g. https://arxiv.org/abs/2401.12345)');
80
+ return;
81
+ }
82
+
83
+ showSection(extractStatus);
84
+ extractLabel.textContent = 'Downloading & analyzing arXiv paper…';
85
+ codeOutput.textContent = '// Downloading PDF from arXiv…';
86
+ codeBadge.textContent = 'extracting';
87
+ codeBadge.className = 'code-badge';
88
+
89
+ try {
90
+ const res = await fetch('/api/extract-arxiv', {
91
+ method: 'POST',
92
+ headers: { 'Content-Type': 'application/json' },
93
+ body: JSON.stringify({ url })
94
+ });
95
+
96
+ if (!res.ok) {
97
+ const err = await res.json().catch(() => ({ detail: 'arXiv extraction failed' }));
98
+ throw new Error(err.detail || 'arXiv extraction failed');
99
+ }
100
+
101
+ const data = await res.json();
102
+ console.log('arXiv extraction complete:', data);
103
+ startStream(data.job_id);
104
+
105
+ } catch (err) {
106
+ showError(err.message);
107
+ }
108
+ }
109
+
110
+ // ── Upload & Extract (Step 1) ──
111
+ async function handleUpload(file) {
112
+ if (!file.name.toLowerCase().endsWith('.pdf')) {
113
+ alert('Please upload a PDF file.');
114
+ return;
115
+ }
116
+
117
+ // Show extraction spinner
118
+ showSection(extractStatus);
119
+ extractLabel.textContent = 'Uploading & analyzing paper…';
120
+ codeOutput.textContent = '// Waiting for paper analysis to complete…';
121
+ codeBadge.textContent = 'extracting';
122
+ codeBadge.className = 'code-badge';
123
+
124
+ const formData = new FormData();
125
+ formData.append('file', file);
126
+
127
+ try {
128
+ const res = await fetch('/api/extract', {
129
+ method: 'POST',
130
+ body: formData
131
+ });
132
+
133
+ if (!res.ok) {
134
+ const err = await res.json().catch(() => ({ detail: 'Extraction failed' }));
135
+ throw new Error(err.detail || 'Extraction failed');
136
+ }
137
+
138
+ const data = await res.json();
139
+ console.log('Extraction complete:', data);
140
+
141
+ // Hide visualize button from previous run if any
142
+ visualizeBtn.classList.add('hidden');
143
+
144
+ // Start streaming (Step 2)
145
+ startStream(data.job_id);
146
+
147
+ } catch (err) {
148
+ showError(err.message);
149
+ }
150
+ }
151
+
152
+ // ── Live Streaming (Step 2) ──
153
+ function startStream(jobId) {
154
+ currentJobId = jobId; // Store immediately
155
+ showSection(streamStatus);
156
+ codeOutput.textContent = '';
157
+ codeBadge.textContent = 'streaming';
158
+ codeBadge.className = 'code-badge streaming';
159
+
160
+ const source = new EventSource(`/api/generate_stream/${jobId}`);
161
+ let hasError = false;
162
+
163
+ source.onmessage = (event) => {
164
+ try {
165
+ const payload = JSON.parse(event.data);
166
+
167
+ if (payload.done) {
168
+ source.close();
169
+ if (payload.success) {
170
+ onStreamComplete(jobId);
171
+ } else {
172
+ // Pipeline finished but failed — show error state
173
+ showError('Pipeline failed to generate notebook. Check the code output panel for details.');
174
+ codeBadge.textContent = 'failed';
175
+ codeBadge.className = 'code-badge';
176
+ }
177
+ return;
178
+ }
179
+
180
+ if (payload.analysis_done) {
181
+ // Show visualize button early!
182
+ visualizeBtn.classList.remove('hidden');
183
+ return;
184
+ }
185
+
186
+ if (payload.text) {
187
+ // Check if it's an error message
188
+ if (payload.text.includes('❌')) {
189
+ hasError = true;
190
+ }
191
+ codeOutput.textContent += payload.text;
192
+ // Auto-scroll to bottom
193
+ codeViewer.scrollTop = codeViewer.scrollHeight;
194
+ }
195
+ } catch (e) {
196
+ console.error('Parse error:', e);
197
+ }
198
+ };
199
+
200
+ source.onerror = (err) => {
201
+ console.error('SSE error:', err);
202
+ source.close();
203
+ showError('Stream connection lost. Please try again.');
204
+ };
205
+ }
206
+
207
+ function onStreamComplete(jobId) {
208
+ showSection(doneSection);
209
+ downloadBtn.href = `/api/download/${jobId}`;
210
+ currentJobId = jobId; // Store for visualization
211
+ codeBadge.textContent = 'complete';
212
+ codeBadge.className = 'code-badge done';
213
+ }
214
+
215
+ // ── Visual Illustration Logic ──
216
+
217
+ visualizeBtn.addEventListener('click', async (e) => {
218
+ console.log('🖱️ Visualize button CLICKED. Event object:', e);
219
+
220
+ if (!currentJobId) {
221
+ console.error('❌ Cannot visualize: currentJobId is null');
222
+ alert('Software Error: Job ID not captured yet. Please wait for analysis or refresh.');
223
+ return;
224
+ }
225
+
226
+ console.log('🎨 Requesting visualization for Job:', currentJobId);
227
+
228
+ // Disable button to prevent double-clicks
229
+ visualizeBtn.disabled = true;
230
+ const originalText = visualizeBtn.textContent;
231
+ visualizeBtn.textContent = '🎨 Painting...';
232
+
233
+ // Show float UI
234
+ imageFloat.classList.remove('hidden');
235
+ imagePill.classList.add('hidden');
236
+ floatImage.classList.add('hidden');
237
+ floatSpinner.classList.remove('hidden');
238
+
239
+ try {
240
+ const url = `/api/visualize/${currentJobId}`;
241
+ console.log('🌐 Fetching:', url);
242
+
243
+ const res = await fetch(url, { method: 'POST' });
244
+ console.log('📥 Response status:', res.status);
245
+
246
+ if (!res.ok) {
247
+ const errDetail = await res.json().catch(() => ({ detail: 'Network error' }));
248
+ throw new Error(errDetail.detail || `Server error ${res.status}`);
249
+ }
250
+
251
+ const data = await res.json();
252
+ console.log('🖼️ Image received! Length:', data.image.length);
253
+
254
+ floatImage.src = data.image;
255
+ floatImage.classList.remove('hidden');
256
+ floatSpinner.classList.add('hidden');
257
+ } catch (err) {
258
+ console.error('❌ Visualization flow error:', err);
259
+ alert(`Painting failed: ${err.message}`);
260
+ imageFloat.classList.add('hidden');
261
+ } finally {
262
+ visualizeBtn.disabled = false;
263
+ visualizeBtn.textContent = originalText;
264
+ console.log('🏁 Visualize flow completed.');
265
+ }
266
+ });
267
+
268
+ // Drag Logic
269
+ let isDragging = false;
270
+ let startX, startY, initialX, initialY;
271
+
272
+ floatHeader.addEventListener('mousedown', (e) => {
273
+ isDragging = true;
274
+ startX = e.clientX;
275
+ startY = e.clientY;
276
+ initialX = imageFloat.offsetLeft;
277
+ initialY = imageFloat.offsetTop;
278
+ imageFloat.style.transition = 'none';
279
+ });
280
+
281
+ document.addEventListener('mousemove', (e) => {
282
+ if (!isDragging) return;
283
+ const dx = e.clientX - startX;
284
+ const dy = e.clientY - startY;
285
+ imageFloat.style.left = (initialX + dx) + 'px';
286
+ imageFloat.style.top = (initialY + dy) + 'px';
287
+ imageFloat.style.bottom = 'auto'; // Remove fixed positioning
288
+ imageFloat.style.right = 'auto';
289
+ });
290
+
291
+ document.addEventListener('mouseup', () => {
292
+ isDragging = false;
293
+ imageFloat.style.transition = '';
294
+ });
295
+
296
+ // Minimize/Close/Download
297
+ floatMinimize.addEventListener('click', () => {
298
+ imageFloat.classList.add('hidden');
299
+ imagePill.classList.remove('hidden');
300
+ });
301
+
302
+ imagePill.addEventListener('click', () => {
303
+ imageFloat.classList.remove('hidden');
304
+ imagePill.classList.add('hidden');
305
+ });
306
+
307
+ floatClose.addEventListener('click', () => {
308
+ imageFloat.classList.add('hidden');
309
+ imagePill.classList.add('hidden');
310
+ });
311
+
312
+ floatDownload.addEventListener('click', () => {
313
+ if (!floatImage.src) return;
314
+ const link = document.createElement('a');
315
+ link.href = floatImage.src;
316
+ link.download = `pundit_feynman_illustration_${currentJobId}.png`;
317
+ link.click();
318
+ });
319
+
320
+ // ── Error & Reset ──
321
+ function showError(msg) {
322
+ showSection(errorSection);
323
+ errorText.textContent = msg;
324
+ codeBadge.textContent = 'error';
325
+ codeBadge.className = 'code-badge';
326
+ // Cleanup float on error
327
+ imageFloat.classList.add('hidden');
328
+ imagePill.classList.add('hidden');
329
+ }
330
+
331
+ function resetUI() {
332
+ showSection(uploadSection);
333
+ fileInput.value = '';
334
+ arxivInput.value = '';
335
+ codeOutput.textContent = '// Upload a paper to see the generated code here…';
336
+ codeBadge.textContent = 'waiting';
337
+ codeBadge.className = 'code-badge';
338
+ currentJobId = null;
339
+ visualizeBtn.classList.add('hidden');
340
+ // Cleanup float on reset
341
+ imageFloat.classList.add('hidden');
342
+ imagePill.classList.add('hidden');
343
+ }
344
+
345
+ resetBtn.addEventListener('click', resetUI);
346
+ errorResetBtn.addEventListener('click', resetUI);
static/style.css ADDED
@@ -0,0 +1,609 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* ── Reset & Base ── */
2
+ *,
3
+ *::before,
4
+ *::after {
5
+ margin: 0;
6
+ padding: 0;
7
+ box-sizing: border-box;
8
+ }
9
+
10
+ :root {
11
+ --bg: #f5f0e8;
12
+ --bg-deep: #ebe4d6;
13
+ --text: #2c2417;
14
+ --text-muted: #7a6e5d;
15
+ --accent: #8b6914;
16
+ --accent-soft: #c9a84c;
17
+ --border: #d4cbb8;
18
+ --code-bg: #1e1e2e;
19
+ --code-text: #cdd6f4;
20
+ --code-accent: #89b4fa;
21
+ --panel-shadow: 0 0 40px rgba(0, 0, 0, 0.06);
22
+ }
23
+
24
+ html,
25
+ body {
26
+ height: 100%;
27
+ overflow: hidden;
28
+ }
29
+
30
+ body {
31
+ font-family: 'Times New Roman', 'Playfair Display', Georgia, serif;
32
+ background: var(--bg);
33
+ color: var(--text);
34
+ display: flex;
35
+ }
36
+
37
+ /* ── Left Panel ── */
38
+ .left-panel {
39
+ width: 380px;
40
+ min-width: 380px;
41
+ height: 100vh;
42
+ background: var(--bg);
43
+ border-right: 1px solid var(--border);
44
+ display: flex;
45
+ flex-direction: column;
46
+ overflow-y: auto;
47
+ }
48
+
49
+ .panel-inner {
50
+ padding: 40px 32px 24px;
51
+ flex: 1;
52
+ display: flex;
53
+ flex-direction: column;
54
+ }
55
+
56
+ /* ── Header ── */
57
+ header {
58
+ margin-bottom: 32px;
59
+ }
60
+
61
+ header h1 {
62
+ font-family: 'Playfair Display', Georgia, serif;
63
+ font-size: 2rem;
64
+ font-weight: 700;
65
+ color: var(--accent);
66
+ margin-bottom: 8px;
67
+ letter-spacing: -0.02em;
68
+ }
69
+
70
+ .header-visualize {
71
+ display: inline-block;
72
+ margin-top: 16px;
73
+ background: transparent;
74
+ border: 1px solid #6b4226;
75
+ color: #6b4226;
76
+ padding: 8px 16px;
77
+ border-radius: 20px;
78
+ font-family: 'Times New Roman', serif;
79
+ font-size: 1rem;
80
+ font-weight: 600;
81
+ cursor: pointer;
82
+ transition: all 0.2s;
83
+ }
84
+
85
+ .header-visualize:hover {
86
+ background: #6b4226;
87
+ color: #fff;
88
+ }
89
+
90
+ .tagline {
91
+ font-size: 0.95rem;
92
+ color: var(--text-muted);
93
+ line-height: 1.5;
94
+ }
95
+
96
+ /* ── Drop Zone ── */
97
+ .drop-zone {
98
+ border: 2px dashed var(--border);
99
+ border-radius: 12px;
100
+ padding: 36px 24px;
101
+ text-align: center;
102
+ cursor: pointer;
103
+ transition: all 0.25s ease;
104
+ background: var(--bg-deep);
105
+ }
106
+
107
+ .drop-zone:hover,
108
+ .drop-zone.drag-over {
109
+ border-color: var(--accent);
110
+ background: rgba(139, 105, 20, 0.06);
111
+ }
112
+
113
+ .upload-icon {
114
+ color: var(--accent-soft);
115
+ margin-bottom: 12px;
116
+ opacity: 0.8;
117
+ }
118
+
119
+ .drop-text {
120
+ font-size: 1rem;
121
+ font-weight: 600;
122
+ margin-bottom: 4px;
123
+ color: var(--text);
124
+ }
125
+
126
+ .drop-hint {
127
+ font-size: 0.85rem;
128
+ color: var(--text-muted);
129
+ }
130
+
131
+ /* ── Divider & arXiv Input ── */
132
+ .divider {
133
+ display: flex;
134
+ align-items: center;
135
+ gap: 12px;
136
+ margin: 16px 0;
137
+ }
138
+
139
+ .divider::before,
140
+ .divider::after {
141
+ content: '';
142
+ flex: 1;
143
+ height: 1px;
144
+ background: var(--border);
145
+ }
146
+
147
+ .divider span {
148
+ font-size: 0.78rem;
149
+ color: var(--text-muted);
150
+ white-space: nowrap;
151
+ letter-spacing: 0.02em;
152
+ }
153
+
154
+ .arxiv-input-row {
155
+ display: flex;
156
+ gap: 8px;
157
+ }
158
+
159
+ .arxiv-input {
160
+ flex: 1;
161
+ padding: 14px 16px;
162
+ border: 1.5px solid var(--border);
163
+ border-radius: 8px;
164
+ background: var(--bg);
165
+ font-family: 'Times New Roman', Georgia, serif;
166
+ font-size: 0.9rem;
167
+ color: var(--text);
168
+ outline: none;
169
+ transition: border-color 0.2s ease;
170
+ }
171
+
172
+ .arxiv-input:focus {
173
+ border-color: var(--accent);
174
+ background: #fff;
175
+ }
176
+
177
+ .arxiv-input::placeholder {
178
+ color: var(--text-muted);
179
+ opacity: 0.6;
180
+ }
181
+
182
+ .arxiv-btn {
183
+ padding: 14px 20px;
184
+ font-size: 0.85rem;
185
+ white-space: nowrap;
186
+ font-family: 'Times New Roman', Georgia, serif;
187
+ }
188
+
189
+ /* ── Status Boxes ── */
190
+ .status-box {
191
+ text-align: center;
192
+ padding: 32px 0;
193
+ }
194
+
195
+ .spinner {
196
+ width: 28px;
197
+ height: 28px;
198
+ border: 2.5px solid var(--border);
199
+ border-top-color: var(--accent);
200
+ border-radius: 50%;
201
+ margin: 0 auto 16px;
202
+ animation: spin 0.7s linear infinite;
203
+ }
204
+
205
+ @keyframes spin {
206
+ to {
207
+ transform: rotate(360deg);
208
+ }
209
+ }
210
+
211
+ .pulse-dot {
212
+ width: 12px;
213
+ height: 12px;
214
+ background: #22c55e;
215
+ border-radius: 50%;
216
+ margin: 0 auto 16px;
217
+ animation: pulse 1.5s ease-in-out infinite;
218
+ }
219
+
220
+ @keyframes pulse {
221
+
222
+ 0%,
223
+ 100% {
224
+ opacity: 1;
225
+ transform: scale(1);
226
+ }
227
+
228
+ 50% {
229
+ opacity: 0.5;
230
+ transform: scale(1.3);
231
+ }
232
+ }
233
+
234
+ .status-label {
235
+ font-family: 'Times New Roman', Georgia, serif;
236
+ font-size: 0.9rem;
237
+ font-weight: 600;
238
+ color: var(--text);
239
+ margin-bottom: 6px;
240
+ text-transform: uppercase;
241
+ letter-spacing: 0.05em;
242
+ }
243
+
244
+ .status-sub {
245
+ font-family: 'Times New Roman', Georgia, serif;
246
+ font-size: 0.82rem;
247
+ color: var(--text-muted);
248
+ line-height: 1.4;
249
+ }
250
+
251
+ .done-check {
252
+ font-size: 2rem;
253
+ color: #22c55e;
254
+ margin-bottom: 8px;
255
+ }
256
+
257
+ .error-x {
258
+ font-size: 2rem;
259
+ color: #ef4444;
260
+ margin-bottom: 8px;
261
+ }
262
+
263
+ /* ── Buttons ── */
264
+ .btn-row {
265
+ display: flex;
266
+ gap: 10px;
267
+ justify-content: center;
268
+ margin-top: 16px;
269
+ }
270
+
271
+ .btn {
272
+ display: inline-flex;
273
+ align-items: center;
274
+ gap: 6px;
275
+ padding: 10px 20px;
276
+ border-radius: 8px;
277
+ font-weight: 600;
278
+ font-size: 0.82rem;
279
+ cursor: pointer;
280
+ border: none;
281
+ text-decoration: none;
282
+ transition: all 0.2s ease;
283
+ font-family: 'Times New Roman', Georgia, serif;
284
+ }
285
+
286
+ .btn-primary {
287
+ background: var(--accent);
288
+ color: #fff;
289
+ }
290
+
291
+ .btn-primary:hover {
292
+ background: #6f5410;
293
+ transform: translateY(-1px);
294
+ }
295
+
296
+ .btn-secondary {
297
+ background: transparent;
298
+ color: var(--text);
299
+ border: 1px solid var(--border);
300
+ }
301
+
302
+ .btn-secondary:hover {
303
+ background: var(--bg-deep);
304
+ }
305
+
306
+ /* ── Footer ── */
307
+ footer {
308
+ margin-top: auto;
309
+ padding-top: 24px;
310
+ text-align: center;
311
+ }
312
+
313
+ footer p {
314
+ font-size: 0.72rem;
315
+ color: var(--text-muted);
316
+ }
317
+
318
+ footer strong {
319
+ color: var(--accent);
320
+ font-weight: 600;
321
+ }
322
+
323
+ /* ── Right Panel: Code Viewer ── */
324
+ .right-panel {
325
+ flex: 1;
326
+ height: 100vh;
327
+ background: var(--code-bg);
328
+ display: flex;
329
+ flex-direction: column;
330
+ overflow: hidden;
331
+ }
332
+
333
+ .code-header {
334
+ display: flex;
335
+ align-items: center;
336
+ justify-content: space-between;
337
+ padding: 14px 24px;
338
+ background: #181825;
339
+ border-bottom: 1px solid #313244;
340
+ }
341
+
342
+ .code-title {
343
+ font-family: 'JetBrains Mono', monospace;
344
+ font-size: 0.78rem;
345
+ color: #6c7086;
346
+ text-transform: uppercase;
347
+ letter-spacing: 0.08em;
348
+ }
349
+
350
+ .code-badge {
351
+ font-family: 'JetBrains Mono', monospace;
352
+ font-size: 0.68rem;
353
+ padding: 3px 10px;
354
+ border-radius: 20px;
355
+ background: #313244;
356
+ color: #6c7086;
357
+ text-transform: uppercase;
358
+ letter-spacing: 0.05em;
359
+ }
360
+
361
+ .code-badge.streaming {
362
+ background: rgba(34, 197, 94, 0.15);
363
+ color: #22c55e;
364
+ animation: pulse 1.5s ease-in-out infinite;
365
+ }
366
+
367
+ .code-badge.done {
368
+ background: rgba(34, 197, 94, 0.15);
369
+ color: #22c55e;
370
+ }
371
+
372
+ .code-viewer {
373
+ flex: 1;
374
+ overflow-y: auto;
375
+ padding: 24px;
376
+ margin: 0;
377
+ font-family: 'Times New Roman', Georgia, serif;
378
+ font-size: 0.95rem;
379
+ line-height: 1.8;
380
+ color: var(--code-text);
381
+ white-space: pre-wrap;
382
+ word-wrap: break-word;
383
+ scrollbar-width: thin;
384
+ scrollbar-color: #313244 transparent;
385
+ }
386
+
387
+ .code-viewer::-webkit-scrollbar {
388
+ width: 6px;
389
+ }
390
+
391
+ .code-viewer::-webkit-scrollbar-thumb {
392
+ background: #313244;
393
+ border-radius: 3px;
394
+ }
395
+
396
+ .code-viewer code {
397
+ font-family: inherit;
398
+ color: inherit;
399
+ }
400
+
401
+ /* Feedback Footer */
402
+ .feedback-footer {
403
+ margin-top: 16px;
404
+ padding-top: 16px;
405
+ border-top: 1px solid rgba(0, 0, 0, 0.08);
406
+ font-size: 0.95rem;
407
+ color: #6b4226;
408
+ line-height: 1.5;
409
+ }
410
+
411
+ .feedback-link {
412
+ display: inline-block;
413
+ margin-top: 6px;
414
+ color: #5a3318;
415
+ text-decoration: none;
416
+ font-size: 1.05rem;
417
+ font-weight: 700;
418
+ transition: opacity 0.2s;
419
+ }
420
+
421
+ .feedback-link:hover {
422
+ text-decoration: underline;
423
+ opacity: 0.8;
424
+ }
425
+
426
+ /* ── Floating Window ── */
427
+ .float-window {
428
+ position: fixed;
429
+ bottom: 24px;
430
+ right: 24px;
431
+ width: 400px;
432
+ background: #fff;
433
+ border-radius: 12px;
434
+ box-shadow: 0 10px 40px rgba(0, 0, 0, 0.15);
435
+ z-index: 1000;
436
+ overflow: hidden;
437
+ border: 1px solid rgba(0, 0, 0, 0.1);
438
+ display: flex;
439
+ flex-direction: column;
440
+ transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.3s;
441
+ }
442
+
443
+ .float-header {
444
+ background: #fdfaf6;
445
+ /* Beige header */
446
+ padding: 12px 16px;
447
+ border-bottom: 1px solid rgba(0, 0, 0, 0.05);
448
+ display: flex;
449
+ justify-content: space-between;
450
+ align-items: center;
451
+ cursor: move;
452
+ /* Indicate draggable */
453
+ user-select: none;
454
+ }
455
+
456
+ .float-title {
457
+ font-family: 'Playfair Display', serif;
458
+ font-weight: 700;
459
+ font-size: 0.9rem;
460
+ color: #6b4226;
461
+ }
462
+
463
+ .float-actions {
464
+ display: flex;
465
+ gap: 8px;
466
+ }
467
+
468
+ .float-btn {
469
+ background: transparent;
470
+ border: none;
471
+ color: #8b8b8b;
472
+ font-size: 1rem;
473
+ cursor: pointer;
474
+ width: 28px;
475
+ height: 28px;
476
+ display: flex;
477
+ align-items: center;
478
+ justify-content: center;
479
+ border-radius: 6px;
480
+ transition: all 0.2s;
481
+ }
482
+
483
+ .float-btn:hover {
484
+ background: rgba(0, 0, 0, 0.05);
485
+ color: #6b4226;
486
+ }
487
+
488
+ .float-body {
489
+ position: relative;
490
+ min-height: 200px;
491
+ max-height: 400px;
492
+ display: flex;
493
+ align-items: center;
494
+ justify-content: center;
495
+ background: #fafafa;
496
+ }
497
+
498
+ .float-image {
499
+ width: 100%;
500
+ height: auto;
501
+ display: block;
502
+ max-height: 400px;
503
+ object-fit: contain;
504
+ }
505
+
506
+ .float-spinner {
507
+ padding: 40px;
508
+ text-align: center;
509
+ color: #8b8b8b;
510
+ font-size: 0.85rem;
511
+ }
512
+
513
+ /* ── Paint Brush Loading ── */
514
+ .paint-brush-container {
515
+ position: relative;
516
+ width: 60px;
517
+ height: 60px;
518
+ margin: 0 auto 16px;
519
+ display: flex;
520
+ align-items: center;
521
+ justify-content: center;
522
+ }
523
+
524
+ .brush {
525
+ font-size: 32px;
526
+ z-index: 2;
527
+ animation: sweep 1.5s infinite ease-in-out;
528
+ transform-origin: bottom center;
529
+ }
530
+
531
+ @keyframes sweep {
532
+
533
+ 0%,
534
+ 100% {
535
+ transform: rotate(-15deg) translateX(-10px);
536
+ }
537
+
538
+ 50% {
539
+ transform: rotate(15deg) translateX(10px);
540
+ }
541
+ }
542
+
543
+ .shimmer-line {
544
+ position: absolute;
545
+ bottom: 10px;
546
+ width: 40px;
547
+ height: 4px;
548
+ background: var(--accent-soft);
549
+ border-radius: 2px;
550
+ opacity: 0.3;
551
+ animation: paint-shimmer 1.5s infinite ease-in-out;
552
+ }
553
+
554
+ @keyframes paint-shimmer {
555
+
556
+ 0%,
557
+ 100% {
558
+ width: 0;
559
+ left: 10px;
560
+ opacity: 0;
561
+ }
562
+
563
+ 50% {
564
+ width: 40px;
565
+ left: 10px;
566
+ opacity: 0.6;
567
+ }
568
+ }
569
+
570
+ .float-spinner p {
571
+ font-family: 'Times New Roman', serif;
572
+ font-style: italic;
573
+ color: var(--text-muted);
574
+ }
575
+
576
+ .header-visualize:disabled {
577
+ opacity: 0.5;
578
+ cursor: not-allowed;
579
+ }
580
+
581
+ /* Minimized Pill */
582
+ .float-pill {
583
+ position: fixed;
584
+ bottom: 24px;
585
+ right: 24px;
586
+ background: #6b4226;
587
+ color: #fff;
588
+ padding: 10px 20px;
589
+ border-radius: 30px;
590
+ font-family: 'Playfair Display', serif;
591
+ font-size: 0.9rem;
592
+ font-weight: 600;
593
+ cursor: pointer;
594
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
595
+ z-index: 1001;
596
+ display: flex;
597
+ align-items: center;
598
+ gap: 8px;
599
+ transition: transform 0.2s;
600
+ }
601
+
602
+ .float-pill:hover {
603
+ transform: translateY(-2px);
604
+ }
605
+
606
+ /* ── Utility ── */
607
+ .hidden {
608
+ display: none !important;
609
+ }
utils/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ # Pundit Feynman Utils Package
utils/llm_client.py ADDED
@@ -0,0 +1,603 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Pundit Feynman LLM Client — 3-Stage Pipeline
3
+ Stage 1: Analyze (images → structured JSON analysis)
4
+ Stage 2: Design (analysis → implementation plan JSON)
5
+ Stage 3: Generate (analysis + design → notebook cells JSON)
6
+ """
7
+
8
+ import os
9
+ import json
10
+ import time
11
+ import re
12
+ import requests
13
+ from openai import OpenAI
14
+ from dotenv import load_dotenv
15
+
16
+ load_dotenv()
17
+
18
+ # ── Configuration ──────────────────────────────────────────────────────────
19
+ API_KEY = os.getenv("NVIDIA_API_KEY", "")
20
+ BASE_URL = os.getenv("NVIDIA_BASE_URL", "https://integrate.api.nvidia.com/v1")
21
+ MODEL = os.getenv("LLM_MODEL", "qwen/qwen3.5-397b-a17b")
22
+ MAX_IMAGES_PER_REQUEST = int(os.getenv("MAX_IMAGES_PER_REQUEST", "8"))
23
+
24
+ # OCR Configuration
25
+ OCR_API_KEY = os.getenv("NVIDIA_OCR_API_KEY", "")
26
+ OCR_API_URL = "https://ai.api.nvidia.com/v1/cv/nvidia/nemoretriever-ocr-v1"
27
+
28
+ # FLUX.1-schnell Image Generation
29
+ FLUX_API_KEY = os.getenv("NVIDIA_FLUX_API_KEY", "")
30
+ FLUX_API_URL = "https://ai.api.nvidia.com/v1/genai/black-forest-labs/flux.1-schnell"
31
+
32
+ MAX_RETRIES = 3
33
+ RETRY_DELAYS = [5, 15, 30]
34
+
35
+ client = OpenAI(base_url=BASE_URL, api_key=API_KEY)
36
+
37
+
38
+ # ── Prompts ────────────────────────────────────────────────────────────────
39
+
40
+ SYSTEM_PROMPT = (
41
+ "You are an expert research engineer and educator who converts academic papers into "
42
+ "clear, educational, executable Python code. You produce structured JSON output for "
43
+ "each stage of the pipeline. When building toy implementations, you create REAL working code "
44
+ "(PyTorch, Transformer layers, actual training loops) at reduced scale that "
45
+ "runs on CPU. You prioritize faithful replication of the paper's architecture "
46
+ "and algorithms while making the code deeply educational with clear explanations, "
47
+ "using the Feynman technique to break down complex math into simple analogies, "
48
+ "verbose logging, and insightful visualizations."
49
+ )
50
+
51
+ ANALYSIS_PROMPT = """Analyze this research paper text and return a JSON object with:
52
+ {
53
+ "title": "exact paper title",
54
+ "authors": ["author names"],
55
+ "research_field": "e.g. NLP, Computer Vision, RL",
56
+ "abstract_summary": "2-3 sentence plain English summary of the paper",
57
+ "feynman_analogy": "A brilliant, everyday analogy that maps perfectly to the paper's core key_insight (e.g., comparing attention mechanisms to a cocktail party)",
58
+ "feynman_core_concept": "Explain the paper's main idea as if teaching a bright 12-year-old, using the analogy above, in 3-5 sentences",
59
+ "key_insight": "the core novel contribution in one sentence",
60
+ "algorithms": [
61
+ {
62
+ "name": "algorithm name",
63
+ "purpose": "what it does",
64
+ "key_equations": ["important formulas in LaTeX notation"],
65
+ "pseudocode_steps": ["step1", "step2"]
66
+ }
67
+ ],
68
+ "architecture": {
69
+ "type": "e.g. Transformer, CNN, GAN",
70
+ "components": ["list of main components"],
71
+ "data_flow": "description of how data flows through the model"
72
+ },
73
+ "datasets_mentioned": ["dataset names"],
74
+ "implementation_requirements": {
75
+ "frameworks": ["PyTorch"],
76
+ "key_hyperparameters": {"param": "value"},
77
+ "estimated_complexity": "low/medium/high for toy version"
78
+ }
79
+ }
80
+
81
+ Return ONLY valid JSON, no markdown, no extra text."""
82
+
83
+ DESIGN_PROMPT = """Based on this paper analysis, create a toy implementation design that runs on CPU.
84
+ Return a JSON object with:
85
+ {
86
+ "model_architecture": {
87
+ "type": "architecture type",
88
+ "embed_dim": 64,
89
+ "num_layers": 2,
90
+ "num_heads": 4,
91
+ "vocab_size": 1000,
92
+ "max_seq_len": 64,
93
+ "components": [
94
+ {
95
+ "name": "component name",
96
+ "class_name": "PythonClassName",
97
+ "description": "what this component does",
98
+ "key_params": {"param": "value"}
99
+ }
100
+ ]
101
+ },
102
+ "training_config": {
103
+ "optimizer": "Adam",
104
+ "learning_rate": 0.001,
105
+ "num_epochs": 5,
106
+ "batch_size": 16,
107
+ "loss_function": "CrossEntropyLoss",
108
+ "dataset_strategy": "synthetic generation approach"
109
+ },
110
+ "visualization_plan": [
111
+ "loss curve",
112
+ "attention heatmap",
113
+ "sample predictions"
114
+ ],
115
+ "estimated_cells": 15,
116
+ "code_structure": [
117
+ {"section": "imports", "description": "required libraries"},
118
+ {"section": "model", "description": "model architecture classes"},
119
+ {"section": "data", "description": "synthetic data generation"},
120
+ {"section": "training", "description": "training loop"},
121
+ {"section": "evaluation", "description": "testing and visualization"}
122
+ ]
123
+ }
124
+
125
+ Return ONLY valid JSON, no markdown, no extra text."""
126
+
127
+ GENERATE_PROMPT_TEMPLATE = """You are generating a Jupyter notebook from a paper analysis and implementation design.
128
+ Analysis: {analysis}
129
+ Design: {design}
130
+
131
+ Note: You are a 397B parameter model (Qwen 3.5) with 17B actively used parameters (MoE architecture).
132
+ This means you have deep expertise and vast knowledge. Use it to produce genuinely educational content.
133
+
134
+ Return a JSON array of notebook cells following this **exact 13-section structure**:
135
+
136
+ 1. **Title & Overview** (markdown) — Paper title, authors, a one-paragraph summary of the paper.
137
+
138
+ 2. **Table of Contents** (markdown) — Numbered list of all 13 sections. Each section name should be a clickable anchor link.
139
+
140
+ 3. **The Feynman Explanation** (markdown) — A step-by-step explanation of the WHOLE paper using the Feynman technique. Break down the core algorithms, math, and architecture into the absolute simplest terms possible. Expand heavily on the `feynman_analogy` and `feynman_core_concept` from the analysis. Use relatable, everyday analogies for each major step so a beginner can intuitively grasp how the system works before seeing the code.
141
+
142
+ 4. **Environment Setup** (code) — pip installs and imports. Include `torch`, `numpy`, `matplotlib`, and any other needed libraries.
143
+
144
+ 5. **Configuration & Hyperparameters** (code) — A single config dict or dataclass with all hyperparameters. Add comments explaining each.
145
+
146
+ 6. **Data Preparation** (code) — Synthetic dataset generation or loading. Must produce realistic dummy data matching the paper's domain.
147
+
148
+ 7. **Model Architecture** (code) — Full PyTorch model implementation. Use `nn.Module` subclasses with detailed docstrings about each component. Include shape comments.
149
+
150
+ 8. **Training Loop** (code) — Complete training loop with loss tracking, progress printing, and gradient clipping.
151
+
152
+ 9. **Training Execution** (code) — Run the training and display results.
153
+
154
+ 10. **Evaluation & Metrics** (code) — Run inference on test data and compute relevant metrics.
155
+
156
+ 11. **Visualizations** (code) — Matplotlib charts: loss curves, attention heatmaps or feature maps, sample predictions.
157
+
158
+ 12. **Key Takeaways** (markdown) — Bullet-point summary of what was learned, what would change at full scale, potential improvements.
159
+
160
+ 13. **References** (markdown) — Paper citation, related work links, library documentation links.
161
+
162
+ Each cell in the JSON array must have:
163
+ {{"cell_type": "code" or "markdown", "source": "cell content as a string"}}
164
+
165
+ RULES:
166
+ - All code must be executable on CPU
167
+ - Use educational variable names and heavy commenting
168
+ - Include print() statements showing tensor shapes and intermediate results
169
+ - Follow the 13-section structure exactly
170
+ - Minimum 15 cells total
171
+ - The Feynman Explanation should be at least 300 words
172
+ - Return ONLY the JSON array, no markdown fences"""
173
+
174
+
175
+ # ── OCR extraction (NVIDIA NeMo Retriever OCR v1) ─────────────────────────
176
+
177
+ def extract_text_from_images(base64_images):
178
+ """Extract text from paper page images using NVIDIA NeMo Retriever OCR API.
179
+ Sends page images to the dedicated OCR model for fast, accurate extraction.
180
+ Falls back to page-by-page if a batch request fails.
181
+ """
182
+ all_text = []
183
+ headers = {
184
+ "Authorization": f"Bearer {OCR_API_KEY}",
185
+ "Accept": "application/json",
186
+ "Content-Type": "application/json",
187
+ }
188
+
189
+ total = len(base64_images)
190
+ print(f" OCR: Processing {total} pages via NVIDIA NeMo Retriever...")
191
+
192
+ for page_idx, img_b64 in enumerate(base64_images):
193
+ print(f" Page {page_idx + 1}/{total}...")
194
+
195
+ payload = {
196
+ "input": [
197
+ {
198
+ "type": "image_url",
199
+ "url": f"data:image/jpeg;base64,{img_b64}"
200
+ }
201
+ ],
202
+ "merge_levels": ["paragraph"]
203
+ }
204
+
205
+ try:
206
+ resp = requests.post(
207
+ OCR_API_URL,
208
+ headers=headers,
209
+ json=payload,
210
+ timeout=60,
211
+ )
212
+ resp.raise_for_status()
213
+ result = resp.json()
214
+
215
+ # Extract text from OCR response
216
+ page_text = _parse_ocr_response(result, page_idx + 1)
217
+ if page_text:
218
+ all_text.append(page_text)
219
+
220
+ except Exception as e:
221
+ print(f" \u26a0 OCR failed for page {page_idx + 1}: {e}")
222
+ # Continue with remaining pages
223
+ continue
224
+
225
+ if not all_text:
226
+ raise RuntimeError("OCR failed: No text extracted from any page")
227
+
228
+ combined = "\n\n".join(all_text)
229
+ print(f" OCR complete: {len(combined)} chars from {len(all_text)}/{total} pages")
230
+ return combined
231
+
232
+
233
+ def _parse_ocr_response(response_json, page_num):
234
+ """Parse the NVIDIA OCR API response into clean text.
235
+ Response format: {"data": [{"text_detections": [{"text_prediction": {"text": ..., "confidence": ...}}]}]}
236
+ """
237
+ texts = []
238
+ try:
239
+ for item in response_json.get("data", []):
240
+ for detection in item.get("text_detections", []):
241
+ pred = detection.get("text_prediction", {})
242
+ text = pred.get("text", "").strip()
243
+ confidence = pred.get("confidence", 0)
244
+ # Only include text with reasonable confidence
245
+ if text and confidence > 0.3:
246
+ texts.append(text)
247
+ except Exception as e:
248
+ print(f" \u26a0 Error parsing OCR response for page {page_num}: {e}")
249
+ return ""
250
+
251
+ return "\n".join(texts)
252
+
253
+
254
+ # ── LLM Call with Retry ───────────────────────────────────────────────────
255
+
256
+ def call_with_retry(messages, max_tokens=4096, temperature=0.3, stream=False):
257
+ """Call the LLM API with retry logic for transient errors."""
258
+ last_error = None
259
+
260
+ for attempt in range(MAX_RETRIES):
261
+ try:
262
+ kwargs = dict(
263
+ model=MODEL,
264
+ messages=messages,
265
+ max_tokens=max_tokens,
266
+ temperature=temperature,
267
+ timeout=300,
268
+ )
269
+ if stream:
270
+ kwargs["stream"] = True
271
+ return client.chat.completions.create(**kwargs)
272
+ else:
273
+ response = client.chat.completions.create(**kwargs)
274
+ return response.choices[0].message.content
275
+
276
+ except Exception as e:
277
+ error_str = str(e).lower()
278
+ if any(kw in error_str for kw in ["429", "rate", "500", "503", "overloaded", "unavailable"]):
279
+ last_error = e
280
+ wait = RETRY_DELAYS[min(attempt, len(RETRY_DELAYS) - 1)]
281
+ print(f" ⚠ Transient error. Waiting {wait}s before retry {attempt + 1}/{MAX_RETRIES}...")
282
+ time.sleep(wait)
283
+ else:
284
+ raise
285
+
286
+ raise RuntimeError(f"Failed after {MAX_RETRIES} retries. Last error: {last_error}")
287
+
288
+
289
+ # ── JSON Parsing ──────────────────────────────────────────────────────────
290
+
291
+ def parse_llm_json(raw_text, step_name):
292
+ """Parse JSON from LLM response, with cleanup and one repair attempt."""
293
+ if raw_text is None:
294
+ print(f" ⚠ LLM returned None for {step_name}")
295
+ return {}
296
+ text = raw_text.strip()
297
+
298
+ # Strip markdown code fences if present
299
+ if text.startswith("```"):
300
+ first_newline = text.index("\n")
301
+ text = text[first_newline + 1:]
302
+ if text.endswith("```"):
303
+ text = text[:-3]
304
+ text = text.strip()
305
+
306
+ # Try direct parse
307
+ try:
308
+ return json.loads(text)
309
+ except json.JSONDecodeError as e:
310
+ print(f" ⚠ JSON parse failed in {step_name}. Attempting repair...")
311
+
312
+ # Attempt auto-repair via LLM
313
+ repair_prompt = (
314
+ f"The following text was supposed to be valid JSON but has a syntax error:\n\n"
315
+ f"{text[:6000]}\n\n"
316
+ f"Error: {e}\n\n"
317
+ f"Return ONLY the corrected valid JSON, nothing else."
318
+ )
319
+ repaired = call_with_retry(
320
+ messages=[
321
+ {"role": "system", "content": "You are a JSON repair tool. Return only valid JSON."},
322
+ {"role": "user", "content": repair_prompt},
323
+ ],
324
+ max_tokens=max(len(text) // 2, 4096),
325
+ temperature=0.1,
326
+ )
327
+ if repaired is None:
328
+ raise ValueError(f"Could not repair JSON from {step_name} — LLM returned None")
329
+ repaired = repaired.strip()
330
+ if repaired.startswith("```"):
331
+ repaired = repaired.split("\n", 1)[1]
332
+ if repaired.endswith("```"):
333
+ repaired = repaired[:-3]
334
+
335
+ try:
336
+ return json.loads(repaired.strip())
337
+ except json.JSONDecodeError:
338
+ # Last resort: try to extract JSON from the text
339
+ json_match = re.search(r'[\[{].*[\]}]', repaired.strip(), re.DOTALL)
340
+ if json_match:
341
+ return json.loads(json_match.group())
342
+ raise ValueError(f"Could not parse JSON from {step_name} even after repair.")
343
+
344
+
345
+ # ── Pipeline Stages ───────────────────────────────────────────────────────
346
+
347
+ def analyze_paper(raw_text):
348
+ """Stage 1: Analyze extracted text into structured JSON."""
349
+ messages = [
350
+ {"role": "system", "content": SYSTEM_PROMPT},
351
+ {"role": "user", "content": f"{ANALYSIS_PROMPT}\n\n--- EXTRACTED PAPER TEXT ---\n\n{raw_text}"},
352
+ ]
353
+ raw = call_with_retry(messages, max_tokens=6144, temperature=0.2)
354
+ return parse_llm_json(raw, "paper_analysis")
355
+
356
+
357
+ def design_implementation(analysis):
358
+ """Stage 2: Create implementation design from analysis."""
359
+ messages = [
360
+ {"role": "system", "content": SYSTEM_PROMPT},
361
+ {"role": "user", "content": f"{DESIGN_PROMPT}\n\n--- PAPER ANALYSIS ---\n\n{json.dumps(analysis, indent=2)}"},
362
+ ]
363
+ raw = call_with_retry(messages, max_tokens=6144, temperature=0.2)
364
+ return parse_llm_json(raw, "implementation_design")
365
+
366
+
367
+ def generate_notebook_cells_stream(analysis, design):
368
+ """
369
+ Stage 3: Generate notebook cells from analysis and design.
370
+ Yields tokens from the LLM for live streaming in the UI.
371
+ Finally yields the parsed cells list.
372
+ """
373
+ prompt = GENERATE_PROMPT_TEMPLATE.format(
374
+ analysis=json.dumps(analysis, indent=2),
375
+ design=json.dumps(design, indent=2),
376
+ )
377
+ messages = [
378
+ {"role": "system", "content": SYSTEM_PROMPT},
379
+ {"role": "user", "content": prompt},
380
+ ]
381
+
382
+ # Use streaming mode
383
+ stream = call_with_retry(messages, max_tokens=65536, temperature=0.3, stream=True)
384
+ full_response = []
385
+
386
+ for chunk in stream:
387
+ if chunk.choices and chunk.choices[0].delta.content:
388
+ token = chunk.choices[0].delta.content
389
+ full_response.append(token)
390
+ yield ("token", token)
391
+
392
+ raw_text = "".join(full_response)
393
+ result = parse_llm_json(raw_text, "notebook_cells")
394
+
395
+ # Final logic to ensure we return a list of cells
396
+ cells = []
397
+ if isinstance(result, dict):
398
+ cells = result.get("cells", [{"cell_type": "markdown", "source": json.dumps(result, indent=2)}])
399
+ elif isinstance(result, list):
400
+ cells = result
401
+ else:
402
+ cells = [{"cell_type": "markdown", "source": raw_text}]
403
+
404
+ yield ("cells_final", cells)
405
+
406
+
407
+ # ── Streaming Pipeline ─────────────────────────────────────────────────────
408
+
409
+ def run_full_pipeline_stream(raw_text):
410
+ """
411
+ Orchestrates the full 3-stage pipeline.
412
+ Yields SSE-formatted text events for the frontend code viewer.
413
+ Returns final cells via the 'cells' key in the last event.
414
+
415
+ Yields tuples of (event_type, data):
416
+ ("text", str) — display text for the code viewer
417
+ ("cells", list) — final cells (only yielded once at end)
418
+ ("analysis", dict) — analysis metadata
419
+ ("error", str) — error message
420
+ """
421
+ try:
422
+ # ── Stage 1: Analyze ──
423
+ yield ("text", "\n Analyzing Paper\n")
424
+ yield ("text", " " + "─" * 40 + "\n\n")
425
+
426
+ analysis = analyze_paper(raw_text)
427
+
428
+ if not analysis:
429
+ yield ("text", " Analysis returned empty. The LLM may have failed.\n\n")
430
+ yield ("error", "Analysis returned empty result")
431
+ return
432
+
433
+ title = analysis.get("title", "Unknown Paper")
434
+ field = analysis.get("research_field", "")
435
+ insight = analysis.get("key_insight", "")
436
+ algos = [a.get("name", "") for a in analysis.get("algorithms", [])]
437
+ feynman_analogy = analysis.get("feynman_analogy", "")
438
+ feynman_concept = analysis.get("feynman_core_concept", "")
439
+
440
+ # Clean, minimal analysis output
441
+ yield ("text", f" {title}\n")
442
+ yield ("text", f" {field}\n\n")
443
+
444
+ # The Feynman Explanation — the star of the show
445
+ if feynman_analogy or feynman_concept:
446
+ yield ("text", " ─── The Feynman Explanation ───\n\n")
447
+ if feynman_analogy:
448
+ yield ("text", f" {feynman_analogy}\n\n")
449
+ if feynman_concept:
450
+ yield ("text", f" {feynman_concept}\n\n")
451
+
452
+ if insight:
453
+ yield ("text", f" Key Insight: {insight}\n\n")
454
+
455
+ yield ("text", " Analysis complete.\n\n")
456
+
457
+ yield ("analysis", {
458
+ "title": title,
459
+ "field": field,
460
+ "insight": insight,
461
+ "algorithms": algos,
462
+ "feynman_analogy": feynman_analogy,
463
+ })
464
+
465
+ # ── Stage 2: Design ──
466
+ yield ("text", "\n Designing Implementation\n")
467
+ yield ("text", " " + "─" * 40 + "\n\n")
468
+
469
+ design = design_implementation(analysis)
470
+ if not design:
471
+ design = {}
472
+
473
+ arch = design.get("model_architecture", {})
474
+ tc = design.get("training_config", {})
475
+ yield ("text", f" Architecture: {arch.get('type', 'N/A')}\n")
476
+ yield ("text", f" Training: {tc.get('optimizer', 'Adam')}, lr={tc.get('learning_rate', 0.001)}, {tc.get('num_epochs', 10)} epochs\n")
477
+ yield ("text", " Design complete.\n\n")
478
+
479
+ # ── Stage 3: Generate (Now with LIVE STREAMING) ──
480
+ yield ("text", "\n Generating Notebook (Live Streaming)\n")
481
+ yield ("text", " " + "─" * 40 + "\n\n")
482
+
483
+ cells = []
484
+ for event_type, data in generate_notebook_cells_stream(analysis, design):
485
+ if event_type == "token":
486
+ # Yield raw tokens to the code viewer for "ghost-writing" effect
487
+ yield ("text", data)
488
+ elif event_type == "cells_final":
489
+ cells = data
490
+
491
+ code_cells = sum(1 for c in cells if c.get("cell_type") == "code")
492
+ md_cells = sum(1 for c in cells if c.get("cell_type") == "markdown")
493
+ yield ("text", f"\n\n ✅ Generation complete: {len(cells)} cells ({code_cells} code, {md_cells} markdown)\n")
494
+ yield ("text", " Notebook ready for download.\n")
495
+
496
+ yield ("cells", cells)
497
+
498
+ except Exception as e:
499
+ yield ("error", str(e))
500
+
501
+
502
+ # ── Legacy compatibility ───────────────────────────────────────────────────
503
+ # Keep old function signatures working for backward compatibility
504
+
505
+ def extract_methodology(base64_images):
506
+ """Legacy wrapper: extracts text from images."""
507
+ return extract_text_from_images(base64_images)
508
+
509
+
510
+ # ── Visual Illustration (FLUX.1-schnell) ───────────────────────────────────
511
+
512
+ # System prompt for Qwen to craft image generation prompts
513
+ IMAGE_PROMPT_SYSTEM = """You are a world-class scientific illustrator and prompt engineer.
514
+ Your job: given a structured analysis of a research paper, write ONE prompt for an
515
+ AI image generator (FLUX) that will produce a clear, beautiful, academic-quality
516
+ visual illustration of the paper's CORE CONCEPT.
517
+
518
+ Rules:
519
+ 1. Focus on the MAIN IDEA — the central algorithm, architecture, or mechanism.
520
+ 2. Describe the visual layout precisely: shapes, arrows, labels, flow direction.
521
+ 3. Use academic illustration style: clean lines, labeled components, white background.
522
+ 4. Include spatial relationships: "on the left", "flowing into", "surrounded by".
523
+ 5. Mention color coding for different components.
524
+ 6. Do NOT include text/equations in the image — focus on visual metaphors.
525
+ 7. Keep it to ONE paragraph, 80-120 words.
526
+ 8. End with style keywords: "scientific diagram, educational poster, vector style,
527
+ clean layout, professional, high resolution"
528
+
529
+ Return ONLY the prompt text, nothing else."""
530
+
531
+ def generate_concept_image(analysis):
532
+ """
533
+ Generate a visual illustration of a paper's core concept.
534
+ Step 1: Qwen crafts a detailed, structured prompt from the analysis.
535
+ Step 2: FLUX.1-schnell generates the image.
536
+ Returns base64-encoded PNG string or None on failure.
537
+ """
538
+ if not FLUX_API_KEY:
539
+ raise RuntimeError("NVIDIA_FLUX_API_KEY not set")
540
+
541
+ # ── Step 1: Qwen → Image Prompt ──
542
+ analysis_summary = json.dumps({
543
+ "title": analysis.get("title", ""),
544
+ "research_field": analysis.get("research_field") or analysis.get("field", ""),
545
+ "key_insight": analysis.get("key_insight") or analysis.get("insight", ""),
546
+ "algorithms": analysis.get("algorithms", []),
547
+ "feynman_analogy": analysis.get("feynman_analogy", ""),
548
+ "feynman_core_concept": analysis.get("feynman_core_concept", ""),
549
+ }, indent=2)
550
+
551
+ prompt_messages = [
552
+ {"role": "system", "content": IMAGE_PROMPT_SYSTEM},
553
+ {"role": "user", "content": f"Create an image generation prompt for this paper:\n\n{analysis_summary}"},
554
+ ]
555
+
556
+ print(" 🎨 Generating image prompt via Qwen...")
557
+ image_prompt = call_with_retry(prompt_messages, max_tokens=300, temperature=0.7)
558
+ if not image_prompt:
559
+ raise RuntimeError("Qwen returned empty image prompt")
560
+
561
+ # Add preamble for FLUX to ensure academic quality
562
+ full_prompt = (
563
+ "A detailed, clean scientific illustration for an academic paper. "
564
+ "Style: professional educational diagram, labeled components, "
565
+ "modern flat vector design, white background, high contrast, "
566
+ "color-coded sections, no text. "
567
+ f"{image_prompt.strip()}"
568
+ )
569
+ print(f" 📝 FLUX prompt ({len(full_prompt)} chars): {full_prompt[:100]}...")
570
+
571
+ # ── Step 2: FLUX.1-schnell → Image ──
572
+ print(" 🖼️ Calling FLUX.1-schnell...")
573
+ headers = {
574
+ "Authorization": f"Bearer {FLUX_API_KEY}",
575
+ "Content-Type": "application/json",
576
+ "Accept": "application/json",
577
+ }
578
+ payload = {
579
+ "prompt": full_prompt,
580
+ "height": 1024,
581
+ "width": 1024,
582
+ "num_inference_steps": 4,
583
+ "guidance_scale": 0.0,
584
+ }
585
+
586
+ response = requests.post(FLUX_API_URL, headers=headers, json=payload, timeout=60)
587
+
588
+ if response.status_code != 200:
589
+ raise RuntimeError(f"FLUX API error {response.status_code}: {response.text[:200]}")
590
+
591
+ result = response.json()
592
+ # FLUX returns {"image": "base64..."} or {"artifacts": [{"base64": "..."}]}
593
+ image_b64 = None
594
+ if "image" in result:
595
+ image_b64 = result["image"]
596
+ elif "artifacts" in result and len(result["artifacts"]) > 0:
597
+ image_b64 = result["artifacts"][0].get("base64", "")
598
+
599
+ if not image_b64:
600
+ raise RuntimeError("FLUX returned no image data")
601
+
602
+ print(f" ✅ Image generated ({len(image_b64)} chars base64)")
603
+ return image_b64
utils/notebook_builder.py ADDED
@@ -0,0 +1,85 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Pundit Feynman Notebook Builder
3
+ Supports both structured JSON cells and legacy free-text → regex approach.
4
+ """
5
+
6
+ import re
7
+ import nbformat
8
+ from nbformat.v4 import new_notebook, new_code_cell, new_markdown_cell
9
+
10
+
11
+ def build_notebook_from_cells(cells_json, output_path):
12
+ """
13
+ Build a .ipynb from a list of structured cell dicts.
14
+ Each cell: {"cell_type": "code"|"markdown", "source": "..."}
15
+ """
16
+ nb = new_notebook()
17
+ nb.metadata["kernelspec"] = {
18
+ "display_name": "Python 3",
19
+ "language": "python",
20
+ "name": "python3",
21
+ }
22
+ nb.metadata["language_info"] = {
23
+ "name": "python",
24
+ "version": "3.9",
25
+ }
26
+
27
+ for cell_data in cells_json:
28
+ cell_type = cell_data.get("cell_type", "code")
29
+ source = cell_data.get("source", "")
30
+
31
+ if cell_type == "markdown":
32
+ nb.cells.append(new_markdown_cell(source))
33
+ elif cell_type == "code":
34
+ nb.cells.append(new_code_cell(source))
35
+ else:
36
+ # Default to code for unknown types
37
+ nb.cells.append(new_code_cell(source))
38
+
39
+ # Fallback: if no cells, add a placeholder
40
+ if not nb.cells:
41
+ nb.cells.append(new_markdown_cell("# No cells were generated"))
42
+
43
+ with open(output_path, "w", encoding="utf-8") as f:
44
+ nbformat.write(nb, f)
45
+
46
+ code_cells = sum(1 for c in nb.cells if c.cell_type == "code")
47
+ md_cells = sum(1 for c in nb.cells if c.cell_type == "markdown")
48
+ print(f" 📓 Notebook saved: {output_path} ({len(nb.cells)} cells: {code_cells} code, {md_cells} markdown)")
49
+ return output_path
50
+
51
+
52
+ def build_notebook(full_text, output_path):
53
+ """
54
+ Legacy: Parses mixed markdown/code text into a Jupyter Notebook.
55
+ Separates ```python code blocks into Code cells, everything else into Markdown cells.
56
+ """
57
+ nb = new_notebook()
58
+ nb.metadata["kernelspec"] = {
59
+ "display_name": "Python 3",
60
+ "language": "python",
61
+ "name": "python3",
62
+ }
63
+
64
+ # Split on ```python ... ``` blocks
65
+ pattern = r"```python\s*\n(.*?)```"
66
+ parts = re.split(pattern, full_text, flags=re.DOTALL)
67
+
68
+ for i, part in enumerate(parts):
69
+ content = part.strip()
70
+ if not content:
71
+ continue
72
+
73
+ if i % 2 == 0:
74
+ nb.cells.append(new_markdown_cell(content))
75
+ else:
76
+ nb.cells.append(new_code_cell(content))
77
+
78
+ if not nb.cells:
79
+ nb.cells.append(new_markdown_cell(full_text))
80
+
81
+ with open(output_path, "w", encoding="utf-8") as f:
82
+ nbformat.write(nb, f)
83
+
84
+ print(f" 📓 Notebook saved: {output_path} ({len(nb.cells)} cells)")
85
+ return output_path
utils/pdf_processor.py ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import base64
2
+ import fitz # PyMuPDF
3
+
4
+
5
+ def process_pdf_to_base64(pdf_path: str, dpi: int = 150) -> list[str]:
6
+ """
7
+ Converts each page of a PDF into a base64-encoded JPEG string.
8
+ Preserves full RGB color (important for color-coded graphs in papers).
9
+ """
10
+ try:
11
+ doc = fitz.open(pdf_path)
12
+ base64_images = []
13
+
14
+ for page in doc:
15
+ pix = page.get_pixmap(dpi=dpi)
16
+ img_bytes = pix.tobytes("jpeg")
17
+ img_b64 = base64.b64encode(img_bytes).decode("utf-8")
18
+ base64_images.append(img_b64)
19
+
20
+ doc.close()
21
+ print(f"Extracted {len(base64_images)} pages at {dpi} DPI (color preserved)")
22
+ return base64_images
23
+ except Exception as e:
24
+ print(f"Error processing PDF: {e}")
25
+ raise e