unknown commited on
Commit
71276a4
·
0 Parent(s):

Deploy Paper2Lab HF Space

Browse files
README.md ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: Paper2Lab
3
+ emoji: 🧪
4
+ colorFrom: purple
5
+ colorTo: blue
6
+ sdk: gradio
7
+ app_file: app.py
8
+ ---
9
+
10
+ # Paper2Lab
11
+
12
+ Turn research papers into actionable lab plans.
13
+
14
+ Paper2Lab is an AI research assistant that transforms scientific papers into:
15
+
16
+ - Structured paper cards
17
+ - Evidence-grounded summaries
18
+ - Dataset and model extraction
19
+ - Reproducibility assessment
20
+ - Experiment roadmaps
21
+ - Lab starter kits
22
+ - Interactive question answering
23
+ - Exportable JSON and Markdown reports
24
+
25
+ ## Features
26
+
27
+ - PDF paper ingestion
28
+ - Structured paper understanding
29
+ - Evidence grounding
30
+ - Reproducibility analysis
31
+ - Lab readiness assessment
32
+ - Ask-the-paper interface
33
+ - Export to JSON and Markdown
34
+
35
+ ## Usage
36
+
37
+ 1. Upload a PDF paper
38
+ 2. Select refinement mode
39
+ 3. Click **Analyze Paper**
40
+ 4. Explore:
41
+ - Paper Summary
42
+ - Evidence Viewer
43
+ - Ask the Paper
44
+ - Advanced Analysis
45
+ - Lab Starter Kit
46
+ - Export
47
+
48
+ Built for researchers, students, engineers, and hackathon teams.
README_HF.md ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: Paper2Lab
3
+ emoji: 🧪
4
+ colorFrom: purple
5
+ colorTo: blue
6
+ sdk: gradio
7
+ app_file: app.py
8
+ ---
9
+
10
+ # Paper2Lab
11
+
12
+ Turn research papers into actionable lab plans.
13
+
14
+ Paper2Lab is an AI research assistant that transforms scientific papers into:
15
+
16
+ - Structured paper cards
17
+ - Evidence-grounded summaries
18
+ - Dataset and model extraction
19
+ - Reproducibility assessment
20
+ - Experiment roadmaps
21
+ - Lab starter kits
22
+ - Interactive question answering
23
+ - Exportable JSON and Markdown reports
24
+
25
+ ## Features
26
+
27
+ - PDF paper ingestion
28
+ - Structured paper understanding
29
+ - Evidence grounding
30
+ - Reproducibility analysis
31
+ - Lab readiness assessment
32
+ - Ask-the-paper interface
33
+ - Export to JSON and Markdown
34
+
35
+ ## Usage
36
+
37
+ 1. Upload a PDF paper
38
+ 2. Select refinement mode
39
+ 3. Click **Analyze Paper**
40
+ 4. Explore:
41
+ - Paper Summary
42
+ - Evidence Viewer
43
+ - Ask the Paper
44
+ - Advanced Analysis
45
+ - Lab Starter Kit
46
+ - Export
47
+
48
+ Built for researchers, students, engineers, and hackathon teams.
app.py ADDED
@@ -0,0 +1,1680 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ import tempfile
3
+ import html
4
+ from pathlib import Path
5
+
6
+ import gradio as gr
7
+
8
+ try:
9
+ from paper2lab.inference.pipeline import PaperPipeline
10
+ except Exception:
11
+ PaperPipeline = None
12
+
13
+ try:
14
+ from paper2lab.rag.qa import answer_from_pipeline_result
15
+ except Exception:
16
+ answer_from_pipeline_result = None
17
+
18
+
19
+ APP_NAME = "Paper2Lab"
20
+
21
+
22
+ def pretty_json(data):
23
+ return json.dumps(data or {}, indent=2, ensure_ascii=False)
24
+
25
+
26
+ def as_list(value):
27
+ if not value:
28
+ return []
29
+ if isinstance(value, list):
30
+ return value
31
+ return [value]
32
+
33
+
34
+ def bullet_list(items, empty="_Not found._"):
35
+ items = as_list(items)
36
+ if not items:
37
+ return empty
38
+ return "\n".join(f"- {x}" for x in items)
39
+
40
+
41
+ def clean_display_text(text, max_len=95):
42
+ text = str(text or "").replace("\n", " ").strip()
43
+ while " " in text:
44
+ text = text.replace(" ", " ")
45
+ if len(text) > max_len:
46
+ return text[:max_len].rstrip() + "..."
47
+ return text
48
+
49
+
50
+ def chip_list(items, empty="Not found"):
51
+ items = as_list(items)
52
+ if not items:
53
+ return f"<span class='muted'>{empty}</span>"
54
+
55
+ html = ""
56
+ for x in items[:6]:
57
+ clean = clean_display_text(x, max_len=70)
58
+ html += f"<span class='chip' title='{str(x)}'>{clean}</span>"
59
+ return html
60
+
61
+
62
+ def get_card(result):
63
+ return result.get("paper_card_final") or result.get("paper_card") or result
64
+
65
+
66
+ def quality_warnings(card):
67
+ warnings = []
68
+
69
+ rq = card.get("research_question", "")
70
+ if rq and ("BERTis" in rq or len(rq.split()) < 5):
71
+ warnings.append("Research question may contain PDF spacing noise.")
72
+
73
+ datasets = as_list(card.get("datasets_or_data_sources"))
74
+ if any(len(str(x)) > 220 for x in datasets):
75
+ warnings.append("Dataset extraction contains long noisy candidates.")
76
+
77
+ for field in ["contributions", "key_findings", "limitations"]:
78
+ if not card.get(field):
79
+ warnings.append(f"{field} is empty.")
80
+
81
+ return warnings
82
+
83
+
84
+ def build_quick_summary_html(result):
85
+ card = get_card(result)
86
+ warnings = quality_warnings(card)
87
+
88
+ status = "Ready"
89
+ if result:
90
+ status = f"{max(0, 6 - len(warnings))} / 6 fields complete"
91
+
92
+ return f"""
93
+ <div class="summary-card">
94
+ <div class="card-head">
95
+ <div><span class="icon">📄</span><b>Quick summary</b></div>
96
+ <span class="status-pill">{status}</span>
97
+ </div>
98
+
99
+ <div class="summary-row">
100
+ <div class="label">Title</div>
101
+ <div class="value">{card.get("title", "Untitled paper")}</div>
102
+ </div>
103
+
104
+ <div class="summary-row">
105
+ <div class="label">Research question</div>
106
+ <div class="value">{card.get("research_question", "Not found")}</div>
107
+ </div>
108
+
109
+ <div class="summary-row">
110
+ <div class="label">Datasets</div>
111
+ <div class="value">{chip_list(card.get("datasets_or_data_sources"))}</div>
112
+ </div>
113
+
114
+ <div class="summary-row">
115
+ <div class="label">Models</div>
116
+ <div class="value">{chip_list(card.get("models_or_methods"))}</div>
117
+ </div>
118
+
119
+ <div class="summary-row">
120
+ <div class="label">Key findings</div>
121
+ <div class="value highlight">{bullet_list(card.get("key_findings")).replace("- ", "• ")}</div>
122
+ </div>
123
+ </div>
124
+ """
125
+
126
+
127
+ def build_paper_summary_md(result):
128
+ card = get_card(result)
129
+ warnings = quality_warnings(card)
130
+
131
+ warning_md = ""
132
+ if warnings:
133
+ warning_md = "## ⚠️ Extraction Quality Warnings\n" + "\n".join(f"- {w}" for w in warnings)
134
+
135
+ return f"""
136
+ # Structured Paper Summary
137
+
138
+ **Title:** {card.get("title", "Untitled paper")}
139
+ **Field:** {card.get("field", "Unknown")}
140
+
141
+ {warning_md}
142
+
143
+ ## Research Question
144
+ {card.get("research_question", "_Not found._")}
145
+
146
+ ## Contributions
147
+ {bullet_list(card.get("contributions"))}
148
+
149
+ ## Methodology
150
+ {bullet_list(card.get("methodology"))}
151
+
152
+ ## Datasets / Data Sources
153
+ {bullet_list(card.get("datasets_or_data_sources"))}
154
+
155
+ ## Models / Methods
156
+ {bullet_list(card.get("models_or_methods"))}
157
+
158
+ ## Metrics / Measurements
159
+ {bullet_list(card.get("metrics_or_measurements"))}
160
+
161
+ ## Key Findings
162
+ {bullet_list(card.get("key_findings"))}
163
+
164
+ ## Limitations
165
+ {bullet_list(card.get("limitations"))}
166
+ """
167
+
168
+
169
+ def build_lab_md(result):
170
+ card = get_card(result)
171
+ kit = card.get("lab_starter_kit") or result.get("lab_starter_kit") or {}
172
+
173
+ return f"""
174
+ # Lab Starter Kit
175
+
176
+ **Starter type:** `{kit.get("starter_type", card.get("paper_type", "unknown"))}`
177
+
178
+ ## Project Structure
179
+ {bullet_list(kit.get("project_structure"))}
180
+
181
+ ## Requirements
182
+ {bullet_list(kit.get("requirements_txt"))}
183
+
184
+ ## Dataset Plan
185
+ {bullet_list(kit.get("dataset_plan") or kit.get("required_data"))}
186
+
187
+ ## Suggested Experiments
188
+ {bullet_list(kit.get("suggested_experiments") or kit.get("experiment_checklist"))}
189
+
190
+ ## Evaluation Plan
191
+ {bullet_list(kit.get("evaluation_plan"))}
192
+
193
+ ## Reproducibility Risks
194
+ {bullet_list(kit.get("reproducibility_risks") or card.get("missing_reproducibility_info"))}
195
+ """
196
+
197
+
198
+ def build_evidence_md(result):
199
+ figs = result.get("figures_and_tables", [])
200
+ card = get_card(result)
201
+
202
+ lines = ["# Evidence Viewer"]
203
+
204
+ if figs:
205
+ lines.append("## Figures and Tables")
206
+ for item in figs[:12]:
207
+ lines.append(
208
+ f"- **{item.get('label', 'Item')}** — page {item.get('page_number', '?')}: "
209
+ f"{item.get('summary', 'No summary')}"
210
+ )
211
+
212
+ lines.append("\n## Extracted Evidence Fields")
213
+ for field in [
214
+ "methodology",
215
+ "datasets_or_data_sources",
216
+ "models_or_methods",
217
+ "metrics_or_measurements",
218
+ "key_findings",
219
+ ]:
220
+ lines.append(f"\n### {field}")
221
+ lines.append(bullet_list(card.get(field)))
222
+
223
+ return "\n".join(lines)
224
+
225
+
226
+ def build_advanced_md(result):
227
+ card = get_card(result)
228
+
229
+ metadata = card.get("metadata", {}) if isinstance(card, dict) else {}
230
+ if not isinstance(metadata, dict):
231
+ metadata = {}
232
+
233
+ quality = metadata.get("quality", {})
234
+ if not isinstance(quality, dict):
235
+ quality = {}
236
+
237
+ repro = card.get("reproducibility_score", {}) if isinstance(card, dict) else {}
238
+ if not isinstance(repro, dict):
239
+ repro = {}
240
+
241
+ selection = result.get("auto_selection", {}).get("selection_report", {})
242
+ if not isinstance(selection, dict):
243
+ selection = {}
244
+
245
+ md = []
246
+ md.append("# ⚙️ Advanced Analysis")
247
+ md.append("")
248
+ md.append("## Extraction Quality")
249
+ md.append("")
250
+ md.append("| Metric | Value |")
251
+ md.append("|---|---|")
252
+ md.append(f"| Quality Score | {quality.get('quality_score', 'N/A')} |")
253
+ md.append(f"| Sections Found | {quality.get('num_sections', 'N/A')} |")
254
+ md.append(f"| References Found | {quality.get('num_references', metadata.get('references_count', 'N/A'))} |")
255
+ md.append(f"| Tables Found | {quality.get('num_tables', 'N/A')} |")
256
+ md.append(f"| Captions Found | {quality.get('num_captions', 'N/A')} |")
257
+ md.append("")
258
+ md.append("---")
259
+ md.append("")
260
+ md.append(f"**Reproducibility Level:** {repro.get('level', 'unknown')}")
261
+ md.append("")
262
+ md.append(f"**Score:** {repro.get('score', 'N/A')}")
263
+ md.append("")
264
+ md.append("### Detected Items")
265
+ md.append(bullet_list(repro.get("detected_items")))
266
+ md.append("")
267
+ md.append("### Missing Items")
268
+ md.append(bullet_list(repro.get("missing_items")))
269
+ md.append("")
270
+ md.append("---")
271
+ md.append("")
272
+ md.append("## Selection Report")
273
+
274
+ if selection.get("fields"):
275
+ md.append("Field-level local vs Nemotron selection was completed.")
276
+ md.append("")
277
+ md.append("```json")
278
+ md.append(pretty_json(selection))
279
+ md.append("```")
280
+ else:
281
+ md.append(
282
+ "No field-level comparison available for this paper. "
283
+ "The final card was generated from the best available extraction pipeline."
284
+ )
285
+
286
+ return "\n".join(md)
287
+
288
+
289
+ def safe_html(value):
290
+ return html.escape(str(value or ""), quote=True)
291
+
292
+
293
+ def short_card_text(value, max_len=48):
294
+ text = clean_display_text(value, max_len=max_len)
295
+ return safe_html(text)
296
+
297
+
298
+ def build_lab_cards_html(result):
299
+ card = get_card(result)
300
+ kit = card.get("lab_starter_kit") or result.get("lab_starter_kit") or {}
301
+
302
+ reqs = as_list(kit.get("requirements_txt"))
303
+ risks = as_list(
304
+ kit.get("reproducibility_risks")
305
+ or card.get("missing_reproducibility_info")
306
+ )
307
+ experiments = as_list(
308
+ kit.get("suggested_experiments")
309
+ or kit.get("experiment_checklist")
310
+ )
311
+
312
+ starter_type = kit.get("starter_type", card.get("paper_type", "unknown"))
313
+ tools = ", ".join(clean_display_text(x, max_len=28) for x in reqs[:3]) if reqs else "Not found"
314
+
315
+ return f"""
316
+ <div class="lab-board">
317
+ <div class="lab-board-header">
318
+ <span class="lab-emoji">🧪</span>
319
+ <span>Lab readiness</span>
320
+ </div>
321
+
322
+ <div class="lab-grid-2">
323
+ <div class="lab-tile lab-green">
324
+ <span>Starter type</span>
325
+ <b>{short_card_text(starter_type)}</b>
326
+ </div>
327
+
328
+ <div class="lab-tile lab-dark">
329
+ <span>Tools</span>
330
+ <b>{short_card_text(tools, max_len=58)}</b>
331
+ </div>
332
+
333
+ <div class="lab-tile lab-purple">
334
+ <span>Experiments</span>
335
+ <b>{len(experiments)} detected</b>
336
+ </div>
337
+
338
+ <div class="lab-tile lab-red">
339
+ <span>Risks</span>
340
+ <b>{len(risks)} issues</b>
341
+ </div>
342
+ </div>
343
+ </div>
344
+ """
345
+
346
+ def analyze_paper(pdf_file, refinement_mode):
347
+ if pdf_file is None:
348
+ raise gr.Error("Please upload a PDF first.")
349
+
350
+ if PaperPipeline is None:
351
+ raise gr.Error("PaperPipeline import failed. Run from the project root or install your package.")
352
+
353
+ pdf_path = pdf_file if isinstance(pdf_file, str) else pdf_file.name
354
+
355
+ pipeline = PaperPipeline(refinement_mode=refinement_mode)
356
+ result = pipeline.run(pdf_path)
357
+ print("PDF PATH:", pdf_path)
358
+ print("MODE:", refinement_mode)
359
+ print("FINAL DATASETS:", result.get("paper_card_final", {}).get("datasets_or_data_sources"))
360
+ print("GET_CARD DATASETS:", get_card(result).get("datasets_or_data_sources"))
361
+
362
+ card = get_card(result)
363
+
364
+ if not card.get("datasets_or_data_sources"):
365
+ roadmap = card.get("reproduction_roadmap") or {}
366
+ kit = card.get("lab_starter_kit") or {}
367
+
368
+ fallback_datasets = []
369
+
370
+ if isinstance(roadmap, dict):
371
+ fallback_datasets += roadmap.get("datasets") or []
372
+
373
+ if isinstance(kit, dict):
374
+ fallback_datasets += kit.get("dataset_plan") or []
375
+
376
+ if fallback_datasets:
377
+ card["datasets_or_data_sources"] = list(dict.fromkeys(fallback_datasets))
378
+ result["paper_card_final"] = card
379
+
380
+ paper_md = build_paper_summary_md(result)
381
+ lab_md = build_lab_md(result)
382
+ evidence_md = build_evidence_md(result)
383
+ advanced_md = build_advanced_md(result)
384
+ quick_html = build_quick_summary_html(result)
385
+ lab_cards = build_lab_cards_html(result)
386
+
387
+ tmp = Path(tempfile.mkdtemp())
388
+ json_path = tmp / "paper2lab_output.json"
389
+ md_path = tmp / "paper2lab_report.md"
390
+
391
+ json_path.write_text(pretty_json(result), encoding="utf-8")
392
+ md_path.write_text(paper_md + "\n\n---\n\n" + lab_md, encoding="utf-8")
393
+
394
+ return (
395
+ result,
396
+ quick_html,
397
+ paper_md,
398
+ lab_md,
399
+ evidence_md,
400
+ advanced_md,
401
+ lab_cards,
402
+ card,
403
+ str(json_path),
404
+ str(md_path),
405
+ )
406
+
407
+ def ask_paper_question(result, question):
408
+ if not result:
409
+ return "⚠️ Analyze a paper first.", {}
410
+
411
+ if not question or not str(question).strip():
412
+ return "⚠️ Please enter a question.", {}
413
+
414
+ question_text = str(question).strip()
415
+ q = question_text.lower()
416
+
417
+ card = get_card(result)
418
+
419
+ def md_items(title, items):
420
+ items = as_list(items)
421
+ items = [x for x in items if str(x).strip()]
422
+ if not items:
423
+ return f"**{title}:**\n\n_Not found in the structured paper card._"
424
+
425
+ return f"**{title}:**\n\n" + "\n".join(f"- {x}" for x in items[:8])
426
+
427
+ # Fast structured answers for demo-critical questions
428
+ if any(k in q for k in ["dataset", "data source", "corpus", "benchmark"]):
429
+ return md_items("Datasets / data sources", card.get("datasets_or_data_sources")), {
430
+ "source": "structured_card",
431
+ "field": "datasets_or_data_sources",
432
+ }
433
+
434
+ if any(k in q for k in ["model", "architecture", "proposed"]):
435
+
436
+ rq = card.get("research_question", "")
437
+
438
+ if rq:
439
+ return (
440
+ f"**Proposed model:**\n\n- {rq}",
441
+ {
442
+ "source": "structured_card",
443
+ "field": "research_question",
444
+ },
445
+ )
446
+
447
+ if any(k in q for k in ["finding", "result", "conclusion", "contribution"]):
448
+ return md_items("Key findings", card.get("key_findings") or card.get("contributions")), {
449
+ "source": "structured_card",
450
+ "field": "key_findings",
451
+ }
452
+
453
+ if any(k in q for k in ["metric", "score", "performance", "evaluation"]):
454
+ return md_items("Metrics / measurements", card.get("metrics_or_measurements")), {
455
+ "source": "structured_card",
456
+ "field": "metrics_or_measurements",
457
+ }
458
+
459
+ if any(k in q for k in ["limitation", "risk", "missing"]):
460
+ return md_items("Limitations / reproducibility risks", card.get("limitations")), {
461
+ "source": "structured_card",
462
+ "field": "limitations",
463
+ }
464
+
465
+ if any(k in q for k in ["reproduce", "reproduction", "roadmap", "steps"]):
466
+ roadmap = card.get("reproduction_roadmap") or {}
467
+ steps = []
468
+ if isinstance(roadmap, dict):
469
+ steps = roadmap.get("experimental_steps") or roadmap.get("missing_for_reproduction") or []
470
+ return md_items("Reproduction roadmap", steps), {
471
+ "source": "structured_card",
472
+ "field": "reproduction_roadmap",
473
+ }
474
+
475
+ if answer_from_pipeline_result is None:
476
+ return (
477
+ "⚠️ RAG module import failed. Check that `paper2lab.rag.qa` is available "
478
+ "and dependencies are installed.",
479
+ {},
480
+ )
481
+
482
+ try:
483
+ qa = answer_from_pipeline_result(
484
+ pipeline_result=result,
485
+ question=question_text,
486
+ top_k=5,
487
+ embedder_backend="local",
488
+ )
489
+
490
+ answer = qa.get("answer", "No answer found.")
491
+ evidence = qa.get("evidence", [])
492
+
493
+ evidence_md = "\n\n## Evidence\n"
494
+ for i, ev in enumerate(evidence, 1):
495
+ page = ev.get("page_start") or ev.get("page_number") or "?"
496
+ title = ev.get("title", "Evidence")
497
+ text = ev.get("text", "")
498
+ evidence_md += f"\n**{i}. {title} — page {page}**\n\n> {text[:700]}\n"
499
+
500
+ return answer + evidence_md, qa
501
+
502
+ except Exception as exc:
503
+ return f"❌ RAG error: {exc}", {}
504
+
505
+
506
+ CSS = """
507
+ /* ─────────────────────────────────────────────
508
+ Paper2Lab UI — fixed layout
509
+ Fixes:
510
+ - no duplicate upload holder/native file input
511
+ - wider hero with Paper2Lab title
512
+ - clean 2-column app layout
513
+ - lab card placed under Quick Summary
514
+ ───────────────────────────────────────────── */
515
+
516
+ body,
517
+ .gradio-container {
518
+ background: #eef2f7 !important;
519
+ color: #111827 !important;
520
+ }
521
+
522
+ * {
523
+ box-sizing: border-box;
524
+ }
525
+
526
+ footer {
527
+ display: none !important;
528
+ }
529
+
530
+ .gradio-container {
531
+ max-width: none !important;
532
+ padding-top: 28px !important;
533
+ }
534
+
535
+ /* Main page width */
536
+ .app-shell {
537
+ width: min(1220px, 94vw);
538
+ margin: 0 auto;
539
+ }
540
+
541
+ /* Remove unwanted Gradio block styling only inside our custom layout */
542
+ .top-layout .block,
543
+ .top-layout .gr-group,
544
+ .top-layout .gr-box,
545
+ .top-layout .gr-panel,
546
+ .result-stack .block,
547
+ .result-stack .gr-group,
548
+ .result-stack .gr-box,
549
+ .result-stack .gr-panel {
550
+ background: transparent !important;
551
+ border: none !important;
552
+ box-shadow: none !important;
553
+ padding: 0 !important;
554
+ margin: 0 !important;
555
+ overflow: visible !important;
556
+ }
557
+
558
+ /* ───────────────── HERO ───────────────── */
559
+
560
+ .hero {
561
+ position: relative;
562
+ overflow: hidden;
563
+ min-height: 245px;
564
+ border-radius: 24px;
565
+ padding: 34px 48px;
566
+ margin: 0 auto 28px;
567
+ background: linear-gradient(135deg, #0f172a 0%, #18253f 48%, #252659 100%);
568
+ border: 1px solid rgba(255,255,255,.08);
569
+ box-shadow: 0 22px 60px rgba(15,23,42,.18);
570
+ }
571
+
572
+ .hero::before {
573
+ content: "";
574
+ position: absolute;
575
+ inset: 0;
576
+ background:
577
+ radial-gradient(circle at 15% 25%, rgba(34,211,238,.14), transparent 30%),
578
+ radial-gradient(circle at 78% 26%, rgba(124,58,237,.22), transparent 35%);
579
+ pointer-events: none;
580
+ }
581
+
582
+ .hero-content {
583
+ position: relative;
584
+ z-index: 2;
585
+ max-width: 920px;
586
+ }
587
+
588
+ .logo-row {
589
+ display: flex;
590
+ align-items: center;
591
+ gap: 10px;
592
+ margin-bottom: 14px;
593
+ }
594
+
595
+ .logo-mark {
596
+ width: 32px;
597
+ height: 32px;
598
+ display: inline-grid;
599
+ place-items: center;
600
+ border-radius: 10px;
601
+ background: rgba(103,232,249,.14);
602
+ border: 1px solid rgba(103,232,249,.22);
603
+ font-size: 18px;
604
+ }
605
+
606
+ .logo-text {
607
+ color: #ffffff !important;
608
+ font-size: 18px;
609
+ font-weight: 950;
610
+ letter-spacing: -0.035em;
611
+ }
612
+
613
+ .kicker {
614
+ color: #67e8f9 !important;
615
+ text-transform: uppercase;
616
+ letter-spacing: .16em;
617
+ font-size: 11px;
618
+ font-weight: 900;
619
+ margin-bottom: 8px;
620
+ }
621
+
622
+ .hero h1 {
623
+ max-width: 960px;
624
+ color: #ffffff !important;
625
+ font-size: 38px;
626
+ line-height: 1.05;
627
+ letter-spacing: -0.052em;
628
+ margin: 0 0 14px;
629
+ }
630
+
631
+ .hero h1 span {
632
+ color: #a78bfa !important;
633
+ }
634
+
635
+ .hero p {
636
+ max-width: 760px;
637
+ color: rgba(255,255,255,.86) !important;
638
+ font-size: 15px;
639
+ line-height: 1.62;
640
+ margin: 0 0 22px;
641
+ }
642
+
643
+ .hero-badges {
644
+ display: flex;
645
+ flex-wrap: wrap;
646
+ gap: 10px;
647
+ }
648
+
649
+ .hero-badge {
650
+ padding: 8px 13px;
651
+ border-radius: 10px;
652
+ background: rgba(255,255,255,.12);
653
+ color: #ffffff !important;
654
+ font-weight: 850;
655
+ font-size: 12px;
656
+ border: 1px solid rgba(255,255,255,.18);
657
+ }
658
+
659
+ /* ───────────────── TWO COLUMN LAYOUT ───────────────── */
660
+
661
+ .top-layout {
662
+ display: grid !important;
663
+ grid-template-columns: 340px minmax(0, 1fr) !important;
664
+ gap: 28px !important;
665
+ align-items: start !important;
666
+ }
667
+
668
+ .control-stack {
669
+ background: #ffffff !important;
670
+ border: 1px solid #e2e8f0 !important;
671
+ border-radius: 24px !important;
672
+ padding: 24px !important;
673
+ box-shadow: 0 14px 38px rgba(15,23,42,.06) !important;
674
+ display: flex !important;
675
+ flex-direction: column !important;
676
+ gap: 18px !important;
677
+ }
678
+
679
+ .result-stack {
680
+ display: flex !important;
681
+ flex-direction: column !important;
682
+ gap: 26px !important;
683
+ background: transparent !important;
684
+ border: none !important;
685
+ padding: 0 !important;
686
+ }
687
+
688
+ /* ───────────────── LEFT PANEL ───────────────── */
689
+
690
+ .panel-title {
691
+ font-size: 16px;
692
+ font-weight: 900;
693
+ color: #111827 !important;
694
+ margin: 0;
695
+ padding-bottom: 10px;
696
+ border-bottom: 1px solid #f1f5f9;
697
+ }
698
+
699
+ /* Button */
700
+ .gradio-container button.primary,
701
+ .gradio-container button[class*="primary"] {
702
+ width: 100% !important;
703
+ min-height: 50px !important;
704
+ border: none !important;
705
+ border-radius: 15px !important;
706
+ background: linear-gradient(135deg, #7c3aed 0%, #4f46e5 100%) !important;
707
+ color: #ffffff !important;
708
+ font-weight: 950 !important;
709
+ font-size: 15px !important;
710
+ box-shadow: 0 12px 26px rgba(124,58,237,.28) !important;
711
+ }
712
+
713
+ .gradio-container button.primary *,
714
+ .gradio-container button[class*="primary"] * {
715
+ color: #ffffff !important;
716
+ }
717
+
718
+ .tip-box {
719
+ padding: 12px 14px;
720
+ border-radius: 14px;
721
+ background: #fafbff;
722
+ border: 1px solid #e5e7eb;
723
+ color: #4b5563 !important;
724
+ font-size: 12px;
725
+ font-weight: 750;
726
+ }
727
+
728
+ /* ───────────────── QUICK SUMMARY ───────────────── */
729
+
730
+ .summary-card {
731
+ background: #ffffff !important;
732
+ border: 1px solid #e2e8f0 !important;
733
+ border-radius: 22px !important;
734
+ overflow: hidden !important;
735
+ box-shadow: 0 14px 38px rgba(15,23,42,.06) !important;
736
+ }
737
+
738
+ .card-head {
739
+ display: flex;
740
+ align-items: center;
741
+ justify-content: space-between;
742
+ padding: 17px 22px;
743
+ border-bottom: 1px solid #f1f5f9;
744
+ }
745
+
746
+ .card-head b {
747
+ color: #111827 !important;
748
+ font-size: 16px;
749
+ }
750
+
751
+ .icon {
752
+ display: inline-grid;
753
+ place-items: center;
754
+ width: 28px;
755
+ height: 28px;
756
+ margin-right: 9px;
757
+ border-radius: 9px;
758
+ background: #ede9fe;
759
+ font-size: 15px;
760
+ }
761
+
762
+ .status-pill {
763
+ background: #dcfce7;
764
+ color: #047857 !important;
765
+ padding: 6px 13px;
766
+ border-radius: 999px;
767
+ font-weight: 900;
768
+ font-size: 12px;
769
+ }
770
+
771
+ .summary-row {
772
+ display: grid;
773
+ grid-template-columns: 140px minmax(0, 1fr);
774
+ gap: 16px;
775
+ padding: 15px 22px;
776
+ border-bottom: 1px solid #f8fafc;
777
+ font-size: 14px;
778
+ }
779
+
780
+ .label {
781
+ color: #6b7280 !important;
782
+ font-weight: 850;
783
+ }
784
+
785
+ .value {
786
+ color: #111827 !important;
787
+ line-height: 1.55;
788
+ overflow-wrap: anywhere;
789
+ }
790
+
791
+ .highlight {
792
+ background: #f5f3ff;
793
+ border-left: 3px solid #7c3aed;
794
+ border-radius: 0 10px 10px 0;
795
+ padding: 10px 12px;
796
+ color: #312e81 !important;
797
+ }
798
+
799
+ .chip {
800
+ display: inline-block;
801
+ margin: 3px 5px 3px 0;
802
+ padding: 6px 10px;
803
+ border-radius: 999px;
804
+ background: #ede9fe;
805
+ color: #4c1d95 !important;
806
+ border: 1px solid #c4b5fd;
807
+ font-weight: 800;
808
+ font-size: 12px;
809
+ }
810
+
811
+ .muted {
812
+ color: #9ca3af !important;
813
+ }
814
+
815
+ /* ───────────────── LAB READINESS ───────────────── */
816
+
817
+ .lab-board {
818
+ background: #ffffff !important;
819
+ border: 1px solid #e2e8f0 !important;
820
+ border-radius: 22px !important;
821
+ padding: 18px !important;
822
+ box-shadow: 0 14px 38px rgba(15,23,42,.06) !important;
823
+ }
824
+
825
+ .lab-board-header {
826
+ display: flex;
827
+ align-items: center;
828
+ gap: 12px;
829
+ background: #f8f9fc;
830
+ border: 1px solid #ebebf0;
831
+ border-radius: 14px;
832
+ padding: 14px 18px;
833
+ margin-bottom: 16px;
834
+ color: #0f172a !important;
835
+ font-size: 18px;
836
+ font-weight: 950;
837
+ letter-spacing: -0.03em;
838
+ }
839
+
840
+ .lab-board-header * {
841
+ color: #0f172a !important;
842
+ }
843
+
844
+ .lab-emoji {
845
+ font-size: 22px;
846
+ line-height: 1;
847
+ flex: 0 0 auto;
848
+ }
849
+
850
+ .lab-grid-2 {
851
+ display: grid;
852
+ grid-template-columns: repeat(2, minmax(0, 1fr));
853
+ gap: 14px;
854
+ }
855
+
856
+ .lab-tile {
857
+ min-height: 108px;
858
+ padding: 18px 20px;
859
+ border-radius: 16px;
860
+ border: 1px solid transparent;
861
+ overflow: hidden;
862
+ }
863
+
864
+ .lab-tile span {
865
+ display: block;
866
+ margin-bottom: 10px;
867
+ font-size: 12px;
868
+ line-height: 1.2;
869
+ font-weight: 900;
870
+ letter-spacing: .04em;
871
+ text-transform: uppercase;
872
+ }
873
+
874
+ .lab-tile b {
875
+ display: block;
876
+ font-size: 23px;
877
+ line-height: 1.15;
878
+ font-weight: 950;
879
+ letter-spacing: -0.035em;
880
+ overflow-wrap: break-word;
881
+ }
882
+
883
+ .lab-green {
884
+ background: #ecfdf5 !important;
885
+ color: #065f46 !important;
886
+ border-color: #a7f3d0 !important;
887
+ }
888
+
889
+ .lab-dark {
890
+ background:
891
+ radial-gradient(circle at 84% 18%, rgba(124,58,237,.28), transparent 36%),
892
+ #1e1b4b !important;
893
+ color: #ffffff !important;
894
+ border-color: #3730a3 !important;
895
+ }
896
+
897
+ .lab-purple {
898
+ background: #f5f3ff !important;
899
+ color: #4c1d95 !important;
900
+ border-color: #ddd6fe !important;
901
+ }
902
+
903
+ .lab-red {
904
+ background: #fff1f2 !important;
905
+ color: #9f1239 !important;
906
+ border-color: #fecdd3 !important;
907
+ }
908
+
909
+ .lab-green *,
910
+ .lab-dark *,
911
+ .lab-purple *,
912
+ .lab-red * {
913
+ color: inherit !important;
914
+ }
915
+
916
+ /* ───────────────── TABS ───────────────── */
917
+
918
+ .tabs-shell {
919
+ margin-top: 28px;
920
+ }
921
+
922
+ .gradio-container .tab-nav {
923
+ gap: 6px !important;
924
+ border-bottom: 1px solid #cbd5e1 !important;
925
+ }
926
+
927
+ .gradio-container .tab-nav button {
928
+ font-size: 14px !important;
929
+ font-weight: 750 !important;
930
+ color: #6b7280 !important;
931
+ padding: 11px 18px !important;
932
+ background: transparent !important;
933
+ border: none !important;
934
+ border-bottom: 3px solid transparent !important;
935
+ white-space: nowrap !important;
936
+ }
937
+
938
+ .gradio-container .tab-nav button.selected {
939
+ color: #6d28d9 !important;
940
+ border-bottom-color: #7c3aed !important;
941
+ }
942
+
943
+ .gradio-container .tabs > .block,
944
+ .gradio-container .tabitem {
945
+ background: #ffffff !important;
946
+ border: 1px solid #e2e8f0 !important;
947
+ border-radius: 18px !important;
948
+ box-shadow: 0 8px 24px rgba(15,23,42,.04) !important;
949
+ overflow: hidden !important;
950
+ }
951
+
952
+ /* General text */
953
+ .gradio-container .markdown,
954
+ .gradio-container p,
955
+ .gradio-container li,
956
+ .gradio-container td,
957
+ .gradio-container th,
958
+ .gradio-container label,
959
+ .gradio-container textarea,
960
+ .gradio-container input {
961
+ color: #111827 !important;
962
+ }
963
+
964
+ /* ───────────────── RESPONSIVE ───────────────── */
965
+
966
+ @media (max-width: 900px) {
967
+ .app-shell {
968
+ width: min(94vw, 760px);
969
+ }
970
+
971
+ .hero {
972
+ min-height: auto;
973
+ padding: 26px 22px;
974
+ }
975
+
976
+ .hero h1 {
977
+ font-size: 28px;
978
+ }
979
+
980
+ .hero p {
981
+ font-size: 14px;
982
+ }
983
+
984
+ .top-layout {
985
+ grid-template-columns: 1fr !important;
986
+ }
987
+
988
+ .summary-row {
989
+ grid-template-columns: 1fr;
990
+ gap: 6px;
991
+ }
992
+
993
+ .lab-grid-2 {
994
+ grid-template-columns: 1fr;
995
+ }
996
+ }
997
+
998
+
999
+ /* "Analyze Paper" text inside Quick Summary */
1000
+ .summary-card .value,
1001
+ .summary-card .value *,
1002
+ .summary-row .value,
1003
+ .summary-row .value * {
1004
+ color: #111827 !important;
1005
+ opacity: 1 !important;
1006
+ visibility: visible !important;
1007
+ }
1008
+
1009
+ /* Quick summary text */
1010
+ .summary-card .value,
1011
+ .summary-card .value *,
1012
+ .summary-row .value,
1013
+ .summary-row .value * {
1014
+ color: #111827 !important;
1015
+ opacity: 1 !important;
1016
+ -webkit-text-fill-color: #111827 !important;
1017
+ }
1018
+
1019
+ /* Tabs text */
1020
+ .gradio-container button[role="tab"],
1021
+ .gradio-container button[role="tab"] *,
1022
+ .gradio-container .tab-nav button,
1023
+ .gradio-container .tab-nav button * {
1024
+ color: #111827 !important;
1025
+ opacity: 1 !important;
1026
+ visibility: visible !important;
1027
+ -webkit-text-fill-color: #111827 !important;
1028
+ }
1029
+
1030
+ .gradio-container button[role="tab"][aria-selected="true"],
1031
+ .gradio-container button[role="tab"][aria-selected="true"] *,
1032
+ .gradio-container .tab-nav button.selected,
1033
+ .gradio-container .tab-nav button.selected * {
1034
+ color: #7c3aed !important;
1035
+ -webkit-text-fill-color: #7c3aed !important;
1036
+ font-weight: 800 !important;
1037
+ }
1038
+
1039
+ /* ===== FINAL LEFT PANEL UPLOAD FIX ===== */
1040
+
1041
+ .upload-caption {
1042
+ display: inline-flex;
1043
+ width: fit-content;
1044
+ align-items: center;
1045
+ gap: 8px;
1046
+ padding: 11px 20px;
1047
+ border-radius: 12px;
1048
+ background: linear-gradient(135deg, #7c3aed, #6d28d9);
1049
+ color: #ffffff !important;
1050
+ -webkit-text-fill-color: #ffffff !important;
1051
+ font-size: 15px;
1052
+ font-weight: 900;
1053
+ box-shadow: 0 12px 24px rgba(124,58,237,.22);
1054
+ margin-bottom: 0 !important;
1055
+ }
1056
+
1057
+ .upload-caption * {
1058
+ color: #ffffff !important;
1059
+ -webkit-text-fill-color: #ffffff !important;
1060
+ }
1061
+
1062
+ #pdf_upload {
1063
+ width: 100% !important;
1064
+ margin: 0 !important;
1065
+ padding: 0 !important;
1066
+ background: transparent !important;
1067
+ border: none !important;
1068
+ box-shadow: none !important;
1069
+ }
1070
+
1071
+ /* Main drop area only */
1072
+ #pdf_upload .wrap {
1073
+ width: 100% !important;
1074
+ min-height: 215px !important;
1075
+ background: #ffffff !important;
1076
+ border: 2px dashed #c4b5fd !important;
1077
+ border-radius: 24px !important;
1078
+ display: grid !important;
1079
+ place-items: center !important;
1080
+ text-align: center !important;
1081
+ position: relative !important;
1082
+ overflow: hidden !important;
1083
+ }
1084
+
1085
+ /* Hide only Gradio placeholder text before upload */
1086
+ #pdf_upload .wrap > div:first-child,
1087
+ #pdf_upload .wrap > span,
1088
+ #pdf_upload .wrap > p {
1089
+ display: none !important;
1090
+ }
1091
+
1092
+ /* Custom placeholder */
1093
+ #pdf_upload .wrap::after {
1094
+ content: "☁️\A Drag & drop your PDF file here\A or click to browse";
1095
+ white-space: pre-line;
1096
+ color: #334155 !important;
1097
+ -webkit-text-fill-color: #334155 !important;
1098
+ font-size: 16px !important;
1099
+ line-height: 1.8 !important;
1100
+ font-weight: 700 !important;
1101
+ text-align: center !important;
1102
+ }
1103
+
1104
+ /* After file upload: remove fake placeholder */
1105
+ #pdf_upload:has([data-testid="file"]) .wrap::after,
1106
+ #pdf_upload:has(.file-preview) .wrap::after {
1107
+ content: "" !important;
1108
+ }
1109
+
1110
+ /* Uploaded file card readability */
1111
+ #pdf_upload [data-testid="file"],
1112
+ #pdf_upload .file-preview,
1113
+ #pdf_upload .file-preview *,
1114
+ #pdf_upload a,
1115
+ #pdf_upload span,
1116
+ #pdf_upload p {
1117
+ color: #111827 !important;
1118
+ -webkit-text-fill-color: #111827 !important;
1119
+ opacity: 1 !important;
1120
+ }
1121
+
1122
+ /* Hide built-in label only */
1123
+ #pdf_upload .label-wrap,
1124
+ #pdf_upload label,
1125
+ #pdf_upload [data-testid="block-label"],
1126
+ #pdf_upload input[type="file"] {
1127
+ display: none !important;
1128
+ }
1129
+
1130
+ /* Engine wrapper */
1131
+ #engine_radio,
1132
+ #engine_radio *,
1133
+ #engine_radio fieldset,
1134
+ #engine_radio .wrap {
1135
+ background: #FFFFFF !important;
1136
+ border: none !important;
1137
+ box-shadow: none !important;
1138
+ }
1139
+
1140
+ .engine-caption {
1141
+ display: inline-flex;
1142
+ width: fit-content;
1143
+ align-items: center;
1144
+ padding: 8px 14px;
1145
+ border-radius: 10px;
1146
+ background: #7c3aed;
1147
+ color: #ffffff !important;
1148
+ -webkit-text-fill-color: #ffffff !important;
1149
+ font-size: 16px;
1150
+ font-weight: 900;
1151
+ margin-bottom: 10px;
1152
+ }
1153
+
1154
+
1155
+ #engine_radio label,
1156
+ #engine_radio label span {
1157
+ background: transparent !important;
1158
+ border: none !important;
1159
+ box-shadow: none !important;
1160
+ color: #8b5cf6 !important;
1161
+ -webkit-text-fill-color: #8b5cf6 !important;
1162
+ font-size: 14px !important;
1163
+ font-weight: 900 !important;
1164
+ }
1165
+
1166
+ /* Selected option purple */
1167
+ #engine_radio label:has(input:checked),
1168
+ #engine_radio label:has(input:checked) * {
1169
+ color: #5b21b6 !important;
1170
+ -webkit-text-fill-color: #5b21b6 !important;
1171
+ }
1172
+
1173
+ /* Radio circles */
1174
+ #engine_radio input[type="radio"] {
1175
+ display: inline-block !important;
1176
+ appearance: auto !important;
1177
+ -webkit-appearance: radio !important;
1178
+ opacity: 1 !important;
1179
+ visibility: visible !important;
1180
+ position: static !important;
1181
+ width: 14px !important;
1182
+ height: 14px !important;
1183
+ margin-right: 8px !important;
1184
+ accent-color: #5b21b6 !important;
1185
+ }
1186
+
1187
+ #engine_radio .wrap {
1188
+ display: flex !important;
1189
+ flex-direction: row !important;
1190
+ gap: 40px !important;
1191
+ align-items: center !important;
1192
+ }
1193
+
1194
+ /* Hide Gradio's built-in title completely */
1195
+ #engine_radio legend,
1196
+ #engine_radio .label-wrap,
1197
+ #engine_radio [data-testid="block-label"] {
1198
+ display: none !important;
1199
+ }
1200
+
1201
+
1202
+ /* EMERGENCY READABILITY FIX */
1203
+
1204
+ .gradio-container h1,
1205
+ .gradio-container h2,
1206
+ .gradio-container h3,
1207
+ .gradio-container h4,
1208
+ .gradio-container h5,
1209
+ .gradio-container h6 {
1210
+ color: #111827 !important;
1211
+ opacity: 1 !important;
1212
+ visibility: visible !important;
1213
+ font-weight: 800 !important;
1214
+ }
1215
+
1216
+ /* Safe readability fix: Markdown only, not JSON/code internals */
1217
+ .gradio-container .markdown,
1218
+ .gradio-container .markdown p,
1219
+ .gradio-container .markdown li,
1220
+ .gradio-container .markdown h1,
1221
+ .gradio-container .markdown h2,
1222
+ .gradio-container .markdown h3,
1223
+ .gradio-container .markdown strong {
1224
+ color: #111827 !important;
1225
+ opacity: 1 !important;
1226
+ }
1227
+
1228
+ /* Keep code/JSON readable */
1229
+ .gradio-container pre,
1230
+ .gradio-container code,
1231
+ .gradio-container pre *,
1232
+ .gradio-container code *,
1233
+ .gradio-container .cm-editor,
1234
+ .gradio-container .cm-editor * {
1235
+ color: inherit !important;
1236
+ -webkit-text-fill-color: inherit !important;
1237
+ }
1238
+
1239
+ /* ================= FINAL TAB READABILITY PATCH ================= */
1240
+
1241
+ /* Normal markdown text, including _Not found._ italic text */
1242
+ .gradio-container .markdown,
1243
+ .gradio-container .markdown *,
1244
+ .gradio-container .prose,
1245
+ .gradio-container .prose * {
1246
+ color: #111827 !important;
1247
+ -webkit-text-fill-color: #111827 !important;
1248
+ opacity: 1 !important;
1249
+ }
1250
+
1251
+ /* Markdown italic fallback like _Not found._ */
1252
+ .gradio-container em,
1253
+ .gradio-container i {
1254
+ color: #475569 !important;
1255
+ -webkit-text-fill-color: #475569 !important;
1256
+ opacity: 1 !important;
1257
+ }
1258
+
1259
+ /* Inline code like `survey_or_review` */
1260
+ .gradio-container .markdown code,
1261
+ .gradio-container p code,
1262
+ .gradio-container li code {
1263
+ background: #f1f5f9 !important;
1264
+ color: #5b21b6 !important;
1265
+ -webkit-text-fill-color: #5b21b6 !important;
1266
+ padding: 2px 6px !important;
1267
+ border-radius: 6px !important;
1268
+ font-weight: 800 !important;
1269
+ }
1270
+
1271
+ /* Force tab panels to stay white */
1272
+ .gradio-container .tabitem,
1273
+ .gradio-container .tabitem > div,
1274
+ .gradio-container .tabitem .block,
1275
+ .gradio-container .tabitem .wrap {
1276
+ background: #ffffff !important;
1277
+ color: #111827 !important;
1278
+ -webkit-text-fill-color: #111827 !important;
1279
+ }
1280
+
1281
+ /* Advanced accordion fix */
1282
+ .gradio-container .accordion,
1283
+ .gradio-container .accordion *,
1284
+ .gradio-container details,
1285
+ .gradio-container details * {
1286
+ background: #ffffff !important;
1287
+ color: #111827 !important;
1288
+ -webkit-text-fill-color: #111827 !important;
1289
+ }
1290
+
1291
+ /* Tables in Advanced Analysis */
1292
+ .gradio-container table,
1293
+ .gradio-container table *,
1294
+ .gradio-container th,
1295
+ .gradio-container td {
1296
+ background: #ffffff !important;
1297
+ color: #111827 !important;
1298
+ -webkit-text-fill-color: #111827 !important;
1299
+ border-color: #cbd5e1 !important;
1300
+ }
1301
+
1302
+ /* Code blocks inside markdown */
1303
+ .gradio-container pre,
1304
+ .gradio-container pre *,
1305
+ .gradio-container code,
1306
+ .gradio-container code * {
1307
+ background: #f8fafc !important;
1308
+ color: #111827 !important;
1309
+ -webkit-text-fill-color: #111827 !important;
1310
+ }
1311
+
1312
+ /* Export JSON/code viewer: make it readable */
1313
+ .gradio-container .cm-editor,
1314
+ .gradio-container .cm-editor *,
1315
+ .gradio-container .cm-scroller,
1316
+ .gradio-container .cm-content,
1317
+ .gradio-container .cm-line {
1318
+ background: #f8fafc !important;
1319
+ color: #111827 !important;
1320
+ -webkit-text-fill-color: #111827 !important;
1321
+ }
1322
+
1323
+ /* Export file boxes */
1324
+ .gradio-container [data-testid="file"],
1325
+ .gradio-container [data-testid="file"] *,
1326
+ .gradio-container .file-preview,
1327
+ .gradio-container .file-preview * {
1328
+ background: #f8fafc !important;
1329
+ color: #111827 !important;
1330
+ -webkit-text-fill-color: #111827 !important;
1331
+ }
1332
+
1333
+ /* ================= LAB READINESS RESTORE ================= */
1334
+
1335
+ /* Restore dark Tools tile readability */
1336
+ .lab-board .lab-dark,
1337
+ .lab-board .lab-dark *,
1338
+ .lab-board .lab-dark span,
1339
+ .lab-board .lab-dark b {
1340
+ color: #ffffff !important;
1341
+ -webkit-text-fill-color: #ffffff !important;
1342
+ opacity: 1 !important;
1343
+ }
1344
+
1345
+ /* Optional: softer label inside dark card */
1346
+ .lab-board .lab-dark span {
1347
+ color: #c4b5fd !important;
1348
+ -webkit-text-fill-color: #c4b5fd !important;
1349
+ }
1350
+
1351
+ /* Keep other lab tiles clean */
1352
+ .lab-board .lab-green,
1353
+ .lab-board .lab-green * {
1354
+ color: #065f46 !important;
1355
+ -webkit-text-fill-color: #065f46 !important;
1356
+ }
1357
+
1358
+ .lab-board .lab-purple,
1359
+ .lab-board .lab-purple * {
1360
+ color: #4c1d95 !important;
1361
+ -webkit-text-fill-color: #4c1d95 !important;
1362
+ }
1363
+
1364
+ .lab-board .lab-red,
1365
+ .lab-board .lab-red * {
1366
+ color: #9f1239 !important;
1367
+ -webkit-text-fill-color: #9f1239 !important;
1368
+ }
1369
+
1370
+ /* ================= HERO COLOR RESTORE ================= */
1371
+
1372
+ .hero,
1373
+ .hero * {
1374
+ color: inherit;
1375
+ }
1376
+
1377
+ .hero .logo-text {
1378
+ color: #ffffff !important;
1379
+ -webkit-text-fill-color: #ffffff !important;
1380
+ }
1381
+
1382
+ .hero .kicker {
1383
+ color: #67e8f9 !important;
1384
+ -webkit-text-fill-color: #67e8f9 !important;
1385
+ }
1386
+
1387
+ .hero h1 {
1388
+ color: #ffffff !important;
1389
+ -webkit-text-fill-color: #ffffff !important;
1390
+ }
1391
+
1392
+ .hero h1 span {
1393
+ color: #a78bfa !important;
1394
+ -webkit-text-fill-color: #a78bfa !important;
1395
+ }
1396
+
1397
+ .hero p {
1398
+ color: rgba(255,255,255,.88) !important;
1399
+ -webkit-text-fill-color: rgba(255,255,255,.88) !important;
1400
+ }
1401
+
1402
+ .hero .hero-badge,
1403
+ .hero .hero-badge * {
1404
+ color: #ffffff !important;
1405
+ -webkit-text-fill-color: #ffffff !important;
1406
+ }
1407
+
1408
+ .hero .logo-mark {
1409
+ color: inherit !important;
1410
+ -webkit-text-fill-color: inherit !important;
1411
+ }
1412
+
1413
+ /* ================= ASK THE PAPER / RAG FIX ================= */
1414
+
1415
+ .rag-panel,
1416
+ .rag-panel * {
1417
+ color: #111827 !important;
1418
+ -webkit-text-fill-color: #111827 !important;
1419
+ }
1420
+
1421
+ .rag-question,
1422
+ .rag-question *,
1423
+ .rag-question textarea,
1424
+ .rag-question input {
1425
+ background: #ffffff !important;
1426
+ color: #111827 !important;
1427
+ -webkit-text-fill-color: #111827 !important;
1428
+ border-color: #cbd5e1 !important;
1429
+ }
1430
+
1431
+ .rag-question textarea::placeholder,
1432
+ .rag-question input::placeholder {
1433
+ color: #64748b !important;
1434
+ -webkit-text-fill-color: #64748b !important;
1435
+ opacity: 1 !important;
1436
+ }
1437
+
1438
+ .rag-answer {
1439
+ background: #ffffff !important;
1440
+ border: 1px solid #e2e8f0 !important;
1441
+ border-radius: 18px !important;
1442
+ padding: 22px !important;
1443
+ box-shadow: 0 8px 24px rgba(15,23,42,.04) !important;
1444
+ line-height: 1.65 !important;
1445
+ }
1446
+
1447
+ .rag-answer,
1448
+ .rag-answer * {
1449
+ color: #111827 !important;
1450
+ -webkit-text-fill-color: #111827 !important;
1451
+ }
1452
+
1453
+ .rag-answer h1,
1454
+ .rag-answer h2,
1455
+ .rag-answer h3 {
1456
+ margin-top: 18px !important;
1457
+ margin-bottom: 10px !important;
1458
+ font-weight: 900 !important;
1459
+ }
1460
+
1461
+ .rag-answer ul {
1462
+ padding-left: 22px !important;
1463
+ }
1464
+
1465
+ .rag-answer li {
1466
+ margin-bottom: 7px !important;
1467
+ }
1468
+
1469
+ .rag-answer blockquote {
1470
+ margin: 12px 0 !important;
1471
+ padding: 12px 16px !important;
1472
+ border-left: 4px solid #7c3aed !important;
1473
+ background: #f8fafc !important;
1474
+ border-radius: 10px !important;
1475
+ color: #334155 !important;
1476
+ -webkit-text-fill-color: #334155 !important;
1477
+ }
1478
+
1479
+ .rag-debug,
1480
+ .rag-debug * {
1481
+ background: #ffffff !important;
1482
+ color: #111827 !important;
1483
+ -webkit-text-fill-color: #111827 !important;
1484
+ }
1485
+ """
1486
+
1487
+ theme = gr.themes.Soft(
1488
+ primary_hue="violet",
1489
+ secondary_hue="blue",
1490
+ neutral_hue="slate",
1491
+ )
1492
+
1493
+
1494
+ with gr.Blocks(title=APP_NAME, theme=theme, css=CSS) as demo:
1495
+ state = gr.State({})
1496
+
1497
+ gr.HTML(
1498
+ """
1499
+ <div class="app-shell">
1500
+ <section class="hero">
1501
+ <div class="hero-content">
1502
+ <div class="logo-row">
1503
+ <span class="logo-mark">🧪</span>
1504
+ <span class="logo-text">Paper2Lab</span>
1505
+ </div>
1506
+
1507
+ <div class="kicker">Research Assistant</div>
1508
+
1509
+ <h1>
1510
+ Turn research papers into
1511
+ <span>actionable lab plans</span>
1512
+ </h1>
1513
+
1514
+ <p>
1515
+ Upload a PDF and get a structured paper card, lab starter kit,
1516
+ evidence grounding, reproducibility assessment and exportable reports.
1517
+ </p>
1518
+
1519
+ <div class="hero-badges">
1520
+ <div class="hero-badge">✦ AI-powered extraction</div>
1521
+ <div class="hero-badge">□ Evidence grounded</div>
1522
+ <div class="hero-badge">☘ Reproducibility ready</div>
1523
+ </div>
1524
+ </div>
1525
+ </section>
1526
+ </div>
1527
+ """
1528
+ )
1529
+
1530
+ with gr.Row(elem_classes=["app-shell", "top-layout"]):
1531
+ with gr.Column(scale=4, min_width=300, elem_classes=["control-stack"]):
1532
+ gr.HTML("""<div class="panel-title">📤 1. Upload your paper</div>""")
1533
+ gr.HTML("""<div class="upload-caption">📄 Drop your PDF here</div>""")
1534
+
1535
+ pdf_input = gr.File(
1536
+ label=None,
1537
+ show_label=False,
1538
+ file_types=[".pdf"],
1539
+ type="filepath",
1540
+ elem_id="pdf_upload",
1541
+ elem_classes=["clean-upload"],
1542
+ )
1543
+
1544
+ gr.HTML("""<div class="panel-title">⚡ 2. Select analysis engine</div>""")
1545
+ gr.HTML("""<div class="engine-caption">⚙️ Analysis engine</div>""")
1546
+
1547
+ refinement_mode = gr.Radio(
1548
+ label=None,
1549
+ container=False,
1550
+ choices=["local", "nemotron"],
1551
+ value="nemotron",
1552
+ elem_id="engine_radio",
1553
+ )
1554
+
1555
+ run_btn = gr.Button(
1556
+ "✨ Analyze Paper",
1557
+ variant="primary",
1558
+ size="lg",
1559
+ elem_classes=["clean-button"],
1560
+ )
1561
+
1562
+ gr.HTML("""<div class="tip-box">💡 Use a clear-text PDF for best results.</div>""")
1563
+
1564
+ with gr.Column(scale=7, min_width=520, elem_classes=["result-stack"]):
1565
+ quick_summary = gr.HTML(
1566
+ """
1567
+ <div class="summary-card">
1568
+ <div class="card-head">
1569
+ <div><span class="icon">📄</span><b>Quick summary</b></div>
1570
+ <span class="status-pill">Ready</span>
1571
+ </div>
1572
+ <div class="summary-row">
1573
+ <div class="label">Status</div>
1574
+ <div class="value">Upload a PDF and click <b>Analyze Paper</b>.</div>
1575
+ </div>
1576
+ </div>
1577
+ """
1578
+ )
1579
+
1580
+ lab_cards_html = gr.HTML(
1581
+ """
1582
+ <div class="lab-board">
1583
+ <div class="lab-board-header">
1584
+ <span class="lab-emoji">🧪</span>
1585
+ <span>Lab readiness</span>
1586
+ </div>
1587
+
1588
+ <div class="lab-grid-2">
1589
+ <div class="lab-tile lab-green">
1590
+ <span>Starter type</span>
1591
+ <b>Waiting</b>
1592
+ </div>
1593
+
1594
+ <div class="lab-tile lab-dark">
1595
+ <span>Tools</span>
1596
+ <b>Waiting</b>
1597
+ </div>
1598
+
1599
+ <div class="lab-tile lab-purple">
1600
+ <span>Experiments</span>
1601
+ <b>Waiting</b>
1602
+ </div>
1603
+
1604
+ <div class="lab-tile lab-red">
1605
+ <span>Risks</span>
1606
+ <b>Waiting</b>
1607
+ </div>
1608
+ </div>
1609
+ </div>
1610
+ """
1611
+ )
1612
+
1613
+ with gr.Row(elem_classes=["app-shell", "tabs-shell"]):
1614
+ with gr.Column():
1615
+ with gr.Tabs():
1616
+ with gr.Tab("📄 Paper Summary"):
1617
+ paper_md = gr.Markdown()
1618
+
1619
+ with gr.Tab("🧪 Lab Starter Kit"):
1620
+ lab_md = gr.Markdown()
1621
+
1622
+ with gr.Tab("🔎 Evidence Viewer"):
1623
+ evidence_md = gr.Markdown()
1624
+
1625
+ with gr.Tab("💬 Ask the Paper"):
1626
+ with gr.Column(elem_classes=["rag-panel"]):
1627
+ rag_question = gr.Textbox(
1628
+ label="Ask a question about the uploaded paper",
1629
+ placeholder="Example: What datasets were used?",
1630
+ lines=2,
1631
+ elem_classes=["rag-question"],
1632
+ )
1633
+
1634
+ rag_btn = gr.Button("Ask", variant="primary")
1635
+
1636
+ rag_answer = gr.Markdown(elem_classes=["rag-answer"])
1637
+
1638
+ rag_json = gr.JSON(
1639
+ label="RAG debug output",
1640
+ elem_classes=["rag-debug"],
1641
+ visible=False, # better for demo
1642
+ )
1643
+
1644
+ with gr.Tab("⚙️ Advanced Analysis"):
1645
+ with gr.Accordion("Model comparison, selection report, metadata", open=False):
1646
+ advanced_md = gr.Markdown()
1647
+
1648
+ with gr.Tab("⬇️ Export"):
1649
+ final_json = gr.JSON(label="Final paper card")
1650
+ json_file = gr.File(label="Download JSON")
1651
+ md_file = gr.File(label="Download Markdown report")
1652
+
1653
+ run_btn.click(
1654
+ fn=analyze_paper,
1655
+ inputs=[pdf_input, refinement_mode],
1656
+ outputs=[
1657
+ state,
1658
+ quick_summary,
1659
+ paper_md,
1660
+ lab_md,
1661
+ evidence_md,
1662
+ advanced_md,
1663
+ lab_cards_html,
1664
+ final_json,
1665
+ json_file,
1666
+ md_file,
1667
+ ],
1668
+ show_progress=True,
1669
+ )
1670
+
1671
+ rag_btn.click(
1672
+ fn=ask_paper_question,
1673
+ inputs=[state, rag_question],
1674
+ outputs=[rag_answer, rag_json],
1675
+ show_progress=True,
1676
+ )
1677
+
1678
+ if __name__ == "__main__":
1679
+ demo.queue()
1680
+ demo.launch()
configs/dataset.yaml ADDED
File without changes
configs/evaluation.yaml ADDED
File without changes
configs/inference.yaml ADDED
File without changes
configs/training.yaml ADDED
File without changes
docs/annotation_schema.md ADDED
File without changes
docs/dataset_card.md ADDED
File without changes
docs/model_card.md ADDED
File without changes
modal_refine.py ADDED
@@ -0,0 +1,85 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import modal
4
+
5
+ app = modal.App("paper2lab-nemotron")
6
+
7
+ image = (
8
+ modal.Image.debian_slim(python_version="3.11")
9
+ .pip_install("requests")
10
+ .add_local_dir("src/paper2lab", remote_path="/root/paper2lab")
11
+ )
12
+
13
+ secret = modal.Secret.from_name("nvidia-api-key")
14
+
15
+
16
+ @app.function(
17
+ image=image,
18
+ secrets=[secret],
19
+ timeout=180,
20
+ )
21
+ def refine_remote(
22
+ llm_evidence_pack: dict,
23
+ model: str = "nvidia/nemotron-3-nano-30b-a3b",
24
+ return_comparison: bool = True,
25
+ ) -> dict:
26
+ from paper2lab.inference.nemotron_refiner import refine_with_nemotron
27
+
28
+ return refine_with_nemotron(
29
+ llm_evidence_pack=llm_evidence_pack,
30
+ model=model,
31
+ return_comparison=return_comparison,
32
+ )
33
+
34
+
35
+ @app.local_entrypoint()
36
+ def main():
37
+ sample_pack = {
38
+ "candidate_paper_card": {
39
+ "title": "Attention Is All You Need",
40
+ "field": "Natural Language Processing",
41
+ "paper_type": "machine_learning",
42
+ "research_question": "The paper proposes the Transformer architecture.",
43
+ "contributions": [
44
+ "The paper proposes a Transformer architecture based on attention."
45
+ ],
46
+ "methodology": [
47
+ "The model uses multi-head self-attention and feed-forward layers."
48
+ ],
49
+ "datasets_or_data_sources": [
50
+ "WMT 2014 English-German dataset",
51
+ "RNN",
52
+ "BerkleyParser"
53
+ ],
54
+ "models_or_methods": [
55
+ "Transformer",
56
+ "multi-head attention"
57
+ ],
58
+ "metrics_or_measurements": [
59
+ "BLEU"
60
+ ],
61
+ "key_findings": [],
62
+ "limitations": [],
63
+ "missing_reproducibility_info": [
64
+ "random seed is not specified"
65
+ ],
66
+ "metadata": {},
67
+ "source_pdf": "attention is all you need.pdf",
68
+ "annotation_version": "v1.0",
69
+ },
70
+ "section_previews": [
71
+ {
72
+ "title": "Training Data and Batching",
73
+ "role_hint": "methodology",
74
+ "page_start": 7,
75
+ "page_end": 7,
76
+ "preview": "We trained on the standard WMT 2014 English-German dataset consisting of about 4.5 million sentence pairs. For English-French, we used the significantly larger WMT 2014 English-French dataset consisting of 36M sentences."
77
+ }
78
+ ],
79
+ "captions": [],
80
+ "tables": [],
81
+ "metadata": {}
82
+ }
83
+
84
+ result = refine_remote.remote(sample_pack)
85
+ print(result)
pyproject.toml ADDED
File without changes
requirements.txt ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # UI
2
+ gradio>=5.0.0
3
+
4
+ # PDF processing
5
+ pymupdf>=1.26.0
6
+ docling>=2.4.0
7
+
8
+ # LLMs
9
+ transformers>=4.53.0
10
+ torch>=2.7.0
11
+ accelerate>=1.8.0
12
+
13
+ # Tokenizers & model loading
14
+ sentencepiece>=0.2.0
15
+ safetensors>=0.5.0
16
+ tokenizers>=0.21.0
17
+
18
+ # Data processing
19
+ numpy>=2.2.0
20
+ pandas>=2.3.0
21
+ pyyaml>=6.0.2
22
+
23
+ # Validation
24
+ pydantic>=2.11.0
25
+
26
+ # Utilities
27
+ tqdm>=4.67.0
28
+ python-dotenv>=1.1.0
29
+
30
+ # Evaluation
31
+ scikit-learn>=1.7.0
32
+
33
+ # RAG
34
+ sentence-transformers
35
+ faiss-cpu
36
+ # Testing
37
+ pytest>=8.4.0
38
+
39
+ #modal
40
+ modal
scripts/audit_pipeline.py ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pathlib import Path
2
+ from paper2lab.inference.pipeline import PaperPipeline
3
+
4
+ p = PaperPipeline(refinement_mode="nemotron")
5
+
6
+ total = 0
7
+ errors = 0
8
+ weak = 0
9
+
10
+ for pdf in Path("Data/papers").rglob("*.pdf"):
11
+ total += 1
12
+
13
+ try:
14
+ r = p.run(str(pdf))
15
+ c = r["paper_card_final"]
16
+
17
+ roadmap = c.get("reproduction_roadmap") or {}
18
+ if not isinstance(roadmap, dict):
19
+ roadmap = {}
20
+
21
+ kit = c.get("lab_starter_kit") or {}
22
+ if not isinstance(kit, dict):
23
+ kit = {}
24
+
25
+ datasets = c.get("datasets_or_data_sources") or []
26
+ findings = c.get("key_findings") or []
27
+ roadmap_steps = roadmap.get("experimental_steps") or []
28
+ kit_structure = kit.get("project_structure") or []
29
+ kit_risks = kit.get("reproducibility_risks") or []
30
+
31
+ is_weak = (
32
+ not c.get("title")
33
+ or not c.get("paper_type")
34
+ or not roadmap
35
+ or not kit
36
+ or len(kit_structure) == 0
37
+ )
38
+
39
+ if is_weak:
40
+ weak += 1
41
+
42
+ print("\n---", pdf.name)
43
+ print("type:", c.get("paper_type"))
44
+ print("datasets:", len(datasets), datasets[:3])
45
+ print("findings:", len(findings))
46
+ print("roadmap steps:", len(roadmap_steps))
47
+ print("lab kit structure:", len(kit_structure))
48
+ print("lab risks:", len(kit_risks))
49
+ print("refinement:", r["refinement"]["status"])
50
+ print("weak:", is_weak)
51
+
52
+ except Exception as e:
53
+ errors += 1
54
+ print("\nERROR:", pdf.name)
55
+ print(str(e))
56
+
57
+ print("\n====================")
58
+ print("TOTAL:", total)
59
+ print("ERRORS:", errors)
60
+ print("WEAK:", weak)
61
+ print("====================")
src/paper2lab/__init__.py ADDED
File without changes
src/paper2lab/evaluation/benchmark.py ADDED
File without changes
src/paper2lab/evaluation/evaluate.py ADDED
File without changes
src/paper2lab/evaluation/metrics.py ADDED
File without changes
src/paper2lab/evaluation/reproducibility.py ADDED
@@ -0,0 +1,313 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ reproducibility.py — Missing-information detection and reproducibility scoring.
3
+
4
+ Scores are heuristic and evidence-based. The goal is to produce a useful local
5
+ candidate before Nemotron refinement, while avoiding overconfident scores when
6
+ PDF extraction evidence is noisy.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import re
12
+ from typing import Any, Dict, List, Tuple
13
+
14
+
15
+ # ---------------------------------------------------------------------------
16
+ # Text helpers
17
+ # ---------------------------------------------------------------------------
18
+
19
+ def _clean(text: str) -> str:
20
+ text = text or ""
21
+ text = text.replace("\x00", " ").replace("\u00a0", " ")
22
+ text = re.sub(r"\s+", " ", text)
23
+ text = re.sub(r"\b10\.\d{4,9}/[-._;()/:A-Za-z0-9]+", "", text)
24
+ return text.strip(" .;:\n\t")
25
+
26
+
27
+ def _joined_text(extracted: Dict[str, Any]) -> str:
28
+ parts: List[str] = []
29
+ for sec in extracted.get("sections", []) or []:
30
+ if sec.get("role") in {"references", "appendix", "boilerplate"}:
31
+ continue
32
+ parts.append(str(sec.get("title", "")))
33
+ parts.append(str(sec.get("text", "")))
34
+ return _clean("\n".join(parts)).lower()
35
+
36
+
37
+ def _has_any(text: str, terms: List[str]) -> bool:
38
+ return any(t.lower() in text for t in terms)
39
+
40
+
41
+ def _matched_terms(text: str, terms: List[str], limit: int = 5) -> List[str]:
42
+ return [t for t in terms if t.lower() in text][:limit]
43
+
44
+
45
+ # ---------------------------------------------------------------------------
46
+ # Paper-type-specific reproducibility checks
47
+ # ---------------------------------------------------------------------------
48
+
49
+ def _check_items(paper_type: str) -> Dict[str, List[str]]:
50
+ if paper_type == "systematic_review":
51
+ return {
52
+ "search databases specified": [
53
+ "pubmed", "scopus", "web of knowledge", "eric", "cochrane",
54
+ "database", "databases",
55
+ ],
56
+ "search date range specified": [
57
+ "january", "february", "march", "april", "may", "june",
58
+ "july", "august", "september", "october", "november", "december",
59
+ "between", "from", "until", "to january", "published between",
60
+ ],
61
+ "inclusion criteria specified": [
62
+ "inclusion criteria", "eligibility criteria", "eligible studies",
63
+ ],
64
+ "exclusion criteria specified": [
65
+ "exclusion criteria", "excluded", "not being", "were excluded",
66
+ ],
67
+ "screening process specified": [
68
+ "screened", "screening", "titles and abstracts", "two independent",
69
+ "reviewers", "duplicates", "endnote",
70
+ ],
71
+ "quality assessment specified": [
72
+ "quality assessment", "risk of bias", "best evidence medical education",
73
+ "valid tool", "critical appraisal", "assessment tool",
74
+ ],
75
+ "number of included studies specified": [
76
+ "included", "enrolled", "final review", "studies were included",
77
+ "articles were included", "10 articles", "ten studies",
78
+ ],
79
+ }
80
+
81
+ if paper_type == "machine_learning":
82
+ return {
83
+ "dataset details specified": [
84
+ "dataset", "training set", "test set", "validation set", "benchmark",
85
+ "corpus", "samples", "instances",
86
+ ],
87
+ "train/validation/test split specified": [
88
+ "train", "validation", "test", "split", "dev set", "development set",
89
+ ],
90
+ "model architecture specified": [
91
+ "architecture", "layers", "encoder", "decoder", "transformer", "cnn",
92
+ "resnet", "bert", "attention", "feed-forward",
93
+ ],
94
+ "hyperparameters specified": [
95
+ "learning rate", "batch size", "epochs", "optimizer", "dropout",
96
+ "weight decay", "warmup", "scheduler",
97
+ ],
98
+ "hardware specified": [
99
+ "gpu", "tpu", "cuda", "p100", "v100", "a100", "nvidia",
100
+ ],
101
+ "evaluation metrics specified": [
102
+ "accuracy", "f1", "auc", "bleu", "rouge", "perplexity", "rmse", "mae",
103
+ "precision", "recall",
104
+ ],
105
+ "code availability specified": [
106
+ "github", "code", "repository", "available at", "source code",
107
+ ],
108
+ "random seed specified": ["random seed", "seed"],
109
+ }
110
+
111
+ if paper_type == "clinical_study":
112
+ return {
113
+ "cohort or participants specified": [
114
+ "patients", "participants", "cohort", "subjects", "population",
115
+ ],
116
+ "inclusion criteria specified": ["inclusion criteria", "eligible"],
117
+ "exclusion criteria specified": ["exclusion criteria", "excluded"],
118
+ "outcomes specified": ["outcome", "endpoint", "mortality", "diagnosis"],
119
+ "statistical analysis specified": [
120
+ "statistical analysis", "p-value", "confidence interval", "regression",
121
+ ],
122
+ "ethics approval specified": [
123
+ "ethics", "institutional review", "informed consent", "irb",
124
+ ],
125
+ }
126
+
127
+ return {
128
+ "data/source details specified": [
129
+ "data", "dataset", "source", "samples", "studies", "articles",
130
+ ],
131
+ "method/procedure specified": [
132
+ "method", "procedure", "approach", "experiment", "analysis",
133
+ ],
134
+ "evaluation or analysis specified": [
135
+ "evaluation", "result", "metric", "analysis", "measured", "assessed",
136
+ ],
137
+ "limitations discussed": ["limitation", "limitations", "future work"],
138
+ }
139
+
140
+
141
+ # ---------------------------------------------------------------------------
142
+ # Evidence quality / noise handling
143
+ # ---------------------------------------------------------------------------
144
+
145
+ _NOISY_EVIDENCE_MARKERS = [
146
+ "the there",
147
+ "being accordingly",
148
+ "endnote teachers",
149
+ "resultsare",
150
+ "analysis of the resultsare",
151
+ "table 2:",
152
+ "department of",
153
+ "university of",
154
+ "medical sciences",
155
+ "corresponding author",
156
+ "access this article online",
157
+ "how to cite",
158
+ "need this systematic review",
159
+ "the that",
160
+ ]
161
+
162
+
163
+ def _roadmap_blob(paper_card: Dict[str, Any]) -> str:
164
+ roadmap = paper_card.get("reproduction_roadmap") or {}
165
+ parts: List[str] = []
166
+
167
+ for key in [
168
+ "datasets",
169
+ "software_requirements",
170
+ "experimental_steps",
171
+ "evaluation_procedure",
172
+ "expected_outputs",
173
+ "missing_for_reproduction",
174
+ ]:
175
+ value = roadmap.get(key, [])
176
+ if isinstance(value, list):
177
+ for item in value:
178
+ if isinstance(item, dict):
179
+ parts.extend(str(v) for v in item.values())
180
+ else:
181
+ parts.append(str(item))
182
+ elif value:
183
+ parts.append(str(value))
184
+
185
+ return _clean(" ".join(parts)).lower()
186
+
187
+
188
+ def _noise_report(extracted: Dict[str, Any], paper_card: Dict[str, Any]) -> Tuple[int, List[str]]:
189
+ """Return count and examples of noisy evidence markers."""
190
+ blob = _roadmap_blob(paper_card)
191
+ if not blob:
192
+ # Fallback to body text only if roadmap is not yet attached.
193
+ blob = _joined_text(extracted)
194
+
195
+ found = [m for m in _NOISY_EVIDENCE_MARKERS if m in blob]
196
+
197
+ # Extra generic noise signals.
198
+ if len(re.findall(r"\[\d+", blob)) >= 12:
199
+ found.append("many citation fragments")
200
+ if re.search(r"\b(the|and|of)\s+\1\b", blob):
201
+ found.append("repeated function-word artifact")
202
+
203
+ return len(found), found[:8]
204
+
205
+
206
+ def _apply_score_caps(
207
+ paper_type: str,
208
+ score: float,
209
+ missing: List[str],
210
+ extracted: Dict[str, Any],
211
+ paper_card: Dict[str, Any],
212
+ ) -> Tuple[float, List[str], Dict[str, Any]]:
213
+ """Prevent misleadingly high scores when evidence is noisy or incomplete."""
214
+ diagnostics: Dict[str, Any] = {}
215
+ noise_count, noise_examples = _noise_report(extracted, paper_card)
216
+ diagnostics["noise_count"] = noise_count
217
+ diagnostics["noise_examples"] = noise_examples
218
+
219
+ if noise_count > 0:
220
+ msg = "some extracted evidence appears noisy due to PDF layout"
221
+ if msg not in missing:
222
+ missing.append(msg)
223
+
224
+ # Systematic reviews should not get 1.0 if roadmap/evidence is visibly noisy.
225
+ if paper_type == "systematic_review":
226
+ if noise_count >= 3:
227
+ score = min(score, 0.65)
228
+ elif noise_count >= 1:
229
+ score = min(score, 0.75)
230
+
231
+ roadmap = paper_card.get("reproduction_roadmap") or {}
232
+ if not roadmap.get("experimental_steps"):
233
+ score = min(score, 0.70)
234
+ if not roadmap.get("evaluation_procedure"):
235
+ score = min(score, 0.70)
236
+
237
+ # ML papers need either hyperparameters or code/hardware to be strong.
238
+ if paper_type == "machine_learning":
239
+ text = _joined_text(extracted)
240
+ has_hparams = _has_any(text, ["learning rate", "batch size", "optimizer", "dropout", "epoch"])
241
+ has_code = _has_any(text, ["github", "repository", "code available", "source code"])
242
+ has_hardware = _has_any(text, ["gpu", "tpu", "cuda", "p100", "v100", "a100"])
243
+ if not has_hparams:
244
+ score = min(score, 0.80)
245
+ if not has_code and not has_hardware:
246
+ score = min(score, 0.85)
247
+
248
+ return round(score, 3), missing, diagnostics
249
+
250
+
251
+ def _score_level(score: float) -> str:
252
+ if score >= 0.80:
253
+ return "strong"
254
+ if score >= 0.50:
255
+ return "partial"
256
+ return "weak"
257
+
258
+
259
+ # ---------------------------------------------------------------------------
260
+ # Public API
261
+ # ---------------------------------------------------------------------------
262
+
263
+ def reproducibility_report(extracted: Dict[str, Any], paper_card: Dict[str, Any]) -> Dict[str, Any]:
264
+ paper_type = paper_card.get("paper_type", "general_research")
265
+ text = _joined_text(extracted)
266
+ checks = _check_items(paper_type)
267
+
268
+ detected: List[str] = []
269
+ missing: List[str] = []
270
+ evidence: Dict[str, List[str]] = {}
271
+
272
+ for label, terms in checks.items():
273
+ if _has_any(text, terms):
274
+ detected.append(label)
275
+ evidence[label] = _matched_terms(text, terms)
276
+ else:
277
+ missing.append(label)
278
+
279
+ # Candidate-card overrides for generic papers.
280
+ if paper_card.get("datasets_or_data_sources") and "data/source details specified" in missing:
281
+ missing.remove("data/source details specified")
282
+ detected.append("data/source details specified")
283
+ evidence["data/source details specified"] = ["paper_card.datasets_or_data_sources"]
284
+
285
+ if paper_card.get("metrics_or_measurements") and "evaluation or analysis specified" in missing:
286
+ missing.remove("evaluation or analysis specified")
287
+ detected.append("evaluation or analysis specified")
288
+ evidence["evaluation or analysis specified"] = ["paper_card.metrics_or_measurements"]
289
+
290
+ total = max(1, len(checks))
291
+ score = len(detected) / total
292
+
293
+ score, missing, diagnostics = _apply_score_caps(
294
+ paper_type=paper_type,
295
+ score=score,
296
+ missing=missing,
297
+ extracted=extracted,
298
+ paper_card=paper_card,
299
+ )
300
+
301
+ # Deduplicate while preserving order.
302
+ detected = list(dict.fromkeys(detected))
303
+ missing = list(dict.fromkeys(missing))
304
+
305
+ return {
306
+ "paper_type": paper_type,
307
+ "score": score,
308
+ "level": _score_level(score),
309
+ "detected_items": detected,
310
+ "missing_items": missing,
311
+ "evidence_terms": evidence,
312
+ "diagnostics": diagnostics,
313
+ }
src/paper2lab/inference/__init__.py ADDED
File without changes
src/paper2lab/inference/auto_select.py ADDED
@@ -0,0 +1,564 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from typing import Any, Dict, List, Tuple, final
5
+
6
+
7
+ FINAL_FIELDS = [
8
+ "title",
9
+ "field",
10
+ "paper_type",
11
+ "research_question",
12
+ "contributions",
13
+ "methodology",
14
+ "datasets_or_data_sources",
15
+ "models_or_methods",
16
+ "metrics_or_measurements",
17
+ "key_findings",
18
+ "limitations",
19
+ "missing_reproducibility_info",
20
+ "reproduction_roadmap",
21
+ "reproducibility_score",
22
+ "figures_and_tables",
23
+ "lab_starter_kit",
24
+ "metadata",
25
+ "source_pdf",
26
+ "annotation_version",
27
+ ]
28
+
29
+
30
+ PREFER_LOCAL_FIELDS = {
31
+ "figures_and_tables",
32
+ "reproducibility_score",
33
+ "metadata",
34
+ "source_pdf",
35
+ "annotation_version",
36
+ }
37
+
38
+ PREFER_REFINED_FIELDS = {
39
+ "research_question",
40
+ "contributions",
41
+ "methodology",
42
+ "datasets_or_data_sources",
43
+ "models_or_methods",
44
+ "metrics_or_measurements",
45
+ "key_findings",
46
+ "limitations",
47
+ "missing_reproducibility_info",
48
+ "reproduction_roadmap",
49
+ "lab_starter_kit",
50
+ }
51
+
52
+
53
+ NOISE_TERMS = [
54
+ "department of",
55
+ "university of",
56
+ "corresponding author",
57
+ "gmail.com",
58
+ "references",
59
+ "table of contents",
60
+ "being accordingly",
61
+ "endnote teachers",
62
+ "the there",
63
+ "resultsare",
64
+ "analysis of the resultsare",
65
+ "access this article online",
66
+ "how to cite",
67
+ ]
68
+
69
+
70
+ def _clean_text(value: Any) -> str:
71
+ text = str(value or "")
72
+ text = re.sub(r"\s+", " ", text)
73
+ return text.strip()
74
+
75
+
76
+ def _flatten(value: Any) -> str:
77
+ if value is None:
78
+ return ""
79
+
80
+ if isinstance(value, str):
81
+ return value
82
+
83
+ if isinstance(value, list):
84
+ parts = []
85
+ for item in value:
86
+ parts.append(_flatten(item))
87
+ return " ".join(parts)
88
+
89
+ if isinstance(value, dict):
90
+ parts = []
91
+ for item in value.values():
92
+ parts.append(_flatten(item))
93
+ return " ".join(parts)
94
+
95
+ return str(value)
96
+
97
+
98
+ def _is_empty(value: Any) -> bool:
99
+ if value is None:
100
+ return True
101
+ if value == "":
102
+ return True
103
+ if isinstance(value, list) and len(value) == 0:
104
+ return True
105
+ if isinstance(value, dict) and len(value) == 0:
106
+ return True
107
+ return False
108
+
109
+
110
+ def _noise_score(value: Any) -> float:
111
+ text = _flatten(value).lower()
112
+ if not text:
113
+ return 1.0
114
+
115
+ score = 0.0
116
+
117
+ for term in NOISE_TERMS:
118
+ if term in text:
119
+ score += 1.0
120
+
121
+ if len(text.split()) > 900:
122
+ score += 2.0
123
+ elif len(text.split()) > 450:
124
+ score += 1.0
125
+
126
+ if len(re.findall(r"\[\d+\]", text)) >= 5:
127
+ score += 1.0
128
+
129
+ return score
130
+
131
+
132
+ def _structure_score(value: Any) -> float:
133
+ if _is_empty(value):
134
+ return 0.0
135
+
136
+ if isinstance(value, list):
137
+ if not value:
138
+ return 0.0
139
+ short_items = 0
140
+ for item in value:
141
+ words = len(_flatten(item).split())
142
+ if 1 <= words <= 35:
143
+ short_items += 1
144
+ return min(1.0, short_items / max(1, len(value)))
145
+
146
+ if isinstance(value, dict):
147
+ return min(1.0, len(value.keys()) / 5)
148
+
149
+ if isinstance(value, str):
150
+ words = len(value.split())
151
+ if 3 <= words <= 60:
152
+ return 1.0
153
+ if words <= 120:
154
+ return 0.6
155
+ return 0.2
156
+
157
+ return 0.4
158
+
159
+
160
+ def _completeness_score(value: Any) -> float:
161
+ if _is_empty(value):
162
+ return 0.0
163
+
164
+ if isinstance(value, list):
165
+ return min(1.0, len(value) / 4)
166
+
167
+ if isinstance(value, dict):
168
+ non_empty = sum(1 for v in value.values() if not _is_empty(v))
169
+ return min(1.0, non_empty / max(1, len(value)))
170
+
171
+ if isinstance(value, str):
172
+ words = len(value.split())
173
+ return min(1.0, words / 20)
174
+
175
+ return 0.5
176
+
177
+
178
+ def _score_field(field: str, value: Any) -> float:
179
+ if _is_empty(value):
180
+ return 0.0
181
+
182
+ completeness = _completeness_score(value)
183
+ structure = _structure_score(value)
184
+ noise = _noise_score(value)
185
+
186
+ score = (0.45 * completeness) + (0.45 * structure) - (0.25 * noise)
187
+
188
+ if field in PREFER_LOCAL_FIELDS:
189
+ score += 0.15
190
+
191
+ if field in PREFER_REFINED_FIELDS:
192
+ score += 0.10
193
+
194
+ return round(max(0.0, min(1.0, score)), 4)
195
+
196
+
197
+ def _similarity(a: Any, b: Any) -> float:
198
+ text_a = set(re.findall(r"[a-z0-9]+", _flatten(a).lower()))
199
+ text_b = set(re.findall(r"[a-z0-9]+", _flatten(b).lower()))
200
+
201
+ if not text_a and not text_b:
202
+ return 1.0
203
+ if not text_a or not text_b:
204
+ return 0.0
205
+
206
+ return len(text_a & text_b) / max(1, len(text_a | text_b))
207
+
208
+
209
+ def _choose_field(
210
+ field: str,
211
+ local_value: Any,
212
+ refined_value: Any,
213
+ ) -> Tuple[Any, Dict[str, Any]]:
214
+ local_score = _score_field(field, local_value)
215
+ refined_score = _score_field(field, refined_value)
216
+ similarity = round(_similarity(local_value, refined_value), 4)
217
+
218
+ # For Lab Starter Kit, prefer local when it is paper-type-aware.
219
+ # Nemotron sometimes converts systematic reviews / clinical papers into ML-style kits.
220
+ if field == "lab_starter_kit" and isinstance(local_value, dict):
221
+ local_text = _flatten(local_value).lower()
222
+ refined_text = _flatten(refined_value).lower()
223
+
224
+ local_is_specialized = any(x in local_text for x in [
225
+ "starter_type",
226
+ "systematic_review",
227
+ "clinical_study",
228
+ "survey_or_review",
229
+ "search_strategy",
230
+ "screening_checklist",
231
+ "cohort_design",
232
+ "literature_mapping_plan",
233
+ "quality_assessment",
234
+ ])
235
+
236
+ refined_looks_ml_generic = any(x in refined_text for x in [
237
+ "train.py",
238
+ "training_configuration",
239
+ "hyperparameters",
240
+ "baseline model",
241
+ "training pipeline",
242
+ "model_or_method",
243
+ ])
244
+
245
+ if local_is_specialized or refined_looks_ml_generic:
246
+ return local_value, {
247
+ "winner": "local",
248
+ "local_score": local_score,
249
+ "nemotron_score": refined_score,
250
+ "similarity": similarity,
251
+ "reason": "local lab_starter_kit is more paper-type-aware",
252
+ }
253
+
254
+ if _is_empty(local_value) and not _is_empty(refined_value):
255
+ winner = "nemotron"
256
+ value = refined_value
257
+ elif _is_empty(refined_value) and not _is_empty(local_value):
258
+ winner = "local"
259
+ value = local_value
260
+ elif field in PREFER_LOCAL_FIELDS and local_score >= refined_score - 0.12:
261
+ winner = "local"
262
+ value = local_value
263
+ elif field in PREFER_REFINED_FIELDS and refined_score >= local_score - 0.08:
264
+ winner = "nemotron"
265
+ value = refined_value
266
+ elif refined_score > local_score:
267
+ winner = "nemotron"
268
+ value = refined_value
269
+ else:
270
+ winner = "local"
271
+ value = local_value
272
+
273
+ return value, {
274
+ "winner": winner,
275
+ "local_score": local_score,
276
+ "nemotron_score": refined_score,
277
+ "similarity": similarity,
278
+ }
279
+
280
+ def _clean_final_datasets(items: Any, paper_type: str = "") -> List[str]:
281
+ if not isinstance(items, list):
282
+ return []
283
+
284
+ paper_type = (paper_type or "").lower()
285
+
286
+ canonical_sources = {
287
+ "pubmed": "PubMed",
288
+ "scopus": "Scopus",
289
+ "web of knowledge": "Web of Knowledge",
290
+ "web of science": "Web of Science",
291
+ "google scholar": "Google Scholar",
292
+ "cochrane": "Cochrane",
293
+ "cochrane library": "Cochrane Library",
294
+ "embase": "Embase",
295
+ "medline": "MEDLINE",
296
+ "clinicaltrials": "ClinicalTrials.gov",
297
+ }
298
+
299
+ reject_terms = [
300
+ "limitation", "limitations", "ecological design", "classification error",
301
+ "incorrect spatial", "temporal assignments", "overfitting", "pseudo-accuracy",
302
+ "beam size", "during inference", "dropout", "optimizer", "learning rate",
303
+ "institutional review board", "informed consent", "validation set",
304
+ "training set", "test set", "cross-validation", "augmentation",
305
+ ]
306
+
307
+ known_dataset_patterns = [
308
+ r"\bPTB-XL\b",
309
+ r"\bMUSE\b",
310
+ r"\bTCGA[- ]?[A-Z0-9]+\b",
311
+ r"\bGSE\d+\b",
312
+ r"\bOECD International Migration Database\b",
313
+ r"\bSeoul Asan Medical Center Hospital\b",
314
+
315
+ # NLP datasets
316
+ r"\bWMT\s*2014\b",
317
+ r"\bWMT\b",
318
+ r"\bPenn Treebank\b",
319
+ r"\bWall Street Journal\b",
320
+ r"\bWSJ\b",
321
+ r"\b\d+\s+samples\b",
322
+
323
+ # ML benchmarks
324
+ r"\bHiggs Boson dataset\b",
325
+ r"\bYahoo!?\s*LTRC\s*dataset\b",
326
+ r"\bAllstate dataset\b",
327
+ r"\bJFT-300M\b",
328
+ r"\bImageNet(?:-21k)?\b",
329
+ r"\bCOCO\b",
330
+ r"\bCityscapes\b",
331
+ r"\bCora\b",
332
+ r"\bCiteseer\b",
333
+ r"\bPubmed\b",
334
+ r"\bNELL\b",
335
+ ]
336
+
337
+ out: List[str] = []
338
+
339
+ for item in items:
340
+ text = _clean_text(item)
341
+ low = text.lower()
342
+
343
+ if not text or any(bad in low for bad in reject_terms):
344
+ continue
345
+
346
+ if paper_type == "systematic_review":
347
+ for key, label in canonical_sources.items():
348
+ if re.search(rf"(?<![a-z0-9]){re.escape(key)}(?![a-z0-9])", low):
349
+ out.append(label)
350
+ continue
351
+
352
+ if paper_type in {"machine_learning", "clinical_study", "survey_study"}:
353
+ for pat in known_dataset_patterns:
354
+ for m in re.finditer(pat, text, flags=re.IGNORECASE):
355
+ out.append(m.group(0).strip())
356
+ continue
357
+
358
+ if len(text.split()) <= 10 and re.search(
359
+ r"\b(dataset|database|repository|registry|cohort|records|patients|participants)\b",
360
+ low,
361
+ ):
362
+ out.append(text)
363
+
364
+ if paper_type == "systematic_review":
365
+ if "ERIC" in out and not any("educational resources" in str(x).lower() for x in items):
366
+ out = [x for x in out if x != "ERIC"]
367
+
368
+ return list(dict.fromkeys(out))
369
+
370
+
371
+ def _clean_final_models(items: Any) -> List[str]:
372
+ if not isinstance(items, list):
373
+ return []
374
+
375
+ known = [
376
+ "pix2pix GAN", "GAN", "ResNet", "U-Net", "U-CS", "U-SS",
377
+ "random forests", "SVM", "support vector machines", "XGBoost",
378
+ "CIBERSORT", "OLS", "PPML", "IV-Poisson", "2SLS",
379
+ "control function approach", "ARIMA", "SIR", "SEIR", "SQUIDER", "LSTM",
380
+ "ChatGPT",
381
+ ]
382
+
383
+ out = []
384
+
385
+ for item in items:
386
+ text = _clean_text(item)
387
+ low = text.lower()
388
+
389
+ for name in known:
390
+ if re.search(
391
+ rf"(?<![a-z0-9]){re.escape(name.lower())}(?![a-z0-9])",
392
+ low,
393
+ ):
394
+ out.append(name)
395
+
396
+ if len(text.split()) <= 8:
397
+ out.append(text)
398
+
399
+ out = list(dict.fromkeys(out))
400
+
401
+ # Canonicalize aliases
402
+ if "SVM" in out:
403
+ out = [x for x in out if x not in {"support vector machines", "support vector machines (SVM)"}]
404
+
405
+ return out
406
+
407
+ def _clean_final_metrics(items: Any) -> List[str]:
408
+ if not isinstance(items, list):
409
+ return []
410
+
411
+ out = []
412
+ blob = " ".join(_clean_text(x) for x in items)
413
+
414
+ patterns = [
415
+ r"\bAUC(?: values?)?\s*(?:approximately|around)?\s*[0-9.]+(?:\s*[-–]\s*[0-9.]+)?",
416
+ r"\bROC(?: curve)?\b",
417
+ r"\bfivefold cross-validation\b",
418
+ r"\bcross-validation\b",
419
+ r"\bheld-out test dataset\b",
420
+ r"\bp[- ]?values?\b",
421
+ ]
422
+
423
+ for pat in patterns:
424
+ for m in re.finditer(pat, blob, flags=re.IGNORECASE):
425
+ out.append(_clean_text(m.group(0)))
426
+
427
+ return list(dict.fromkeys(out))
428
+
429
+ def _clean_final_findings(items: Any) -> List[str]:
430
+ if not isinstance(items, list):
431
+ return []
432
+
433
+ out = []
434
+
435
+ for item in items:
436
+ text = _clean_text(item)
437
+ low = text.lower()
438
+
439
+ if not text:
440
+ continue
441
+
442
+ if len(text.split()) > 45:
443
+ if "auc" in low:
444
+ out.append("XGBoost and Random Forest achieved moderate predictive performance with AUC values around 0.57–0.58.")
445
+ elif "surviving patients" in low:
446
+ out.append("Surviving patients showed longer survival durations than deceased patients.")
447
+ elif "enriched pathways" in low:
448
+ out.append("Enriched pathways included protein targeting to the endoplasmic reticulum, viral transcription, and cadherin-mediated binding.")
449
+ continue
450
+
451
+ out.append(text)
452
+
453
+ return list(dict.fromkeys(out))[:6]
454
+
455
+
456
+ def build_auto_best_card(
457
+ local_card: Dict[str, Any],
458
+ refinement: Dict[str, Any],
459
+ ) -> Dict[str, Any]:
460
+ """
461
+ Build a hybrid final card by selecting the best field from:
462
+ - local rule-based extraction
463
+ - Nemotron-refined extraction
464
+
465
+ If Nemotron failed or was skipped, returns local card.
466
+ """
467
+
468
+ if refinement.get("status") != "ok":
469
+ return {
470
+ "status": "local_only",
471
+ "final_paper_card": local_card,
472
+ "selection_report": {
473
+ "reason": "Nemotron refinement was skipped or failed.",
474
+ "fields": {},
475
+ },
476
+ }
477
+
478
+ refined_card = refinement.get("after_refinement")
479
+ if not isinstance(refined_card, dict):
480
+ return {
481
+ "status": "local_only",
482
+ "final_paper_card": local_card,
483
+ "selection_report": {
484
+ "reason": "Nemotron output was not a valid dictionary.",
485
+ "fields": {},
486
+ },
487
+ }
488
+
489
+ final: Dict[str, Any] = {}
490
+ report: Dict[str, Any] = {}
491
+
492
+ all_fields = list(dict.fromkeys(FINAL_FIELDS + list(local_card.keys()) + list(refined_card.keys())))
493
+
494
+ for field in all_fields:
495
+ if field == "llm_evidence_pack":
496
+ continue
497
+
498
+ local_value = local_card.get(field)
499
+ refined_value = refined_card.get(field)
500
+
501
+ value, field_report = _choose_field(field, local_value, refined_value)
502
+
503
+ final[field] = value
504
+ report[field] = field_report
505
+
506
+ local_count = sum(1 for r in report.values() if r.get("winner") == "local")
507
+ nemotron_count = sum(1 for r in report.values() if r.get("winner") == "nemotron")
508
+
509
+ final["selection_metadata"] = {
510
+ "strategy": "field_level_auto_best",
511
+ "local_fields_used": local_count,
512
+ "nemotron_fields_used": nemotron_count,
513
+ "total_fields_compared": len(report),
514
+ }
515
+
516
+ final["datasets_or_data_sources"] = _clean_final_datasets(
517
+ final.get("datasets_or_data_sources", []),
518
+ final.get("paper_type", ""),
519
+ )
520
+
521
+ if not final.get("datasets_or_data_sources"):
522
+ roadmap = final.get("reproduction_roadmap")
523
+ if isinstance(roadmap, dict):
524
+ final["datasets_or_data_sources"] = _clean_final_datasets(
525
+ roadmap.get("datasets", []),
526
+ final.get("paper_type", ""),
527
+ )
528
+
529
+ if not final.get("datasets_or_data_sources"):
530
+ kit = final.get("lab_starter_kit")
531
+ if isinstance(kit, dict):
532
+ final["datasets_or_data_sources"] = _clean_final_datasets(
533
+ kit.get("dataset_plan", []),
534
+ final.get("paper_type", ""),
535
+ )
536
+
537
+ final["models_or_methods"] = _clean_final_models(
538
+ final.get("models_or_methods", [])
539
+ )
540
+
541
+ final["metrics_or_measurements"] = _clean_final_metrics(
542
+ final.get("metrics_or_measurements", [])
543
+ )
544
+
545
+ final["key_findings"] = _clean_final_findings(
546
+ final.get("key_findings", [])
547
+ )
548
+
549
+ if isinstance(final.get("lab_starter_kit"), dict):
550
+ for key in ["dataset_plan", "search_strategy", "literature_mapping_plan"]:
551
+ if key in final["lab_starter_kit"]:
552
+ final["lab_starter_kit"][key] = _clean_final_datasets(
553
+ final["lab_starter_kit"].get(key, []),
554
+ "machine_learning" if key == "dataset_plan" else final.get("paper_type", ""),
555
+ )
556
+
557
+ return {
558
+ "status": "ok",
559
+ "final_paper_card": final,
560
+ "selection_report": {
561
+ "strategy": "field_level_auto_best",
562
+ "fields": report,
563
+ },
564
+ }
src/paper2lab/inference/gradio_pipeline.py ADDED
@@ -0,0 +1,262 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ gradio_pipeline.py — Gradio UI for Paper2Lab section-aware extraction.
3
+
4
+ This UI matches the current pre-LLM pipeline:
5
+ - No Anthropic parameters.
6
+ - Shows section roles and whether references/appendix were removed from clean text.
7
+ - Downloads the paper_card JSON.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import tempfile
14
+ from typing import Any, Dict, Tuple
15
+
16
+ import gradio as gr
17
+
18
+ from paper2lab.inference.pipeline import PaperPipeline
19
+
20
+
21
+ # ---------------------------------------------------------------------------
22
+ # Helpers
23
+ # ---------------------------------------------------------------------------
24
+
25
+ def _fmt_list(items: list[str] | None, max_items: int = 8) -> str:
26
+ if not items:
27
+ return "_None detected_"
28
+ shown = items[:max_items]
29
+ suffix = f"\n… +{len(items) - max_items} more" if len(items) > max_items else ""
30
+ return "\n".join(f"- {s}" for s in shown) + suffix
31
+
32
+
33
+ def _quality_badge(score: float) -> str:
34
+ if score >= 0.75:
35
+ return f"🟢 Quality score: {score:.2f}"
36
+ if score >= 0.45:
37
+ return f"🟡 Quality score: {score:.2f}"
38
+ return f"🔴 Quality score: {score:.2f} — extraction may be incomplete"
39
+
40
+
41
+ def _build_overview(result: Dict[str, Any]) -> str:
42
+ card = result["paper_card"]
43
+ ext = result["extraction"]
44
+ quality = ext.get("quality", {})
45
+ metadata = card.get("metadata", {})
46
+
47
+ lines = [
48
+ f"## {card.get('title') or '_(title not found)_'}",
49
+ f"**Field:** {card.get('field', '—')} | "
50
+ f"**Pages:** {ext.get('num_pages', '?')} | "
51
+ f"**Engine:** {ext.get('extraction_engine', '?')} | "
52
+ + _quality_badge(float(quality.get("quality_score", 0.0))),
53
+ "",
54
+ "### Extraction Safety",
55
+ f"- References removed from body text: {'✅' if metadata.get('references_removed_from_body') else '⚠️ not detected'}",
56
+ f"- Appendix removed from body text: {'✅' if metadata.get('appendix_removed_from_body') else '—'}",
57
+ f"- Methodology section found: {'✅' if quality.get('methodology_section_found') else '⚠️ fallback may be used'}",
58
+ "",
59
+ "### Research Question",
60
+ card.get("research_question") or "_Not detected_",
61
+ "",
62
+ "### Abstract",
63
+ ext.get("abstract") or "_Not extracted_",
64
+ "",
65
+ "### Contributions",
66
+ _fmt_list(card.get("contributions")),
67
+ "",
68
+ "### Methodology",
69
+ _fmt_list(card.get("methodology")),
70
+ "",
71
+ "### Datasets / Data Sources",
72
+ _fmt_list(card.get("datasets_or_data_sources")),
73
+ "",
74
+ "### Models / Methods",
75
+ _fmt_list(card.get("models_or_methods")),
76
+ "",
77
+ "### Metrics & Measurements",
78
+ _fmt_list(card.get("metrics_or_measurements")),
79
+ "",
80
+ "### Key Findings",
81
+ _fmt_list(card.get("key_findings")),
82
+ "",
83
+ "### Limitations",
84
+ _fmt_list(card.get("limitations")),
85
+ "",
86
+ "### Missing Reproducibility Info",
87
+ _fmt_list(card.get("missing_reproducibility_info")),
88
+ ]
89
+ return "\n".join(lines)
90
+
91
+
92
+ def _build_extraction_details(result: Dict[str, Any]) -> str:
93
+ ext = result["extraction"]
94
+ sections = ext.get("sections", [])
95
+ refs = ext.get("references", [])
96
+ captions = ext.get("captions", [])
97
+ tables = ext.get("tables", [])
98
+ quality = ext.get("quality", {})
99
+
100
+ section_list = "\n".join(
101
+ f" - **{s.get('title', '?')}** — role `{s.get('role', 'other')}`, "
102
+ f"pages {s.get('page_start', '?')}–{s.get('page_end', '?')}, "
103
+ f"{len((s.get('text') or '').split())} words"
104
+ for s in sections
105
+ )
106
+ ref_sample = "\n".join(f" {i + 1}. {r[:140]}…" for i, r in enumerate(refs[:5]))
107
+ cap_sample = "\n".join(
108
+ f" - **{c.get('label')}**: {(c.get('caption') or '')[:120]}…" for c in captions[:5]
109
+ )
110
+ table_info = "\n".join(
111
+ f" - Page {t.get('page_number', '?')}, {len(t.get('data', []))} rows × "
112
+ f"{len(t.get('data', [[]])[0]) if t.get('data') else '?'} cols"
113
+ for t in tables[:5]
114
+ )
115
+
116
+ lines = [
117
+ "## Extraction Details",
118
+ "",
119
+ "### Quality",
120
+ f"- Title found: {'✅' if quality.get('title_found') else '❌'}",
121
+ f"- Abstract found: {'✅' if quality.get('abstract_found') else '❌'}",
122
+ f"- Sections: {quality.get('num_sections', 0)}",
123
+ f"- Section roles: `{', '.join(quality.get('section_roles', []))}`",
124
+ f"- References: {quality.get('num_references', 0)}",
125
+ f"- Captions: {quality.get('num_captions', 0)}",
126
+ f"- Tables: {quality.get('num_tables', 0)}",
127
+ "",
128
+ "### Sections Detected",
129
+ section_list or "_None_",
130
+ "",
131
+ "### References moved to metadata/body-excluded area — first 5",
132
+ ref_sample or "_None_",
133
+ "",
134
+ "### Captions — first 5",
135
+ cap_sample or "_None_",
136
+ "",
137
+ "### Tables — first 5",
138
+ table_info or "_None_",
139
+ "",
140
+ "### Clean Text Preview — references excluded",
141
+ "```",
142
+ ext.get("text_preview", "")[:1800],
143
+ "```",
144
+ ]
145
+ return "\n".join(lines)
146
+
147
+
148
+ # ---------------------------------------------------------------------------
149
+ # Processing
150
+ # ---------------------------------------------------------------------------
151
+
152
+ def process_pdf(pdf_file: Any, engine: str, include_llm_pack: bool) -> Tuple[str, str, str, str]:
153
+ if pdf_file is None:
154
+ return "", "", "", "⚠️ Please upload a PDF first."
155
+
156
+ pipeline = PaperPipeline(
157
+ pdf_engine=engine,
158
+ include_extraction=True,
159
+ include_llm_pack=include_llm_pack,
160
+ )
161
+
162
+ try:
163
+ result = pipeline.run(pdf_file.name if hasattr(pdf_file, "name") else pdf_file)
164
+ except Exception as exc:
165
+ return "", "", "", f"❌ Error: {exc}"
166
+
167
+ overview = _build_overview(result)
168
+ details = _build_extraction_details(result)
169
+ card_preview = {
170
+ k: v for k, v in result["paper_card"].items()
171
+ if k != "llm_evidence_pack"
172
+ }
173
+ json_preview = json.dumps(card_preview, indent=2, ensure_ascii=False)
174
+ score = result["extraction"].get("quality", {}).get("quality_score", 0.0)
175
+ status = f"✅ Done — section-aware extraction quality: {score:.2f}"
176
+ return overview, details, json_preview, status
177
+
178
+
179
+ def download_json(pdf_file: Any, engine: str, include_llm_pack: bool) -> str | None:
180
+ if pdf_file is None:
181
+ return None
182
+ pipeline = PaperPipeline(
183
+ pdf_engine=engine,
184
+ include_extraction=True,
185
+ include_llm_pack=include_llm_pack,
186
+ )
187
+ try:
188
+ result = pipeline.run(pdf_file.name if hasattr(pdf_file, "name") else pdf_file)
189
+ except Exception:
190
+ return None
191
+
192
+ tmp = tempfile.NamedTemporaryFile(suffix=".json", delete=False, mode="w", encoding="utf-8")
193
+ json.dump(result["paper_card"], tmp, indent=2, ensure_ascii=False)
194
+ tmp.close()
195
+ return tmp.name
196
+
197
+
198
+ # ---------------------------------------------------------------------------
199
+ # UI
200
+ # ---------------------------------------------------------------------------
201
+
202
+ def build_ui() -> gr.Blocks:
203
+ with gr.Blocks(title="Paper2Lab", theme=gr.themes.Soft()) as demo:
204
+ gr.Markdown("# 📄 Paper2Lab — Section-Aware Academic Paper Extractor")
205
+ gr.Markdown(
206
+ "Upload a research paper PDF. The pipeline detects section headers, removes references from body text, "
207
+ "and builds a structured paper card ready for later Nemotron refinement."
208
+ )
209
+
210
+ with gr.Row():
211
+ with gr.Column(scale=1):
212
+ pdf_input = gr.File(label="Upload PDF", file_types=[".pdf"])
213
+ engine = gr.Radio(
214
+ choices=["pymupdf", "docling", "auto"],
215
+ value="pymupdf",
216
+ label="Extraction engine",
217
+ info="pymupdf = fast; docling = optional complex-layout engine; auto = compare quality",
218
+ )
219
+ include_llm_pack = gr.Checkbox(
220
+ label="Include llm_evidence_pack",
221
+ value=True,
222
+ info="Useful for later Nemotron/LLM refinement; turn off for simpler JSON.",
223
+ )
224
+ run_btn = gr.Button("▶ Extract", variant="primary")
225
+ status_box = gr.Textbox(label="Status", interactive=False)
226
+ download_btn = gr.Button("⬇ Download Paper Card JSON")
227
+ download_file = gr.File(label="JSON download", interactive=False)
228
+
229
+ with gr.Column(scale=2):
230
+ with gr.Tabs():
231
+ with gr.Tab("📋 Paper Card"):
232
+ overview_md = gr.Markdown()
233
+ with gr.Tab("🔬 Extraction Details"):
234
+ details_md = gr.Markdown()
235
+ with gr.Tab("{ } JSON Preview"):
236
+ json_box = gr.Code(language="json", interactive=False)
237
+
238
+ run_btn.click(
239
+ fn=process_pdf,
240
+ inputs=[pdf_input, engine, include_llm_pack],
241
+ outputs=[overview_md, details_md, json_box, status_box],
242
+ )
243
+ download_btn.click(
244
+ fn=download_json,
245
+ inputs=[pdf_input, engine, include_llm_pack],
246
+ outputs=download_file,
247
+ )
248
+ return demo
249
+
250
+
251
+ pipeline = PaperPipeline(pdf_engine="pymupdf")
252
+
253
+
254
+ def process_pdf_simple(pdf_file: Any) -> Dict[str, Any]:
255
+ if pdf_file is None:
256
+ return {"error": "No PDF uploaded"}
257
+ return pipeline.run(pdf_file.name if hasattr(pdf_file, "name") else pdf_file)
258
+
259
+
260
+ if __name__ == "__main__":
261
+ ui = build_ui()
262
+ ui.launch(share=False)
src/paper2lab/inference/lab_starter_kit.py ADDED
@@ -0,0 +1,325 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Dict, List
4
+
5
+
6
+
7
+
8
+ KNOWN_SOURCE_NAMES = [
9
+ "PubMed",
10
+ "Scopus",
11
+ "Web of Knowledge",
12
+ "Web of Science",
13
+ "ERIC",
14
+ "Educational Resources and Information Center",
15
+ "Cochrane",
16
+ "Embase",
17
+ "MEDLINE",
18
+ "Google Scholar",
19
+ ]
20
+
21
+
22
+ def _clean_sources(items: List[str]) -> List[str]:
23
+ text = " ".join(str(x) for x in items).lower()
24
+ found = []
25
+
26
+ for name in KNOWN_SOURCE_NAMES:
27
+ if name.lower() in text:
28
+ found.append(name)
29
+
30
+ if found:
31
+ return _dedupe(found)
32
+
33
+ # Fallback: keep only short source-like entries.
34
+ return _dedupe([
35
+ x for x in items
36
+ if len(str(x).split()) <= 6
37
+ ])
38
+
39
+ def _dedupe(items: List[str]) -> List[str]:
40
+ seen = set()
41
+ out = []
42
+
43
+ for item in items:
44
+ item = str(item).strip()
45
+ key = item.lower()
46
+
47
+ if item and key not in seen:
48
+ seen.add(key)
49
+ out.append(item)
50
+
51
+ return out
52
+
53
+
54
+ def _as_list(value: Any) -> List[str]:
55
+ if isinstance(value, list):
56
+ return [str(x).strip() for x in value if str(x).strip()]
57
+ if value:
58
+ return [str(value).strip()]
59
+ return []
60
+
61
+
62
+ def _roadmap_list(roadmap: Dict[str, Any], key: str) -> List[str]:
63
+ value = roadmap.get(key, [])
64
+
65
+ if not isinstance(value, list):
66
+ return _as_list(value)
67
+
68
+ out: List[str] = []
69
+
70
+ for item in value:
71
+ if isinstance(item, dict):
72
+ desc = item.get("description") or item.get("text") or item.get("step")
73
+ if desc:
74
+ out.append(str(desc).strip())
75
+ elif item:
76
+ out.append(str(item).strip())
77
+
78
+ return _dedupe(out)
79
+
80
+
81
+ def build_lab_starter_kit(paper_card: Dict[str, Any]) -> Dict[str, Any]:
82
+ paper_type = (paper_card.get("paper_type") or "general_research").lower()
83
+
84
+ roadmap = paper_card.get("reproduction_roadmap", {}) or {}
85
+
86
+ datasets = _as_list(paper_card.get("datasets_or_data_sources"))
87
+ methods = _as_list(paper_card.get("models_or_methods"))
88
+ methodology = _as_list(paper_card.get("methodology"))
89
+ metrics = _as_list(paper_card.get("metrics_or_measurements"))
90
+ missing = _as_list(paper_card.get("missing_reproducibility_info"))
91
+
92
+ roadmap_datasets = _roadmap_list(roadmap, "datasets") if isinstance(roadmap, dict) else []
93
+ roadmap_eval = _roadmap_list(roadmap, "evaluation_procedure") if isinstance(roadmap, dict) else []
94
+ roadmap_steps = _roadmap_list(roadmap, "experimental_steps") if isinstance(roadmap, dict) else []
95
+
96
+ if not datasets and roadmap_datasets:
97
+ datasets = roadmap_datasets
98
+ if paper_type == "systematic_review":
99
+ datasets = _clean_sources(datasets)
100
+
101
+ blob = " ".join(datasets + methods + methodology + metrics).lower()
102
+
103
+ base_structure = [
104
+ "paper2lab_project/",
105
+ "paper2lab_project/data/",
106
+ "paper2lab_project/configs/",
107
+ "paper2lab_project/src/",
108
+ "paper2lab_project/outputs/",
109
+ "paper2lab_project/README.md",
110
+ ]
111
+
112
+ base_requirements = [
113
+ "python>=3.10",
114
+ "numpy",
115
+ "pandas",
116
+ "matplotlib",
117
+ ]
118
+
119
+ # ------------------------------------------------------------------
120
+ # Machine-learning papers
121
+ # ------------------------------------------------------------------
122
+ if paper_type == "machine_learning":
123
+ deps = base_requirements + ["scikit-learn"]
124
+
125
+ if any(x in blob for x in ["transformer", "bert", "gpt", "neural", "attention", "pytorch"]):
126
+ deps += ["torch", "transformers", "datasets", "tokenizers", "evaluate"]
127
+
128
+ if any(x in blob for x in ["tensorflow", "keras"]):
129
+ deps.append("tensorflow")
130
+
131
+ if any(x in blob for x in ["bleu", "translation", "wmt"]):
132
+ deps += ["sacrebleu", "sentencepiece"]
133
+
134
+ hyperparams = [
135
+ item for item in methodology
136
+ if any(k in item.lower() for k in [
137
+ "learning rate",
138
+ "batch",
139
+ "epoch",
140
+ "optimizer",
141
+ "dropout",
142
+ "warmup",
143
+ "steps",
144
+ "gpu",
145
+ "label smoothing",
146
+ ])
147
+ ]
148
+
149
+ return {
150
+ "starter_type": "machine_learning",
151
+ "project_structure": base_structure + [
152
+ "paper2lab_project/data/raw/",
153
+ "paper2lab_project/data/processed/",
154
+ "paper2lab_project/src/preprocess.py",
155
+ "paper2lab_project/src/train.py",
156
+ "paper2lab_project/src/evaluate.py",
157
+ "paper2lab_project/configs/train_config.yaml",
158
+ "paper2lab_project/requirements.txt",
159
+ ],
160
+ "requirements_txt": _dedupe(deps),
161
+ "dataset_plan": datasets or ["Dataset/source not clearly specified."],
162
+ "training_configuration": {
163
+ "model_or_method": methods[:6] or ["Model/method not clearly specified."],
164
+ "hyperparameters": hyperparams or [
165
+ "Hyperparameters are incomplete or not clearly specified."
166
+ ],
167
+ },
168
+ "experiment_checklist": roadmap_steps or [
169
+ "Download or prepare the reported datasets.",
170
+ "Reproduce preprocessing/tokenization steps.",
171
+ "Implement the reported model or method.",
172
+ "Configure training hyperparameters.",
173
+ "Run training or analysis pipeline.",
174
+ "Evaluate using the reported metrics.",
175
+ "Compare reproduced outputs with paper results.",
176
+ "Document missing details and deviations.",
177
+ ],
178
+ "evaluation_plan": metrics or roadmap_eval or ["Evaluation metrics not clearly specified."],
179
+ "reproducibility_risks": missing or ["No major missing information detected."],
180
+ }
181
+
182
+ # ------------------------------------------------------------------
183
+ # Systematic reviews / meta-analyses / scoping reviews
184
+ # ------------------------------------------------------------------
185
+ if paper_type == "systematic_review":
186
+ deps = base_requirements + ["openpyxl", "python-docx"]
187
+
188
+ inclusion_exclusion = [
189
+ item for item in methodology
190
+ if any(k in item.lower() for k in [
191
+ "inclusion",
192
+ "exclusion",
193
+ "eligibility",
194
+ "criteria",
195
+ ])
196
+ ]
197
+
198
+ return {
199
+ "starter_type": "systematic_review",
200
+ "project_structure": base_structure + [
201
+ "paper2lab_project/data/search_results/",
202
+ "paper2lab_project/data/screening/",
203
+ "paper2lab_project/src/search_strategy.py",
204
+ "paper2lab_project/src/deduplicate.py",
205
+ "paper2lab_project/src/screening_table.py",
206
+ "paper2lab_project/src/quality_assessment.py",
207
+ "paper2lab_project/outputs/prisma_flow.md",
208
+ "paper2lab_project/outputs/synthesis_report.md",
209
+ "paper2lab_project/requirements.txt",
210
+ ],
211
+ "requirements_txt": _dedupe(deps),
212
+ "search_strategy": datasets or ["Bibliographic databases not clearly specified."],
213
+ "screening_checklist": roadmap_steps or [
214
+ "Define search query and date range.",
215
+ "Export records from each database.",
216
+ "Remove duplicate records.",
217
+ "Screen titles and abstracts.",
218
+ "Review full texts.",
219
+ "Apply inclusion criteria.",
220
+ "Apply exclusion criteria.",
221
+ "Record reasons for exclusion.",
222
+ "Build PRISMA-style flow summary.",
223
+ ],
224
+ "inclusion_exclusion_criteria": inclusion_exclusion or [
225
+ "Inclusion/exclusion criteria not clearly specified."
226
+ ],
227
+ "quality_assessment_tools": methods or [
228
+ "Quality assessment tool not clearly specified."
229
+ ],
230
+ "evaluation_plan": metrics or roadmap_eval or [
231
+ "Number of records identified.",
232
+ "Number of included studies.",
233
+ "Quality assessment summary.",
234
+ ],
235
+ "reproducibility_risks": missing or ["No major missing information detected."],
236
+ }
237
+
238
+ # ------------------------------------------------------------------
239
+ # Clinical studies
240
+ # ------------------------------------------------------------------
241
+ if paper_type == "clinical_study":
242
+ deps = base_requirements + ["scipy", "statsmodels", "openpyxl"]
243
+
244
+ return {
245
+ "starter_type": "clinical_study",
246
+ "project_structure": base_structure + [
247
+ "paper2lab_project/data/raw/",
248
+ "paper2lab_project/data/processed/",
249
+ "paper2lab_project/src/cohort_selection.py",
250
+ "paper2lab_project/src/statistical_analysis.py",
251
+ "paper2lab_project/src/outcome_analysis.py",
252
+ "paper2lab_project/outputs/tables/",
253
+ "paper2lab_project/requirements.txt",
254
+ ],
255
+ "requirements_txt": _dedupe(deps),
256
+ "cohort_design": {
257
+ "population_or_data_source": datasets or [
258
+ "Cohort/data source not clearly specified."
259
+ ],
260
+ "outcomes": metrics or [
261
+ "Clinical outcomes/endpoints not clearly specified."
262
+ ],
263
+ },
264
+ "data_collection_plan": methodology or [
265
+ "Data collection procedure not clearly specified."
266
+ ],
267
+ "analysis_plan": methods or [
268
+ "Statistical analysis method not clearly specified."
269
+ ],
270
+ "evaluation_plan": metrics or roadmap_eval or [
271
+ "Outcome measurement plan not clearly specified."
272
+ ],
273
+ "reproducibility_risks": missing or ["No major missing information detected."],
274
+ }
275
+
276
+ # ------------------------------------------------------------------
277
+ # Surveys, narrative reviews, guides, reports
278
+ # ------------------------------------------------------------------
279
+ if paper_type in {"survey_paper", "review_paper", "survey_study", "guide_or_report", "survey_or_review"}:
280
+ deps = base_requirements + ["openpyxl", "python-docx"]
281
+
282
+ return {
283
+ "starter_type": "survey_or_review",
284
+ "project_structure": base_structure + [
285
+ "paper2lab_project/data/literature/",
286
+ "paper2lab_project/src/literature_mapping.py",
287
+ "paper2lab_project/src/comparison_matrix.py",
288
+ "paper2lab_project/src/synthesis_report.py",
289
+ "paper2lab_project/outputs/comparison_matrix.xlsx",
290
+ "paper2lab_project/requirements.txt",
291
+ ],
292
+ "requirements_txt": _dedupe(deps),
293
+ "literature_mapping_plan": datasets or [
294
+ "Literature sources not clearly specified."
295
+ ],
296
+ "survey_dimensions": methodology or [
297
+ "Survey/review dimensions not clearly specified."
298
+ ],
299
+ "comparison_framework": methods or [
300
+ "Comparison framework not clearly specified."
301
+ ],
302
+ "evaluation_plan": metrics or roadmap_eval or [
303
+ "Synthesis/evaluation criteria not clearly specified."
304
+ ],
305
+ "reproducibility_risks": missing or ["No major missing information detected."],
306
+ }
307
+
308
+ # ------------------------------------------------------------------
309
+ # Generic fallback
310
+ # ------------------------------------------------------------------
311
+ return {
312
+ "starter_type": "general_research",
313
+ "project_structure": base_structure + [
314
+ "paper2lab_project/src/reproduce.py",
315
+ "paper2lab_project/src/evaluate.py",
316
+ "paper2lab_project/requirements.txt",
317
+ ],
318
+ "requirements_txt": _dedupe(base_requirements),
319
+ "dataset_plan": datasets or ["Dataset/source not clearly specified."],
320
+ "method_or_procedure": methodology or methods or [
321
+ "Method/procedure not clearly specified."
322
+ ],
323
+ "evaluation_plan": metrics or roadmap_eval or ["Evaluation metrics not clearly specified."],
324
+ "reproducibility_risks": missing or ["No major missing information detected."],
325
+ }
src/paper2lab/inference/nemotron_refiner.py ADDED
@@ -0,0 +1,579 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import copy
4
+ import json
5
+ import os
6
+ import re
7
+ from typing import Any, Dict, List
8
+
9
+ import requests
10
+
11
+
12
+ NVIDIA_CHAT_URL = "https://integrate.api.nvidia.com/v1/chat/completions"
13
+ DEFAULT_MODEL = "nvidia/nemotron-3-nano-30b-a3b"
14
+
15
+
16
+ LIST_FIELDS = [
17
+ "contributions",
18
+ "methodology",
19
+ "datasets_or_data_sources",
20
+ "models_or_methods",
21
+ "metrics_or_measurements",
22
+ "key_findings",
23
+ "limitations",
24
+ "missing_reproducibility_info",
25
+ ]
26
+
27
+
28
+ def _clean_string(value: Any) -> str:
29
+ text = str(value or "").strip()
30
+ text = re.sub(r"\s+", " ", text)
31
+ return text.strip(" .;:\n\t")
32
+
33
+
34
+ def _extract_json(text: str) -> Dict[str, Any]:
35
+ text = text.strip()
36
+ text = re.sub(r"^```json", "", text).strip()
37
+ text = re.sub(r"^```", "", text).strip()
38
+ text = re.sub(r"```$", "", text).strip()
39
+
40
+ try:
41
+ return json.loads(text)
42
+ except json.JSONDecodeError:
43
+ match = re.search(r"\{.*\}", text, flags=re.DOTALL)
44
+ if not match:
45
+ raise ValueError("Nemotron returned invalid JSON.")
46
+ return json.loads(match.group(0))
47
+
48
+
49
+ def _clean_list(items: Any, max_items: int = 10) -> List[str]:
50
+ if not isinstance(items, list):
51
+ return []
52
+
53
+ out: List[str] = []
54
+ seen = set()
55
+
56
+ for item in items:
57
+ if isinstance(item, dict):
58
+ item = (
59
+ item.get("value")
60
+ or item.get("description")
61
+ or item.get("text")
62
+ or item.get("summary")
63
+ or ""
64
+ )
65
+
66
+ text = _clean_string(item)
67
+ key = re.sub(r"[^a-z0-9]+", " ", text.lower()).strip()
68
+
69
+ if not text or key in seen:
70
+ continue
71
+
72
+ seen.add(key)
73
+ out.append(text)
74
+
75
+ if len(out) >= max_items:
76
+ break
77
+
78
+ return out
79
+
80
+
81
+ def _filter_datasets(items: List[str]) -> List[str]:
82
+ canonical_sources = {
83
+ "pubmed": "PubMed",
84
+ "scopus": "Scopus",
85
+ "web of knowledge": "Web of Knowledge",
86
+ "web of science": "Web of Science",
87
+ "google scholar": "Google Scholar",
88
+ "cochrane": "Cochrane",
89
+ "cochrane library": "Cochrane Library",
90
+ "embase": "Embase",
91
+ "medline": "MEDLINE",
92
+ "eric": "ERIC",
93
+ "clinicaltrials": "ClinicalTrials.gov",
94
+ }
95
+
96
+ hard_reject = [
97
+ "limitation", "limitations", "ecological design", "classification",
98
+ "spatial", "temporal", "errors", "overfitting", "pseudo-accuracy",
99
+ "beam size", "during inference", "dropout", "optimizer",
100
+ "learning rate", "attention key size",
101
+ ]
102
+
103
+ model_only = {
104
+ "rnn", "lstm", "gru", "transformer", "parser",
105
+ "berkeleyparser", "berkleyparser", "baseline",
106
+ "architecture", "model",
107
+ }
108
+
109
+ found: List[str] = []
110
+
111
+ for item in items:
112
+ clean = _clean_string(item)
113
+ low = clean.lower()
114
+
115
+ if not clean:
116
+ continue
117
+
118
+ if any(bad in low for bad in hard_reject):
119
+ continue
120
+
121
+ if low in model_only:
122
+ continue
123
+
124
+ # Canonical exact/word-boundary source extraction.
125
+ for key, label in canonical_sources.items():
126
+ if re.search(rf"(?<![a-z0-9]){re.escape(key)}(?![a-z0-9])", low):
127
+ found.append(label)
128
+
129
+ # Keep short explicit dataset/source phrases only.
130
+ if len(clean.split()) <= 8 and re.search(
131
+ r"\b(dataset|corpus|benchmark|database|registry|repository|cohort|records|patients|participants)\b",
132
+ low,
133
+ ):
134
+ found.append(clean)
135
+
136
+ return list(dict.fromkeys(found))
137
+
138
+
139
+ def _build_lab_starter_kit(card: Dict[str, Any]) -> Dict[str, Any]:
140
+ paper_type = (card.get("paper_type") or "general_research").lower()
141
+
142
+ datasets = card.get("datasets_or_data_sources") or []
143
+ methods = card.get("models_or_methods") or []
144
+ methodology = card.get("methodology") or []
145
+ metrics = card.get("metrics_or_measurements") or []
146
+ missing = card.get("missing_reproducibility_info") or []
147
+ roadmap = card.get("reproduction_roadmap") or {}
148
+
149
+ # Prefer roadmap datasets when card datasets are empty.
150
+ roadmap_datasets = roadmap.get("datasets") if isinstance(roadmap, dict) else []
151
+ if not datasets and isinstance(roadmap_datasets, list):
152
+ datasets = roadmap_datasets
153
+
154
+ blob = " ".join(
155
+ str(x) for x in (datasets + methods + methodology + metrics)
156
+ ).lower()
157
+
158
+ base_project_structure = [
159
+ "paper2lab_project/",
160
+ "paper2lab_project/data/",
161
+ "paper2lab_project/configs/",
162
+ "paper2lab_project/src/",
163
+ "paper2lab_project/outputs/",
164
+ "paper2lab_project/README.md",
165
+ ]
166
+
167
+ requirements = [
168
+ "python>=3.10",
169
+ "numpy",
170
+ "pandas",
171
+ "matplotlib",
172
+ ]
173
+
174
+ if paper_type == "machine_learning":
175
+ ml_requirements = requirements + [
176
+ "scikit-learn",
177
+ ]
178
+
179
+ if any(x in blob for x in ["transformer", "attention", "bert", "gpt", "neural", "pytorch"]):
180
+ ml_requirements += [
181
+ "torch",
182
+ "transformers",
183
+ "datasets",
184
+ "tokenizers",
185
+ "evaluate",
186
+ ]
187
+
188
+ if "tensorflow" in blob or "keras" in blob:
189
+ ml_requirements.append("tensorflow")
190
+
191
+ if any(x in blob for x in ["bleu", "translation", "wmt"]):
192
+ ml_requirements += ["sacrebleu", "sentencepiece"]
193
+
194
+ ml_requirements = list(dict.fromkeys(ml_requirements))
195
+
196
+ hyperparams = [
197
+ x for x in methodology
198
+ if any(k in x.lower() for k in [
199
+ "learning rate", "batch", "epoch", "optimizer",
200
+ "dropout", "warmup", "steps", "gpu", "label smoothing"
201
+ ])
202
+ ]
203
+
204
+ return {
205
+ "starter_type": "machine_learning",
206
+ "project_structure": base_project_structure + [
207
+ "paper2lab_project/src/preprocess.py",
208
+ "paper2lab_project/src/train.py",
209
+ "paper2lab_project/src/evaluate.py",
210
+ "paper2lab_project/configs/train_config.yaml",
211
+ "paper2lab_project/requirements.txt",
212
+ ],
213
+ "requirements_txt": ml_requirements,
214
+ "dataset_plan": datasets or ["Dataset/source not clearly specified."],
215
+ "training_configuration": {
216
+ "model_or_method": methods[:6] or ["Model/method not clearly specified."],
217
+ "hyperparameters": hyperparams or [
218
+ "Hyperparameters are incomplete or not clearly specified."
219
+ ],
220
+ },
221
+ "experiment_checklist": [
222
+ "Download or prepare the reported datasets.",
223
+ "Reproduce preprocessing/tokenization steps.",
224
+ "Implement the reported model or method.",
225
+ "Configure training hyperparameters.",
226
+ "Run training or analysis pipeline.",
227
+ "Evaluate using the reported metrics.",
228
+ "Compare reproduced outputs with paper results.",
229
+ "Document missing details and deviations.",
230
+ ],
231
+ "evaluation_plan": metrics or ["Evaluation metrics not clearly specified."],
232
+ "reproducibility_risks": missing or ["No major missing information detected."],
233
+ }
234
+
235
+ if paper_type == "systematic_review":
236
+ review_requirements = list(dict.fromkeys(requirements + [
237
+ "openpyxl",
238
+ "python-docx",
239
+ ]))
240
+
241
+ return {
242
+ "starter_type": "systematic_review",
243
+ "project_structure": base_project_structure + [
244
+ "paper2lab_project/data/search_results/",
245
+ "paper2lab_project/data/screening/",
246
+ "paper2lab_project/src/search_strategy.py",
247
+ "paper2lab_project/src/deduplicate.py",
248
+ "paper2lab_project/src/screening_table.py",
249
+ "paper2lab_project/src/quality_assessment.py",
250
+ "paper2lab_project/outputs/prisma_flow.md",
251
+ "paper2lab_project/requirements.txt",
252
+ ],
253
+ "requirements_txt": review_requirements,
254
+ "search_strategy": datasets or ["Bibliographic databases not clearly specified."],
255
+ "screening_checklist": [
256
+ "Define search query and date range.",
257
+ "Export records from each database.",
258
+ "Remove duplicate records.",
259
+ "Screen titles and abstracts.",
260
+ "Review full texts.",
261
+ "Apply inclusion criteria.",
262
+ "Apply exclusion criteria.",
263
+ "Record reasons for exclusion.",
264
+ "Build PRISMA-style flow summary.",
265
+ ],
266
+ "inclusion_exclusion_criteria": [
267
+ x for x in methodology
268
+ if any(k in x.lower() for k in [
269
+ "inclusion", "exclusion", "eligibility", "criteria"
270
+ ])
271
+ ] or ["Inclusion/exclusion criteria not clearly specified."],
272
+ "quality_assessment_tools": methods or [
273
+ "Quality assessment tool not clearly specified."
274
+ ],
275
+ "evaluation_plan": metrics or [
276
+ "Number of records identified.",
277
+ "Number of included studies.",
278
+ "Quality assessment summary.",
279
+ ],
280
+ "reproducibility_risks": missing or ["No major missing information detected."],
281
+ }
282
+
283
+ if paper_type == "clinical_study":
284
+ clinical_requirements = list(dict.fromkeys(requirements + [
285
+ "scipy",
286
+ "statsmodels",
287
+ "openpyxl",
288
+ ]))
289
+
290
+ return {
291
+ "starter_type": "clinical_study",
292
+ "project_structure": base_project_structure + [
293
+ "paper2lab_project/data/raw/",
294
+ "paper2lab_project/data/processed/",
295
+ "paper2lab_project/src/cohort_selection.py",
296
+ "paper2lab_project/src/statistical_analysis.py",
297
+ "paper2lab_project/src/outcome_analysis.py",
298
+ "paper2lab_project/outputs/tables/",
299
+ "paper2lab_project/requirements.txt",
300
+ ],
301
+ "requirements_txt": clinical_requirements,
302
+ "cohort_design": {
303
+ "population_or_data_source": datasets or [
304
+ "Cohort/data source not clearly specified."
305
+ ],
306
+ "outcomes": metrics or [
307
+ "Clinical outcomes/endpoints not clearly specified."
308
+ ],
309
+ },
310
+ "data_collection_plan": methodology or [
311
+ "Data collection procedure not clearly specified."
312
+ ],
313
+ "analysis_plan": methods or [
314
+ "Statistical analysis method not clearly specified."
315
+ ],
316
+ "evaluation_plan": metrics or [
317
+ "Outcome measurement plan not clearly specified."
318
+ ],
319
+ "reproducibility_risks": missing or ["No major missing information detected."],
320
+ }
321
+
322
+ if paper_type in {"survey_paper", "review_paper", "survey_study", "guide_or_report"}:
323
+ survey_requirements = list(dict.fromkeys(requirements + [
324
+ "openpyxl",
325
+ "python-docx",
326
+ ]))
327
+
328
+ return {
329
+ "starter_type": "survey_or_review",
330
+ "project_structure": base_project_structure + [
331
+ "paper2lab_project/data/literature/",
332
+ "paper2lab_project/src/literature_mapping.py",
333
+ "paper2lab_project/src/comparison_matrix.py",
334
+ "paper2lab_project/src/synthesis_report.py",
335
+ "paper2lab_project/outputs/comparison_matrix.xlsx",
336
+ "paper2lab_project/requirements.txt",
337
+ ],
338
+ "requirements_txt": survey_requirements,
339
+ "literature_mapping_plan": datasets or [
340
+ "Literature sources not clearly specified."
341
+ ],
342
+ "survey_dimensions": methodology or [
343
+ "Survey/review dimensions not clearly specified."
344
+ ],
345
+ "comparison_framework": methods or [
346
+ "Comparison framework not clearly specified."
347
+ ],
348
+ "evaluation_plan": metrics or [
349
+ "Synthesis/evaluation criteria not clearly specified."
350
+ ],
351
+ "reproducibility_risks": missing or ["No major missing information detected."],
352
+ }
353
+
354
+ return {
355
+ "starter_type": "general_research",
356
+ "project_structure": base_project_structure + [
357
+ "paper2lab_project/src/reproduce.py",
358
+ "paper2lab_project/src/evaluate.py",
359
+ "paper2lab_project/requirements.txt",
360
+ ],
361
+ "requirements_txt": list(dict.fromkeys(requirements)),
362
+ "dataset_plan": datasets or ["Dataset/source not clearly specified."],
363
+ "method_or_procedure": methodology or methods or [
364
+ "Method/procedure not clearly specified."
365
+ ],
366
+ "evaluation_plan": metrics or ["Evaluation metrics not clearly specified."],
367
+ "reproducibility_risks": missing or ["No major missing information detected."],
368
+ }
369
+
370
+
371
+ def _compact_evidence_pack(pack: Dict[str, Any]) -> Dict[str, Any]:
372
+ candidate = copy.deepcopy(pack.get("candidate_paper_card", {}))
373
+
374
+ compact_sections = []
375
+ for sec in pack.get("section_previews", [])[:12]:
376
+ compact_sections.append({
377
+ "title": sec.get("title"),
378
+ "role_hint": sec.get("role_hint"),
379
+ "page_start": sec.get("page_start"),
380
+ "page_end": sec.get("page_end"),
381
+ "preview": _clean_string(sec.get("preview", ""))[:1800],
382
+ })
383
+
384
+ return {
385
+ "candidate_paper_card": candidate,
386
+ "section_previews": compact_sections,
387
+ "captions": pack.get("captions", [])[:8],
388
+ "tables": pack.get("tables", [])[:3],
389
+ "metadata": pack.get("metadata", {}),
390
+ }
391
+
392
+
393
+ def validate_refined_card(refined: Dict[str, Any], fallback: Dict[str, Any]) -> Dict[str, Any]:
394
+ final = copy.deepcopy(fallback)
395
+
396
+ for key, value in refined.items():
397
+ if key == "llm_evidence_pack":
398
+ continue
399
+ final[key] = value
400
+
401
+ for field in LIST_FIELDS:
402
+ final[field] = _clean_list(final.get(field))
403
+
404
+ final["datasets_or_data_sources"] = _filter_datasets(
405
+ final.get("datasets_or_data_sources", [])
406
+ )
407
+
408
+ final["title"] = _clean_string(final.get("title")) or fallback.get("title")
409
+ final["field"] = _clean_string(final.get("field")) or fallback.get("field")
410
+ final["paper_type"] = _clean_string(final.get("paper_type")) or fallback.get("paper_type")
411
+ final["research_question"] = (
412
+ _clean_string(final.get("research_question"))
413
+ or fallback.get("research_question")
414
+ )
415
+
416
+ final["annotation_version"] = fallback.get("annotation_version", "v1.0")
417
+ final["source_pdf"] = fallback.get("source_pdf")
418
+ final["metadata"] = fallback.get("metadata", {})
419
+
420
+ if not isinstance(final.get("lab_starter_kit"), dict):
421
+ final["lab_starter_kit"] = _build_lab_starter_kit(final)
422
+ if not isinstance(final.get("lab_starter_kit"), dict):
423
+ final["lab_starter_kit"] = _build_lab_starter_kit(final)
424
+ return final
425
+
426
+
427
+ def diff_cards(before: Dict[str, Any], after: Dict[str, Any]) -> Dict[str, Any]:
428
+ changed, added, removed = [], [], []
429
+
430
+ for key in sorted(set(before.keys()) | set(after.keys())):
431
+ if key == "llm_evidence_pack":
432
+ continue
433
+ if key not in before:
434
+ added.append(key)
435
+ elif key not in after:
436
+ removed.append(key)
437
+ elif before.get(key) != after.get(key):
438
+ changed.append(key)
439
+
440
+ return {
441
+ "changed_fields": changed,
442
+ "added_fields": added,
443
+ "removed_fields": removed,
444
+ }
445
+
446
+
447
+ def _call_nvidia(prompt: str, model: str, timeout: int = 180) -> str:
448
+ api_key = (
449
+ os.getenv("NVIDIA_API_KEY")
450
+ or os.getenv("NVIDIA_API_KEY".lower())
451
+ or os.getenv("nvidia-api-key")
452
+ )
453
+
454
+ if not api_key:
455
+ raise RuntimeError("Missing NVIDIA_API_KEY. Set it in your environment before using refinement_mode='nemotron'.")
456
+
457
+ payload = {
458
+ "model": model,
459
+ "messages": [
460
+ {
461
+ "role": "system",
462
+ "content": "You are a precise scientific JSON refiner. Return only valid JSON. No markdown.",
463
+ },
464
+ {
465
+ "role": "user",
466
+ "content": prompt,
467
+ },
468
+ ],
469
+ "temperature": 0.1,
470
+ "top_p": 0.7,
471
+ "max_tokens": 8192,
472
+ }
473
+
474
+ response = requests.post(
475
+ NVIDIA_CHAT_URL,
476
+ headers={
477
+ "Authorization": f"Bearer {api_key}",
478
+ "Content-Type": "application/json",
479
+ },
480
+ json=payload,
481
+ timeout=timeout,
482
+ )
483
+
484
+ if not response.ok:
485
+ raise RuntimeError(
486
+ f"NVIDIA API error {response.status_code}: {response.text[:1000]}"
487
+ )
488
+
489
+ data = response.json()
490
+
491
+ try:
492
+ return data["choices"][0]["message"]["content"]
493
+ except Exception as exc:
494
+ raise RuntimeError(f"Unexpected NVIDIA response: {data}") from exc
495
+
496
+
497
+ def refine_with_nemotron(
498
+ llm_evidence_pack: Dict[str, Any],
499
+ model: str = DEFAULT_MODEL,
500
+ return_comparison: bool = True,
501
+ ) -> Dict[str, Any]:
502
+ if "candidate_paper_card" not in llm_evidence_pack:
503
+ raise ValueError("llm_evidence_pack must contain candidate_paper_card.")
504
+
505
+ compact_pack = _compact_evidence_pack(llm_evidence_pack)
506
+ before = copy.deepcopy(compact_pack["candidate_paper_card"])
507
+
508
+ prompt = f"""
509
+ You are Paper2Lab.
510
+
511
+ Refine candidate_paper_card using ONLY the provided evidence.
512
+
513
+ Return ONLY valid JSON.
514
+
515
+ Strict rules:
516
+ - Do not invent facts.
517
+ - Do not add facts that are not in the evidence pack.
518
+ - Remove boilerplate, references, author contributions, affiliations, acknowledgements, and duplicate claims.
519
+ - datasets_or_data_sources must contain only real datasets, corpora, benchmarks, databases, or data sources.
520
+ - Do NOT put models, parsers, methods, architectures, baselines, algorithms, or metrics in datasets_or_data_sources.
521
+ - Preserve annotation_version.
522
+ - Preserve source_pdf.
523
+ - Keep outputs concise.
524
+ - If evidence is insufficient, use [] or null.
525
+ - Add or improve lab_starter_kit.
526
+
527
+ Return a compact JSON object with ONLY these keys:
528
+ - title
529
+ - field
530
+ - paper_type
531
+ - research_question
532
+ - contributions
533
+ - methodology
534
+ - datasets_or_data_sources
535
+ - models_or_methods
536
+ - metrics_or_measurements
537
+ - key_findings
538
+ - limitations
539
+ - missing_reproducibility_info
540
+ - reproduction_roadmap
541
+ - reproducibility_score
542
+ - lab_starter_kit
543
+ - source_pdf
544
+ - annotation_version
545
+
546
+ Do not return metadata.
547
+ Do not return long nested evidence_terms.
548
+ Do not repeat large diagnostics objects.
549
+ Keep every list to maximum 6 items.
550
+
551
+ Evidence pack:
552
+ {json.dumps(compact_pack, indent=2, ensure_ascii=False)}
553
+ """.strip()
554
+
555
+ raw = _call_nvidia(prompt=prompt, model=model)
556
+
557
+ try:
558
+ refined_raw = _extract_json(raw)
559
+ except Exception as exc:
560
+ return {
561
+ "status": "error",
562
+ "model": model,
563
+ "error": str(exc),
564
+ "before_refinement": before,
565
+ "raw_model_output": raw,
566
+ }
567
+
568
+ after = validate_refined_card(refined_raw, before)
569
+
570
+ if not return_comparison:
571
+ return after
572
+
573
+ return {
574
+ "status": "ok",
575
+ "model": model,
576
+ "before_refinement": before,
577
+ "after_refinement": after,
578
+ "diff_summary": diff_cards(before, after),
579
+ }
src/paper2lab/inference/paper_card.py ADDED
@@ -0,0 +1,766 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ paper_card.py — Dynamic section-aware rule-based paper card builder for Paper2Lab.
3
+
4
+ Purpose
5
+ -------
6
+ Builds a clean candidate paper card before LLM/Nemotron refinement.
7
+
8
+ Design principles
9
+ -----------------
10
+ - Uses extraction-safe clean_text from pdf_loader.py.
11
+ - Uses structured sections and roles instead of raw full-PDF text whenever possible.
12
+ - Detects paper_type first, then chooses extraction strategy.
13
+ - Avoids ML-only assumptions for systematic reviews, clinical studies, surveys, and reports.
14
+ - Keeps references/appendix/boilerplate out of candidate fields.
15
+ - Produces llm_evidence_pack for later Modal/Nemotron refinement.
16
+
17
+ Public API
18
+ ----------
19
+ build_paper_card(extracted: Dict[str, Any]) -> Dict[str, Any]
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import json
25
+ import re
26
+ from collections import Counter
27
+ from typing import Any, Dict, Iterable, List, Tuple
28
+
29
+
30
+ # ---------------------------------------------------------------------------
31
+ # Pattern banks
32
+ # ---------------------------------------------------------------------------
33
+
34
+ MODEL_PATTERNS = [
35
+ r"\btransformer\b", r"\bself[- ]attention\b", r"\bmulti[- ]head attention\b",
36
+ r"\bscaled dot[- ]product attention\b", r"\bencoder[- ]decoder\b",
37
+ r"\bcnn\b", r"\bconvolutional neural network\b", r"\bu[- ]net\b",
38
+ r"\bbert\b", r"\bgpt\b", r"\bllm\b", r"\blarge language model\b",
39
+ r"\bvision transformer\b", r"\bvit\b", r"\bdiffusion model\b", r"\bgan\b",
40
+ r"\bresnet\b", r"\blstm\b", r"\bgru\b", r"\bsvm\b", r"\brandom forest\b",
41
+ r"\bxgboost\b", r"\blightgbm\b", r"\blogistic regression\b",
42
+ r"\blinear regression\b", r"\bgraph neural network\b", r"\bgnn\b", r"\brag\b",
43
+ ]
44
+
45
+ ML_DATASET_PATTERNS = [
46
+ r"\bwmt\s*\d{4}\b", r"\bwsj\b", r"\bwall street journal\b", r"\bpenn treebank\b",
47
+ r"\bcifar[- ]?\d+\b", r"\bimagenet\b", r"\bmnist\b", r"\bcoco\b",
48
+ r"\bglue\b", r"\bsuperglue\b", r"\bsquad\b", r"\bbookscorpus\b",
49
+ r"\bwikipedia\b", r"\bcommonvoice\b", r"\blibrispeech\b",
50
+ r"\b\d+(?:\.\d+)?\s*(?:m|million|b|billion|k|thousand)?\s*"
51
+ r"(?:sentence pairs|sentences|tokens|images|patients|samples|records|documents|cases|examples|instances)\b",
52
+ r"\btraining data\b", r"\btraining dataset\b", r"\bvalidation set\b", r"\btest set\b",
53
+ r"\bdataset\b", r"\bdata source\b", r"\bclinical data\b", r"\bpublic dataset\b", r"\bbenchmark\b",
54
+ ]
55
+
56
+ REVIEW_SOURCE_PATTERNS = [
57
+ r"\bpubmed\b", r"\bscopus\b", r"\bweb of knowledge\b", r"\bweb of science\b",
58
+ r"\beric\b", r"\beducational resources and information center\b",
59
+ r"\bcochrane\b", r"\bembase\b", r"\bmedline\b", r"\bgoogle scholar\b",
60
+ r"\bdatabases?\b", r"\barticles?\b", r"\bstudies\b", r"\bpublications?\b",
61
+ r"\brecords identified\b", r"\bselected studies\b", r"\bincluded studies\b",
62
+ r"\bgray literature\b", r"\bgrey literature\b",
63
+ ]
64
+
65
+ METHODOLOGY_PATTERNS = [
66
+ # ML / computational methods
67
+ r"\bwe (?:train|trained|fine[- ]?tune|fine[- ]?tuned|evaluate|evaluated|optimize|optimized|pre[- ]?train|pre[- ]?trained)\b",
68
+ r"\bmodel (?:architecture|consists|uses|contains|is trained|was trained)\b",
69
+ r"\barchitecture\b", r"\btraining procedure\b", r"\bexperimental setup\b",
70
+ r"\boptimizer\b", r"\badamw?\b", r"\bsgd\b", r"\blearning rate\b",
71
+ r"\bbatch size\b", r"\bepoch\b", r"\bwarm[- ]?up\b", r"\bscheduler\b",
72
+ r"\bdropout\b", r"\blayer normalization\b", r"\bbatch normalization\b", r"\bweight decay\b",
73
+ r"\btokenization\b", r"\bbyte[- ]pair encoding\b", r"\bpositional encoding\b",
74
+ r"\bself[- ]attention\b", r"\bscaled dot[- ]product attention\b", r"\bmulti[- ]head attention\b",
75
+ r"\bcross[- ]validation\b", r"\btrain[- ]test split\b", r"\brandom seed\b",
76
+ r"\bpre[- ]?processed\b", r"\baugmentation\b",
77
+ # General empirical / review methods
78
+ r"\bsystematic review\b", r"\bliterature review\b", r"\bscoping review\b",
79
+ r"\bdatabases? (?:were|was) searched\b", r"\bsearched\b",
80
+ r"\binclusion criteria\b", r"\bexclusion criteria\b", r"\beligibility criteria\b",
81
+ r"\bscreen(?:ed|ing)\b", r"\bstudies were selected\b", r"\bdata extraction\b",
82
+ r"\bstudy design\b", r"\bparticipants\b", r"\bprocedure\b", r"\bintervention\b",
83
+ ]
84
+
85
+ METRIC_PATTERNS = [
86
+ # ML/AI metrics
87
+ r"\bbleu\b", r"\bperplexity\b", r"\baccuracy\b", r"\bprecision\b", r"\brecall\b",
88
+ r"\bf1[- ]?score\b", r"\bf1\b", r"\bauc\b", r"\broc\b", r"\bsensitivity\b",
89
+ r"\bspecificity\b", r"\brmse\b", r"\bmae\b", r"\bmse\b", r"\br\s*[²2]\b",
90
+ r"\bmap\b", r"\biou\b", r"\bwer\b", r"\bcer\b", r"\brouge\b", r"\bbertscore\b",
91
+ r"\bloss\b", r"\bcross[- ]entropy\b",
92
+ # Review / clinical / social-science measurement patterns
93
+ r"\b\d+\s+(?:articles|studies|records|participants|patients|students)\b",
94
+ r"\bfinal review included\b", r"\bwere enrolled\b", r"\bselected for further review\b",
95
+ r"\bbetween\s+(?:january\s+)?\d{4}\s+and\s+(?:january\s+)?\d{4}\b",
96
+ r"\bfrom\s+(?:january\s+)?\d{4}\s+to\s+(?:january\s+)?\d{4}\b",
97
+ ]
98
+
99
+ FINDING_PATTERNS = [
100
+ r"\bachieves?\b", r"\boutperforms?\b", r"\bimproves?\b", r"\bincreases?\b",
101
+ r"\bdecreases?\b", r"\bstate[- ]of[- ]the[- ]art\b", r"\bresults show\b",
102
+ r"\bfindings show\b", r"\bsignificantly\b", r"\bsuperior\b", r"\bcomparable\b",
103
+ r"\bconsistently\b", r"\bobtains?\b", r"\bshowed that\b", r"\bfound that\b",
104
+ r"\bpositive (?:responses|attitudes|effects|outcomes)\b", r"\bwas effective\b",
105
+ ]
106
+
107
+ CONTRIBUTION_PATTERNS = [
108
+ r"\bwe propose\b", r"\bwe introduce\b", r"\bwe present\b", r"\bwe develop\b",
109
+ r"\bwe designed\b", r"\bwe show\b", r"\bwe demonstrate\b", r"\bwe release\b",
110
+ r"\bwe open[- ]source\b", r"\bthis paper proposes\b", r"\bthis work proposes\b",
111
+ r"\bthis study developed\b", r"\bour contribution\b", r"\bour main contribution\b",
112
+ r"\bnovel\b", r"\bfirst to\b", r"\bfirst systematic review\b",
113
+ ]
114
+
115
+ LIMITATION_PATTERNS = [
116
+ r"\blimitation\b", r"\blimitations\b", r"\bfuture work\b", r"\bmore data\b",
117
+ r"\bsmall dataset\b", r"\bfalse[- ]positive\b", r"\bfalse[- ]negative\b",
118
+ r"\bnot sufficient\b", r"\blacking\b", r"\bwe did not\b", r"\bcannot\b",
119
+ r"\bwe leave\b", r"\bdoes not generalize\b", r"\bbias\b", r"\boutside the scope\b",
120
+ r"\bnot evaluated\b", r"\bnot tested\b", r"\black of\b", r"\bmay have led to bias\b",
121
+ ]
122
+
123
+ NOISE_MARKERS = [
124
+ "acknowledgement", "acknowledgment", "author contribution", "competing interests",
125
+ "correspondence", "publisher", "open access", "license", "copyright", "gmail.com",
126
+ "references", "bibliography", "arxiv:", "how to cite", "access this article online",
127
+ "quick response code", "website:", "doi:", "www.", "http://", "https://",
128
+ # Known author-contribution noise from some papers
129
+ "llion also experimented", "jakob proposed", "ashish", "noam proposed", "niki selected",
130
+ "aidan designed", "illia", "google brain",
131
+ ]
132
+
133
+ AFFILIATION_MARKERS = [
134
+ "department of", "university of", "faculty of", "school of", "institute of",
135
+ "medical sciences", "corresponding author", "email", "journal of education",
136
+ ]
137
+
138
+ _FIELD_REJECT: Dict[str, List[str]] = {
139
+ "contributions": ["author", "google brain", "also experimented", "selected work", "department of"],
140
+ "datasets_or_data_sources": [
141
+ "achieves", "outperforms", "state-of-the-art", "results show", "during inference",
142
+ "beam size", "parser training", "section 23", "table 4", "department of",
143
+ ],
144
+ "models_or_methods": ["author", "university", "gmail", "in the following sections", "references"],
145
+ "methodology": ["in the following sections", "to the best of our knowledge", "references"],
146
+ "findings": ["table of contents", "during inference", "parser training", "references"],
147
+ }
148
+
149
+ ML_DATASET_MUST_CONTAIN = [
150
+ "dataset", "data", "wmt", "wsj", "wall street journal", "penn treebank", "bookcorpus",
151
+ "wikipedia", "sentence pairs", "sentences", "tokens", "images", "patients", "samples",
152
+ "records", "cases", "examples", "instances", "benchmark", "training set", "test set",
153
+ "validation set", "english-german", "english-french",
154
+ ]
155
+
156
+ REVIEW_SOURCE_MUST_CONTAIN = [
157
+ "pubmed", "scopus", "web of knowledge", "web of science",
158
+ "educational resources", "cochrane", "embase", "medline",
159
+ "google scholar", "database", "databases", "studies",
160
+ "articles", "publications", "records", "gray literature", "grey literature",
161
+ ]
162
+
163
+ # ---------------------------------------------------------------------------
164
+ # Generic utilities
165
+ # ---------------------------------------------------------------------------
166
+
167
+ def _strip_doi_noise(text: str) -> str:
168
+ text = text or ""
169
+ text = re.sub(r"\bdoi\s*[::]?\s*10\.\d{4,9}/[-._;()/:A-Za-z0-9]+", " ", text, flags=re.IGNORECASE)
170
+ text = re.sub(r"\b10\.\d{4,9}/[-._;()/:A-Za-z0-9]+", " ", text)
171
+ return text
172
+
173
+
174
+ def _clean(text: str) -> str:
175
+ text = text or ""
176
+ text = text.replace("\x00", " ").replace("\u00a0", " ")
177
+ text = text.replace("\ufb01", "fi").replace("\ufb02", "fl")
178
+ text = text.replace("\u2013", "-").replace("\u2014", "-")
179
+ text = text.replace("\u2018", "'").replace("\u2019", "'")
180
+ text = text.replace("\u201c", '"').replace("\u201d", '"')
181
+ text = _strip_doi_noise(text)
182
+ text = re.sub(r"\s+", " ", text)
183
+ return text.strip()
184
+
185
+
186
+ def _normalize_key(text: str) -> str:
187
+ return re.sub(r"[^a-z0-9]+", " ", _clean(text).lower()).strip()
188
+
189
+
190
+ def _bad_sentence_quality(sentence: str) -> bool:
191
+ """Reject merged-column, affiliation, citation, and boilerplate artifacts."""
192
+ s = _clean(sentence)
193
+ low = s.lower()
194
+
195
+ if not s:
196
+ return True
197
+
198
+ if any(x in low for x in AFFILIATION_MARKERS):
199
+ return True
200
+
201
+ # Too many citation markers often means merged reference/body text.
202
+ if len(re.findall(r"\[\d+\]", s)) >= 2:
203
+ return True
204
+
205
+ # Known symptoms of two-column stitching / line interleaving.
206
+ bad_regexes = [
207
+ r"\bneed\s+this systematic review\b",
208
+ r"\bof the there\b",
209
+ r"\band the that\b",
210
+ r"\bwere as follows: being\b",
211
+ r"\bdata were included to improve\b",
212
+ r"\bto adopt a new style of learning\s*\.\s*medical courses\b",
213
+ r"\bfisher \(\d{4}\) discuss intended studies\b",
214
+ ]
215
+ if any(re.search(p, low) for p in bad_regexes):
216
+ return True
217
+
218
+ # Merged sentences are usually very long and contain unrelated cues.
219
+ if len(s.split()) > 55 and any(x in low for x in [
220
+ "students are required", "there is a good deal", "department", "university",
221
+ "corresponding author", "access this article",
222
+ ]):
223
+ return True
224
+
225
+ # Odd punctuation/table artifacts.
226
+ if s.count("|") >= 2 or s.count("%") >= 6 or s.count("@") >= 1:
227
+ return True
228
+
229
+ return False
230
+
231
+
232
+ def _is_noise(sentence: str) -> bool:
233
+ low = sentence.lower()
234
+ if any(m in low for m in NOISE_MARKERS):
235
+ return True
236
+ if len(sentence.split()) > 85:
237
+ return True
238
+ if re.search(r"^\d+(?:\.\d+)*\s+[A-Z][A-Za-z ]{2,50}$", sentence):
239
+ return True
240
+ if _bad_sentence_quality(sentence):
241
+ return True
242
+ return False
243
+
244
+
245
+ def _split_sentences(text: str) -> List[str]:
246
+ text = _clean(text)
247
+ if not text:
248
+ return []
249
+ raw = re.split(r"(?<=[.!?])\s+(?=[A-Z0-9])", text)
250
+ sentences: List[str] = []
251
+ for s in raw:
252
+ s = _clean(s)
253
+ if 35 <= len(s) <= 420 and not _is_noise(s):
254
+ sentences.append(s)
255
+ return sentences
256
+
257
+
258
+ def _dedupe(items: Iterable[str]) -> List[str]:
259
+ seen: set[str] = set()
260
+ out: List[str] = []
261
+ for item in items:
262
+ item = _clean(item)
263
+ key = _normalize_key(item)[:220]
264
+ if key and key not in seen:
265
+ seen.add(key)
266
+ out.append(item)
267
+ return out
268
+
269
+
270
+ def _json_safe(value: Any) -> Any:
271
+ try:
272
+ json.dumps(value)
273
+ return value
274
+ except Exception:
275
+ return str(value)
276
+
277
+
278
+ def _section_title(sec: Dict[str, Any]) -> str:
279
+ return _clean(sec.get("title") or "")
280
+
281
+
282
+ def _section_role(sec: Dict[str, Any]) -> str:
283
+ return sec.get("role") or "other"
284
+
285
+
286
+ def _sections_by_role(
287
+ extracted: Dict[str, Any],
288
+ roles: List[str],
289
+ title_contains: List[str] | None = None,
290
+ ) -> str:
291
+ chunks: List[str] = []
292
+ title_contains = [t.lower() for t in (title_contains or [])]
293
+ paper_title = _clean(extracted.get("title") or "").lower()
294
+
295
+ blocked_roles = {"references", "appendix", "boilerplate"}
296
+ blocked_titles = {"front matter", "keywords", "keywords:", "table of contents"}
297
+
298
+ for sec in extracted.get("sections", []):
299
+ title_raw = _section_title(sec)
300
+ title = title_raw.lower()
301
+ role = _section_role(sec)
302
+
303
+ if role in blocked_roles:
304
+ continue
305
+ if title in blocked_titles:
306
+ continue
307
+ if paper_title and title == paper_title:
308
+ continue
309
+
310
+ if role in roles or any(t in title for t in title_contains):
311
+ chunks.append(sec.get("text", ""))
312
+
313
+ return "\n".join(chunks)
314
+
315
+
316
+ def _find_sentences(
317
+ text: str,
318
+ patterns: List[str],
319
+ max_items: int = 8,
320
+ require_number: bool = False,
321
+ ) -> List[str]:
322
+ found: List[str] = []
323
+ for sentence in _split_sentences(text):
324
+ if require_number and not re.search(r"\d", sentence):
325
+ continue
326
+ if any(re.search(p, sentence, flags=re.IGNORECASE) for p in patterns):
327
+ found.append(sentence)
328
+ if len(found) >= max_items:
329
+ break
330
+ return _dedupe(found)
331
+
332
+
333
+ def _filter_field(items: List[str], field: str, paper_type: str = "general_research") -> List[str]:
334
+ reject_terms = _FIELD_REJECT.get(field, [])
335
+ filtered: List[str] = []
336
+
337
+ for item in items:
338
+ item = _clean(item)
339
+ low = item.lower()
340
+ if not item or _is_noise(item):
341
+ continue
342
+ if any(term in low for term in reject_terms):
343
+ continue
344
+
345
+ if field == "datasets_or_data_sources":
346
+ if paper_type == "systematic_review":
347
+ if not any(term in low for term in REVIEW_SOURCE_MUST_CONTAIN):
348
+ continue
349
+ else:
350
+ if not any(term in low for term in ML_DATASET_MUST_CONTAIN):
351
+ continue
352
+ if any(bad in low for bad in ["beam size", "during inference", "parser training", "section 23"]):
353
+ continue
354
+
355
+ filtered.append(item)
356
+
357
+ return _dedupe(filtered)
358
+
359
+
360
+ # ---------------------------------------------------------------------------
361
+ # Dynamic paper type and field inference
362
+ # ---------------------------------------------------------------------------
363
+
364
+ def _top_terms(text: str, k: int = 10) -> List[str]:
365
+ stop = {
366
+ "the", "and", "for", "with", "that", "this", "from", "were", "was", "are", "has",
367
+ "have", "had", "their", "they", "into", "using", "used", "study", "paper", "article",
368
+ "results", "method", "methods", "data", "based", "between", "through", "there",
369
+ }
370
+ words = re.findall(r"\b[a-z][a-z]{3,}\b", text.lower())
371
+ words = [w for w in words if w not in stop]
372
+ return [w for w, _ in Counter(words).most_common(k)]
373
+
374
+
375
+ def _infer_paper_type(title: str, abstract: str, sections: List[Dict[str, Any]], clean_text: str) -> str:
376
+ title_abs = f"{title} {abstract}".lower()
377
+ section_titles = " ".join(_section_title(s) for s in sections).lower()
378
+ probe = f"{title_abs} {section_titles} {clean_text[:9000].lower()}"
379
+
380
+ review_score = 0
381
+ for term in [
382
+ "systematic review",
383
+ "systematic literature search",
384
+ "systematic literature review",
385
+ "literature search",
386
+ "literature review",
387
+ "prisma",
388
+ "study selection",
389
+ "bibliographic search",
390
+ "databases were searched",
391
+ "database search",
392
+ "included studies",
393
+ "included articles",
394
+ "screening",
395
+ "eligibility criteria",
396
+ ]:
397
+ if term in probe:
398
+ review_score += 1
399
+
400
+ if review_score >= 2:
401
+ return "systematic_review"
402
+
403
+ if any(x in probe for x in ["meta-analysis", "meta analysis", "scoping review"]):
404
+ return "systematic_review"
405
+
406
+ narrative_review_terms = [
407
+ "review of important findings",
408
+ "review of previous research",
409
+ "previous research on",
410
+ "literature on",
411
+ "research on teacher education",
412
+ "implications for improving",
413
+ ]
414
+ if any(term in probe for term in narrative_review_terms):
415
+ return "survey_or_review"
416
+
417
+ if any(x in probe for x in ["randomized controlled trial", "cohort", "case-control", "clinical trial"]):
418
+ return "clinical_study"
419
+
420
+ if any(x in probe for x in ["transformer", "bert", "neural network", "optimizer", "training loss", "fine-tuned", "imagenet", "bleu"]):
421
+ return "machine_learning"
422
+
423
+ if any(x in probe for x in ["survey", "questionnaire", "respondents", "participants"]):
424
+ return "survey_study"
425
+
426
+ if any(x in probe for x in ["recommendations", "checklist", "best practices"]):
427
+ return "guide_or_report"
428
+
429
+ return "general_research"
430
+
431
+
432
+ def _infer_field(title: str, abstract: str, clean_text: str, paper_type: str) -> str:
433
+ title_abs = f"{title} {abstract}".lower()
434
+ probe = f"{title_abs} {clean_text[:5000].lower()}"
435
+
436
+ domain_scores: Dict[str, int] = {
437
+ "Education": 0,
438
+ "Natural Language Processing": 0,
439
+ "Computer Vision": 0,
440
+ "Medical / Clinical Research": 0,
441
+ "Medical AI": 0,
442
+ "Biology / Life Sciences": 0,
443
+ "Graph Learning": 0,
444
+ "Reinforcement Learning": 0,
445
+ "Generative Models": 0,
446
+ "Social Science": 0,
447
+ "Machine Learning": 0,
448
+ }
449
+
450
+ weighted_terms: Dict[str, List[str]] = {
451
+ "Education": ["education", "educational", "academic performance", "students", "higher education", "preclinical academic"],
452
+ "Natural Language Processing": ["translation", "language model", "question answering", "summarization", "bleu", "token", "bert", "gpt", "corpus"],
453
+ "Computer Vision": ["image classification", "object detection", "segmentation", "imagenet", "resnet", "vision transformer", "pixel"],
454
+ "Medical / Clinical Research": ["patient", "clinical", "cohort", "diagnosis", "mortality", "disease", "treatment", "medical students"],
455
+ "Medical AI": ["clinical prediction", "medical image", "radiograph", "x-ray", "ct scan", "mri", "ehr", "icu", "machine learning"],
456
+ "Biology / Life Sciences": ["protein", "molecule", "drug", "genomics", "dna", "rna", "crispr", "gene"],
457
+ "Graph Learning": ["graph neural network", "gnn", "node classification", "link prediction", "message passing"],
458
+ "Reinforcement Learning": ["reward", "agent", "policy", "q-learning", "ppo", "dqn", "actor-critic"],
459
+ "Generative Models": ["diffusion model", "gan", "generative adversarial", "vae", "denoising"],
460
+ "Social Science": ["survey", "questionnaire", "social science", "respondents", "interviews"],
461
+ "Machine Learning": ["machine learning", "deep learning", "neural network", "training", "optimizer", "classification"],
462
+ }
463
+
464
+ for domain, terms in weighted_terms.items():
465
+ for term in terms:
466
+ if term in probe:
467
+ domain_scores[domain] += 1
468
+ if term in title_abs:
469
+ domain_scores[domain] += 2
470
+
471
+ # Paper-type-aware tie breaks.
472
+ if paper_type == "systematic_review":
473
+ if domain_scores["Education"] >= 2:
474
+ return "Education"
475
+ if domain_scores["Medical / Clinical Research"] >= 2:
476
+ return "Medical / Clinical Research"
477
+ if domain_scores["Social Science"] >= 2:
478
+ return "Social Science"
479
+
480
+ if domain_scores["Medical AI"] >= 3 and domain_scores["Machine Learning"] >= 2:
481
+ return "Medical AI"
482
+
483
+ best = max(domain_scores, key=domain_scores.get)
484
+ if domain_scores[best] <= 1:
485
+ return "General Research"
486
+ return best
487
+
488
+
489
+ # ---------------------------------------------------------------------------
490
+ # Field-specific extraction helpers
491
+ # ---------------------------------------------------------------------------
492
+
493
+ def _extract_research_question(abstract: str, intro: str, title: str, paper_type: str) -> str:
494
+ text = _clean(f"{abstract} {intro}")
495
+
496
+ if paper_type == "systematic_review":
497
+ review_markers = [
498
+ "purpose", "aim", "objective", "intended", "present review", "systematic review",
499
+ "with the purpose", "the aim of", "the objective of",
500
+ ]
501
+ for sentence in _split_sentences(text):
502
+ low = sentence.lower()
503
+ if any(m in low for m in review_markers):
504
+ return sentence
505
+
506
+ markers = [
507
+ "we propose", "we introduce", "we investigate", "we evaluate", "we present",
508
+ "we developed", "this paper proposes", "this work proposes", "the goal",
509
+ "the aim", "the objective", "our goal", "our aim", "this study aims",
510
+ ]
511
+ for sentence in _split_sentences(text):
512
+ low = sentence.lower()
513
+ if any(m in low for m in markers):
514
+ return sentence
515
+
516
+ clean_abstract = _split_sentences(abstract)
517
+ if clean_abstract:
518
+ return clean_abstract[0]
519
+
520
+ return title or ""
521
+
522
+
523
+ def _extract_contributions(abstract: str, intro: str, conclusion: str, paper_type: str) -> List[str]:
524
+ text = f"{abstract} {intro} {conclusion}"
525
+ items = _find_sentences(text, CONTRIBUTION_PATTERNS, 8)
526
+ if paper_type == "systematic_review" and not items:
527
+ # Many reviews do not have explicit contribution language. Avoid hallucinating.
528
+ return []
529
+ return _filter_field(items, "contributions", paper_type)
530
+
531
+
532
+ def _extract_methodology(methods: str, experiments: str, intro: str, paper_type: str) -> List[str]:
533
+ primary = f"{methods}\n{experiments}"
534
+
535
+ if paper_type == "systematic_review":
536
+ review_patterns = [
537
+ r"\b(?:pubmed|scopus|web of knowledge|web of science|eric|cochrane|embase|medline)\b.*\bsearched\b",
538
+ r"\bdatabases?\b.*\bsearched\b",
539
+ r"\binclusion criteria\b.*",
540
+ r"\bexclusion criteria\b.*",
541
+ r"\beligibility criteria\b.*",
542
+ r"\btitles and abstracts were screened\b.*",
543
+ r"\barticles were imported\b.*",
544
+ r"\bdata extraction\b.*",
545
+ r"\bsystematic review was conducted\b.*",
546
+ ]
547
+ items = _find_sentences(primary, review_patterns, max_items=10)
548
+ if len(items) < 3:
549
+ items += _find_sentences(f"{intro}\n{primary}", review_patterns, max_items=10)
550
+ return _filter_field(items, "methodology", paper_type)[:8]
551
+
552
+ items = _find_sentences(primary, METHODOLOGY_PATTERNS, max_items=12)
553
+ if len(items) < 3:
554
+ items += _find_sentences(intro, METHODOLOGY_PATTERNS, max_items=5)
555
+ return _filter_field(items, "methodology", paper_type)[:10]
556
+
557
+
558
+ def _extract_datasets(clean_text: str, methods: str, experiments: str, results: str, paper_type: str) -> List[str]:
559
+ priority_text = f"{methods}\n{experiments}\n{results}"
560
+
561
+ if paper_type == "systematic_review":
562
+ items = _find_sentences(priority_text, REVIEW_SOURCE_PATTERNS, max_items=14)
563
+ if len(items) < 4:
564
+ items += _find_sentences(clean_text[:12000], REVIEW_SOURCE_PATTERNS, max_items=14)
565
+
566
+ # Also add compact database names when explicitly found.
567
+ compact: List[str] = []
568
+ db_names = [
569
+ "PubMed", "Scopus", "Web of Knowledge", "Web of Science", "ERIC",
570
+ "Educational Resources and Information Center", "Cochrane", "Embase", "Medline", "Google Scholar",
571
+ ]
572
+ low = priority_text.lower() + " " + clean_text[:8000].lower()
573
+ for name in db_names:
574
+ if name.lower() in low:
575
+ compact.append(name)
576
+ compact += _filter_field(items, "datasets_or_data_sources", paper_type)
577
+ return _dedupe(compact)[:10]
578
+
579
+ items = _find_sentences(priority_text, ML_DATASET_PATTERNS, 12)
580
+ if len(items) < 4:
581
+ items += _find_sentences(clean_text[:15000], ML_DATASET_PATTERNS, 12)
582
+ return _filter_field(items, "datasets_or_data_sources", paper_type)[:10]
583
+
584
+
585
+ def _extract_models(clean_text: str, methods: str, experiments: str, paper_type: str) -> List[str]:
586
+ if paper_type in {"systematic_review", "guide_or_report", "survey_study"}:
587
+ return []
588
+ priority_text = f"{methods}\n{experiments}"
589
+ items = _find_sentences(priority_text, MODEL_PATTERNS, 12)
590
+ if len(items) < 4:
591
+ items += _find_sentences(clean_text[:15000], MODEL_PATTERNS, 12)
592
+ return _filter_field(items, "models_or_methods", paper_type)[:10]
593
+
594
+
595
+ def _extract_metrics(clean_text: str, results: str, experiments: str, methods: str, paper_type: str) -> List[str]:
596
+ priority_text = f"{results}\n{experiments}\n{methods}"
597
+
598
+ if paper_type == "systematic_review":
599
+ review_metric_patterns = [
600
+ r"\b\d+\s+(?:articles|studies|records|publications)\b.*",
601
+ r"\b(?:final review|review) included\s+\d+\b.*",
602
+ r"\b\d+\s+articles were selected\b.*",
603
+ r"\bbetween\s+(?:january\s+)?\d{4}\s+and\s+(?:january\s+)?\d{4}\b.*",
604
+ r"\bfrom\s+(?:january\s+)?\d{4}\s+to\s+(?:january\s+)?\d{4}\b.*",
605
+ ]
606
+ items = _find_sentences(priority_text, review_metric_patterns, 10, require_number=True)
607
+ if len(items) < 3:
608
+ items += _find_sentences(clean_text[:12000], review_metric_patterns, 10, require_number=True)
609
+ return _dedupe(items)[:8]
610
+
611
+ items = _find_sentences(priority_text, METRIC_PATTERNS, 12, require_number=True)
612
+ if len(items) < 3:
613
+ items += _find_sentences(clean_text[:15000], METRIC_PATTERNS, 10, require_number=True)
614
+ if not items:
615
+ items = _find_sentences(priority_text or clean_text[:15000], METRIC_PATTERNS, 8)
616
+ return _dedupe([x for x in items if "references" not in x.lower()])[:10]
617
+
618
+
619
+ def _extract_findings(results: str, conclusion: str, abstract: str, paper_type: str) -> List[str]:
620
+ text = f"{results} {conclusion} {abstract}"
621
+ items = _find_sentences(text, FINDING_PATTERNS, 10)
622
+ return _filter_field(items, "findings", paper_type)[:5]
623
+
624
+
625
+ def _extract_limitations(clean_text: str, limitations: str, discussion: str, conclusion: str, paper_type: str) -> List[str]:
626
+ text = f"{limitations} {discussion} {conclusion} {clean_text[-6000:]}"
627
+ return _filter_field(_find_sentences(text, LIMITATION_PATTERNS, 8), "limitations", paper_type)[:6]
628
+
629
+
630
+ def _missing_repro_info(card: Dict[str, Any], extracted: Dict[str, Any], paper_type: str) -> List[str]:
631
+ missing: List[str] = []
632
+ method_text = _sections_by_role(extracted, ["methodology", "experiments"])
633
+ low = method_text.lower()
634
+
635
+ if paper_type == "systematic_review":
636
+ checks = {
637
+ "search date range is not clearly specified": ["from", "between", "1987", "2018", "date", "time limitation"],
638
+ "inclusion/exclusion criteria are not clearly specified": ["inclusion criteria", "exclusion criteria", "eligibility criteria"],
639
+ "screening process is not clearly specified": ["screened", "titles and abstracts", "two independent"],
640
+ "quality assessment method is not clearly specified": ["quality", "assessment", "best evidence", "risk of bias"],
641
+ }
642
+ for label, terms in checks.items():
643
+ if not any(t in low for t in terms):
644
+ missing.append(label)
645
+ if not card.get("datasets_or_data_sources"):
646
+ missing.append("bibliographic databases or study sources could not be reliably extracted")
647
+ return missing[:6]
648
+
649
+ if paper_type == "machine_learning":
650
+ checks = {
651
+ "training hyperparameters are incomplete": ["learning rate", "batch size", "epoch", "steps"],
652
+ "random seed is not specified": ["seed", "random seed"],
653
+ "code availability is not clearly specified": ["code", "github", "repository"],
654
+ "dataset split details are not clearly specified": ["train", "validation", "test", "split"],
655
+ }
656
+ else:
657
+ checks = {
658
+ "study design details are incomplete": ["study design", "method", "procedure"],
659
+ "sample/data source details are incomplete": ["participants", "patients", "samples", "data source", "dataset"],
660
+ "analysis or measurement method is not clearly specified": ["analysis", "measure", "outcome", "metric"],
661
+ }
662
+
663
+ for label, terms in checks.items():
664
+ if not any(t in low for t in terms):
665
+ missing.append(label)
666
+ if not card.get("datasets_or_data_sources") and paper_type == "machine_learning":
667
+ missing.append("dataset or data source could not be reliably extracted")
668
+ return missing[:6]
669
+
670
+
671
+ # ---------------------------------------------------------------------------
672
+ # Evidence pack
673
+ # ---------------------------------------------------------------------------
674
+
675
+ def _llm_evidence_pack(extracted: Dict[str, Any], paper_card: Dict[str, Any]) -> Dict[str, Any]:
676
+ compact_sections: List[Dict[str, Any]] = []
677
+ for sec in extracted.get("sections", []):
678
+ text = _clean(sec.get("text", ""))
679
+ if not text:
680
+ continue
681
+ compact_sections.append({
682
+ "title": sec.get("title", ""),
683
+ "role_hint": sec.get("role", "other"),
684
+ "page_start": sec.get("page_start"),
685
+ "page_end": sec.get("page_end"),
686
+ "preview": text[:2000],
687
+ })
688
+
689
+ candidate = {k: v for k, v in paper_card.items() if k != "llm_evidence_pack"}
690
+ return {
691
+ "system_prompt": (
692
+ "You are a scientific paper analyst. Correct the candidate JSON paper card using only the provided evidence. "
693
+ "Do not invent facts. Remove boilerplate, references, author affiliations, acknowledgements, duplicate claims, "
694
+ "and generic text. Return only valid JSON with the same keys as candidate_paper_card."
695
+ ),
696
+ "instruction": (
697
+ "Refine candidate_paper_card. Keep facts specific, concise, and grounded in section previews, captions, and tables. "
698
+ "Respect paper_type: for systematic reviews, extract databases, inclusion/exclusion criteria, screening process, "
699
+ "selected studies, synthesis method, findings, and review limitations instead of ML hyperparameters. "
700
+ "Use null or [] when a field cannot be determined."
701
+ ),
702
+ "candidate_paper_card": candidate,
703
+ "section_previews": compact_sections[:16],
704
+ "captions": extracted.get("captions", [])[:10],
705
+ "tables": [_json_safe(t) for t in extracted.get("tables", [])[:4]],
706
+ "metadata": {
707
+ "references_count": len(extracted.get("references", [])),
708
+ "references_removed_from_body": bool(extracted.get("references_text")),
709
+ "appendix_removed_from_body": bool(extracted.get("appendix_text")),
710
+ "quality": extracted.get("quality", {}),
711
+ },
712
+ }
713
+
714
+
715
+ # ---------------------------------------------------------------------------
716
+ # Public API
717
+ # ---------------------------------------------------------------------------
718
+
719
+ def build_paper_card(extracted: Dict[str, Any]) -> Dict[str, Any]:
720
+ title = extracted.get("title") or ""
721
+ abstract = extracted.get("abstract") or ""
722
+ clean_text = extracted.get("clean_text") or extracted.get("text") or ""
723
+ sections = extracted.get("sections", [])
724
+
725
+ intro = _sections_by_role(extracted, ["introduction", "background"])
726
+ methods = _sections_by_role(extracted, ["methodology"])
727
+ experiments = _sections_by_role(extracted, ["experiments"])
728
+ results = _sections_by_role(extracted, ["results", "experiments"])
729
+ discussion = _sections_by_role(extracted, ["discussion"])
730
+ limitations_text = _sections_by_role(extracted, ["limitations"])
731
+ conclusion = _sections_by_role(extracted, ["conclusion"])
732
+
733
+ paper_type = _infer_paper_type(title, abstract, sections, clean_text)
734
+ field = _infer_field(title, abstract, clean_text, paper_type)
735
+
736
+ card: Dict[str, Any] = {
737
+ "title": title or None,
738
+ "field": field,
739
+ "paper_type": paper_type,
740
+ "research_question": _extract_research_question(abstract, intro, title, paper_type) or None,
741
+ "contributions": _extract_contributions(abstract, intro, conclusion, paper_type),
742
+ "methodology": _extract_methodology(methods, experiments, intro, paper_type),
743
+ "datasets_or_data_sources": _extract_datasets(clean_text, methods, experiments, results, paper_type),
744
+ "models_or_methods": _extract_models(clean_text, methods, experiments, paper_type),
745
+ "metrics_or_measurements": _extract_metrics(clean_text, results, experiments, methods, paper_type),
746
+ "key_findings": _extract_findings(results, conclusion, abstract, paper_type),
747
+ "limitations": _extract_limitations(clean_text, limitations_text, discussion, conclusion, paper_type),
748
+ "missing_reproducibility_info": [],
749
+ "metadata": {
750
+ "source_pdf": extracted.get("source_pdf"),
751
+ "num_pages": extracted.get("num_pages"),
752
+ "extraction_engine": extracted.get("extraction_engine"),
753
+ "quality": extracted.get("quality", {}),
754
+ "references_count": len(extracted.get("references", [])),
755
+ "references_removed_from_body": bool(extracted.get("references_text")),
756
+ "appendix_removed_from_body": bool(extracted.get("appendix_text")),
757
+ "section_roles": extracted.get("quality", {}).get("section_roles", []),
758
+ "top_terms": _top_terms(f"{title} {abstract} {clean_text[:5000]}", 10),
759
+ },
760
+ "source_pdf": extracted.get("source_pdf"),
761
+ "annotation_version": "v1.0",
762
+ }
763
+
764
+ card["missing_reproducibility_info"] = _missing_repro_info(card, extracted, paper_type)
765
+ card["llm_evidence_pack"] = _llm_evidence_pack(extracted, card)
766
+ return card
src/paper2lab/inference/pipeline.py ADDED
@@ -0,0 +1,202 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ pipeline.py — Paper2Lab extraction + optional refinement pipeline.
3
+
4
+ PDF
5
+ → section-aware pdf_loader.extract_pdf()
6
+ → rule-based paper_card
7
+ → local modules
8
+ → optional Nemotron refinement
9
+
10
+ Default behavior is local-only. Nemotron is optional and safe:
11
+ if refinement fails, the local result is still returned.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ from pathlib import Path
18
+ from typing import Any, Dict, List, Literal
19
+
20
+ from paper2lab.data.pdf_loader import extract_pdf
21
+ from paper2lab.evaluation.reproducibility import reproducibility_report
22
+ from paper2lab.inference.lab_starter_kit import build_lab_starter_kit
23
+ from paper2lab.inference.paper_card import build_paper_card
24
+ from paper2lab.inference.refinement import refine_optional
25
+ from paper2lab.inference.roadmap import build_reproduction_roadmap
26
+ from paper2lab.inference.visual_explainer import explain_figures_and_tables
27
+ from paper2lab.inference.auto_select import build_auto_best_card
28
+
29
+
30
+ RefinementMode = Literal["none", "nemotron"]
31
+
32
+
33
+ class PaperPipeline:
34
+ def __init__(
35
+ self,
36
+ pdf_engine: str = "pymupdf",
37
+ include_extraction: bool = True,
38
+ include_llm_pack: bool = True,
39
+ include_local_modules: bool = True,
40
+ refinement_mode: RefinementMode = "none",
41
+ ) -> None:
42
+ self.pdf_engine = pdf_engine
43
+ self.include_extraction = include_extraction
44
+ self.include_llm_pack = include_llm_pack
45
+ self.include_local_modules = include_local_modules
46
+ self.refinement_mode = refinement_mode
47
+
48
+ def run(
49
+ self,
50
+ pdf_path: str | Path,
51
+ refinement_mode: RefinementMode | None = None,
52
+ ) -> Dict[str, Any]:
53
+ active_refinement_mode = refinement_mode or self.refinement_mode
54
+
55
+ extracted = extract_pdf(pdf_path, engine=self.pdf_engine)
56
+ paper_card = build_paper_card(extracted)
57
+
58
+ if self.include_local_modules:
59
+ reproduction_roadmap = build_reproduction_roadmap(extracted, paper_card)
60
+ figures_and_tables = explain_figures_and_tables(extracted)
61
+
62
+ paper_card["methodology_steps"] = reproduction_roadmap.get("experimental_steps", [])
63
+ paper_card["reproduction_roadmap"] = reproduction_roadmap
64
+ paper_card["figures_and_tables"] = figures_and_tables
65
+ paper_card["reproducibility_score"] = reproducibility_report(extracted, paper_card)
66
+ paper_card["lab_starter_kit"] = build_lab_starter_kit(paper_card)
67
+
68
+ # Keep the LLM evidence pack aligned with the final local candidate.
69
+ if "llm_evidence_pack" in paper_card:
70
+ paper_card["llm_evidence_pack"]["candidate_paper_card"] = {
71
+ k: v for k, v in paper_card.items()
72
+ if k != "llm_evidence_pack"
73
+ }
74
+
75
+ refinement = refine_optional(
76
+ paper_card=paper_card,
77
+ mode=active_refinement_mode,
78
+ return_comparison=True,
79
+ )
80
+ auto_selection = build_auto_best_card(
81
+ local_card=paper_card,
82
+ refinement=refinement,
83
+ )
84
+
85
+ final_paper_card = auto_selection["final_paper_card"]
86
+
87
+ refined_card = refinement.get("after_refinement", paper_card)
88
+ if not isinstance(refined_card, dict):
89
+ refined_card = paper_card
90
+
91
+ if not self.include_llm_pack:
92
+ paper_card = {
93
+ k: v for k, v in paper_card.items()
94
+ if k != "llm_evidence_pack"
95
+ }
96
+ refined_card = {
97
+ k: v for k, v in refined_card.items()
98
+ if k != "llm_evidence_pack"
99
+ }
100
+
101
+ if isinstance(refinement.get("before_refinement"), dict):
102
+ refinement["before_refinement"] = {
103
+ k: v for k, v in refinement["before_refinement"].items()
104
+ if k != "llm_evidence_pack"
105
+ }
106
+
107
+ if isinstance(refinement.get("after_refinement"), dict):
108
+ refinement["after_refinement"] = {
109
+ k: v for k, v in refinement["after_refinement"].items()
110
+ if k != "llm_evidence_pack"
111
+ }
112
+
113
+ result: Dict[str, Any] = {
114
+ "status": "ok",
115
+ "refinement_mode": active_refinement_mode,
116
+ "paper_card": paper_card,
117
+ "paper_card_refined": refinement.get("after_refinement", paper_card),
118
+ "paper_card_final": final_paper_card,
119
+ "refinement": refinement,
120
+ "auto_selection": auto_selection,
121
+ }
122
+
123
+ if self.include_extraction:
124
+ result["extraction"] = {
125
+ "source_pdf": extracted.get("source_pdf"),
126
+ "num_pages": extracted.get("num_pages"),
127
+ "title": extracted.get("title"),
128
+ "abstract": extracted.get("abstract"),
129
+ "extraction_engine": extracted.get("extraction_engine"),
130
+ "quality": extracted.get("quality", {}),
131
+ "metadata": extracted.get("metadata", {}),
132
+ "sections": extracted.get("sections", []),
133
+ "all_sections": extracted.get("all_sections", []),
134
+ "references": extracted.get("references", []),
135
+ "references_text_preview": (extracted.get("references_text") or "")[:2000],
136
+ "appendix_text_preview": (extracted.get("appendix_text") or "")[:1500],
137
+ "boilerplate_text_preview": (extracted.get("boilerplate_text") or "")[:1500],
138
+ "captions": extracted.get("captions", []),
139
+ "tables": extracted.get("tables", []),
140
+ "clean_text_preview": (
141
+ extracted.get("clean_text")
142
+ or extracted.get("text")
143
+ or ""
144
+ )[:3000],
145
+ "raw_text_preview": (extracted.get("raw_text") or "")[:3000],
146
+ "text_preview": (
147
+ extracted.get("clean_text")
148
+ or extracted.get("text")
149
+ or ""
150
+ )[:3000],
151
+ }
152
+
153
+ return result
154
+
155
+ def run_batch(
156
+ self,
157
+ pdf_paths: List[str | Path],
158
+ refinement_mode: RefinementMode | None = None,
159
+ ) -> List[Dict[str, Any]]:
160
+ results: List[Dict[str, Any]] = []
161
+
162
+ for path in pdf_paths:
163
+ try:
164
+ results.append(
165
+ self.run(
166
+ path,
167
+ refinement_mode=refinement_mode,
168
+ )
169
+ )
170
+ except Exception as exc:
171
+ results.append({
172
+ "status": "error",
173
+ "source_pdf": str(path),
174
+ "error": str(exc),
175
+ "paper_card": None,
176
+ "paper_card_refined": None,
177
+ "refinement": {
178
+ "status": "error",
179
+ "mode": refinement_mode or self.refinement_mode,
180
+ "error": str(exc),
181
+ },
182
+ "extraction": None,
183
+ })
184
+
185
+ return results
186
+
187
+ def save_json(
188
+ self,
189
+ pdf_path: str | Path,
190
+ output_path: str | Path,
191
+ refinement_mode: RefinementMode | None = None,
192
+ ) -> None:
193
+ result = self.run(
194
+ pdf_path,
195
+ refinement_mode=refinement_mode,
196
+ )
197
+
198
+ output_path = Path(output_path)
199
+ output_path.parent.mkdir(parents=True, exist_ok=True)
200
+
201
+ with output_path.open("w", encoding="utf-8") as f:
202
+ json.dump(result, f, indent=2, ensure_ascii=False)
src/paper2lab/inference/refinement.py ADDED
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Dict, Literal
4
+
5
+ from paper2lab.inference.nemotron_refiner import refine_with_nemotron
6
+
7
+
8
+ RefinementMode = Literal["none", "nemotron"]
9
+
10
+
11
+ def refine_optional(
12
+ paper_card: Dict[str, Any],
13
+ mode: RefinementMode = "none",
14
+ return_comparison: bool = True,
15
+ ) -> Dict[str, Any]:
16
+ """
17
+ Default: keep local hardcoded/rule-based extraction.
18
+ Optional: refine the candidate with Nemotron.
19
+ """
20
+
21
+ if mode == "none":
22
+ return {
23
+ "status": "skipped",
24
+ "mode": "none",
25
+ "before_refinement": paper_card,
26
+ "after_refinement": paper_card,
27
+ "diff_summary": {
28
+ "changed_fields": [],
29
+ "added_fields": [],
30
+ "removed_fields": [],
31
+ },
32
+ }
33
+
34
+ if mode == "nemotron":
35
+ pack = paper_card.get("llm_evidence_pack")
36
+
37
+ if not pack:
38
+ return {
39
+ "status": "error",
40
+ "mode": "nemotron",
41
+ "error": "Missing llm_evidence_pack in paper_card.",
42
+ "before_refinement": paper_card,
43
+ "after_refinement": paper_card,
44
+ "diff_summary": {
45
+ "changed_fields": [],
46
+ "added_fields": [],
47
+ "removed_fields": [],
48
+ },
49
+ }
50
+
51
+ try:
52
+ return refine_with_nemotron(
53
+ llm_evidence_pack=pack,
54
+ return_comparison=return_comparison,
55
+ )
56
+ except Exception as exc:
57
+ return {
58
+ "status": "error",
59
+ "mode": "nemotron",
60
+ "error": str(exc),
61
+ "before_refinement": paper_card,
62
+ "after_refinement": paper_card,
63
+ "diff_summary": {
64
+ "changed_fields": [],
65
+ "added_fields": [],
66
+ "removed_fields": [],
67
+ },
68
+ }
69
+
70
+ raise ValueError(f"Unsupported refinement mode: {mode}")
src/paper2lab/inference/roadmap.py ADDED
@@ -0,0 +1,436 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ roadmap.py — Deterministic reproduction-roadmap builder for Paper2Lab.
3
+
4
+ Input: section-aware extraction dict + optional paper_card.
5
+ Output: structured reproduction roadmap candidate.
6
+
7
+ This module is intentionally local/rule-based. Modal/Nemotron should refine this
8
+ later, not replace it.
9
+
10
+ Design goals:
11
+ - Keep the public API stable: build_reproduction_roadmap(extracted, paper_card=None)
12
+ - Be paper-type aware: ML papers, systematic reviews, clinical/general papers.
13
+ - Avoid noisy merged PDF lines from two-column layouts.
14
+ - Keep concise evidence with section/page locations.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import re
20
+ from typing import Any, Dict, List, Sequence
21
+
22
+
23
+ # ---------------------------------------------------------------------------
24
+ # Marker banks
25
+ # ---------------------------------------------------------------------------
26
+
27
+ _DATASET_MARKERS = [
28
+ "dataset", "data", "corpus", "benchmark", "training set", "test set", "validation set",
29
+ "patients", "samples", "records", "images", "sentences", "tokens", "articles", "studies",
30
+ "pubmed", "scopus", "web of knowledge", "eric", "educational resources and information center",
31
+ "cochrane", "wmt", "imagenet", "cifar", "mnist", "glue", "squad", "penn treebank", "wsj",
32
+ ]
33
+
34
+ _KNOWN_DATA_SOURCES = [
35
+ "PubMed",
36
+ "Scopus",
37
+ "Web of Knowledge",
38
+ "ERIC",
39
+ "Educational Resources and Information Center",
40
+ "Cochrane",
41
+ "WMT 2014",
42
+ "WMT",
43
+ "ImageNet",
44
+ "CIFAR-10",
45
+ "CIFAR-100",
46
+ "MNIST",
47
+ "GLUE",
48
+ "SuperGLUE",
49
+ "SQuAD",
50
+ "Penn Treebank",
51
+ "Wall Street Journal",
52
+ "WSJ",
53
+ ]
54
+
55
+ _SOFTWARE_MARKERS = [
56
+ "python", "pytorch", "tensorflow", "keras", "scikit-learn", "sklearn", "r", "matlab",
57
+ "cuda", "gpu", "github", "repository", "code", "implementation", "package", "library",
58
+ "endnote", "excel", "spss", "stata", "prisma", "docker",
59
+ ]
60
+
61
+ _KNOWN_SOFTWARE = [
62
+ "Python", "PyTorch", "TensorFlow", "Keras", "Scikit-learn", "R", "MATLAB",
63
+ "CUDA", "Docker", "GitHub", "EndNote", "Excel", "SPSS", "Stata", "PRISMA",
64
+ ]
65
+
66
+ _EXPERIMENT_MARKERS = [
67
+ "trained", "fine-tuned", "pre-trained", "evaluated", "optimized", "searched", "screened",
68
+ "selected", "included", "excluded", "randomized", "split", "preprocessed", "augmented",
69
+ "we train", "we trained", "we evaluate", "we evaluated", "we search", "we searched",
70
+ "inclusion criteria", "exclusion criteria", "eligibility criteria", "data extraction",
71
+ "titles and abstracts", "duplicate", "endnote", "excel",
72
+ ]
73
+
74
+ _EVAL_MARKERS = [
75
+ "accuracy", "precision", "recall", "f1", "auc", "roc", "bleu", "rouge", "perplexity",
76
+ "loss", "rmse", "mae", "statistical", "p-value", "confidence interval", "evaluation",
77
+ "assessed", "measured", "score", "metric", "kirkpatrick", "quality assessment", "meta-analysis",
78
+ "best evidence medical education", "beme", "final review", "included studies",
79
+ ]
80
+
81
+ _OUTPUT_MARKERS = [
82
+ "achieved", "achieves", "outperformed", "outperforms", "improved", "score", "accuracy",
83
+ "bleu", "f1", "auc", "included", "selected", "final review", "articles", "studies",
84
+ ]
85
+
86
+
87
+ # ---------------------------------------------------------------------------
88
+ # Cleaning and sentence filtering
89
+ # ---------------------------------------------------------------------------
90
+
91
+ def _clean(text: str) -> str:
92
+ text = text or ""
93
+ text = text.replace("\x00", " ").replace("\u00a0", " ")
94
+ text = text.replace("\ufb01", "fi").replace("\ufb02", "fl")
95
+ text = re.sub(r"\b10\.\d{4,9}/[-._;()/:A-Za-z0-9]+", "", text)
96
+ text = re.sub(r"\s+", " ", text)
97
+ text = re.sub(r"\s+([.,;:])", r"\1", text)
98
+ return text.strip(" .;:\n\t")
99
+
100
+
101
+ def _is_noisy(sentence: str) -> bool:
102
+ s = _clean(sentence)
103
+ low = s.lower()
104
+
105
+ bad_fragments = [
106
+ "corresponding author",
107
+ "how to cite",
108
+ "access this article online",
109
+ "department of",
110
+ "university of",
111
+ "medical sciences",
112
+ "received:",
113
+ "accepted:",
114
+ "published:",
115
+ "copyright",
116
+ "license",
117
+ "all rights reserved",
118
+ "gmail.com",
119
+ "@",
120
+ "table of contents",
121
+ "journal of education and health promotion",
122
+ "endnote teachers",
123
+ "being accordingly",
124
+ "need this systematic review",
125
+ "the that",
126
+ "of the there",
127
+ "the the evidence",
128
+ "table 1:",
129
+ "table 2:",
130
+ "table 3:",
131
+ "table 4:",
132
+ ]
133
+ if any(x in low for x in bad_fragments):
134
+ return True
135
+
136
+ if len(s.split()) > 48:
137
+ return True
138
+
139
+ if len(re.findall(r"\[\d+", s)) >= 2:
140
+ return True
141
+
142
+ if sentence.count("|") >= 2 or sentence.count("%") >= 6:
143
+ return True
144
+
145
+ # Many merged two-column artifacts have two unrelated capitalized clauses
146
+ # without a normal sentence boundary.
147
+ if re.search(r"\b(the|this|therefore|besides|fisher)\b.+\b(the|this|therefore|accordingly)\b", low) and len(s.split()) > 34:
148
+ return True
149
+
150
+ # Reject strings that look like fragments rather than standalone steps.
151
+ if len(s.split()) < 5:
152
+ return True
153
+
154
+ return False
155
+
156
+
157
+ def _split_sentences(text: str) -> List[str]:
158
+ text = _clean(text)
159
+ # Also split before uppercase section labels that PyMuPDF sometimes merges.
160
+ text = re.sub(r"\b(ABSTRACT|INTRODUCTION|MATERIALS AND METHODS|RESULTS|DISCUSSION|CONCLUSION):", r". \1:", text)
161
+ raw = re.split(r"(?<=[.!?])\s+(?=[A-Z0-9])", text)
162
+ out: List[str] = []
163
+ for s in raw:
164
+ s = _clean(s)
165
+ if 35 <= len(s) <= 340 and not _is_noisy(s):
166
+ out.append(s)
167
+ return out
168
+
169
+
170
+ def _dedupe_strings(items: Sequence[str]) -> List[str]:
171
+ seen: set[str] = set()
172
+ out: List[str] = []
173
+ for item in items:
174
+ clean = _clean(str(item))
175
+ key = re.sub(r"[^a-z0-9]+", " ", clean.lower()).strip()[:180]
176
+ if key and key not in seen:
177
+ seen.add(key)
178
+ out.append(clean)
179
+ return out
180
+
181
+
182
+ def _dedupe_dicts(items: List[Dict[str, Any]], key_name: str = "text") -> List[Dict[str, Any]]:
183
+ seen: set[str] = set()
184
+ out: List[Dict[str, Any]] = []
185
+ for item in items:
186
+ text = _clean(str(item.get(key_name, "")))
187
+ key = re.sub(r"[^a-z0-9]+", " ", text.lower()).strip()[:180]
188
+ if key and key not in seen:
189
+ seen.add(key)
190
+ out.append(item)
191
+ return out
192
+
193
+
194
+ # ---------------------------------------------------------------------------
195
+ # Section helpers
196
+ # ---------------------------------------------------------------------------
197
+
198
+ def _section_texts(extracted: Dict[str, Any], roles: set[str]) -> List[Dict[str, Any]]:
199
+ rows: List[Dict[str, Any]] = []
200
+ blocked_titles = {"front matter", "keywords", "keywords:", "table of contents"}
201
+ paper_title = _clean(extracted.get("title") or "").lower()
202
+
203
+ for sec in extracted.get("sections", []):
204
+ role = sec.get("role", "other")
205
+ title = _clean(sec.get("title") or "")
206
+ low_title = title.lower()
207
+ if role not in roles:
208
+ continue
209
+ if low_title in blocked_titles:
210
+ continue
211
+ if paper_title and low_title == paper_title:
212
+ continue
213
+ rows.append(sec)
214
+ return rows
215
+
216
+
217
+ def _find_evidence(
218
+ sections: List[Dict[str, Any]],
219
+ markers: List[str],
220
+ limit: int = 8,
221
+ require_number: bool = False,
222
+ ) -> List[Dict[str, Any]]:
223
+ hits: List[Dict[str, Any]] = []
224
+ marker_lows = [m.lower() for m in markers]
225
+
226
+ for sec in sections:
227
+ title = sec.get("title", "")
228
+ role = sec.get("role", "other")
229
+ for sent in _split_sentences(sec.get("text", "")):
230
+ low = sent.lower()
231
+ if require_number and not re.search(r"\d", sent):
232
+ continue
233
+ if any(m in low for m in marker_lows):
234
+ hits.append({
235
+ "text": sent,
236
+ "section": title,
237
+ "role": role,
238
+ "page_start": sec.get("page_start"),
239
+ "page_end": sec.get("page_end"),
240
+ })
241
+ if len(hits) >= limit:
242
+ return _dedupe_dicts(hits)
243
+ return _dedupe_dicts(hits)
244
+
245
+
246
+ # ---------------------------------------------------------------------------
247
+ # Structured extraction helpers
248
+ # ---------------------------------------------------------------------------
249
+
250
+ def _extract_known_sources(text: str) -> List[str]:
251
+ low = text.lower()
252
+ found: List[str] = []
253
+ aliases = {
254
+ "PubMed": ["pubmed"],
255
+ "Scopus": ["scopus"],
256
+ "Web of Knowledge": ["web of knowledge", "thomson reuters"],
257
+ "ERIC": ["eric", "educational resources and information center"],
258
+ "Educational Resources and Information Center": ["educational resources and information center"],
259
+ "Cochrane": ["cochrane"],
260
+ "WMT 2014": ["wmt 2014"],
261
+ "WMT": ["wmt"],
262
+ "ImageNet": ["imagenet"],
263
+ "CIFAR-10": ["cifar-10", "cifar 10"],
264
+ "CIFAR-100": ["cifar-100", "cifar 100"],
265
+ "MNIST": ["mnist"],
266
+ "GLUE": ["glue"],
267
+ "SuperGLUE": ["superglue"],
268
+ "SQuAD": ["squad"],
269
+ "Penn Treebank": ["penn treebank"],
270
+ "Wall Street Journal": ["wall street journal"],
271
+ "WSJ": ["wsj"],
272
+ }
273
+ for canonical, keys in aliases.items():
274
+ if any(k in low for k in keys):
275
+ found.append(canonical)
276
+ return _dedupe_strings(found)
277
+
278
+
279
+ def _extract_software_from_text(text: str) -> List[str]:
280
+ low = text.lower()
281
+ found: List[str] = []
282
+ for name in _KNOWN_SOFTWARE:
283
+ # Avoid false positive: single-letter R appears everywhere, require context.
284
+ if name == "R":
285
+ if re.search(r"\bR\b", text) and any(x in low for x in ["statistical", "analysis", "software", "package"]):
286
+ found.append(name)
287
+ continue
288
+ if name.lower() in low:
289
+ found.append(name)
290
+ return _dedupe_strings(found)
291
+
292
+
293
+ def _extract_count_outputs(text: str) -> List[str]:
294
+ outputs: List[str] = []
295
+ patterns = [
296
+ r"\b(?:totally|overall|in total),?\s*\d+[\w\s-]{0,40}\b(?:articles|studies|records|abstracts|patients|samples)\b",
297
+ r"\b(?:final review|review)\s+(?:included|enrolled)\s+\d+\s+(?:articles|studies)\b",
298
+ r"\b\d+\s+(?:articles|studies|records|abstracts|patients|samples)\s+(?:were|was)\s+(?:selected|included|enrolled|identified)\b",
299
+ r"\bbetween\s+[A-Z][a-z]+\s+\d{4}\s+and\s+[A-Z][a-z]+\s+\d{4}\b",
300
+ r"\bfrom\s+[A-Z][a-z]+\s+\d{4}\s+to\s+[A-Z][a-z]+\s+\d{4}\b",
301
+ ]
302
+ for pat in patterns:
303
+ for m in re.finditer(pat, text, flags=re.IGNORECASE):
304
+ outputs.append(_clean(m.group(0)))
305
+ return _dedupe_strings(outputs)[:8]
306
+
307
+
308
+ def _compact_datasets(paper_card: Dict[str, Any], evidence: List[Dict[str, Any]], all_text: str) -> List[str]:
309
+ datasets: List[str] = []
310
+
311
+ # Prefer canonical names over noisy sentences.
312
+ datasets.extend(_extract_known_sources(all_text))
313
+
314
+ # Keep short, clean card items.
315
+ for item in paper_card.get("datasets_or_data_sources") or []:
316
+ clean = _clean(item)
317
+ if not clean or _is_noisy(clean):
318
+ continue
319
+ if len(clean.split()) <= 12:
320
+ datasets.append(clean)
321
+
322
+ # Add concise evidence only if it is clean and informative.
323
+ for hit in evidence:
324
+ clean = _clean(hit.get("text", ""))
325
+ if not clean or _is_noisy(clean):
326
+ continue
327
+ if len(clean.split()) <= 24:
328
+ datasets.append(clean)
329
+
330
+ return _dedupe_strings(datasets)[:10]
331
+
332
+
333
+ def _roadmap_level(missing_count: int, detected_count: int, noisy_evidence_count: int = 0) -> str:
334
+ # Be less overconfident when evidence is present but noisy.
335
+ if noisy_evidence_count >= 4:
336
+ return "partial" if detected_count >= 5 else "weak"
337
+ if detected_count >= 8 and missing_count <= 2:
338
+ return "strong"
339
+ if detected_count >= 5 and missing_count <= 4:
340
+ return "partial"
341
+ return "weak"
342
+
343
+
344
+ # ---------------------------------------------------------------------------
345
+ # Public API
346
+ # ---------------------------------------------------------------------------
347
+
348
+ def build_reproduction_roadmap(
349
+ extracted: Dict[str, Any],
350
+ paper_card: Dict[str, Any] | None = None,
351
+ ) -> Dict[str, Any]:
352
+ """Build a local, evidence-grounded reproduction roadmap candidate."""
353
+ paper_card = paper_card or {}
354
+ paper_type = paper_card.get("paper_type", "general_research")
355
+
356
+ method_sections = _section_texts(extracted, {"methodology", "experiments"})
357
+ result_sections = _section_texts(extracted, {"results", "discussion", "conclusion"})
358
+ all_body_sections = _section_texts(
359
+ extracted,
360
+ {"methodology", "experiments", "results", "discussion", "conclusion", "introduction", "abstract"},
361
+ )
362
+
363
+ all_text = "\n".join(sec.get("text", "") for sec in all_body_sections)
364
+ method_text = "\n".join(sec.get("text", "") for sec in method_sections)
365
+
366
+ dataset_evidence = _find_evidence(all_body_sections, _DATASET_MARKERS, limit=12)
367
+ software = _extract_software_from_text(all_text)
368
+ experimental_steps = _find_evidence(method_sections or all_body_sections, _EXPERIMENT_MARKERS, limit=10)
369
+ evaluation = _find_evidence(result_sections + method_sections, _EVAL_MARKERS, limit=10)
370
+
371
+ datasets = _compact_datasets(paper_card, dataset_evidence, all_text)
372
+
373
+ expected_outputs: List[str] = []
374
+ for item in paper_card.get("metrics_or_measurements", [])[:5]:
375
+ clean = _clean(item)
376
+ if clean and not _is_noisy(clean):
377
+ expected_outputs.append(clean)
378
+ for item in paper_card.get("key_findings", [])[:5]:
379
+ clean = _clean(item)
380
+ if clean and not _is_noisy(clean):
381
+ expected_outputs.append(clean)
382
+ expected_outputs.extend(_extract_count_outputs(all_text))
383
+ expected_outputs = _dedupe_strings(expected_outputs)[:8]
384
+
385
+ missing: List[str] = []
386
+ if not datasets:
387
+ missing.append("dataset or source corpus details are missing")
388
+ if not experimental_steps:
389
+ missing.append("experimental or procedural steps are missing")
390
+ if not evaluation:
391
+ missing.append("evaluation procedure is missing")
392
+ if paper_type == "machine_learning" and not software:
393
+ missing.append("software/framework requirements are missing")
394
+ if paper_type == "systematic_review":
395
+ low = method_text.lower() or all_text.lower()
396
+ if not any(x in low for x in ["inclusion criteria", "eligibility criteria"]):
397
+ missing.append("inclusion criteria are missing")
398
+ if "exclusion criteria" not in low:
399
+ missing.append("exclusion criteria are missing")
400
+ if not any(x in low for x in ["quality assessment", "risk of bias", "best evidence medical education", "valid tool"]):
401
+ missing.append("quality assessment method is missing")
402
+
403
+ noisy_count = 0
404
+ for item in experimental_steps + evaluation + dataset_evidence:
405
+ if _is_noisy(item.get("text", "")):
406
+ noisy_count += 1
407
+
408
+ detected_count = len(datasets) + len(software) + len(experimental_steps) + len(evaluation) + len(expected_outputs)
409
+
410
+ return {
411
+ "paper_type": paper_type,
412
+ "datasets": datasets,
413
+ "software_requirements": software,
414
+ "experimental_steps": [
415
+ {
416
+ "step": i + 1,
417
+ "description": x["text"],
418
+ "section": x["section"],
419
+ "page_start": x.get("page_start"),
420
+ "page_end": x.get("page_end"),
421
+ }
422
+ for i, x in enumerate(experimental_steps[:10])
423
+ ],
424
+ "evaluation_procedure": [
425
+ {
426
+ "description": x["text"],
427
+ "section": x["section"],
428
+ "page_start": x.get("page_start"),
429
+ "page_end": x.get("page_end"),
430
+ }
431
+ for x in evaluation[:10]
432
+ ],
433
+ "expected_outputs": expected_outputs,
434
+ "missing_for_reproduction": missing,
435
+ "estimated_reproducibility": _roadmap_level(len(missing), detected_count, noisy_count),
436
+ }
src/paper2lab/inference/visual_explainer.py ADDED
@@ -0,0 +1,124 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ visual_explainer.py — Caption/table understanding for Paper2Lab.
3
+
4
+ This is caption-grounded visual understanding. It does not use image pixels yet.
5
+ For the hackathon MVP, this gives useful figure/table summaries without GPU cost.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+ from typing import Any, Dict, List
12
+
13
+
14
+ _METRIC_WORDS = [
15
+ "accuracy", "precision", "recall", "f1", "auc", "bleu", "rouge", "loss", "perplexity",
16
+ "score", "performance", "results", "comparison", "evaluation", "training cost", "p-value",
17
+ ]
18
+
19
+ _METHOD_WORDS = [
20
+ "architecture", "pipeline", "framework", "workflow", "model", "method", "procedure",
21
+ "attention", "encoder", "decoder", "algorithm", "overview",
22
+ ]
23
+
24
+ _DATA_WORDS = [
25
+ "dataset", "data", "samples", "patients", "images", "sentences", "articles", "studies",
26
+ "distribution", "statistics", "characteristics",
27
+ ]
28
+
29
+
30
+ def _clean(text: str) -> str:
31
+ text = text or ""
32
+ text = re.sub(r"\s+", " ", text)
33
+ return text.strip(" .;:\n\t")
34
+
35
+
36
+ def _label_type(label: str) -> str:
37
+ low = (label or "").lower()
38
+ if "table" in low or "tbl" in low:
39
+ return "table"
40
+ if "figure" in low or "fig" in low:
41
+ return "figure"
42
+ if "algorithm" in low:
43
+ return "algorithm"
44
+ if "scheme" in low:
45
+ return "scheme"
46
+ return "visual"
47
+
48
+
49
+ def _purpose(caption: str, visual_type: str) -> str:
50
+ low = caption.lower()
51
+ if any(w in low for w in _METHOD_WORDS):
52
+ return "method_or_architecture"
53
+ if any(w in low for w in _METRIC_WORDS):
54
+ return "results_or_evaluation"
55
+ if any(w in low for w in _DATA_WORDS):
56
+ return "data_or_dataset_description"
57
+ if visual_type == "table":
58
+ return "structured_results_or_metadata"
59
+ return "illustrative_figure"
60
+
61
+
62
+ def _summary_from_caption(label: str, caption: str, visual_type: str) -> str:
63
+ caption = _clean(caption)
64
+ if not caption:
65
+ return f"{label} is a {visual_type}, but no caption text was extracted."
66
+ # Keep concise, but grounded in caption text.
67
+ if len(caption) <= 220:
68
+ return caption
69
+ first_sentence = re.split(r"(?<=[.!?])\s+", caption)[0]
70
+ return _clean(first_sentence[:260])
71
+
72
+
73
+ def _summarize_table_data(table: Dict[str, Any]) -> str | None:
74
+ data = table.get("data")
75
+ if not isinstance(data, list) or not data:
76
+ return None
77
+ rows = [r for r in data if isinstance(r, list)]
78
+ if not rows:
79
+ return None
80
+ n_rows = len(rows)
81
+ n_cols = max((len(r) for r in rows), default=0)
82
+ header = rows[0] if rows else []
83
+ header_text = ", ".join(str(x).strip() for x in header if str(x).strip())[:180]
84
+ if header_text:
85
+ return f"Extracted table with approximately {n_rows} rows and {n_cols} columns. Header fields include: {header_text}."
86
+ return f"Extracted table with approximately {n_rows} rows and {n_cols} columns."
87
+
88
+
89
+ def explain_figures_and_tables(extracted: Dict[str, Any]) -> List[Dict[str, Any]]:
90
+ """Return concise explanations for extracted captions and tables."""
91
+ outputs: List[Dict[str, Any]] = []
92
+
93
+ for cap in extracted.get("captions", []) or []:
94
+ label = cap.get("label", "")
95
+ caption = _clean(cap.get("caption", ""))
96
+ visual_type = _label_type(label)
97
+ outputs.append({
98
+ "label": label,
99
+ "type": visual_type,
100
+ "purpose": _purpose(caption, visual_type),
101
+ "summary": _summary_from_caption(label, caption, visual_type),
102
+ "evidence": caption,
103
+ "page_number": cap.get("page_number"),
104
+ })
105
+
106
+ # Add tables that have data but no caption match.
107
+ existing_table_pages = {(o.get("page_number"), o.get("label")) for o in outputs if o.get("type") == "table"}
108
+ for table in extracted.get("tables", []) or []:
109
+ page = table.get("page_number")
110
+ caption = _clean(table.get("caption") or "")
111
+ label = f"Table extracted on page {page}" if page is not None else "Extracted table"
112
+ if (page, label) in existing_table_pages:
113
+ continue
114
+ data_summary = _summarize_table_data(table)
115
+ outputs.append({
116
+ "label": label,
117
+ "type": "table",
118
+ "purpose": _purpose(caption or data_summary or "", "table"),
119
+ "summary": caption or data_summary or "A table was detected, but its content could not be summarized reliably.",
120
+ "evidence": caption or data_summary or "",
121
+ "page_number": page,
122
+ })
123
+
124
+ return outputs[:20]
src/paper2lab/prompts/extraction.txt ADDED
File without changes
src/paper2lab/prompts/reproduction.txt ADDED
File without changes
src/paper2lab/prompts/summary.txt ADDED
File without changes
src/paper2lab/rag/__init__.py ADDED
File without changes
src/paper2lab/rag/indexer.py ADDED
@@ -0,0 +1,391 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ indexer.py — Local FAISS RAG index for Paper2Lab.
3
+
4
+ Purpose
5
+ -------
6
+ Build a retrieval index from section-aware PDF extraction output.
7
+
8
+ Default mode is local and cheap:
9
+ sentence-transformers + FAISS
10
+
11
+ Optional mode supports NVIDIA/Nemotron-style embedding endpoints through
12
+ langchain_nvidia_ai_endpoints when NVIDIA_API_KEY is available.
13
+
14
+ The public functions are intentionally simple:
15
+ build_rag_index(extracted)
16
+ save_rag_index(index, path)
17
+ load_rag_index(path, embedder_backend="local")
18
+
19
+ No LLM generation happens here. qa.py handles answer synthesis.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import json
25
+ import pickle
26
+ import re
27
+ from dataclasses import dataclass, asdict
28
+ from pathlib import Path
29
+ from typing import Any, Dict, Iterable, List, Literal, Optional
30
+
31
+ import numpy as np
32
+
33
+ try:
34
+ import faiss # type: ignore
35
+ except ImportError as exc: # pragma: no cover
36
+ raise ImportError(
37
+ "faiss-cpu is required for Paper2Lab RAG. Install with: pip install faiss-cpu"
38
+ ) from exc
39
+
40
+
41
+ EmbedderBackend = Literal["local", "nvidia"]
42
+
43
+
44
+ @dataclass
45
+ class RagChunk:
46
+ chunk_id: str
47
+ text: str
48
+ source_type: str # section | caption | table | metadata
49
+ title: str
50
+ role: str = "other"
51
+ page_start: Optional[int] = None
52
+ page_end: Optional[int] = None
53
+ label: Optional[str] = None
54
+ score: Optional[float] = None
55
+
56
+ def to_evidence(self) -> Dict[str, Any]:
57
+ return {
58
+ "chunk_id": self.chunk_id,
59
+ "source_type": self.source_type,
60
+ "title": self.title,
61
+ "role": self.role,
62
+ "page_start": self.page_start,
63
+ "page_end": self.page_end,
64
+ "label": self.label,
65
+ "score": self.score,
66
+ "text": self.text,
67
+ }
68
+
69
+
70
+ @dataclass
71
+ class RagIndex:
72
+ index: Any
73
+ chunks: List[RagChunk]
74
+ embedder_backend: EmbedderBackend
75
+ embedder_model: str
76
+ normalize_embeddings: bool = True
77
+
78
+
79
+ # ---------------------------------------------------------------------------
80
+ # Text utilities
81
+ # ---------------------------------------------------------------------------
82
+
83
+
84
+ def _clean(text: str) -> str:
85
+ text = text or ""
86
+ text = text.replace("\x00", " ").replace("\u00a0", " ")
87
+ text = re.sub(r"\s+", " ", text)
88
+ text = re.sub(r"\b10\.\d{4,9}/[-._;()/:A-Za-z0-9]+", "", text)
89
+ return text.strip(" .;:\n\t")
90
+
91
+
92
+ def _bad_chunk(text: str) -> bool:
93
+ low = text.lower()
94
+ bad = [
95
+ "corresponding author",
96
+ "how to cite",
97
+ "access this article online",
98
+ "copyright",
99
+ "all rights reserved",
100
+ "gmail.com",
101
+ "@",
102
+ "provided proper attribution",
103
+ "permission to reproduce",
104
+ ]
105
+ if any(x in low for x in bad):
106
+ return True
107
+ if len(text.split()) < 8:
108
+ return True
109
+ if text.count("|") >= 2:
110
+ return True
111
+ return False
112
+
113
+
114
+ def _split_into_windows(text: str, max_words: int = 170, overlap_words: int = 35) -> List[str]:
115
+ """Chunk text by word windows. Simple and robust for noisy PDF text."""
116
+ text = _clean(text)
117
+ if not text:
118
+ return []
119
+ words = text.split()
120
+ if len(words) <= max_words:
121
+ return [] if _bad_chunk(text) else [text]
122
+
123
+ chunks: List[str] = []
124
+ start = 0
125
+ while start < len(words):
126
+ end = min(len(words), start + max_words)
127
+ chunk = _clean(" ".join(words[start:end]))
128
+ if chunk and not _bad_chunk(chunk):
129
+ chunks.append(chunk)
130
+ if end == len(words):
131
+ break
132
+ start = max(0, end - overlap_words)
133
+ return chunks
134
+
135
+
136
+ def _table_to_text(table: Dict[str, Any]) -> str:
137
+ data = table.get("data")
138
+ caption = _clean(table.get("caption") or "")
139
+ if not isinstance(data, list):
140
+ return caption
141
+ rows: List[str] = []
142
+ for row in data[:12]:
143
+ if isinstance(row, list):
144
+ cells = [_clean(str(c)) for c in row if c is not None and _clean(str(c))]
145
+ if cells:
146
+ rows.append(" | ".join(cells[:8]))
147
+ body = "\n".join(rows)
148
+ return _clean(f"{caption}\n{body}")
149
+
150
+
151
+ # ---------------------------------------------------------------------------
152
+ # Chunk extraction
153
+ # ---------------------------------------------------------------------------
154
+
155
+
156
+ def build_chunks(extracted: Dict[str, Any], include_tables: bool = True, include_captions: bool = True) -> List[RagChunk]:
157
+ chunks: List[RagChunk] = []
158
+ counter = 0
159
+
160
+ blocked_roles = {"references", "appendix", "boilerplate"}
161
+ blocked_titles = {"front matter", "keywords", "table of contents"}
162
+
163
+ for sec_idx, sec in enumerate(extracted.get("sections", []) or []):
164
+ role = sec.get("role", "other")
165
+ title = _clean(sec.get("title") or "Untitled section")
166
+ if role in blocked_roles or title.lower() in blocked_titles:
167
+ continue
168
+ text = sec.get("text") or ""
169
+ for window in _split_into_windows(text):
170
+ counter += 1
171
+ chunks.append(
172
+ RagChunk(
173
+ chunk_id=f"section-{sec_idx}-{counter}",
174
+ text=window,
175
+ source_type="section",
176
+ title=title,
177
+ role=role,
178
+ page_start=sec.get("page_start"),
179
+ page_end=sec.get("page_end"),
180
+ )
181
+ )
182
+
183
+ if include_captions:
184
+ for cap_idx, cap in enumerate(extracted.get("captions", []) or []):
185
+ label = _clean(cap.get("label") or f"caption-{cap_idx}")
186
+ caption = _clean(cap.get("caption") or "")
187
+ if caption and not _bad_chunk(caption):
188
+ chunks.append(
189
+ RagChunk(
190
+ chunk_id=f"caption-{cap_idx}",
191
+ text=caption,
192
+ source_type="caption",
193
+ title=label,
194
+ role="caption",
195
+ page_start=cap.get("page_number"),
196
+ page_end=cap.get("page_number"),
197
+ label=label,
198
+ )
199
+ )
200
+
201
+ if include_tables:
202
+ for table_idx, table in enumerate(extracted.get("tables", []) or []):
203
+ text = _table_to_text(table)
204
+ if text and not _bad_chunk(text):
205
+ label = f"Table {table_idx + 1}"
206
+ chunks.append(
207
+ RagChunk(
208
+ chunk_id=f"table-{table_idx}",
209
+ text=text[:2500],
210
+ source_type="table",
211
+ title=label,
212
+ role="table",
213
+ page_start=table.get("page_number"),
214
+ page_end=table.get("page_number"),
215
+ label=label,
216
+ )
217
+ )
218
+
219
+ return chunks
220
+
221
+
222
+ # ---------------------------------------------------------------------------
223
+ # Embedding backends
224
+ # ---------------------------------------------------------------------------
225
+
226
+
227
+ class BaseEmbedder:
228
+ def encode_documents(self, texts: List[str]) -> np.ndarray:
229
+ raise NotImplementedError
230
+
231
+ def encode_query(self, text: str) -> np.ndarray:
232
+ raise NotImplementedError
233
+
234
+
235
+ class LocalSentenceTransformerEmbedder(BaseEmbedder):
236
+ def __init__(self, model_name: str = "BAAI/bge-small-en-v1.5") -> None:
237
+ try:
238
+ from sentence_transformers import SentenceTransformer
239
+ except ImportError as exc: # pragma: no cover
240
+ raise ImportError(
241
+ "sentence-transformers is required. Install with: pip install sentence-transformers"
242
+ ) from exc
243
+ self.model_name = model_name
244
+ self.model = SentenceTransformer(model_name)
245
+
246
+ def encode_documents(self, texts: List[str]) -> np.ndarray:
247
+ # BGE-style instruction prefix helps retrieval quality.
248
+ docs = [f"passage: {t}" for t in texts]
249
+ arr = self.model.encode(docs, convert_to_numpy=True, normalize_embeddings=True, show_progress_bar=False)
250
+ return arr.astype("float32")
251
+
252
+ def encode_query(self, text: str) -> np.ndarray:
253
+ arr = self.model.encode([f"query: {text}"], convert_to_numpy=True, normalize_embeddings=True, show_progress_bar=False)
254
+ return arr.astype("float32")
255
+
256
+
257
+ class NvidiaEndpointEmbedder(BaseEmbedder):
258
+ """NVIDIA API/NIM embedding backend.
259
+
260
+ Requires:
261
+ pip install langchain-nvidia-ai-endpoints
262
+ set NVIDIA_API_KEY=...
263
+
264
+ Default model uses NVIDIA's retrieval QA embedding endpoint.
265
+ """
266
+
267
+ def __init__(self, model_name: str = "nvidia/nv-embedqa-e5-v5") -> None:
268
+ try:
269
+ from langchain_nvidia_ai_endpoints import NVIDIAEmbeddings
270
+ except ImportError as exc: # pragma: no cover
271
+ raise ImportError(
272
+ "Install NVIDIA embeddings support with: pip install langchain-nvidia-ai-endpoints"
273
+ ) from exc
274
+ self.model_name = model_name
275
+ self.embedder = NVIDIAEmbeddings(model=model_name)
276
+
277
+ def encode_documents(self, texts: List[str]) -> np.ndarray:
278
+ # NVIDIA embedding endpoints support document embedding methods through LangChain.
279
+ vecs = self.embedder.embed_documents(texts)
280
+ arr = np.array(vecs, dtype="float32")
281
+ return _l2_normalize(arr)
282
+
283
+ def encode_query(self, text: str) -> np.ndarray:
284
+ vec = self.embedder.embed_query(text)
285
+ arr = np.array([vec], dtype="float32")
286
+ return _l2_normalize(arr)
287
+
288
+
289
+ def _l2_normalize(arr: np.ndarray) -> np.ndarray:
290
+ norms = np.linalg.norm(arr, axis=1, keepdims=True)
291
+ norms[norms == 0] = 1.0
292
+ return (arr / norms).astype("float32")
293
+
294
+
295
+ def make_embedder(backend: EmbedderBackend = "local", model_name: Optional[str] = None) -> BaseEmbedder:
296
+ if backend == "local":
297
+ return LocalSentenceTransformerEmbedder(model_name or "BAAI/bge-small-en-v1.5")
298
+ if backend == "nvidia":
299
+ return NvidiaEndpointEmbedder(model_name or "nvidia/nv-embedqa-e5-v5")
300
+ raise ValueError(f"Unknown embedder backend: {backend}")
301
+
302
+
303
+ # ---------------------------------------------------------------------------
304
+ # Index build/search/save/load
305
+ # ---------------------------------------------------------------------------
306
+
307
+
308
+ def build_rag_index(
309
+ extracted: Dict[str, Any],
310
+ embedder_backend: EmbedderBackend = "local",
311
+ embedder_model: Optional[str] = None,
312
+ include_tables: bool = True,
313
+ include_captions: bool = True,
314
+ ) -> RagIndex:
315
+ chunks = build_chunks(extracted, include_tables=include_tables, include_captions=include_captions)
316
+ if not chunks:
317
+ raise ValueError("No usable chunks found for RAG indexing.")
318
+
319
+ embedder = make_embedder(embedder_backend, embedder_model)
320
+ texts = [c.text for c in chunks]
321
+ embeddings = embedder.encode_documents(texts)
322
+ embeddings = _l2_normalize(embeddings)
323
+
324
+ dim = embeddings.shape[1]
325
+ index = faiss.IndexFlatIP(dim)
326
+ index.add(embeddings)
327
+
328
+ return RagIndex(
329
+ index=index,
330
+ chunks=chunks,
331
+ embedder_backend=embedder_backend,
332
+ embedder_model=embedder_model or ("BAAI/bge-small-en-v1.5" if embedder_backend == "local" else "nvidia/nv-embedqa-e5-v5"),
333
+ normalize_embeddings=True,
334
+ )
335
+
336
+
337
+ def search_rag_index(rag_index: RagIndex, query: str, top_k: int = 5) -> List[RagChunk]:
338
+ embedder = make_embedder(rag_index.embedder_backend, rag_index.embedder_model)
339
+ q = embedder.encode_query(query)
340
+ q = _l2_normalize(q)
341
+ scores, ids = rag_index.index.search(q, top_k)
342
+ hits: List[RagChunk] = []
343
+ for score, idx in zip(scores[0].tolist(), ids[0].tolist()):
344
+ if idx < 0 or idx >= len(rag_index.chunks):
345
+ continue
346
+ chunk = rag_index.chunks[idx]
347
+ hits.append(
348
+ RagChunk(
349
+ chunk_id=chunk.chunk_id,
350
+ text=chunk.text,
351
+ source_type=chunk.source_type,
352
+ title=chunk.title,
353
+ role=chunk.role,
354
+ page_start=chunk.page_start,
355
+ page_end=chunk.page_end,
356
+ label=chunk.label,
357
+ score=round(float(score), 4),
358
+ )
359
+ )
360
+ return hits
361
+
362
+
363
+ def save_rag_index(rag_index: RagIndex, path: str | Path) -> None:
364
+ path = Path(path)
365
+ path.mkdir(parents=True, exist_ok=True)
366
+ faiss.write_index(rag_index.index, str(path / "index.faiss"))
367
+ metadata = {
368
+ "embedder_backend": rag_index.embedder_backend,
369
+ "embedder_model": rag_index.embedder_model,
370
+ "normalize_embeddings": rag_index.normalize_embeddings,
371
+ "chunks": [asdict(c) for c in rag_index.chunks],
372
+ }
373
+ (path / "metadata.json").write_text(json.dumps(metadata, indent=2, ensure_ascii=False), encoding="utf-8")
374
+
375
+
376
+ def load_rag_index(path: str | Path) -> RagIndex:
377
+ path = Path(path)
378
+ index_path = path / "index.faiss"
379
+ meta_path = path / "metadata.json"
380
+ if not index_path.exists() or not meta_path.exists():
381
+ raise FileNotFoundError(f"Missing FAISS index files in {path}")
382
+ index = faiss.read_index(str(index_path))
383
+ metadata = json.loads(meta_path.read_text(encoding="utf-8"))
384
+ chunks = [RagChunk(**c) for c in metadata.get("chunks", [])]
385
+ return RagIndex(
386
+ index=index,
387
+ chunks=chunks,
388
+ embedder_backend=metadata.get("embedder_backend", "local"),
389
+ embedder_model=metadata.get("embedder_model", "BAAI/bge-small-en-v1.5"),
390
+ normalize_embeddings=bool(metadata.get("normalize_embeddings", True)),
391
+ )
src/paper2lab/rag/qa.py ADDED
@@ -0,0 +1,594 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ qa.py — Evidence-grounded local RAG Q&A for Paper2Lab.
3
+
4
+ This module returns extractive answers with evidence and source locations.
5
+ It does not call an LLM. Nemotron can later rewrite the answer using the same evidence.
6
+
7
+ Design:
8
+ - Classify the question into a small intent taxonomy.
9
+ - Retrieve evidence with FAISS through indexer.py.
10
+ - Synthesize answers using intent-specific extractive logic.
11
+ - Avoid hardcoding known dataset names; discover entities from local evidence.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import re
17
+ from typing import Any, Dict, Iterable, List, Optional, Tuple
18
+
19
+ from paper2lab.rag.indexer import RagIndex, build_rag_index, search_rag_index
20
+
21
+
22
+ # ---------------------------------------------------------------------------
23
+ # Intent classification
24
+ # ---------------------------------------------------------------------------
25
+
26
+ _QUERY_INTENTS: Dict[str, List[str]] = {
27
+ "datasets": [
28
+ "dataset", "datasets", "data", "corpus", "corpora", "benchmark", "benchmarks",
29
+ "source", "sources", "database", "databases", "training set", "test set",
30
+ "validation set", "dev set", "patients", "samples", "records", "articles", "studies",
31
+ ],
32
+ "methodology": [
33
+ "method", "methods", "methodology", "procedure", "procedures", "steps", "approach",
34
+ "how", "trained", "training", "fine-tuned", "pretrained", "searched", "screened",
35
+ "selected", "included", "excluded", "implementation", "architecture", "pipeline",
36
+ ],
37
+ "evaluation": [
38
+ "evaluate", "evaluated", "evaluation", "metric", "metrics", "score", "accuracy",
39
+ "precision", "recall", "f1", "auc", "bleu", "rouge", "perplexity", "result",
40
+ "results", "performance", "finding", "findings", "outcome", "outcomes",
41
+ ],
42
+ "figures": [
43
+ "figure", "fig", "table", "caption", "diagram", "plot", "chart", "architecture",
44
+ "visual", "illustration", "show", "shows",
45
+ ],
46
+ "reproducibility": [
47
+ "missing", "reproduce", "reproduction", "reproducibility", "hyperparameter",
48
+ "hyperparameters", "software", "code", "github", "repository", "settings", "requirements",
49
+ "seed", "hardware", "gpu", "implementation details",
50
+ ],
51
+ }
52
+
53
+ _INTENT_QUERY_EXPANSIONS: Dict[str, str] = {
54
+ "datasets": "dataset corpus benchmark training data test set validation data source database articles studies",
55
+ "methodology": "method methodology approach procedure steps training implementation experimental setup search screening inclusion exclusion",
56
+ "evaluation": "evaluation metrics results performance score accuracy f1 bleu rouge auc outcome analysis measured assessed",
57
+ "figures": "figure table caption diagram architecture plot shows illustrates",
58
+ "reproducibility": "reproducibility missing information hyperparameters dataset details software code hardware seed experimental settings",
59
+ "general": "paper evidence method results conclusion",
60
+ }
61
+
62
+
63
+ # ---------------------------------------------------------------------------
64
+ # Cleaning / sentence utilities
65
+ # ---------------------------------------------------------------------------
66
+
67
+ def _clean(text: str) -> str:
68
+ text = text or ""
69
+ text = text.replace("\x00", " ").replace("\u00a0", " ")
70
+ text = re.sub(r"\b10\.\d{4,9}/[-._;()/:A-Za-z0-9]+", "", text)
71
+ text = re.sub(r"\s+", " ", text)
72
+ return text.strip(" .;:\n\t")
73
+
74
+
75
+ def _is_noisy_sentence(sentence: str) -> bool:
76
+ s = _clean(sentence)
77
+ low = s.lower()
78
+
79
+ bad_fragments = [
80
+ "corresponding author", "how to cite", "access this article online", "department of",
81
+ "university of", "medical sciences", "received:", "accepted:", "published:",
82
+ "copyright", "license", "all rights reserved", "gmail.com", "@",
83
+ "being accordingly", "endnote teachers", "the there", "resultsare",
84
+ "analysis of the resultsare", "need this systematic review",
85
+ ]
86
+ if any(x in low for x in bad_fragments):
87
+ return True
88
+ if len(re.findall(r"\[\d+", s)) >= 3:
89
+ return True
90
+ if s.count("|") >= 2 or s.count("%") >= 6:
91
+ return True
92
+ if len(s.split()) > 85:
93
+ return True
94
+ return False
95
+
96
+
97
+ def _split_sentences(text: str) -> List[str]:
98
+ text = _clean(text)
99
+ raw = re.split(r"(?<=[.!?])\s+(?=[A-Z0-9])", text)
100
+ out: List[str] = []
101
+ for sent in raw:
102
+ sent = _clean(sent)
103
+ if 25 <= len(sent) <= 420 and not _is_noisy_sentence(sent):
104
+ out.append(sent)
105
+ if not out and text and not _is_noisy_sentence(text):
106
+ out = [text[:420]]
107
+ return out
108
+
109
+
110
+ def _query_terms(question: str) -> List[str]:
111
+ words = re.findall(r"[a-zA-Z][a-zA-Z0-9_-]{2,}", question.lower())
112
+ stop = {
113
+ "what", "which", "where", "when", "how", "were", "was", "are", "the", "and",
114
+ "used", "use", "paper", "study", "does", "did", "for", "with", "from", "that",
115
+ "this", "these", "those", "show", "shows", "tell", "about", "explain",
116
+ }
117
+ return [w for w in words if w not in stop]
118
+
119
+
120
+ def _dedupe_strings(items: Iterable[str], limit: int = 10) -> List[str]:
121
+ seen: set[str] = set()
122
+ out: List[str] = []
123
+ for item in items:
124
+ item = _clean(item)
125
+ if not item:
126
+ continue
127
+ key = re.sub(r"[^a-z0-9]+", " ", item.lower()).strip()[:180]
128
+ if key and key not in seen:
129
+ seen.add(key)
130
+ out.append(item)
131
+ if len(out) >= limit:
132
+ break
133
+ return out
134
+
135
+
136
+ def _intent(question: str) -> str:
137
+ low = question.lower()
138
+ scores: Dict[str, int] = {}
139
+ for intent, keys in _QUERY_INTENTS.items():
140
+ score = 0
141
+ for k in keys:
142
+ if k in low:
143
+ score += 2 if " " in k else 1
144
+ scores[intent] = score
145
+ best = max(scores, key=scores.get)
146
+ return best if scores[best] > 0 else "general"
147
+
148
+
149
+ def _expanded_query(question: str, intent: str) -> str:
150
+ expansion = _INTENT_QUERY_EXPANSIONS.get(intent, "")
151
+ return _clean(f"{question} {expansion}")
152
+
153
+
154
+ # ---------------------------------------------------------------------------
155
+ # Evidence helpers
156
+ # ---------------------------------------------------------------------------
157
+
158
+ def _evidence_texts(hits: List[Any]) -> List[str]:
159
+ return [getattr(h, "text", "") for h in hits if getattr(h, "text", "")]
160
+
161
+
162
+ def _rank_sentences(question: str, evidence_texts: List[str], max_sentences: int = 4) -> List[str]:
163
+ terms = _query_terms(question)
164
+ candidates: List[Tuple[int, int, str]] = []
165
+ for text in evidence_texts:
166
+ for sent in _split_sentences(text):
167
+ low = sent.lower()
168
+ lexical_score = sum(1 for t in terms if t in low)
169
+ length_penalty = max(0, len(sent.split()) - 45)
170
+ candidates.append((lexical_score, -length_penalty, sent))
171
+ candidates.sort(key=lambda x: (x[0], x[1]), reverse=True)
172
+
173
+ selected: List[str] = []
174
+ seen: set[str] = set()
175
+ for score, _, sent in candidates:
176
+ key = re.sub(r"[^a-z0-9]+", " ", sent.lower()).strip()[:180]
177
+ if key in seen:
178
+ continue
179
+ if score == 0 and selected:
180
+ continue
181
+ seen.add(key)
182
+ selected.append(sent)
183
+ if len(selected) >= max_sentences:
184
+ break
185
+ return selected
186
+
187
+
188
+ # ---------------------------------------------------------------------------
189
+ # Dataset/data-source discovery without hardcoded dataset names
190
+ # ---------------------------------------------------------------------------
191
+
192
+ _DATA_CONTEXT_WORDS = [
193
+ "dataset", "datasets", "corpus", "corpora", "benchmark", "benchmarks", "training data",
194
+ "training set", "test set", "validation set", "dev set", "data source", "databases",
195
+ "database", "articles", "studies", "patients", "samples", "records", "images",
196
+ "sentences", "tokens", "documents", "cases", "examples", "instances",
197
+ ]
198
+
199
+ _KNOWN_DATABASE_GENERIC = [
200
+ "PubMed", "Scopus", "Web of Knowledge", "ERIC", "Educational Resources and Information Center",
201
+ "Cochrane", "IEEE Xplore", "ACM Digital Library", "Google Scholar", "MEDLINE", "Embase",
202
+ ]
203
+
204
+ _DATASET_REJECT_TERMS = [
205
+ "parser", "berkeleyparser", "berkleyparser", "rnn", "lstm", "gru",
206
+ "transformer", "recurrent neural network", "neural network grammar",
207
+ "model", "architecture", "baseline", "beam size", "during inference",
208
+ "dropout", "optimizer", "learning rate", "attention", "encoder", "decoder",
209
+ ]
210
+
211
+ _DATASET_ALLOW_TERMS = [
212
+ "dataset", "corpus", "corpora", "benchmark", "treebank", "wsj",
213
+ "wmt", "penn treebank", "wall street journal", "sentence pairs",
214
+ "sentences", "tokens", "training set", "test set", "validation set",
215
+ "dev set", "patients", "samples", "records", "articles", "studies",
216
+ ]
217
+
218
+ def _extract_capitalized_entities_near_data_terms(sentence: str) -> List[str]:
219
+ """Discover likely dataset names from context without fixed known dataset list."""
220
+ s = _clean(sentence)
221
+ low = s.lower()
222
+ if not any(w in low for w in _DATA_CONTEXT_WORDS):
223
+ return []
224
+
225
+ found: List[str] = []
226
+
227
+ # Pattern: "standard X dataset", "larger X corpus", "on X benchmark".
228
+ context_patterns = [
229
+ r"(?:standard|larger|public|available|benchmark|the)\s+([A-Z][A-Za-z0-9._/-]*(?:\s+[A-Z]?[A-Za-z0-9._/-]+){0,6})\s+(?:dataset|datasets|corpus|corpora|benchmark|benchmarks)",
230
+ r"(?:on|using|from|with)\s+(?:the\s+)?([A-Z][A-Za-z0-9._/-]*(?:\s+[A-Z]?[A-Za-z0-9._/-]+){0,7})\s+(?:dataset|datasets|corpus|corpora|benchmark|benchmarks)",
231
+ r"([A-Z][A-Za-z0-9._/-]*(?:\s+[A-Z]?[A-Za-z0-9._/-]+){0,7})\s+(?:dataset|datasets|corpus|corpora|benchmark|benchmarks)",
232
+ ]
233
+ for pat in context_patterns:
234
+ for m in re.finditer(pat, s):
235
+ cand = _clean(m.group(1))
236
+ if _valid_dataset_candidate(cand):
237
+ found.append(cand)
238
+
239
+ # Pattern: explicit study/data counts.
240
+ count_patterns = [
241
+ r"\b(?:about|approximately|around)?\s*\d+(?:\.\d+)?\s*(?:k|m|million|billion|thousand)?\s+(?:sentence pairs|sentences|tokens|images|patients|samples|records|documents|cases|examples|instances|articles|studies)\b",
242
+ r"\b\d+\s+(?:articles|studies|patients|samples|records)\s+(?:were\s+)?(?:included|enrolled|selected|used)\b",
243
+ ]
244
+ for pat in count_patterns:
245
+ for m in re.finditer(pat, s, flags=re.IGNORECASE):
246
+ found.append(_clean(m.group(0)))
247
+
248
+ # Known scholarly databases are generic enough to keep; not paper-specific datasets.
249
+ for db in _KNOWN_DATABASE_GENERIC:
250
+ if db.lower() in low:
251
+ found.append(db)
252
+
253
+ # Parenthetical abbreviations after a named source: Wall Street Journal (WSJ), etc.
254
+ for m in re.finditer(r"([A-Z][A-Za-z]+(?:\s+[A-Z][A-Za-z]+){1,5})\s*\(([A-Z0-9-]{2,10})\)", s):
255
+ cand = _clean(f"{m.group(1)} ({m.group(2)})")
256
+ if _valid_dataset_candidate(cand):
257
+ found.append(cand)
258
+
259
+ # Generic dataset-style identifiers near data terms: WMT2014, CIFAR-10, SQuAD-v2, XYZ-500.
260
+ for m in re.finditer(
261
+ r"\b[A-Z]{2,}[A-Za-z]*[- ]?\d{2,4}(?:[- ][A-Za-z]+)*\b",
262
+ s,
263
+ ):
264
+ cand = _clean(m.group(0))
265
+ if _valid_dataset_candidate(cand):
266
+ found.append(cand)
267
+
268
+ # Named corpora/treebanks/splits with abbreviations.
269
+ for m in re.finditer(
270
+ r"\b(?:Wall Street Journal|Penn Treebank|[A-Z]{2,6})\b(?:\s*\([A-Z0-9-]{2,10}\))?",
271
+ s,
272
+ ):
273
+ cand = _clean(m.group(0))
274
+ if _valid_dataset_candidate(cand):
275
+ found.append(cand)
276
+ return _dedupe_strings(found, limit=12)
277
+
278
+
279
+ def _valid_dataset_candidate(candidate: str) -> bool:
280
+ cand = _clean(candidate)
281
+ low = cand.lower()
282
+
283
+ if not cand or len(cand) < 3 or len(cand.split()) > 10:
284
+ return False
285
+
286
+ if any(term in low for term in _DATASET_REJECT_TERMS):
287
+ return False
288
+
289
+ bad_exact = {
290
+ "the", "standard", "larger", "public", "available", "training",
291
+ "test", "validation", "we", "our", "this", "that", "section",
292
+ "table", "figure", "results", "parser",
293
+ }
294
+ if low in bad_exact:
295
+ return False
296
+
297
+ if any(x in low for x in ["section describes", "the following", "in this", "of the"]):
298
+ return False
299
+
300
+ # Accept known dataset-like abbreviations only when context looks data-related.
301
+ if re.fullmatch(r"[A-Z0-9-]{2,12}", cand):
302
+ return True
303
+
304
+ return True
305
+
306
+
307
+ def _looks_like_dataset_detail(text: str) -> bool:
308
+ low = _clean(text).lower()
309
+ return bool(
310
+ re.search(
311
+ r"\b(?:about|approximately|around)?\s*\d+(?:\.\d+)?\s*"
312
+ r"(?:k|m|million|billion|thousand)?\s+"
313
+ r"(?:sentence pairs|sentences|tokens|images|patients|samples|records|documents|cases|examples|instances|articles|studies)\b",
314
+ low,
315
+ )
316
+ or re.search(r"\b\d+\s*(?:k|m)?\s*tokens\b", low)
317
+ or re.search(r"\b\d+\s*(?:k|m)?\s*training sentences\b", low)
318
+ )
319
+
320
+
321
+ def _answer_datasets(evidence_texts: List[str]) -> str:
322
+ dataset_names: List[str] = []
323
+ dataset_sizes: List[str] = []
324
+ vocabulary_details: List[str] = []
325
+ support_sentences: List[str] = []
326
+
327
+ for text in evidence_texts:
328
+ for sent in _split_sentences(text):
329
+ found = _extract_capitalized_entities_near_data_terms(sent)
330
+
331
+ for item in found:
332
+ if _looks_like_dataset_detail(item):
333
+ dataset_sizes.append(item)
334
+ else:
335
+ dataset_names.append(item)
336
+
337
+ # Dataset size extraction, excluding vocabulary/token-only details.
338
+ for m in re.finditer(
339
+ r"\b(?:about|approximately|around)?\s*\d+(?:\.\d+)?\s*"
340
+ r"(?:k|m|million|billion|thousand)?\s+"
341
+ r"(?:sentence pairs|sentences|images|patients|samples|records|documents|cases|examples|instances|articles|studies)\b",
342
+ sent,
343
+ flags=re.IGNORECASE,
344
+ ):
345
+ dataset_sizes.append(_clean(m.group(0)))
346
+
347
+ # Vocabulary / tokenization details are useful but not datasets.
348
+ for m in re.finditer(
349
+ r"\b(?:about|approximately|around)?\s*\d+(?:\.\d+)?\s*"
350
+ r"(?:k|m|million|billion|thousand)?\s+"
351
+ r"(?:tokens|word-piece vocabulary|vocabulary)\b",
352
+ sent,
353
+ flags=re.IGNORECASE,
354
+ ):
355
+ vocabulary_details.append(_clean(m.group(0)))
356
+
357
+ if found or dataset_sizes or vocabulary_details:
358
+ support_sentences.append(sent)
359
+
360
+ dataset_names = _dedupe_strings(dataset_names, limit=10)
361
+ dataset_sizes = _dedupe_strings(dataset_sizes, limit=10)
362
+ vocabulary_details = _dedupe_strings(vocabulary_details, limit=10)
363
+ support_sentences = _dedupe_strings(support_sentences, limit=3)
364
+ dataset_names = [
365
+ x for x in dataset_names
366
+ if not any(bad in x.lower() for bad in _DATASET_REJECT_TERMS)
367
+ ]
368
+ if dataset_names or dataset_sizes or vocabulary_details:
369
+ parts: List[str] = []
370
+
371
+ if dataset_names:
372
+ parts.append(
373
+ "Datasets / data sources:\n"
374
+ + "\n".join(f"- {x}" for x in dataset_names)
375
+ )
376
+
377
+ if dataset_sizes:
378
+ parts.append(
379
+ "Dataset sizes:\n"
380
+ + "\n".join(f"- {x}" for x in dataset_sizes)
381
+ )
382
+
383
+ if vocabulary_details:
384
+ parts.append(
385
+ "Vocabulary / tokenization details:\n"
386
+ + "\n".join(f"- {x}" for x in vocabulary_details)
387
+ )
388
+
389
+ if support_sentences:
390
+ parts.append(
391
+ "Evidence snippets:\n"
392
+ + "\n".join(f"- {s}" for s in support_sentences)
393
+ )
394
+
395
+ return "\n\n".join(parts)
396
+
397
+ fallback = _rank_sentences("datasets data corpus benchmark", evidence_texts, max_sentences=3)
398
+ if fallback:
399
+ return (
400
+ "I could not confidently isolate dataset names, but the most relevant evidence is:\n"
401
+ + "\n".join(f"- {s}" for s in fallback)
402
+ )
403
+
404
+ return "I could not find enough evidence about datasets or data sources in the extracted paper text."
405
+
406
+
407
+ # ---------------------------------------------------------------------------
408
+ # Specialized answer synthesis
409
+ # ---------------------------------------------------------------------------
410
+
411
+ _METHOD_STEP_MARKERS = [
412
+ "we trained", "we train", "trained", "fine-tuned", "pre-trained", "optimizer", "learning rate",
413
+ "batch", "epochs", "searched", "screened", "included", "excluded", "inclusion criteria",
414
+ "exclusion criteria", "data extraction", "preprocessed", "augmentation", "architecture",
415
+ ]
416
+
417
+ _EVAL_MARKERS = [
418
+ "accuracy", "precision", "recall", "f1", "auc", "bleu", "rouge", "perplexity",
419
+ "loss", "rmse", "mae", "score", "performance", "outperform", "achieve", "result",
420
+ "evaluation", "measured", "assessed", "statistical", "p-value", "confidence interval",
421
+ ]
422
+
423
+ _REPRO_MARKERS = [
424
+ "learning rate", "batch size", "epoch", "optimizer", "dropout", "weight decay", "seed",
425
+ "gpu", "hardware", "code", "github", "repository", "dataset", "split", "software",
426
+ "implementation", "inclusion criteria", "exclusion criteria", "screening", "quality assessment",
427
+ ]
428
+
429
+
430
+ def _answer_methodology(evidence_texts: List[str]) -> str:
431
+ steps: List[str] = []
432
+ for text in evidence_texts:
433
+ for sent in _split_sentences(text):
434
+ low = sent.lower()
435
+ if any(m in low for m in _METHOD_STEP_MARKERS):
436
+ steps.append(sent)
437
+ steps = _dedupe_strings(steps, limit=6)
438
+ if not steps:
439
+ steps = _rank_sentences("methodology procedure steps approach", evidence_texts, max_sentences=4)
440
+ if not steps:
441
+ return "I could not find enough methodology evidence in the extracted paper text."
442
+ return "The paper describes these methodological elements:\n" + "\n".join(f"- {s}" for s in steps)
443
+
444
+
445
+ def _answer_evaluation(evidence_texts: List[str]) -> str:
446
+ items: List[str] = []
447
+ for text in evidence_texts:
448
+ for sent in _split_sentences(text):
449
+ low = sent.lower()
450
+ if any(m in low for m in _EVAL_MARKERS) and (re.search(r"\d", sent) or "result" in low or "performance" in low):
451
+ items.append(sent)
452
+ items = _dedupe_strings(items, limit=6)
453
+ if not items:
454
+ items = _rank_sentences("evaluation metrics results performance", evidence_texts, max_sentences=4)
455
+ if not items:
456
+ return "I could not find enough evaluation evidence in the extracted paper text."
457
+ return "The paper reports these evaluation/result details:\n" + "\n".join(f"- {s}" for s in items)
458
+
459
+
460
+ def _answer_figures(evidence_texts: List[str]) -> str:
461
+ items: List[str] = []
462
+ for text in evidence_texts:
463
+ for sent in _split_sentences(text):
464
+ low = sent.lower()
465
+ if any(x in low for x in ["figure", "fig.", "table", "caption", "shown", "illustrates"]):
466
+ items.append(sent)
467
+ items = _dedupe_strings(items, limit=5)
468
+ if not items:
469
+ items = _rank_sentences("figure table caption shows", evidence_texts, max_sentences=3)
470
+ if not items:
471
+ return "I could not find enough figure or table evidence in the extracted paper text."
472
+ return "The relevant figure/table evidence says:\n" + "\n".join(f"- {s}" for s in items)
473
+
474
+
475
+ def _answer_reproducibility(evidence_texts: List[str]) -> str:
476
+ found: List[str] = []
477
+ for text in evidence_texts:
478
+ for sent in _split_sentences(text):
479
+ low = sent.lower()
480
+ if any(m in low for m in _REPRO_MARKERS):
481
+ found.append(sent)
482
+ found = _dedupe_strings(found, limit=6)
483
+ if not found:
484
+ found = _rank_sentences("reproducibility missing hyperparameters software code settings", evidence_texts, max_sentences=4)
485
+ if not found:
486
+ return "I could not find enough reproducibility evidence in the extracted paper text."
487
+ return "The reproducibility-relevant evidence is:\n" + "\n".join(f"- {s}" for s in found)
488
+
489
+
490
+ def _answer_general(question: str, evidence_texts: List[str]) -> str:
491
+ sents = _rank_sentences(question, evidence_texts, max_sentences=4)
492
+ if not sents:
493
+ return "I could not find enough evidence in the extracted paper text to answer this question."
494
+ return "Based on the retrieved evidence:\n" + "\n".join(f"- {s}" for s in sents)
495
+
496
+
497
+ def _synthesize_answer(question: str, evidence_texts: List[str], intent: str) -> str:
498
+ if intent == "datasets":
499
+ return _answer_datasets(evidence_texts)
500
+ if intent == "methodology":
501
+ return _answer_methodology(evidence_texts)
502
+ if intent == "evaluation":
503
+ return _answer_evaluation(evidence_texts)
504
+ if intent == "figures":
505
+ return _answer_figures(evidence_texts)
506
+ if intent == "reproducibility":
507
+ return _answer_reproducibility(evidence_texts)
508
+ return _answer_general(question, evidence_texts)
509
+
510
+
511
+ # ---------------------------------------------------------------------------
512
+ # Public API
513
+ # ---------------------------------------------------------------------------
514
+
515
+ def answer_question(
516
+ extracted: Dict[str, Any],
517
+ question: str,
518
+ rag_index: Optional[RagIndex] = None,
519
+ top_k: int = 5,
520
+ embedder_backend: str = "local",
521
+ embedder_model: Optional[str] = None,
522
+ ) -> Dict[str, Any]:
523
+ """Answer a question using retrieved chunks from one extracted paper.
524
+
525
+ Parameters
526
+ ----------
527
+ extracted:
528
+ Output of pdf_loader.extract_pdf().
529
+ question:
530
+ User question.
531
+ rag_index:
532
+ Optional prebuilt index. If omitted, this function builds an in-memory index.
533
+ top_k:
534
+ Number of evidence chunks to retrieve.
535
+ embedder_backend:
536
+ "local" or "nvidia". Used only when rag_index is omitted.
537
+ embedder_model:
538
+ Optional embedding model name.
539
+ """
540
+ question = _clean(question)
541
+ if not question:
542
+ return {"answer": "No question was provided.", "evidence": [], "query": question}
543
+
544
+ intent = _intent(question)
545
+ retrieval_query = _expanded_query(question, intent)
546
+
547
+ if rag_index is None:
548
+ rag_index = build_rag_index(
549
+ extracted,
550
+ embedder_backend=embedder_backend, # type: ignore[arg-type]
551
+ embedder_model=embedder_model,
552
+ )
553
+
554
+ # Retrieve slightly more than displayed evidence so the synthesizer has more context.
555
+ internal_top_k = max(top_k, min(10, top_k + 3))
556
+ hits = search_rag_index(rag_index, retrieval_query, top_k=internal_top_k)
557
+ answer = _synthesize_answer(question, _evidence_texts(hits), intent)
558
+
559
+ # Keep user-facing evidence compact.
560
+ evidence = [h.to_evidence() for h in hits[:top_k]]
561
+
562
+ return {
563
+ "query": question,
564
+ "intent": intent,
565
+ "retrieval_query": retrieval_query,
566
+ "answer": answer,
567
+ "evidence": evidence,
568
+ "rag": {
569
+ "top_k": top_k,
570
+ "embedder_backend": rag_index.embedder_backend,
571
+ "embedder_model": rag_index.embedder_model,
572
+ "num_chunks": len(rag_index.chunks),
573
+ },
574
+ }
575
+
576
+
577
+ def answer_from_pipeline_result(
578
+ pipeline_result: Dict[str, Any],
579
+ question: str,
580
+ top_k: int = 5,
581
+ embedder_backend: str = "local",
582
+ embedder_model: Optional[str] = None,
583
+ ) -> Dict[str, Any]:
584
+ """Convenience helper for results returned by PaperPipeline.run()."""
585
+ extraction = pipeline_result.get("extraction") or {}
586
+ if not extraction:
587
+ return {"answer": "No extraction object was found in the pipeline result.", "evidence": [], "query": question}
588
+ return answer_question(
589
+ extraction,
590
+ question,
591
+ top_k=top_k,
592
+ embedder_backend=embedder_backend,
593
+ embedder_model=embedder_model,
594
+ )
src/paper2lab/utils/io.py ADDED
File without changes
src/paper2lab/utils/logging.py ADDED
File without changes
src/paper2lab/utils/seed.py ADDED
File without changes
test_nemotron.py ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # test_nemotron_real.py
2
+
3
+ import json
4
+
5
+ from paper2lab.data.pdf_loader import extract_pdf
6
+ from paper2lab.inference.paper_card import build_paper_card
7
+ from paper2lab.inference.roadmap import build_reproduction_roadmap
8
+ from paper2lab.evaluation.reproducibility import reproducibility_report
9
+ from paper2lab.inference.visual_explainer import explain_figures_and_tables
10
+ from paper2lab.inference.nemotron_refiner import refine_with_nemotron
11
+
12
+ pdf_path = "Data/papers/train/Education intervention.pdf"
13
+
14
+ extracted = extract_pdf(pdf_path)
15
+ card = build_paper_card(extracted)
16
+
17
+ roadmap = build_reproduction_roadmap(extracted, card)
18
+ figures_tables = explain_figures_and_tables(extracted)
19
+ repro_score = reproducibility_report(extracted, card)
20
+
21
+ card["reproduction_roadmap"] = roadmap
22
+ card["figures_and_tables"] = figures_tables
23
+ card["reproducibility_score"] = repro_score
24
+
25
+ pack = card["llm_evidence_pack"]
26
+ pack["candidate_paper_card"] = {
27
+ k: v for k, v in card.items()
28
+ if k != "llm_evidence_pack"
29
+ }
30
+
31
+ result = refine_with_nemotron(pack)
32
+
33
+ print(json.dumps(result, indent=2, ensure_ascii=False))