unknown commited on
Commit
e831c40
·
1 Parent(s): 8204fb2

Add data package to HF Space

Browse files
src/paper2lab/data/__init__.py ADDED
File without changes
src/paper2lab/data/__pycache__/__init__.cpython-311.pyc ADDED
Binary file (160 Bytes). View file
 
src/paper2lab/data/__pycache__/pdf_loader.cpython-311.pyc ADDED
Binary file (57.6 kB). View file
 
src/paper2lab/data/annotation_loader.py ADDED
File without changes
src/paper2lab/data/dataset_builder.py ADDED
File without changes
src/paper2lab/data/pdf_loader.py ADDED
@@ -0,0 +1,974 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ pdf_loader.py — Paper2Lab universal section-aware PDF ingestion.
3
+
4
+ Final extraction-layer guarantees:
5
+ - Field-agnostic section detection across ML, NLP, CV, biomedical, physics,
6
+ education, social-science, economics, and interdisciplinary papers.
7
+ - Multi-signal heading confidence scoring: lexical headings, numbered/roman
8
+ headings, ALL-CAPS headings, font size, bold text, and vertical spacing.
9
+ - General anti-noise rules: boilerplate, metric-only headings, table-cell-like
10
+ headings, reference items, and fragmented tables.
11
+ - Clean body text excludes References/Bibliography, Appendix/Supplementary, and
12
+ boilerplate so downstream extraction does not get polluted.
13
+ - raw_text and all_sections are preserved for traceability/debugging.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import re
19
+ from collections import Counter, defaultdict
20
+ from pathlib import Path
21
+ from typing import Any, Dict, List, Optional, Set, Tuple
22
+
23
+ import fitz # PyMuPDF
24
+
25
+
26
+ class PDFIngestionError(Exception):
27
+ pass
28
+
29
+
30
+ # ---------------------------------------------------------------------------
31
+ # Section taxonomy
32
+ # ---------------------------------------------------------------------------
33
+
34
+ SECTION_KEYWORDS: Set[str] = {
35
+ # Universal/front matter
36
+ "abstract", "keywords", "introduction", "background", "overview",
37
+ "motivation", "problem statement", "problem formulation",
38
+
39
+ # Related work / literature
40
+ "related work", "related works", "prior work", "prior art",
41
+ "literature review", "literature survey", "state of the art",
42
+
43
+ # Theory / framework
44
+ "theoretical background", "theory", "framework", "preliminaries",
45
+ "notation", "problem definition", "mathematical background",
46
+
47
+ # Methodology
48
+ "materials and methods", "material and methods", "methods", "method",
49
+ "methodology", "approach", "proposed approach", "proposed method",
50
+ "model", "model architecture", "architecture", "system design",
51
+ "system overview", "implementation", "implementation details",
52
+ "technical details", "training", "training details", "training procedure",
53
+ "experimental setup", "experimental settings", "experimental design",
54
+ "data collection", "data preprocessing", "data preparation",
55
+ "study design", "participants", "procedure",
56
+
57
+ # Experiments/results
58
+ "experiments", "experiment", "evaluation", "evaluations",
59
+ "results", "results and discussion", "results and analysis",
60
+ "empirical evaluation", "empirical results", "analysis",
61
+ "quantitative analysis", "qualitative analysis", "ablation",
62
+ "ablation study", "ablation studies", "case study", "case studies",
63
+
64
+ # Discussion/limitations/conclusion
65
+ "discussion", "limitations", "limitation", "future work",
66
+ "future directions", "conclusion", "conclusions", "summary",
67
+ "concluding remarks",
68
+
69
+ # Admin/back matter
70
+ "data availability", "code availability", "availability",
71
+ "ethics statement", "ethical considerations", "ethical approval",
72
+ "acknowledgements", "acknowledgments", "funding",
73
+ "conflict of interest", "competing interests", "author contributions",
74
+ "references", "bibliography", "works cited", "literature cited",
75
+ "appendix", "appendices", "supplementary material",
76
+ "supplementary information", "supplemental material", "supplementary",
77
+ "supplemental",
78
+ }
79
+
80
+ REFERENCE_SECTION_NAMES: Set[str] = {
81
+ "references", "bibliography", "works cited", "literature cited",
82
+ }
83
+
84
+ APPENDIX_SECTION_NAMES: Set[str] = {
85
+ "appendix", "appendices", "supplementary material",
86
+ "supplementary information", "supplemental material", "supplementary",
87
+ "supplemental",
88
+ }
89
+
90
+ BOILERPLATE_SECTION_NAMES: Set[str] = {
91
+ "acknowledgements", "acknowledgments", "author contributions",
92
+ "competing interests", "conflict of interest", "additional information",
93
+ "publisher's note", "open access", "correspondence",
94
+ "reprints and permissions", "funding", "ethics statement",
95
+ "ethical considerations", "ethical approval", "data availability",
96
+ "code availability", "availability",
97
+ }
98
+
99
+ ROLE_ALIASES: Dict[str, List[str]] = {
100
+ "front_matter": ["front matter"],
101
+ "abstract": ["abstract"],
102
+ "keywords": ["keywords"],
103
+ "introduction": [
104
+ "introduction", "overview", "motivation", "problem statement",
105
+ "problem formulation",
106
+ ],
107
+ "related_work": ["related work", "related works", "prior work", "prior art"],
108
+ "background": ["background", "preliminaries", "notation"],
109
+ "theory": [
110
+ "theoretical background", "theory", "framework",
111
+ "mathematical background", "problem definition",
112
+ ],
113
+ "methodology": [
114
+ "materials and methods", "material and methods", "methods", "method",
115
+ "methodology", "approach", "proposed approach", "proposed method",
116
+ "model", "model architecture", "architecture", "system design",
117
+ "system overview", "implementation", "implementation details",
118
+ "technical details", "training", "training details", "training procedure",
119
+ "experimental setup", "experimental settings", "experimental design",
120
+ "data collection", "data preprocessing", "data preparation",
121
+ "study design", "participants", "procedure",
122
+ ],
123
+ "experiments": [
124
+ "experiments", "experiment", "evaluation", "evaluations",
125
+ "empirical evaluation", "ablation", "ablation study", "ablation studies",
126
+ "case study", "case studies",
127
+ ],
128
+ "results": [
129
+ "results", "results and discussion", "results and analysis",
130
+ "empirical results", "quantitative analysis", "qualitative analysis",
131
+ "analysis",
132
+ ],
133
+ "discussion": ["discussion"],
134
+ "limitations": ["limitations", "limitation"],
135
+ "future_work": ["future work", "future directions"],
136
+ "conclusion": ["conclusion", "conclusions", "summary", "concluding remarks"],
137
+ "references": list(REFERENCE_SECTION_NAMES),
138
+ "appendix": list(APPENDIX_SECTION_NAMES),
139
+ "boilerplate": list(BOILERPLATE_SECTION_NAMES),
140
+ }
141
+
142
+ BOILERPLATE_PHRASES: List[str] = [
143
+ "provided proper attribution", "google hereby grants permission",
144
+ "permission to reproduce", "all rights reserved", "copyright ©",
145
+ "under the terms of", "creative commons", "open access article",
146
+ "preprint server", "arxiv:", "doi:", "received:", "accepted:",
147
+ "published online", "correspondence to",
148
+ ]
149
+
150
+ METRIC_ONLY_TERMS: Set[str] = {
151
+ "accuracy", "acc", "precision", "recall", "sensitivity", "specificity",
152
+ "f1", "f1 score", "auc", "roc auc", "auroc", "auprc", "bleu",
153
+ "rouge", "rouge-l", "meteor", "map", "ndcg", "wer", "cer",
154
+ "perplexity", "ppl", "loss", "rmse", "mae", "mse", "iou",
155
+ "dice", "ari", "nmi", "top-1", "top-5", "er", "hr",
156
+ }
157
+
158
+ TABLE_CELL_HINTS: List[str] = [
159
+ "baseline", "ours", "oracle", "ensemble", "single model", "dev", "test",
160
+ "train", "training", "validation", "params", "flops", "gpu", "cpu",
161
+ "memory", "latency",
162
+ ]
163
+
164
+ _HEADING_CONFIDENCE_THRESHOLD = 0.55
165
+ _MIN_SECTION_WORDS = 20
166
+
167
+
168
+ # ---------------------------------------------------------------------------
169
+ # Text normalization
170
+ # ---------------------------------------------------------------------------
171
+
172
+ def _clean_text(text: str) -> str:
173
+ if not text:
174
+ return ""
175
+ text = (
176
+ text.replace("\x00", " ")
177
+ .replace("\u00a0", " ")
178
+ .replace("\u000f", " ")
179
+ .replace("\ufb01", "fi")
180
+ .replace("\ufb02", "fl")
181
+ .replace("\u2013", "-")
182
+ .replace("\u2014", "-")
183
+ .replace("\u2018", "'")
184
+ .replace("\u2019", "'")
185
+ .replace("\u201c", '"')
186
+ .replace("\u201d", '"')
187
+ )
188
+ text = re.sub(r"-\n(?=[a-z])", "", text)
189
+ text = re.sub(r"[ \t]+", " ", text)
190
+ text = re.sub(r"\n[ \t]+", "\n", text)
191
+ text = re.sub(r"\n{3,}", "\n\n", text)
192
+ return text.strip()
193
+
194
+
195
+ def _normalize_heading(text: str) -> str:
196
+ text = _clean_text(text).strip()
197
+ text = re.sub(r"^[#*\s]+", "", text)
198
+ text = re.sub(r"^(section|chapter|part)\s+", "", text, flags=re.IGNORECASE)
199
+ text = re.sub(r"^[IVXLCDM]+[.)]\s+", "", text, flags=re.IGNORECASE)
200
+ text = re.sub(r"^[A-Z][.)]\s+", "", text)
201
+ text = re.sub(r"^\d+(?:\.\d+)*[.)]?\s+", "", text)
202
+ text = re.sub(r"[:.\s]+$", "", text)
203
+ return re.sub(r"\s+", " ", text).lower().strip()
204
+
205
+
206
+ def _heading_level(text: str) -> int:
207
+ match = re.match(r"^(\d+(?:\.\d+)*)", text.strip())
208
+ if match:
209
+ return min(6, match.group(1).count(".") + 1)
210
+ return 1
211
+
212
+
213
+ def _role_for_title(title: str) -> str:
214
+ norm = _normalize_heading(title)
215
+ for role, aliases in ROLE_ALIASES.items():
216
+ if norm in aliases:
217
+ return role
218
+ for role, aliases in ROLE_ALIASES.items():
219
+ if any(alias in norm for alias in aliases):
220
+ return role
221
+ return "other"
222
+
223
+
224
+ def _line_key(text: str) -> str:
225
+ return re.sub(r"[^a-z0-9]+", " ", _normalize_heading(text)).strip()
226
+
227
+
228
+ # ---------------------------------------------------------------------------
229
+ # Low-level PyMuPDF line extraction
230
+ # ---------------------------------------------------------------------------
231
+
232
+ def _span_is_bold(span: Dict[str, Any]) -> bool:
233
+ font = span.get("font", "") or ""
234
+ flags = int(span.get("flags", 0) or 0)
235
+ return "bold" in font.lower() or "heavy" in font.lower() or bool(flags & 16)
236
+
237
+
238
+ def _extract_page_lines(page: fitz.Page, page_number: int) -> List[Dict[str, Any]]:
239
+ data = page.get_text("dict", flags=fitz.TEXT_PRESERVE_WHITESPACE)
240
+ page_width = float(page.rect.width)
241
+ mid_x = page_width / 2.0
242
+ raw: List[Dict[str, Any]] = []
243
+
244
+ for block in data.get("blocks", []):
245
+ if block.get("type") != 0:
246
+ continue
247
+ block_no = block.get("number", 0)
248
+ for line in block.get("lines", []):
249
+ spans = [s for s in line.get("spans", []) if (s.get("text") or "").strip()]
250
+ if not spans:
251
+ continue
252
+ text = _clean_text("".join(s.get("text", "") for s in spans))
253
+ if not text:
254
+ continue
255
+
256
+ sizes = [round(float(s.get("size", 0.0)), 1) for s in spans if s.get("size")]
257
+ max_size = max(sizes) if sizes else 0.0
258
+ avg_size = sum(sizes) / len(sizes) if sizes else 0.0
259
+ bold_ratio = sum(1 for s in spans if _span_is_bold(s)) / max(1, len(spans))
260
+ bbox = tuple(line.get("bbox") or block.get("bbox") or (0, 0, 0, 0))
261
+ x0 = float(bbox[0])
262
+ y0 = float(bbox[1])
263
+ col = 0 if x0 < mid_x else 1
264
+
265
+ raw.append({
266
+ "text": text,
267
+ "page_number": page_number,
268
+ "block_no": block_no,
269
+ "bbox": bbox,
270
+ "x0": x0,
271
+ "y0": y0,
272
+ "max_size": max_size,
273
+ "avg_size": avg_size,
274
+ "bold": bold_ratio >= 0.5,
275
+ "span_count": len(spans),
276
+ "col": col,
277
+ })
278
+
279
+ if raw:
280
+ max_y = max(float(l["y0"]) for l in raw)
281
+ band_height = max(max_y / 20.0, 1.0)
282
+ raw.sort(key=lambda l: (round(float(l["y0"]) / band_height), int(l["col"]), float(l["y0"])))
283
+
284
+ return raw
285
+
286
+
287
+ def _dominant_body_size(lines: List[Dict[str, Any]]) -> float:
288
+ sizes: List[float] = []
289
+ for line in lines:
290
+ size = float(line.get("avg_size") or line.get("max_size") or 0.0)
291
+ words = len(str(line.get("text", "")).split())
292
+ if 7.0 <= size <= 16.0 and words >= 4:
293
+ sizes.append(round(size, 1))
294
+ return Counter(sizes).most_common(1)[0][0] if sizes else 10.0
295
+
296
+
297
+ # ---------------------------------------------------------------------------
298
+ # Heading scoring
299
+ # ---------------------------------------------------------------------------
300
+
301
+ def _boilerplate_line(text: str) -> bool:
302
+ low = _clean_text(text).lower()
303
+ return any(phrase in low for phrase in BOILERPLATE_PHRASES)
304
+
305
+
306
+ def _metric_only_heading(text: str) -> bool:
307
+ norm = _normalize_heading(text)
308
+ compact = re.sub(r"[^a-z0-9]+", " ", norm).strip()
309
+ if compact in METRIC_ONLY_TERMS:
310
+ return True
311
+
312
+ metric_re = r"\b(" + "|".join(re.escape(m) for m in sorted(METRIC_ONLY_TERMS, key=len, reverse=True)) + r")\b"
313
+ if len(text.split()) <= 5 and re.search(metric_re, compact, flags=re.IGNORECASE):
314
+ if not any(w in compact for w in ["results", "evaluation", "experiment", "analysis", "method"]):
315
+ return True
316
+
317
+ letters = re.sub(r"[^A-Za-z]", "", text)
318
+ if 2 <= len(letters) <= 10 and letters.isupper() and len(text.split()) <= 3:
319
+ return True
320
+ return False
321
+
322
+
323
+ def _probable_table_cell(text: str) -> bool:
324
+ raw = _clean_text(text)
325
+ low = raw.lower()
326
+ words = raw.split()
327
+ if not raw or len(words) > 9:
328
+ return False
329
+
330
+ if re.search(r"\[\d+\]", raw) and len(words) <= 7:
331
+ return True
332
+
333
+ chars = [c for c in raw if not c.isspace()]
334
+ if chars:
335
+ symbol_digit_ratio = sum(1 for c in chars if c.isdigit() or c in ".,±+-×*/=()%[]") / len(chars)
336
+ if symbol_digit_ratio >= 0.35 and len(words) <= 6:
337
+ return True
338
+
339
+ if len(words) <= 5 and any(h in low for h in TABLE_CELL_HINTS):
340
+ if not any(sw in low for sw in ["result", "method", "experiment", "evaluation", "discussion", "conclusion"]):
341
+ return True
342
+
343
+ if len(words) <= 5 and re.search(r"\b[A-Z][A-Za-z0-9-]*\s*(\+|/|vs\.?|&|×)\s*[A-Z]", raw):
344
+ return True
345
+
346
+ return False
347
+
348
+
349
+ def _looks_like_reference_item(line: str) -> bool:
350
+ return bool(re.match(r"^(\[\d+\]|\d+[.)])\s+", line.strip())) and len(line.split()) > 6
351
+
352
+
353
+ _NUMBERED_HEADING_RE = re.compile(
354
+ r"^(?:\d+(?:\.\d+)*[.)]?|[IVXLCDM]+[.)]|[A-Z][.)])\s+"
355
+ r"[A-Z\u00C0-\u024F][A-Za-z0-9\u00C0-\u024F ,/()\-–—:&']+$",
356
+ re.IGNORECASE,
357
+ )
358
+
359
+
360
+ def _heading_confidence(
361
+ line: Dict[str, Any],
362
+ body_size: float,
363
+ prev_line: Optional[Dict[str, Any]],
364
+ next_line: Optional[Dict[str, Any]],
365
+ ) -> float:
366
+ text = _clean_text(line.get("text", ""))
367
+ if not text:
368
+ return 0.0
369
+ if _boilerplate_line(text) or _metric_only_heading(text) or _probable_table_cell(text):
370
+ return 0.0
371
+ if _looks_like_reference_item(text):
372
+ return 0.0
373
+ if len(text) > 150 or len(text.split()) > 18:
374
+ return 0.0
375
+ if text.endswith(",") or text.endswith(";"):
376
+ return 0.0
377
+
378
+ norm = _normalize_heading(text)
379
+ if not norm or norm in {"figure", "table", "fig", "eq", "equation"}:
380
+ return 0.0
381
+
382
+ score = 0.0
383
+
384
+ if norm in SECTION_KEYWORDS:
385
+ score += 0.70
386
+ else:
387
+ for keyword in SECTION_KEYWORDS:
388
+ if keyword in norm and len(keyword) >= 6:
389
+ score += 0.30
390
+ break
391
+
392
+ if _NUMBERED_HEADING_RE.match(text) and len(text.split()) <= 14:
393
+ score += 0.55
394
+
395
+ letters = re.sub(r"[^A-Za-z]", "", text)
396
+ if len(letters) >= 4 and letters.isupper() and len(text.split()) <= 10:
397
+ score += 0.40
398
+
399
+ size = float(line.get("max_size") or line.get("avg_size") or body_size)
400
+ size_ratio = size / body_size if body_size else 1.0
401
+ if size_ratio >= 1.25:
402
+ score += 0.35
403
+ elif size_ratio >= 1.10:
404
+ score += 0.20
405
+
406
+ if line.get("bold"):
407
+ score += 0.20
408
+
409
+ bbox = line.get("bbox") or (0, 0, 0, 0)
410
+ y0 = float(bbox[1])
411
+ y1 = float(bbox[3])
412
+ prev_gap = 999.0
413
+ next_gap = 999.0
414
+ if prev_line and prev_line.get("page_number") == line.get("page_number"):
415
+ prev_gap = y0 - float((prev_line.get("bbox") or (0, 0, 0, 0))[3])
416
+ if next_line and next_line.get("page_number") == line.get("page_number"):
417
+ next_gap = float((next_line.get("bbox") or (0, 0, 0, 0))[1]) - y1
418
+ if prev_gap >= 3.0 or next_gap >= 2.0:
419
+ score += 0.15
420
+
421
+ if re.match(r"^[A-Z\u00C0-\u024F][A-Za-z0-9\u00C0-\u024F ,/()\-–—:&']+$", text):
422
+ if not re.search(r"\b(the|and|or|but|because|while|which|that)\b.+\.$", text.lower()):
423
+ score += 0.10
424
+
425
+ if len(text.split()) < 2 and score < 0.70:
426
+ score *= 0.40
427
+
428
+ return min(score, 1.0)
429
+
430
+
431
+ def _detect_heading_keys(lines: List[Dict[str, Any]], body_size: float) -> Set[str]:
432
+ candidate_scores: Dict[str, List[float]] = defaultdict(list)
433
+
434
+ for i, line in enumerate(lines):
435
+ prev_line = lines[i - 1] if i else None
436
+ next_line = lines[i + 1] if i + 1 < len(lines) else None
437
+ confidence = _heading_confidence(line, body_size, prev_line, next_line)
438
+ if confidence > 0.0:
439
+ key = _line_key(line["text"])
440
+ if key:
441
+ candidate_scores[key].append(confidence)
442
+
443
+ accepted: Set[str] = set()
444
+ for key, scores in candidate_scores.items():
445
+ best = max(scores)
446
+ if best >= _HEADING_CONFIDENCE_THRESHOLD:
447
+ accepted.add(key)
448
+ elif best >= 0.35 and len(scores) >= 2:
449
+ accepted.add(key)
450
+ return accepted
451
+
452
+
453
+ # ---------------------------------------------------------------------------
454
+ # Section splitting and back-matter separation
455
+ # ---------------------------------------------------------------------------
456
+
457
+ def _split_sections_from_lines(lines: List[Dict[str, Any]], heading_keys: Set[str]) -> List[Dict[str, Any]]:
458
+ sections: List[Dict[str, Any]] = []
459
+ current_title = "Front Matter"
460
+ current_level = 1
461
+ current_role = "front_matter"
462
+ current_lines: List[str] = []
463
+ page_start: Optional[int] = lines[0]["page_number"] if lines else None
464
+ page_end: Optional[int] = page_start
465
+
466
+ def flush() -> None:
467
+ nonlocal current_lines, current_title, current_level, current_role, page_start, page_end
468
+ text = _clean_text("\n".join(current_lines))
469
+ if text:
470
+ sections.append({
471
+ "title": current_title,
472
+ "text": text,
473
+ "level": current_level,
474
+ "role": current_role,
475
+ "page_start": page_start,
476
+ "page_end": page_end,
477
+ "word_count": len(text.split()),
478
+ })
479
+ current_lines = []
480
+
481
+ for line in lines:
482
+ text = _clean_text(line["text"])
483
+ key = _line_key(text)
484
+ if key in heading_keys:
485
+ flush()
486
+ current_title = text
487
+ current_level = _heading_level(text)
488
+ current_role = _role_for_title(text)
489
+ page_start = line.get("page_number")
490
+ page_end = page_start
491
+ else:
492
+ current_lines.append(text)
493
+ page_end = line.get("page_number")
494
+
495
+ flush()
496
+
497
+ merged: List[Dict[str, Any]] = []
498
+ for sec in sections:
499
+ norm = _normalize_heading(sec["title"])
500
+ words = int(sec.get("word_count", len(sec.get("text", "").split())))
501
+ if sec["title"] != "Front Matter" and norm not in SECTION_KEYWORDS and words < _MIN_SECTION_WORDS and merged:
502
+ merged[-1]["text"] = _clean_text(merged[-1]["text"] + "\n" + sec["title"] + "\n" + sec["text"])
503
+ merged[-1]["page_end"] = sec.get("page_end") or merged[-1].get("page_end")
504
+ merged[-1]["word_count"] = len(merged[-1]["text"].split())
505
+ else:
506
+ merged.append(sec)
507
+ return merged
508
+
509
+
510
+ def _separate_back_matter(sections: List[Dict[str, Any]]) -> Tuple[List[Dict[str, Any]], str, str, str]:
511
+ body: List[Dict[str, Any]] = []
512
+ references_parts: List[str] = []
513
+ appendix_parts: List[str] = []
514
+ boilerplate_parts: List[str] = []
515
+
516
+ mode = "body"
517
+ for sec in sections:
518
+ role = sec.get("role") or _role_for_title(sec.get("title", ""))
519
+ norm = _normalize_heading(sec.get("title", ""))
520
+ packed = _clean_text(f'{sec.get("title", "")}\n{sec.get("text", "")}')
521
+
522
+ if role == "references" or norm in REFERENCE_SECTION_NAMES:
523
+ mode = "references"
524
+ elif role == "appendix" or norm in APPENDIX_SECTION_NAMES:
525
+ if mode != "references":
526
+ mode = "appendix"
527
+ elif role == "boilerplate" or norm in BOILERPLATE_SECTION_NAMES:
528
+ if mode not in {"references", "appendix"}:
529
+ mode = "boilerplate"
530
+ elif mode == "boilerplate":
531
+ mode = "body"
532
+
533
+ if mode == "references":
534
+ references_parts.append(packed)
535
+ elif mode == "appendix":
536
+ appendix_parts.append(packed)
537
+ elif mode == "boilerplate":
538
+ boilerplate_parts.append(packed)
539
+ else:
540
+ body.append(sec)
541
+
542
+ return body, "\n\n".join(references_parts), "\n\n".join(appendix_parts), "\n\n".join(boilerplate_parts)
543
+
544
+
545
+ # ---------------------------------------------------------------------------
546
+ # Title, abstract, references, captions, tables
547
+ # ---------------------------------------------------------------------------
548
+
549
+ _BAD_TITLE_MARKERS = [
550
+ "provided proper attribution", "google hereby grants permission", "arxiv",
551
+ "preprint", "license", "copyright", "all rights reserved",
552
+ ]
553
+
554
+ _BLOCKED_TITLE_FRAGMENTS = [
555
+ "vol.", "http", "www.", "received", "accepted", "department", "university",
556
+ "hospital", "college", "institute", "email", "@", "proceedings",
557
+ "journal of", "conference on",
558
+ ]
559
+
560
+
561
+ def _extract_title(lines: List[Dict[str, Any]]) -> Optional[str]:
562
+ first_page = [line for line in lines if line.get("page_number") == 1]
563
+ candidates: List[Dict[str, Any]] = []
564
+
565
+ for line in first_page[:100]:
566
+ text = _clean_text(line["text"])
567
+ low = text.lower()
568
+ if len(text) < 8 or len(text) > 200:
569
+ continue
570
+ if any(x in low for x in _BAD_TITLE_MARKERS + _BLOCKED_TITLE_FRAGMENTS):
571
+ continue
572
+ if re.match(r"^\d+$", text) or _normalize_heading(text) in SECTION_KEYWORDS:
573
+ continue
574
+ if text.endswith(".") and len(text.split()) < 10:
575
+ continue
576
+ candidates.append(line)
577
+
578
+ if not candidates:
579
+ return None
580
+
581
+ max_size = max(float(c.get("max_size") or 0) for c in candidates)
582
+ title_lines = [c for c in candidates if float(c.get("max_size") or 0) >= max_size - 0.6]
583
+
584
+ by_block: Dict[int, List[str]] = defaultdict(list)
585
+ for line in title_lines:
586
+ by_block[int(line.get("block_no", 0))].append(line["text"])
587
+
588
+ for _, parts in sorted(by_block.items()):
589
+ title = _clean_text(" ".join(parts))
590
+ if 3 <= len(title.split()) <= 35:
591
+ return title
592
+
593
+ for line in candidates[:25]:
594
+ text = _clean_text(line["text"])
595
+ if 3 <= len(text.split()) <= 30 and not text.endswith("."):
596
+ return text
597
+ return None
598
+
599
+
600
+ _ABSTRACT_MARKERS = [
601
+ "we propose", "we present", "we introduce", "we developed", "we investigate",
602
+ "this paper", "this work", "this study", "this article", "here we",
603
+ "in this paper", "in this work", "in this study",
604
+ ]
605
+
606
+
607
+ def _extract_abstract_from_sections(sections: List[Dict[str, Any]], clean_text: str) -> Optional[str]:
608
+ for sec in sections:
609
+ if sec.get("role") == "abstract" or _normalize_heading(sec.get("title", "")) == "abstract":
610
+ text = _clean_text(sec.get("text", ""))
611
+ text = re.sub(r"^abstract[:\s—–-]*", "", text, flags=re.IGNORECASE).strip()
612
+ if len(text.split()) >= 30:
613
+ return text
614
+
615
+ explicit = re.search(
616
+ r"\babstract\b[:\s—–-]*(.{100,2500}?)(?=\n\s*(?:\d+[.)]?\s*)?(?:keywords|introduction|background|1[.\s]|i[.\s])\b)",
617
+ clean_text,
618
+ flags=re.IGNORECASE | re.DOTALL,
619
+ )
620
+ if explicit:
621
+ candidate = _clean_text(explicit.group(1))
622
+ if len(candidate.split()) >= 30:
623
+ return candidate
624
+
625
+ paragraphs = [_clean_text(p) for p in re.split(r"\n\s*\n", clean_text[:8000]) if _clean_text(p)]
626
+ for para in paragraphs[:10]:
627
+ low = para.lower()
628
+ if len(para.split()) >= 40 and any(marker in low for marker in _ABSTRACT_MARKERS):
629
+ return para
630
+ return None
631
+
632
+
633
+ _REF_SPLIT_RE = re.compile(
634
+ r"\n(?=\s*(?:\[\d+\]|\d+[.)]|[A-Z][a-zA-Z\-]+,\s+[A-Z]|\(\d{4}\)))"
635
+ )
636
+
637
+
638
+ def _parse_references(references_text: str) -> List[str]:
639
+ references_text = _clean_text(references_text)
640
+ if not references_text:
641
+ return []
642
+ references_text = re.sub(r"^references\s*", "", references_text, flags=re.IGNORECASE).strip()
643
+ parts = _REF_SPLIT_RE.split(references_text)
644
+
645
+ refs: List[str] = []
646
+ for part in parts:
647
+ ref = _clean_text(part)
648
+ if len(ref) >= 25 and _normalize_heading(ref) not in BOILERPLATE_SECTION_NAMES:
649
+ refs.append(ref)
650
+
651
+ seen: Set[str] = set()
652
+ unique: List[str] = []
653
+ for ref in refs:
654
+ key = re.sub(r"\s+", " ", ref.lower())[:160]
655
+ if key not in seen:
656
+ seen.add(key)
657
+ unique.append(ref)
658
+ return unique[:300]
659
+
660
+
661
+ _CAPTION_RE = re.compile(
662
+ r"^(?P<label>"
663
+ r"(?:figure|fig\.?|table|tbl\.?|scheme|supplementary figure|supp\.?\s*fig\.?|"
664
+ r"extended data figure|algorithm|listing)\s*[\dIVXLC]+[A-Za-z]?"
665
+ r")[\.::\s\-–—]*(?P<caption>.+)$",
666
+ re.IGNORECASE,
667
+ )
668
+
669
+
670
+ def _extract_captions(lines: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
671
+ captions: List[Dict[str, Any]] = []
672
+ seen: Set[str] = set()
673
+ texts = [line["text"] for line in lines]
674
+
675
+ for i, line in enumerate(lines):
676
+ m = _CAPTION_RE.match(_clean_text(line["text"]))
677
+ if not m:
678
+ continue
679
+ label = m.group("label").strip()
680
+ caption = m.group("caption").strip()
681
+ if re.match(r"^(shows|illustrates|presents|depicts|demonstrates)\b", caption.lower()):
682
+ continue
683
+
684
+ continuation: List[str] = []
685
+ for next_text in texts[i + 1:i + 6]:
686
+ nt = _clean_text(next_text)
687
+ if _CAPTION_RE.match(nt) or _normalize_heading(nt) in SECTION_KEYWORDS:
688
+ break
689
+ if len(nt.split()) < 4:
690
+ break
691
+ continuation.append(nt)
692
+ if continuation:
693
+ caption = _clean_text(caption + " " + " ".join(continuation))
694
+
695
+ key = re.sub(r"[^a-z0-9]+", "", label.lower())
696
+ if key in seen:
697
+ continue
698
+ seen.add(key)
699
+ captions.append({
700
+ "label": label,
701
+ "caption": caption,
702
+ "page_number": line.get("page_number"),
703
+ })
704
+ return captions
705
+
706
+
707
+ def _is_valid_table(data: Any) -> bool:
708
+ if not isinstance(data, list) or len(data) < 2:
709
+ return False
710
+ rows = [row for row in data if isinstance(row, list)]
711
+ if len(rows) < 2:
712
+ return False
713
+ cols = max((len(row) for row in rows), default=0)
714
+ if cols < 2:
715
+ return False
716
+
717
+ cells = [str(cell).strip() for row in rows for cell in row if cell is not None and str(cell).strip()]
718
+ non_empty = len(cells)
719
+ total_cells = sum(len(row) for row in rows)
720
+ if non_empty < 6:
721
+ return False
722
+
723
+ flat = " ".join(cells).lower()
724
+ if non_empty <= 8 and any(noise in flat for noise in ["www.", "http", "vol.", "doi"]):
725
+ return False
726
+
727
+ fill_ratio = non_empty / max(total_cells, 1)
728
+ avg_cell_len = sum(len(cell) for cell in cells) / max(non_empty, 1)
729
+ one_char_ratio = sum(1 for cell in cells if len(cell) <= 1) / max(non_empty, 1)
730
+ very_short_ratio = sum(1 for cell in cells if len(cell) <= 2) / max(non_empty, 1)
731
+
732
+ if cols > 12 and (fill_ratio < 0.35 or avg_cell_len < 3.0 or one_char_ratio > 0.35):
733
+ return False
734
+ if avg_cell_len < 2.5 and very_short_ratio > 0.60:
735
+ return False
736
+ if cols > 20 and non_empty < 80:
737
+ return False
738
+
739
+ meaningful_rows = sum(
740
+ 1 for row in rows
741
+ if len([str(c).strip() for c in row if c is not None and str(c).strip()]) >= 2
742
+ and len(" ".join(str(c) for c in row)) >= 12
743
+ )
744
+ return meaningful_rows >= 2
745
+
746
+
747
+ def _quality_report(result: Dict[str, Any]) -> Dict[str, Any]:
748
+ title = result.get("title") or ""
749
+ abstract = result.get("abstract") or ""
750
+ sections = result.get("sections", [])
751
+ roles = {section.get("role", "other") for section in sections}
752
+ n_refs = len(result.get("references", []))
753
+
754
+ score = 0.0
755
+ score += 0.15 if len(title.split()) >= 4 else 0.0
756
+ score += 0.20 if len(abstract.split()) >= 30 else 0.0
757
+ score += min(0.20, len(sections) * 0.022)
758
+ score += 0.10 if "methodology" in roles else 0.0
759
+ score += 0.08 if {"results", "experiments"} & roles else 0.0
760
+ score += 0.07 if "conclusion" in roles else 0.0
761
+ score += 0.08 if n_refs >= 5 else (0.03 if n_refs else 0.0)
762
+ score += 0.05 if len(result.get("clean_text", "")) > 4000 else 0.0
763
+ score += 0.04 if result.get("captions") else 0.0
764
+ score += 0.03 if result.get("tables") else 0.0
765
+
766
+ if len(abstract.split()) < 20:
767
+ score -= 0.10
768
+ if not sections:
769
+ score -= 0.20
770
+
771
+ return {
772
+ "title_found": len(title.split()) >= 4,
773
+ "abstract_found": len(abstract.split()) >= 30,
774
+ "num_sections": len(sections),
775
+ "section_roles": sorted(roles),
776
+ "methodology_section_found": "methodology" in roles,
777
+ "num_references": n_refs,
778
+ "num_captions": len(result.get("captions", [])),
779
+ "num_tables": len(result.get("tables", [])),
780
+ "references_removed_from_clean_text": bool(result.get("references_text")),
781
+ "appendix_removed_from_clean_text": bool(result.get("appendix_text")),
782
+ "quality_score": round(max(0.0, min(score, 0.98)), 3),
783
+ }
784
+
785
+
786
+ # ---------------------------------------------------------------------------
787
+ # Engines
788
+ # ---------------------------------------------------------------------------
789
+
790
+ def extract_with_pymupdf(pdf_path: str | Path) -> Dict[str, Any]:
791
+ path = Path(pdf_path)
792
+ if not path.exists():
793
+ raise PDFIngestionError(f"PDF not found: {path}")
794
+
795
+ doc = fitz.open(path)
796
+ try:
797
+ all_lines: List[Dict[str, Any]] = []
798
+ pages: List[Dict[str, Any]] = []
799
+ tables: List[Dict[str, Any]] = []
800
+
801
+ for page_index, page in enumerate(doc):
802
+ page_number = page_index + 1
803
+ page_lines = _extract_page_lines(page, page_number)
804
+ all_lines.extend(page_lines)
805
+ page_text = _clean_text("\n".join(line["text"] for line in page_lines))
806
+ pages.append({"page_number": page_number, "text": page_text})
807
+
808
+ try:
809
+ found = page.find_tables()
810
+ for table_index, table in enumerate(found.tables):
811
+ data = table.extract()
812
+ if _is_valid_table(data):
813
+ tables.append({
814
+ "page_number": page_number,
815
+ "table_index": table_index,
816
+ "data": data,
817
+ "engine": "pymupdf",
818
+ "caption": None,
819
+ })
820
+ except Exception:
821
+ pass
822
+
823
+ full_text = _clean_text("\n\n".join(page["text"] for page in pages))
824
+ body_size = _dominant_body_size(all_lines)
825
+ heading_keys = _detect_heading_keys(all_lines, body_size)
826
+ sections_all = _split_sections_from_lines(all_lines, heading_keys)
827
+ body_sections, references_text, appendix_text, boilerplate_text = _separate_back_matter(sections_all)
828
+ clean_text = _clean_text("\n\n".join(f'{s["title"]}\n{s["text"]}' for s in body_sections))
829
+
830
+ title = _extract_title(all_lines)
831
+ abstract = _extract_abstract_from_sections(body_sections, clean_text)
832
+ references = _parse_references(references_text)
833
+ captions = _extract_captions(all_lines)
834
+
835
+ # Attach table captions when labels and table indices align.
836
+ cap_map: Dict[str, str] = {}
837
+ for cap in captions:
838
+ label = cap.get("label", "").lower()
839
+ if "table" in label or "tbl" in label:
840
+ key = re.sub(r"[^a-z0-9]", "", label)
841
+ cap_map[key] = cap.get("caption", "")
842
+ for table in tables:
843
+ idx_str = str(table.get("table_index", ""))
844
+ for key, caption in cap_map.items():
845
+ if idx_str in key or key.endswith(idx_str):
846
+ table["caption"] = caption
847
+ break
848
+
849
+ result: Dict[str, Any] = {
850
+ "source_pdf": path.name,
851
+ "num_pages": len(doc),
852
+ "title": title,
853
+ "abstract": abstract,
854
+ "text": clean_text,
855
+ "clean_text": clean_text,
856
+ "raw_text": full_text,
857
+ "pages": pages,
858
+ "sections": body_sections,
859
+ "all_sections": sections_all,
860
+ "references": references,
861
+ "references_text": references_text,
862
+ "appendix_text": appendix_text,
863
+ "boilerplate_text": boilerplate_text,
864
+ "captions": captions,
865
+ "tables": tables,
866
+ "metadata": {
867
+ "body_font_size": body_size,
868
+ "heading_count": len(heading_keys),
869
+ "removed_back_matter": {
870
+ "references": bool(references_text),
871
+ "appendix": bool(appendix_text),
872
+ "boilerplate": bool(boilerplate_text),
873
+ },
874
+ },
875
+ "extraction_engine": "pymupdf-section-aware-final",
876
+ }
877
+ result["quality"] = _quality_report(result)
878
+ return result
879
+ finally:
880
+ doc.close()
881
+
882
+
883
+ def extract_with_docling(pdf_path: str | Path) -> Dict[str, Any]:
884
+ path = Path(pdf_path)
885
+ if not path.exists():
886
+ raise PDFIngestionError(f"PDF not found: {path}")
887
+
888
+ try:
889
+ from docling.document_converter import DocumentConverter
890
+ except ImportError as exc:
891
+ raise PDFIngestionError("Docling is not installed. Run: pip install docling") from exc
892
+
893
+ converter = DocumentConverter()
894
+ doc = converter.convert(str(path)).document
895
+ markdown = _clean_text(doc.export_to_markdown())
896
+
897
+ fake_lines: List[Dict[str, Any]] = []
898
+ for i, raw in enumerate(markdown.splitlines()):
899
+ is_heading = raw.startswith("#")
900
+ text = _clean_text(re.sub(r"^#+\s*", "", raw))
901
+ if text:
902
+ fake_lines.append({
903
+ "text": text,
904
+ "page_number": None,
905
+ "block_no": i,
906
+ "bbox": (0, i * 10, 0, i * 10 + 8),
907
+ "max_size": 13.0 if is_heading else 10.0,
908
+ "avg_size": 13.0 if is_heading else 10.0,
909
+ "bold": is_heading,
910
+ "col": 0,
911
+ })
912
+
913
+ heading_keys = _detect_heading_keys(fake_lines, 10.0)
914
+ for line in fake_lines:
915
+ if line.get("bold") and _normalize_heading(line["text"]) in SECTION_KEYWORDS:
916
+ heading_keys.add(_line_key(line["text"]))
917
+
918
+ sections_all = _split_sections_from_lines(fake_lines, heading_keys)
919
+ body_sections, references_text, appendix_text, boilerplate_text = _separate_back_matter(sections_all)
920
+ clean_text = _clean_text("\n\n".join(f'{s["title"]}\n{s["text"]}' for s in body_sections))
921
+
922
+ result: Dict[str, Any] = {
923
+ "source_pdf": path.name,
924
+ "num_pages": None,
925
+ "title": _extract_title(fake_lines),
926
+ "abstract": _extract_abstract_from_sections(body_sections, clean_text),
927
+ "text": clean_text,
928
+ "clean_text": clean_text,
929
+ "raw_text": markdown,
930
+ "pages": [],
931
+ "sections": body_sections,
932
+ "all_sections": sections_all,
933
+ "references": _parse_references(references_text),
934
+ "references_text": references_text,
935
+ "appendix_text": appendix_text,
936
+ "boilerplate_text": boilerplate_text,
937
+ "captions": [],
938
+ "tables": [],
939
+ "metadata": {
940
+ "body_font_size": 10.0,
941
+ "heading_count": len(heading_keys),
942
+ "removed_back_matter": {
943
+ "references": bool(references_text),
944
+ "appendix": bool(appendix_text),
945
+ "boilerplate": bool(boilerplate_text),
946
+ },
947
+ },
948
+ "extraction_engine": "docling-section-aware-final",
949
+ }
950
+ result["quality"] = _quality_report(result)
951
+ return result
952
+
953
+
954
+ def extract_pdf(pdf_path: str | Path, engine: str = "pymupdf") -> Dict[str, Any]:
955
+ engine = engine.lower().strip()
956
+ if engine == "pymupdf":
957
+ return extract_with_pymupdf(pdf_path)
958
+ if engine == "docling":
959
+ return extract_with_docling(pdf_path)
960
+ if engine == "auto":
961
+ try:
962
+ pymupdf_result = extract_with_pymupdf(pdf_path)
963
+ try:
964
+ docling_result = extract_with_docling(pdf_path)
965
+ return (
966
+ pymupdf_result
967
+ if pymupdf_result["quality"]["quality_score"] >= docling_result["quality"]["quality_score"]
968
+ else docling_result
969
+ )
970
+ except PDFIngestionError:
971
+ return pymupdf_result
972
+ except Exception:
973
+ return extract_with_docling(pdf_path)
974
+ raise PDFIngestionError(f"Unknown engine '{engine}'. Choose: pymupdf | docling | auto.")
src/paper2lab/data/preprocessing.py ADDED
File without changes
src/paper2lab/data/schema.py ADDED
@@ -0,0 +1,92 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ schema.py — Paper2Lab data contracts.
3
+
4
+ Field-agnostic contracts for PDF extraction across ML, NLP, CV, biomedical,
5
+ physics, education, social-science, economics, and interdisciplinary papers.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import asdict, dataclass, field
11
+ from typing import Any, Dict, List, Optional
12
+
13
+
14
+ KNOWN_ROLES = frozenset({
15
+ "front_matter",
16
+ "abstract",
17
+ "keywords",
18
+ "introduction",
19
+ "related_work",
20
+ "background",
21
+ "theory",
22
+ "methodology",
23
+ "experiments",
24
+ "results",
25
+ "discussion",
26
+ "limitations",
27
+ "future_work",
28
+ "conclusion",
29
+ "references",
30
+ "appendix",
31
+ "boilerplate",
32
+ "other",
33
+ })
34
+
35
+
36
+ @dataclass
37
+ class Section:
38
+ title: str
39
+ text: str
40
+ level: int = 1
41
+ page_start: Optional[int] = None
42
+ page_end: Optional[int] = None
43
+ role: str = "other"
44
+ word_count: int = 0
45
+
46
+ def __post_init__(self) -> None:
47
+ if self.role not in KNOWN_ROLES:
48
+ self.role = "other"
49
+ if not self.word_count:
50
+ self.word_count = len((self.text or "").split())
51
+
52
+
53
+ @dataclass
54
+ class Caption:
55
+ label: str
56
+ caption: str
57
+ page_number: Optional[int] = None
58
+
59
+
60
+ @dataclass
61
+ class Table:
62
+ page_number: Optional[int]
63
+ table_index: int
64
+ data: Any
65
+ engine: str = "unknown"
66
+ caption: Optional[str] = None
67
+
68
+
69
+ @dataclass
70
+ class DocumentExtraction:
71
+ source_pdf: str
72
+ title: Optional[str]
73
+ abstract: Optional[str]
74
+ text: str
75
+ clean_text: str
76
+ raw_text: str = ""
77
+ num_pages: Optional[int] = None
78
+ sections: List[Section] = field(default_factory=list)
79
+ all_sections: List[Section] = field(default_factory=list)
80
+ references: List[str] = field(default_factory=list)
81
+ references_text: str = ""
82
+ appendix_text: str = ""
83
+ boilerplate_text: str = ""
84
+ captions: List[Caption] = field(default_factory=list)
85
+ tables: List[Table] = field(default_factory=list)
86
+ pages: List[Dict[str, Any]] = field(default_factory=list)
87
+ metadata: Dict[str, Any] = field(default_factory=dict)
88
+ quality: Dict[str, Any] = field(default_factory=dict)
89
+ extraction_engine: str = "unknown"
90
+
91
+ def to_dict(self) -> Dict[str, Any]:
92
+ return asdict(self)