pyPaperFlow 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pyPaperFlow/__init__.py +1 -0
- pyPaperFlow/cli.py +662 -0
- pyPaperFlow/integrations/mineru_parser.py +1315 -0
- pyPaperFlow/integrations/pdf_fetch.py +2080 -0
- pyPaperFlow/preprint/arxiv_fetcher.py +549 -0
- pyPaperFlow/preprint/biorxiv_fetcher.py +404 -0
- pyPaperFlow/preprint/source_models.py +29 -0
- pyPaperFlow/preprint/source_utils.py +131 -0
- pyPaperFlow/pubmed/__init__.py +0 -0
- pyPaperFlow/pubmed/pubmed_fetcher.py +1910 -0
- pyPaperFlow/pubmed/pubmed_merger.py +958 -0
- pyPaperFlow/utils.py +70 -0
- pypaperflow-0.2.0.dist-info/METADATA +2025 -0
- pypaperflow-0.2.0.dist-info/RECORD +17 -0
- pypaperflow-0.2.0.dist-info/WHEEL +4 -0
- pypaperflow-0.2.0.dist-info/entry_points.txt +2 -0
- pypaperflow-0.2.0.dist-info/licenses/LICENSE +674 -0
|
@@ -0,0 +1,1315 @@
|
|
|
1
|
+
"""
|
|
2
|
+
MinerU Content Parser for pyPaperFlow
|
|
3
|
+
|
|
4
|
+
Parse mineru >= 3.0 ``content_list_v2.json`` into canonical sectioned JSON
|
|
5
|
+
with metadata extraction and section aggregation.
|
|
6
|
+
|
|
7
|
+
Two classification backends are supported:
|
|
8
|
+
|
|
9
|
+
- **regex** (default): improved regex patterns with cursor-based ordering
|
|
10
|
+
and context keyword scanning. No API calls needed.
|
|
11
|
+
- **ai**: batch-classifies section titles via Claude / GPT API.
|
|
12
|
+
|
|
13
|
+
Usage::
|
|
14
|
+
|
|
15
|
+
from pyPaperFlow.integrations.mineru_parser import (
|
|
16
|
+
MinerUContentParser, RegexSectionClassifier, AISectionClassifier,
|
|
17
|
+
)
|
|
18
|
+
classifier = RegexSectionClassifier.from_config()
|
|
19
|
+
parser = MinerUContentParser(classifier)
|
|
20
|
+
result = parser.parse("path/to/content_list_v2.json")
|
|
21
|
+
|
|
22
|
+
For more details, please refer to the official documentation
|
|
23
|
+
https://opendatalab.github.io/MinerU/zh/reference/output_files/
|
|
24
|
+
"""
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
import json
|
|
27
|
+
import os
|
|
28
|
+
import re
|
|
29
|
+
from abc import ABC, abstractmethod
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
from typing import *
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
#############################################################
|
|
35
|
+
# 1, Mineru Content Parser
|
|
36
|
+
#############################################################
|
|
37
|
+
|
|
38
|
+
# ── shared constants ────────────────────────────────────────────────
|
|
39
|
+
|
|
40
|
+
_NOISE_TYPES = frozenset({
|
|
41
|
+
"page_header", "page_footer", "page_number",
|
|
42
|
+
"page_aside_text", "page_footnote",
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
_YEAR_RE = re.compile(r"\b(19|20)\d{2}\b")
|
|
46
|
+
_DOI_RE = re.compile(r"\b10\.\d{4,}/[^\s]+\b")
|
|
47
|
+
_EMAIL_RE = re.compile(r"E-mail:\s*\S+@\S+", re.I)
|
|
48
|
+
|
|
49
|
+
_LEADING_NUM_RE = re.compile(r"^\s*(?:\d+[\.\)]\s*)+(.*)$")
|
|
50
|
+
|
|
51
|
+
_DEFAULT_CANONICAL_ORDER = [
|
|
52
|
+
"abstract", "introduction", "results", "discussion",
|
|
53
|
+
"methods", "conclusion", "supplementary", "availability",
|
|
54
|
+
"funding", "acknowledgements", "author_contributions",
|
|
55
|
+
"keywords", "conflicts", "references", "other",
|
|
56
|
+
]
|
|
57
|
+
|
|
58
|
+
_DEFAULT_DISPLAY_NAMES: dict[str, str] = {
|
|
59
|
+
"abstract": "Abstract",
|
|
60
|
+
"introduction": "Introduction",
|
|
61
|
+
"results": "Results",
|
|
62
|
+
"discussion": "Discussion",
|
|
63
|
+
"methods": "Methods",
|
|
64
|
+
"conclusion": "Conclusion",
|
|
65
|
+
"supplementary": "Supplementary Material",
|
|
66
|
+
"availability": "Data & Code Availability",
|
|
67
|
+
"funding": "Funding",
|
|
68
|
+
"acknowledgements": "Acknowledgements",
|
|
69
|
+
"author_contributions": "Author Contributions",
|
|
70
|
+
"keywords": "Keywords",
|
|
71
|
+
"conflicts": "Competing Interests",
|
|
72
|
+
"references": "References",
|
|
73
|
+
"other": "Other",
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
def _strip_section_number(title: str) -> str:
|
|
77
|
+
m = _LEADING_NUM_RE.match(title)
|
|
78
|
+
return m.group(1) if m else title
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
# ── config loader ───────────────────────────────────────────────────
|
|
82
|
+
|
|
83
|
+
_DEFAULT_CONFIG_PATH = Path(__file__).parent / "mineru_config.yaml"
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _load_yaml_config(path: str | Path | None = None) -> dict:
|
|
87
|
+
"""Load a YAML config file.
|
|
88
|
+
|
|
89
|
+
If *path* is ``None``, tries the default ``mineru_config.yaml`` shipped
|
|
90
|
+
alongside this module. Returns ``{}`` if YAML is not installed or the
|
|
91
|
+
file is missing / unreadable.
|
|
92
|
+
"""
|
|
93
|
+
try:
|
|
94
|
+
import yaml
|
|
95
|
+
except ImportError:
|
|
96
|
+
return {}
|
|
97
|
+
|
|
98
|
+
p = Path(path) if path else _DEFAULT_CONFIG_PATH
|
|
99
|
+
if not p.exists():
|
|
100
|
+
return {}
|
|
101
|
+
try:
|
|
102
|
+
with open(p, "r") as f:
|
|
103
|
+
return yaml.safe_load(f) or {}
|
|
104
|
+
except Exception:
|
|
105
|
+
return {}
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
# ── SectionClassifier ───────────────────────────────────────────────
|
|
109
|
+
|
|
110
|
+
class SectionClassifier(ABC):
|
|
111
|
+
"""Base class for pluggable section-title classifiers."""
|
|
112
|
+
|
|
113
|
+
def __init__(
|
|
114
|
+
self,
|
|
115
|
+
canonical_order: list[str] | None = None,
|
|
116
|
+
display_names: dict[str, str] | None = None,
|
|
117
|
+
):
|
|
118
|
+
self.canonical_order = canonical_order or list(_DEFAULT_CANONICAL_ORDER)
|
|
119
|
+
self.display_names = display_names or dict(_DEFAULT_DISPLAY_NAMES)
|
|
120
|
+
|
|
121
|
+
@abstractmethod
|
|
122
|
+
def normalize(self, raw_title: str, context_text: str = "") -> tuple[str, str]:
|
|
123
|
+
"""Return (canonical_type, display_title)."""
|
|
124
|
+
...
|
|
125
|
+
|
|
126
|
+
def batch_normalize(
|
|
127
|
+
self, titles: list[dict[str, Any]]
|
|
128
|
+
) -> list[tuple[str, str]]:
|
|
129
|
+
"""Classify multiple titles. Default impl calls normalize() one-by-one."""
|
|
130
|
+
return [self.normalize(t["raw"], t.get("context", "")) for t in titles]
|
|
131
|
+
|
|
132
|
+
def display_for(self, canonical_type: str) -> str:
|
|
133
|
+
return self.display_names.get(
|
|
134
|
+
canonical_type,
|
|
135
|
+
canonical_type.replace("_", " ").title(),
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
def order_key(self, canonical_type: str) -> int:
|
|
139
|
+
try:
|
|
140
|
+
return self.canonical_order.index(canonical_type)
|
|
141
|
+
except ValueError:
|
|
142
|
+
return len(self.canonical_order)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
# ── alias builders (patterns come from YAML config only) ─────────────
|
|
146
|
+
|
|
147
|
+
def _build_strong_aliases(
|
|
148
|
+
canonical_order: list[str], alias_cfg: dict
|
|
149
|
+
) -> dict[str, set[str]]:
|
|
150
|
+
"""Build strong (exact) aliases from config. Config is the single source."""
|
|
151
|
+
result: dict[str, set[str]] = {}
|
|
152
|
+
for ctype in canonical_order:
|
|
153
|
+
entry = alias_cfg.get(ctype, {})
|
|
154
|
+
strong = {s.lower() for s in (entry.get("strong") or [])}
|
|
155
|
+
if strong:
|
|
156
|
+
result[ctype] = strong
|
|
157
|
+
return result
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _build_weak_patterns(
|
|
161
|
+
canonical_order: list[str], alias_cfg: dict
|
|
162
|
+
) -> dict[str, list[re.Pattern]]:
|
|
163
|
+
"""Build weak (regex) patterns from config. Config is the single source."""
|
|
164
|
+
result: dict[str, list[re.Pattern]] = {}
|
|
165
|
+
for ctype in canonical_order:
|
|
166
|
+
entry = alias_cfg.get(ctype, {})
|
|
167
|
+
pat_strs = entry.get("weak") or []
|
|
168
|
+
if pat_strs:
|
|
169
|
+
result[ctype] = [re.compile(p, re.I) for p in pat_strs]
|
|
170
|
+
return result
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _build_context_keywords(
|
|
174
|
+
canonical_order: list[str], alias_cfg: dict
|
|
175
|
+
) -> dict[str, list[re.Pattern]]:
|
|
176
|
+
"""Build context keyword patterns from config. Config is the single source."""
|
|
177
|
+
result: dict[str, list[re.Pattern]] = {}
|
|
178
|
+
for ctype in canonical_order:
|
|
179
|
+
entry = alias_cfg.get(ctype, {})
|
|
180
|
+
pat_strs = entry.get("context_keywords") or []
|
|
181
|
+
if pat_strs:
|
|
182
|
+
result[ctype] = [re.compile(p, re.I) for p in pat_strs]
|
|
183
|
+
return result
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
# ── RegexSectionClassifier ──────────────────────────────────────────
|
|
187
|
+
|
|
188
|
+
class RegexSectionClassifier(SectionClassifier):
|
|
189
|
+
"""Classify section titles using configurable alias patterns + context.
|
|
190
|
+
|
|
191
|
+
Match order:
|
|
192
|
+
1. **strong** — exact lowercase string match (fast, best precision)
|
|
193
|
+
2. **weak** — regex ``re.search`` against stripped title
|
|
194
|
+
3. **context_keywords** — regex against first ~300 chars of body text
|
|
195
|
+
following the title (fallback for ambiguous one-word titles like
|
|
196
|
+
``"Overview"``)
|
|
197
|
+
|
|
198
|
+
A sliding cursor tracks the expected next position in ``canonical_order``.
|
|
199
|
+
When a title matches multiple canonical types, the one closest to the
|
|
200
|
+
cursor (in document order) wins.
|
|
201
|
+
"""
|
|
202
|
+
|
|
203
|
+
def __init__(
|
|
204
|
+
self,
|
|
205
|
+
canonical_order: list[str] | None = None,
|
|
206
|
+
display_names: dict[str, str] | None = None,
|
|
207
|
+
strong_aliases: dict[str, set[str]] | None = None,
|
|
208
|
+
weak_patterns: dict[str, list[re.Pattern]] | None = None,
|
|
209
|
+
context_keywords: dict[str, list[re.Pattern]] | None = None,
|
|
210
|
+
):
|
|
211
|
+
super().__init__(canonical_order, display_names)
|
|
212
|
+
self._strong = strong_aliases or {}
|
|
213
|
+
self._weak = weak_patterns or {}
|
|
214
|
+
self._ctx_kw = context_keywords or {}
|
|
215
|
+
self._cursor = 0 # index into canonical_order
|
|
216
|
+
|
|
217
|
+
def reset_cursor(self) -> None:
|
|
218
|
+
self._cursor = 0
|
|
219
|
+
|
|
220
|
+
@classmethod
|
|
221
|
+
def from_config(cls, config_path: str | Path | None = None) -> RegexSectionClassifier:
|
|
222
|
+
"""Build from a YAML config file, falling back to built-in defaults."""
|
|
223
|
+
cfg = _load_yaml_config(config_path)
|
|
224
|
+
|
|
225
|
+
canonical_order = cfg.get("canonical_order") or list(_DEFAULT_CANONICAL_ORDER)
|
|
226
|
+
display_names = cfg.get("display_names") or dict(_DEFAULT_DISPLAY_NAMES)
|
|
227
|
+
alias_cfg = cfg.get("aliases") or {}
|
|
228
|
+
|
|
229
|
+
strong_aliases: dict[str, set[str]] = _build_strong_aliases(canonical_order, alias_cfg)
|
|
230
|
+
weak_patterns: dict[str, list[re.Pattern]] = _build_weak_patterns(canonical_order, alias_cfg)
|
|
231
|
+
context_keywords: dict[str, list[re.Pattern]] = _build_context_keywords(canonical_order, alias_cfg)
|
|
232
|
+
|
|
233
|
+
return cls(
|
|
234
|
+
canonical_order=canonical_order,
|
|
235
|
+
display_names=display_names,
|
|
236
|
+
strong_aliases=strong_aliases,
|
|
237
|
+
weak_patterns=weak_patterns,
|
|
238
|
+
context_keywords=context_keywords,
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
# ── public API ────────────────────────────────────────────
|
|
242
|
+
|
|
243
|
+
def normalize(self, raw_title: str, context_text: str = "") -> tuple[str, str]:
|
|
244
|
+
stripped = _strip_section_number(raw_title).strip()
|
|
245
|
+
lower = stripped.lower().strip(" .:-—–\t\n\r")
|
|
246
|
+
|
|
247
|
+
if not lower:
|
|
248
|
+
return "other", raw_title.strip()
|
|
249
|
+
|
|
250
|
+
# 1. strong match
|
|
251
|
+
ctype = self._strong_match(lower)
|
|
252
|
+
if ctype:
|
|
253
|
+
self._advance_cursor(ctype)
|
|
254
|
+
return ctype, self.display_for(ctype)
|
|
255
|
+
|
|
256
|
+
# 2. weak regex match
|
|
257
|
+
ctype = self._weak_match(lower)
|
|
258
|
+
if ctype:
|
|
259
|
+
self._advance_cursor(ctype)
|
|
260
|
+
return ctype, self.display_for(ctype)
|
|
261
|
+
|
|
262
|
+
# 3. context keyword fallback
|
|
263
|
+
if context_text:
|
|
264
|
+
ctype = self._context_match(context_text)
|
|
265
|
+
if ctype:
|
|
266
|
+
self._advance_cursor(ctype)
|
|
267
|
+
return ctype, self.display_for(ctype)
|
|
268
|
+
|
|
269
|
+
return "other", raw_title.strip()
|
|
270
|
+
|
|
271
|
+
# ── matching helpers ──────────────────────────────────────
|
|
272
|
+
|
|
273
|
+
def _strong_match(self, lower: str) -> str | None:
|
|
274
|
+
candidates: list[tuple[int, str]] = []
|
|
275
|
+
for ctype, aliases in self._strong.items():
|
|
276
|
+
if lower == ctype or lower in aliases:
|
|
277
|
+
candidates.append((self.order_key(ctype), ctype))
|
|
278
|
+
return self._pick_best(candidates)
|
|
279
|
+
|
|
280
|
+
def _weak_match(self, lower: str) -> str | None:
|
|
281
|
+
candidates: list[tuple[int, str]] = []
|
|
282
|
+
for ctype, patterns in self._weak.items():
|
|
283
|
+
for pat in patterns:
|
|
284
|
+
if pat.search(lower):
|
|
285
|
+
candidates.append((self.order_key(ctype), ctype))
|
|
286
|
+
break
|
|
287
|
+
return self._pick_best(candidates)
|
|
288
|
+
|
|
289
|
+
def _context_match(self, context_text: str) -> str | None:
|
|
290
|
+
if not context_text:
|
|
291
|
+
return None
|
|
292
|
+
candidates: list[tuple[int, str]] = []
|
|
293
|
+
for ctype, patterns in self._ctx_kw.items():
|
|
294
|
+
for pat in patterns:
|
|
295
|
+
if pat.search(context_text):
|
|
296
|
+
candidates.append((self.order_key(ctype), ctype))
|
|
297
|
+
break
|
|
298
|
+
return self._pick_best(candidates)
|
|
299
|
+
|
|
300
|
+
def _pick_best(self, candidates: list[tuple[int, str]]) -> str | None:
|
|
301
|
+
"""Pick the candidate closest to the cursor (lowest positive distance)."""
|
|
302
|
+
if not candidates:
|
|
303
|
+
return None
|
|
304
|
+
if len(candidates) == 1:
|
|
305
|
+
return candidates[0][1]
|
|
306
|
+
# prefer the candidate whose canonical index is >= cursor and closest
|
|
307
|
+
best = min(
|
|
308
|
+
candidates,
|
|
309
|
+
key=lambda x: (
|
|
310
|
+
0 if x[0] >= self._cursor else 1,
|
|
311
|
+
abs(x[0] - self._cursor),
|
|
312
|
+
),
|
|
313
|
+
)
|
|
314
|
+
return best[1]
|
|
315
|
+
|
|
316
|
+
def _advance_cursor(self, ctype: str) -> None:
|
|
317
|
+
idx = self.order_key(ctype)
|
|
318
|
+
if idx >= self._cursor:
|
|
319
|
+
self._cursor = idx + 1
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
# ── AISectionClassifier ─────────────────────────────────────────────
|
|
323
|
+
|
|
324
|
+
_AI_SYSTEM_PROMPT = """\
|
|
325
|
+
You are a scientific document section classifier. Given a list of section \
|
|
326
|
+
titles from an academic paper (biomedical / computational biology), classify \
|
|
327
|
+
each title into exactly one canonical type.
|
|
328
|
+
|
|
329
|
+
Canonical types:
|
|
330
|
+
- abstract: paper abstract or summary
|
|
331
|
+
- introduction: introduction, background, motivation
|
|
332
|
+
- results: results, findings, experimental outcomes
|
|
333
|
+
- discussion: discussion, interpretation, conclusion and future directions
|
|
334
|
+
- methods: methods, materials and methods, experimental section, \
|
|
335
|
+
computational details, model architecture, training regime, algorithm details
|
|
336
|
+
- conclusion: conclusion, concluding remarks (standalone, not combined with discussion)
|
|
337
|
+
- supplementary: supplementary material, supporting information, \
|
|
338
|
+
reporting summary, supplemental figures/tables
|
|
339
|
+
- availability: data availability, code availability, software availability
|
|
340
|
+
- funding: funding, financial support, grant information
|
|
341
|
+
- acknowledgements: acknowledgements, thanks
|
|
342
|
+
- author_contributions: author contributions, author statement
|
|
343
|
+
- keywords: keywords or key words
|
|
344
|
+
- conflicts: competing interests, conflict of interest, declaration of interests
|
|
345
|
+
- references: references, bibliography
|
|
346
|
+
- other: anything that does not fit the above (notes, article masthead, \
|
|
347
|
+
online content, key resources table, diversity statement, etc.)
|
|
348
|
+
|
|
349
|
+
Return a JSON object:
|
|
350
|
+
{"classifications": [{"index": <int>, "canonical_type": "<type>"}, ...]}
|
|
351
|
+
Only return JSON, no other text."""
|
|
352
|
+
|
|
353
|
+
_AI_USER_PROMPT_TEMPLATE = """\
|
|
354
|
+
Classify the following section titles from a scientific paper.
|
|
355
|
+
|
|
356
|
+
Titles:
|
|
357
|
+
{titles_json}"""
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
class AISectionClassifier(SectionClassifier):
|
|
361
|
+
"""Classify section titles using an LLM API.
|
|
362
|
+
|
|
363
|
+
Two modes, selected automatically:
|
|
364
|
+
|
|
365
|
+
- **base_url set** → OpenAI chat-completions format against that endpoint.
|
|
366
|
+
Covers DeepSeek, university proxies, self-hosted vLLM, etc.
|
|
367
|
+
- **base_url not set** → Native Anthropic SDK (ANTHROPIC_API_KEY) or
|
|
368
|
+
native OpenAI SDK (OPENAI_API_KEY), tried in that order.
|
|
369
|
+
|
|
370
|
+
Sends all section titles from a paper in a single batch API call.
|
|
371
|
+
"""
|
|
372
|
+
|
|
373
|
+
def __init__(
|
|
374
|
+
self,
|
|
375
|
+
canonical_order: list[str] | None = None,
|
|
376
|
+
display_names: dict[str, str] | None = None,
|
|
377
|
+
model: str = "claude-haiku-4-5",
|
|
378
|
+
api_key: str | None = None,
|
|
379
|
+
base_url: str | None = None,
|
|
380
|
+
):
|
|
381
|
+
super().__init__(canonical_order, display_names)
|
|
382
|
+
self.model = model
|
|
383
|
+
self.api_key = api_key
|
|
384
|
+
self.base_url = base_url
|
|
385
|
+
|
|
386
|
+
def _get_api_key(self) -> str:
|
|
387
|
+
if self.api_key:
|
|
388
|
+
return self.api_key
|
|
389
|
+
if self.base_url:
|
|
390
|
+
return os.environ.get("OPENAI_API_KEY", "")
|
|
391
|
+
return os.environ.get("ANTHROPIC_API_KEY", "") or os.environ.get("OPENAI_API_KEY", "")
|
|
392
|
+
|
|
393
|
+
@classmethod
|
|
394
|
+
def from_config(cls, config_path: str | Path | None = None) -> AISectionClassifier:
|
|
395
|
+
cfg = _load_yaml_config(config_path)
|
|
396
|
+
ai_cfg = cfg.get("ai", {}) if cfg else {}
|
|
397
|
+
|
|
398
|
+
model = ai_cfg.get("model", "claude-haiku-4-5")
|
|
399
|
+
api_key = ai_cfg.get("api_key") or None
|
|
400
|
+
base_url = ai_cfg.get("base_url") or None
|
|
401
|
+
|
|
402
|
+
canonical_order = cfg.get("canonical_order") or list(_DEFAULT_CANONICAL_ORDER)
|
|
403
|
+
display_names = cfg.get("display_names") or dict(_DEFAULT_DISPLAY_NAMES)
|
|
404
|
+
|
|
405
|
+
return cls(
|
|
406
|
+
canonical_order=canonical_order,
|
|
407
|
+
display_names=display_names,
|
|
408
|
+
model=model,
|
|
409
|
+
api_key=api_key,
|
|
410
|
+
base_url=base_url,
|
|
411
|
+
)
|
|
412
|
+
|
|
413
|
+
def normalize(self, raw_title: str, context_text: str = "") -> tuple[str, str]:
|
|
414
|
+
# Single-title fallback: delegate to batch
|
|
415
|
+
results = self.batch_normalize([{"raw": raw_title, "context": context_text}])
|
|
416
|
+
return results[0]
|
|
417
|
+
|
|
418
|
+
def batch_normalize(
|
|
419
|
+
self, titles: list[dict[str, Any]]
|
|
420
|
+
) -> list[tuple[str, str]]:
|
|
421
|
+
if not titles:
|
|
422
|
+
return []
|
|
423
|
+
|
|
424
|
+
# Build the JSON payload
|
|
425
|
+
items = []
|
|
426
|
+
for i, t in enumerate(titles):
|
|
427
|
+
item: dict = {"index": i, "title": t["raw"].strip()}
|
|
428
|
+
ctx = t.get("context", "")
|
|
429
|
+
if ctx:
|
|
430
|
+
item["context_preview"] = ctx[:200]
|
|
431
|
+
items.append(item)
|
|
432
|
+
|
|
433
|
+
titles_json = json.dumps(items, ensure_ascii=False, indent=2)
|
|
434
|
+
user_prompt = _AI_USER_PROMPT_TEMPLATE.format(titles_json=titles_json)
|
|
435
|
+
|
|
436
|
+
try:
|
|
437
|
+
raw_response = self._call_api(user_prompt)
|
|
438
|
+
classifications = self._parse_response(raw_response)
|
|
439
|
+
except Exception:
|
|
440
|
+
# Fall back to "other" for all titles on any API failure
|
|
441
|
+
return [("other", t["raw"].strip()) for t in titles]
|
|
442
|
+
|
|
443
|
+
# Build result list preserving input order
|
|
444
|
+
classified: dict[int, str] = {}
|
|
445
|
+
for c in classifications:
|
|
446
|
+
idx = c.get("index", -1)
|
|
447
|
+
ctype = c.get("canonical_type", "other")
|
|
448
|
+
if ctype not in set(self.canonical_order):
|
|
449
|
+
ctype = "other"
|
|
450
|
+
classified[idx] = ctype
|
|
451
|
+
|
|
452
|
+
results: list[tuple[str, str]] = []
|
|
453
|
+
for i, t in enumerate(titles):
|
|
454
|
+
ctype = classified.get(i, "other")
|
|
455
|
+
results.append((ctype, self.display_for(ctype)))
|
|
456
|
+
return results
|
|
457
|
+
|
|
458
|
+
def _call_api(self, user_prompt: str) -> str:
|
|
459
|
+
if self.base_url:
|
|
460
|
+
return self._call_openai_compatible(user_prompt)
|
|
461
|
+
# No base_url: try Anthropic first, fall back to OpenAI
|
|
462
|
+
if os.environ.get("ANTHROPIC_API_KEY") or self.api_key:
|
|
463
|
+
try:
|
|
464
|
+
return self._call_anthropic(user_prompt)
|
|
465
|
+
except Exception:
|
|
466
|
+
pass
|
|
467
|
+
return self._call_openai(user_prompt)
|
|
468
|
+
|
|
469
|
+
def _call_openai_compatible(self, user_prompt: str) -> str:
|
|
470
|
+
"""OpenAI chat-completions against a custom base_url (DeepSeek, proxy, etc.)."""
|
|
471
|
+
from openai import OpenAI
|
|
472
|
+
|
|
473
|
+
api_key = self._get_api_key()
|
|
474
|
+
if not api_key:
|
|
475
|
+
raise ValueError(
|
|
476
|
+
"API key not set. Set OPENAI_API_KEY env var or pass --api-key."
|
|
477
|
+
)
|
|
478
|
+
|
|
479
|
+
client = OpenAI(api_key=api_key, base_url=self.base_url)
|
|
480
|
+
response = client.chat.completions.create(
|
|
481
|
+
model=self.model,
|
|
482
|
+
messages=[
|
|
483
|
+
{"role": "system", "content": _AI_SYSTEM_PROMPT},
|
|
484
|
+
{"role": "user", "content": user_prompt},
|
|
485
|
+
],
|
|
486
|
+
)
|
|
487
|
+
return response.choices[0].message.content or ""
|
|
488
|
+
|
|
489
|
+
def _call_openai(self, user_prompt: str) -> str:
|
|
490
|
+
from openai import OpenAI
|
|
491
|
+
|
|
492
|
+
api_key = self.api_key or os.environ.get("OPENAI_API_KEY", "")
|
|
493
|
+
if not api_key:
|
|
494
|
+
raise ValueError("OPENAI_API_KEY not set")
|
|
495
|
+
|
|
496
|
+
client = OpenAI(api_key=api_key)
|
|
497
|
+
response = client.chat.completions.create(
|
|
498
|
+
model=self.model,
|
|
499
|
+
messages=[
|
|
500
|
+
{"role": "system", "content": _AI_SYSTEM_PROMPT},
|
|
501
|
+
{"role": "user", "content": user_prompt},
|
|
502
|
+
],
|
|
503
|
+
)
|
|
504
|
+
return response.choices[0].message.content or ""
|
|
505
|
+
|
|
506
|
+
def _call_anthropic(self, user_prompt: str) -> str:
|
|
507
|
+
import anthropic
|
|
508
|
+
|
|
509
|
+
api_key = self.api_key or os.environ.get("ANTHROPIC_API_KEY", "")
|
|
510
|
+
if not api_key:
|
|
511
|
+
raise ValueError("ANTHROPIC_API_KEY not set")
|
|
512
|
+
|
|
513
|
+
client = anthropic.Anthropic(api_key=api_key)
|
|
514
|
+
message = client.messages.create(
|
|
515
|
+
model=self.model,
|
|
516
|
+
max_tokens=500,
|
|
517
|
+
system=_AI_SYSTEM_PROMPT,
|
|
518
|
+
messages=[{"role": "user", "content": user_prompt}],
|
|
519
|
+
)
|
|
520
|
+
return message.content[0].text
|
|
521
|
+
|
|
522
|
+
def _parse_response(self, raw: str) -> list[dict]:
|
|
523
|
+
# Strip markdown code fences if present
|
|
524
|
+
text = raw.strip()
|
|
525
|
+
if text.startswith("```"):
|
|
526
|
+
text = re.sub(r"^```(?:json)?\s*", "", text)
|
|
527
|
+
text = re.sub(r"```\s*$", "", text)
|
|
528
|
+
data = json.loads(text)
|
|
529
|
+
return data.get("classifications", [])
|
|
530
|
+
|
|
531
|
+
|
|
532
|
+
# ── Parser ──────────────────────────────────────────────────────────
|
|
533
|
+
|
|
534
|
+
class MinerUContentParser:
|
|
535
|
+
"""Parse MinerU ``content_list_v2.json`` into canonical sectioned JSON.
|
|
536
|
+
|
|
537
|
+
Parameters
|
|
538
|
+
----------
|
|
539
|
+
classifier:
|
|
540
|
+
A ``SectionClassifier`` instance. If *None*, a default
|
|
541
|
+
``RegexSectionClassifier`` is created from the built-in config.
|
|
542
|
+
"""
|
|
543
|
+
|
|
544
|
+
def __init__(self, classifier: SectionClassifier | None = None):
|
|
545
|
+
self.classifier = classifier or RegexSectionClassifier.from_config()
|
|
546
|
+
|
|
547
|
+
def parse(self, json_path: str | Path) -> dict[str, Any]:
|
|
548
|
+
json_path = Path(json_path)
|
|
549
|
+
with open(json_path, "r") as f:
|
|
550
|
+
pages: list[list[dict]] = json.load(f)
|
|
551
|
+
|
|
552
|
+
blocks = self._flatten(pages)
|
|
553
|
+
if not blocks:
|
|
554
|
+
return self._empty(json_path)
|
|
555
|
+
|
|
556
|
+
raw = self._collect_raw(pages)
|
|
557
|
+
metadata = self._extract_metadata(raw, blocks)
|
|
558
|
+
abstract = self._extract_abstract(blocks)
|
|
559
|
+
sections = self._build_sections(blocks)
|
|
560
|
+
sections = self._aggregate_sections(sections)
|
|
561
|
+
|
|
562
|
+
# Ensure abstract is always the first section
|
|
563
|
+
if abstract and not any(s["canonical_type"] == "abstract" for s in sections):
|
|
564
|
+
sections.insert(0, {
|
|
565
|
+
"canonical_type": "abstract",
|
|
566
|
+
"raw_title": "Abstract",
|
|
567
|
+
"display_title": self.classifier.display_for("abstract"),
|
|
568
|
+
"level": 2,
|
|
569
|
+
"paragraphs": [abstract],
|
|
570
|
+
})
|
|
571
|
+
|
|
572
|
+
backend = (
|
|
573
|
+
"ai" if isinstance(self.classifier, AISectionClassifier) else "regex"
|
|
574
|
+
)
|
|
575
|
+
|
|
576
|
+
return {
|
|
577
|
+
"source": "mineru",
|
|
578
|
+
"file": json_path.name,
|
|
579
|
+
"backend": backend,
|
|
580
|
+
"metadata": metadata,
|
|
581
|
+
"sections": sections,
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
# ── flatten / collect ─────────────────────────────────────
|
|
585
|
+
|
|
586
|
+
def _flatten(self, pages: list[list[dict]]) -> list[dict]:
|
|
587
|
+
out: list[dict] = []
|
|
588
|
+
for pi, page in enumerate(pages):
|
|
589
|
+
for block in page:
|
|
590
|
+
if not isinstance(block, dict):
|
|
591
|
+
continue
|
|
592
|
+
if block.get("type", "") in _NOISE_TYPES:
|
|
593
|
+
continue
|
|
594
|
+
block["_page"] = pi
|
|
595
|
+
out.append(block)
|
|
596
|
+
return out
|
|
597
|
+
|
|
598
|
+
def _collect_raw(self, pages: list[list[dict]]) -> list[dict]:
|
|
599
|
+
out: list[dict] = []
|
|
600
|
+
for pi, page in enumerate(pages):
|
|
601
|
+
for block in page:
|
|
602
|
+
if isinstance(block, dict):
|
|
603
|
+
block["_page"] = pi
|
|
604
|
+
out.append(block)
|
|
605
|
+
return out
|
|
606
|
+
|
|
607
|
+
# ── text extraction ───────────────────────────────────────
|
|
608
|
+
|
|
609
|
+
def _extract_text(self, block: dict) -> str | None:
|
|
610
|
+
btype = block.get("type", "")
|
|
611
|
+
content = block.get("content", {})
|
|
612
|
+
|
|
613
|
+
if btype == "title":
|
|
614
|
+
items = content.get("title_content") or []
|
|
615
|
+
return " ".join(
|
|
616
|
+
i.get("content", "") for i in items if i.get("type") == "text"
|
|
617
|
+
).strip() or None
|
|
618
|
+
|
|
619
|
+
if btype == "paragraph":
|
|
620
|
+
items = content.get("paragraph_content") or []
|
|
621
|
+
texts: list[str] = []
|
|
622
|
+
for i in items:
|
|
623
|
+
if i.get("type") == "text":
|
|
624
|
+
t = i.get("content", "").strip()
|
|
625
|
+
if t:
|
|
626
|
+
texts.append(t)
|
|
627
|
+
elif i.get("type") == "equation_inline":
|
|
628
|
+
eq = i.get("content", "").strip()
|
|
629
|
+
if eq:
|
|
630
|
+
texts.append(f"${eq}$")
|
|
631
|
+
return " ".join(texts).strip() or None
|
|
632
|
+
|
|
633
|
+
if btype == "equation_interline":
|
|
634
|
+
math = content.get("math_content", "").strip()
|
|
635
|
+
return f"$${math}$$" if math else None
|
|
636
|
+
|
|
637
|
+
if btype == "code":
|
|
638
|
+
return content.get("code_content", "") or None
|
|
639
|
+
|
|
640
|
+
if btype in ("list", "index"):
|
|
641
|
+
items = content.get("list_items") or []
|
|
642
|
+
lines = []
|
|
643
|
+
for it in items:
|
|
644
|
+
t = self._extract_text(
|
|
645
|
+
{"type": "paragraph", "content": {"paragraph_content": [it]}}
|
|
646
|
+
)
|
|
647
|
+
if t:
|
|
648
|
+
lines.append(t)
|
|
649
|
+
return "\n".join(lines) or None
|
|
650
|
+
|
|
651
|
+
if btype in ("image", "chart"):
|
|
652
|
+
caption = content.get("image_caption") or content.get("chart_caption") or []
|
|
653
|
+
text = " ".join(
|
|
654
|
+
i.get("content", "") for i in caption if i.get("type") == "text"
|
|
655
|
+
).strip()
|
|
656
|
+
return f"[Figure: {text}]" if text else None
|
|
657
|
+
|
|
658
|
+
return None
|
|
659
|
+
|
|
660
|
+
def _extract_text_raw(self, block: dict) -> str:
|
|
661
|
+
t = self._extract_text(block)
|
|
662
|
+
if t:
|
|
663
|
+
return t
|
|
664
|
+
content = block.get("content", {})
|
|
665
|
+
for key in (
|
|
666
|
+
"page_header_content",
|
|
667
|
+
"page_footer_content",
|
|
668
|
+
"page_footnote_content",
|
|
669
|
+
"page_aside_text_content",
|
|
670
|
+
):
|
|
671
|
+
items = content.get(key) or []
|
|
672
|
+
text = " ".join(
|
|
673
|
+
i.get("content", "") for i in items if i.get("type") == "text"
|
|
674
|
+
).strip()
|
|
675
|
+
if text:
|
|
676
|
+
return text
|
|
677
|
+
return ""
|
|
678
|
+
|
|
679
|
+
def _is_title(self, block: dict) -> bool:
|
|
680
|
+
return block.get("type") == "title"
|
|
681
|
+
|
|
682
|
+
def _title_level(self, block: dict) -> int:
|
|
683
|
+
return (block.get("content") or {}).get("level", 2)
|
|
684
|
+
|
|
685
|
+
# ── context snippet (for classifier) ──────────────────────
|
|
686
|
+
|
|
687
|
+
def _collect_context(
|
|
688
|
+
self, blocks: list[dict], title_idx: int, max_chars: int = 300
|
|
689
|
+
) -> str:
|
|
690
|
+
"""Collect text from the 2-3 paragraphs following a title block."""
|
|
691
|
+
snippets: list[str] = []
|
|
692
|
+
total = 0
|
|
693
|
+
for i in range(title_idx + 1, min(title_idx + 4, len(blocks))):
|
|
694
|
+
b = blocks[i]
|
|
695
|
+
if b.get("type") in ("paragraph",):
|
|
696
|
+
t = self._extract_text(b)
|
|
697
|
+
if t:
|
|
698
|
+
snippets.append(t)
|
|
699
|
+
total += len(t)
|
|
700
|
+
if total >= max_chars:
|
|
701
|
+
break
|
|
702
|
+
return " ".join(snippets)
|
|
703
|
+
|
|
704
|
+
# ── metadata ──────────────────────────────────────────────
|
|
705
|
+
|
|
706
|
+
def _extract_metadata(
|
|
707
|
+
self, raw_blocks: list[dict], blocks: list[dict]
|
|
708
|
+
) -> dict:
|
|
709
|
+
return {
|
|
710
|
+
"title": self._extract_title(blocks),
|
|
711
|
+
"authors": self._extract_authors(blocks),
|
|
712
|
+
"year": self._extract_year(raw_blocks),
|
|
713
|
+
"doi": self._extract_doi(raw_blocks),
|
|
714
|
+
"journal": self._extract_journal(raw_blocks),
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
def _extract_title(self, blocks: list[dict]) -> str:
|
|
718
|
+
for b in blocks:
|
|
719
|
+
if self._is_title(b) and self._title_level(b) == 1:
|
|
720
|
+
return self._extract_text(b) or ""
|
|
721
|
+
return ""
|
|
722
|
+
|
|
723
|
+
def _extract_authors(self, blocks: list[dict]) -> str:
|
|
724
|
+
found_title = False
|
|
725
|
+
for b in blocks:
|
|
726
|
+
if self._is_title(b) and self._title_level(b) == 1:
|
|
727
|
+
found_title = True
|
|
728
|
+
continue
|
|
729
|
+
if not found_title:
|
|
730
|
+
continue
|
|
731
|
+
if self._is_title(b) and self._title_level(b) >= 2:
|
|
732
|
+
break
|
|
733
|
+
if b.get("type") != "paragraph":
|
|
734
|
+
continue
|
|
735
|
+
text = self._extract_text(b)
|
|
736
|
+
if not text:
|
|
737
|
+
continue
|
|
738
|
+
if re.search(r"^https?://", text.strip()):
|
|
739
|
+
continue
|
|
740
|
+
if re.search(
|
|
741
|
+
r"^(?:Received|Accepted|Published|Open access|Check for)",
|
|
742
|
+
text.strip(),
|
|
743
|
+
re.I,
|
|
744
|
+
):
|
|
745
|
+
continue
|
|
746
|
+
if _EMAIL_RE.search(text) and text.count(",") < 3:
|
|
747
|
+
continue
|
|
748
|
+
if text.count(",") >= 2:
|
|
749
|
+
lines = text.split(" ")
|
|
750
|
+
clean = []
|
|
751
|
+
for line in lines:
|
|
752
|
+
if _EMAIL_RE.search(line):
|
|
753
|
+
continue
|
|
754
|
+
if re.match(r"^\d", line.strip()):
|
|
755
|
+
continue
|
|
756
|
+
clean.append(line.strip())
|
|
757
|
+
return " ".join(clean).strip()
|
|
758
|
+
return ""
|
|
759
|
+
|
|
760
|
+
def _extract_year(self, blocks: list[dict]) -> int | None:
|
|
761
|
+
# 1. page_footer — journal citation lines (most reliable)
|
|
762
|
+
for b in blocks:
|
|
763
|
+
if b.get("type") != "page_footer":
|
|
764
|
+
continue
|
|
765
|
+
text = self._extract_text_raw(b)
|
|
766
|
+
m = _YEAR_RE.search(text)
|
|
767
|
+
if m:
|
|
768
|
+
y = int(m.group())
|
|
769
|
+
if 1900 <= y <= 2100:
|
|
770
|
+
return y
|
|
771
|
+
|
|
772
|
+
# 2. page_aside_text — arXiv date stamp
|
|
773
|
+
# e.g. "arXiv:2409.02240v1 [physics.bio-ph] 3 Sep 2024"
|
|
774
|
+
# Only consider if the block looks like an arXiv header (has "arXiv" or a month name).
|
|
775
|
+
for b in blocks:
|
|
776
|
+
if b.get("type") != "page_aside_text":
|
|
777
|
+
continue
|
|
778
|
+
text = self._extract_text_raw(b)
|
|
779
|
+
if "arXiv" not in text and "arxiv" not in text:
|
|
780
|
+
continue
|
|
781
|
+
for part in text.split():
|
|
782
|
+
m = _YEAR_RE.search(part)
|
|
783
|
+
if m:
|
|
784
|
+
y = int(m.group())
|
|
785
|
+
if 2000 <= y <= 2100:
|
|
786
|
+
return y
|
|
787
|
+
|
|
788
|
+
# 3. fallback — scan first 3 pages first (abstract/body), then the rest
|
|
789
|
+
for b in blocks:
|
|
790
|
+
if b.get("_page", 999) > 2:
|
|
791
|
+
continue
|
|
792
|
+
text = self._extract_text_raw(b)
|
|
793
|
+
m = _YEAR_RE.search(text)
|
|
794
|
+
if m:
|
|
795
|
+
y = int(m.group())
|
|
796
|
+
if 2000 <= y <= 2100:
|
|
797
|
+
return y
|
|
798
|
+
for b in blocks:
|
|
799
|
+
text = self._extract_text_raw(b)
|
|
800
|
+
m = _YEAR_RE.search(text)
|
|
801
|
+
if m:
|
|
802
|
+
y = int(m.group())
|
|
803
|
+
if 2000 <= y <= 2100:
|
|
804
|
+
return y
|
|
805
|
+
return None
|
|
806
|
+
|
|
807
|
+
def _extract_doi(self, blocks: list[dict]) -> str:
|
|
808
|
+
for b in blocks:
|
|
809
|
+
text = self._extract_text_raw(b)
|
|
810
|
+
m = _DOI_RE.search(text)
|
|
811
|
+
if m:
|
|
812
|
+
return m.group().rstrip(".,;")
|
|
813
|
+
return ""
|
|
814
|
+
|
|
815
|
+
def _extract_journal(self, blocks: list[dict]) -> str:
|
|
816
|
+
candidates: list[tuple[int, str]] = []
|
|
817
|
+
|
|
818
|
+
def _scan(b: dict) -> None:
|
|
819
|
+
text = self._extract_text_raw(b)
|
|
820
|
+
if not text:
|
|
821
|
+
return
|
|
822
|
+
if "bioRxiv" in text or "medRxiv" in text:
|
|
823
|
+
m = re.search(r"(?:bioRxiv|medRxiv)", text, re.I)
|
|
824
|
+
if m:
|
|
825
|
+
nonlocal journal
|
|
826
|
+
journal = m.group()
|
|
827
|
+
upper_chars = sum(1 for c in text if c.isupper())
|
|
828
|
+
if upper_chars > len(text) * 0.5 and 2 <= len(text.split()) <= 5:
|
|
829
|
+
candidates.append((upper_chars, text))
|
|
830
|
+
|
|
831
|
+
journal = ""
|
|
832
|
+
for b in blocks:
|
|
833
|
+
if b.get("type") == "page_header":
|
|
834
|
+
_scan(b)
|
|
835
|
+
if journal:
|
|
836
|
+
return journal
|
|
837
|
+
for b in blocks:
|
|
838
|
+
if b.get("type") == "page_footer":
|
|
839
|
+
text = self._extract_text_raw(b)
|
|
840
|
+
if not text:
|
|
841
|
+
continue
|
|
842
|
+
m = re.match(
|
|
843
|
+
r"^([A-Z][a-z]+(?:\s+[A-Z][a-z]+)*)\s*\|", text
|
|
844
|
+
)
|
|
845
|
+
if m:
|
|
846
|
+
return m.group(1)
|
|
847
|
+
if candidates:
|
|
848
|
+
candidates.sort(
|
|
849
|
+
key=lambda x: (-(x[0] / max(len(x[1]), 1)), len(x[1]))
|
|
850
|
+
)
|
|
851
|
+
return candidates[0][1]
|
|
852
|
+
return ""
|
|
853
|
+
|
|
854
|
+
# ── abstract ──────────────────────────────────────────────
|
|
855
|
+
|
|
856
|
+
def _extract_abstract(self, blocks: list[dict]) -> str:
|
|
857
|
+
_META_SKIP_RES = [
|
|
858
|
+
re.compile(r"^https?://", re.I),
|
|
859
|
+
re.compile(r"^(?:Received|Accepted|Published)", re.I),
|
|
860
|
+
re.compile(r"^(?:Open access|Check for)", re.I),
|
|
861
|
+
re.compile(r"^\*?\s*(?:Corresponding|Lead|Co-)", re.I),
|
|
862
|
+
re.compile(r"^Email:", re.I),
|
|
863
|
+
re.compile(r"^\d+\s", re.I),
|
|
864
|
+
re.compile(r"^bioRxiv\s+preprint", re.I),
|
|
865
|
+
re.compile(r"^©\s", re.I),
|
|
866
|
+
re.compile(r"^\+\s", re.I),
|
|
867
|
+
re.compile(r"^Shruthi|^shruthiv", re.I),
|
|
868
|
+
]
|
|
869
|
+
|
|
870
|
+
paras: list[str] = []
|
|
871
|
+
hit_abstract_heading = False
|
|
872
|
+
authors_parsed = False
|
|
873
|
+
|
|
874
|
+
for b in blocks:
|
|
875
|
+
if self._is_title(b):
|
|
876
|
+
raw = self._extract_text(b) or ""
|
|
877
|
+
canonical, _ = self.classifier.normalize(raw)
|
|
878
|
+
if canonical == "abstract":
|
|
879
|
+
hit_abstract_heading = True
|
|
880
|
+
continue
|
|
881
|
+
if hit_abstract_heading or self._title_level(b) >= 2:
|
|
882
|
+
break
|
|
883
|
+
continue
|
|
884
|
+
|
|
885
|
+
if b.get("type") != "paragraph":
|
|
886
|
+
continue
|
|
887
|
+
|
|
888
|
+
text = self._extract_text(b)
|
|
889
|
+
if not text:
|
|
890
|
+
continue
|
|
891
|
+
|
|
892
|
+
if not authors_parsed:
|
|
893
|
+
if text.count(",") >= 2:
|
|
894
|
+
authors_parsed = True
|
|
895
|
+
continue
|
|
896
|
+
|
|
897
|
+
if any(pat.search(text) for pat in _META_SKIP_RES):
|
|
898
|
+
continue
|
|
899
|
+
|
|
900
|
+
paras.append(text)
|
|
901
|
+
if len(" ".join(paras)) > 3000:
|
|
902
|
+
break
|
|
903
|
+
|
|
904
|
+
return " ".join(paras) if paras else ""
|
|
905
|
+
|
|
906
|
+
# ── section building ──────────────────────────────────────
|
|
907
|
+
|
|
908
|
+
def _build_sections(self, blocks: list[dict]) -> list[dict]:
|
|
909
|
+
sections: list[dict] = []
|
|
910
|
+
current: dict | None = None
|
|
911
|
+
sub_current: dict | None = None
|
|
912
|
+
pending: list[str] = []
|
|
913
|
+
|
|
914
|
+
# Reset cursor for regex classifier before processing document
|
|
915
|
+
if hasattr(self.classifier, "reset_cursor"):
|
|
916
|
+
self.classifier.reset_cursor()
|
|
917
|
+
|
|
918
|
+
# Collect title blocks with context for AI batch classification
|
|
919
|
+
if isinstance(self.classifier, AISectionClassifier):
|
|
920
|
+
sections = self._build_sections_ai(blocks)
|
|
921
|
+
else:
|
|
922
|
+
sections = self._build_sections_regex(blocks)
|
|
923
|
+
|
|
924
|
+
if not sections and pending:
|
|
925
|
+
sections.append({
|
|
926
|
+
"canonical_type": "other",
|
|
927
|
+
"raw_title": "Other",
|
|
928
|
+
"display_title": "Other",
|
|
929
|
+
"level": 0,
|
|
930
|
+
"paragraphs": pending,
|
|
931
|
+
})
|
|
932
|
+
|
|
933
|
+
return sections
|
|
934
|
+
|
|
935
|
+
def _build_sections_regex(self, blocks: list[dict]) -> list[dict]:
|
|
936
|
+
"""Build sections using the regex classifier (per-title calls)."""
|
|
937
|
+
sections: list[dict] = []
|
|
938
|
+
current: dict | None = None
|
|
939
|
+
sub_current: dict | None = None
|
|
940
|
+
pending: list[str] = []
|
|
941
|
+
|
|
942
|
+
def _flush_section() -> None:
|
|
943
|
+
nonlocal current, sub_current
|
|
944
|
+
if current is not None and (
|
|
945
|
+
current.get("paragraphs") or current.get("subsections") or pending
|
|
946
|
+
):
|
|
947
|
+
if pending:
|
|
948
|
+
current.setdefault("paragraphs", []).extend(pending)
|
|
949
|
+
sections.append(current)
|
|
950
|
+
current = None
|
|
951
|
+
sub_current = None
|
|
952
|
+
|
|
953
|
+
for bi, b in enumerate(blocks):
|
|
954
|
+
if self._is_title(b):
|
|
955
|
+
raw = self._extract_text(b) or ""
|
|
956
|
+
level = self._title_level(b)
|
|
957
|
+
|
|
958
|
+
if level <= 1:
|
|
959
|
+
continue
|
|
960
|
+
|
|
961
|
+
is_sub = (
|
|
962
|
+
current is not None
|
|
963
|
+
and (
|
|
964
|
+
level >= 3
|
|
965
|
+
or bool(re.match(r"^\s*(?:\d+[\.\)]\s*){2,}", raw))
|
|
966
|
+
)
|
|
967
|
+
)
|
|
968
|
+
|
|
969
|
+
if is_sub and current is not None:
|
|
970
|
+
subs = current.setdefault("subsections", [])
|
|
971
|
+
sub_current = {
|
|
972
|
+
"raw_title": raw,
|
|
973
|
+
"display_title": raw,
|
|
974
|
+
"level": level,
|
|
975
|
+
"paragraphs": [],
|
|
976
|
+
}
|
|
977
|
+
subs.append(sub_current)
|
|
978
|
+
continue
|
|
979
|
+
|
|
980
|
+
_flush_section()
|
|
981
|
+
pending.clear()
|
|
982
|
+
|
|
983
|
+
ctx = self._collect_context(blocks, bi)
|
|
984
|
+
canonical, display = self.classifier.normalize(raw, ctx)
|
|
985
|
+
current = {
|
|
986
|
+
"canonical_type": canonical,
|
|
987
|
+
"raw_title": raw,
|
|
988
|
+
"display_title": display,
|
|
989
|
+
"level": level,
|
|
990
|
+
"paragraphs": [],
|
|
991
|
+
}
|
|
992
|
+
sub_current = None
|
|
993
|
+
continue
|
|
994
|
+
|
|
995
|
+
text = self._extract_text(b)
|
|
996
|
+
if not text:
|
|
997
|
+
continue
|
|
998
|
+
|
|
999
|
+
if sub_current is not None:
|
|
1000
|
+
sub_current["paragraphs"].append(text)
|
|
1001
|
+
elif current is not None:
|
|
1002
|
+
current["paragraphs"].append(text)
|
|
1003
|
+
else:
|
|
1004
|
+
pending.append(text)
|
|
1005
|
+
|
|
1006
|
+
if pending:
|
|
1007
|
+
pending.clear()
|
|
1008
|
+
_flush_section()
|
|
1009
|
+
return sections
|
|
1010
|
+
|
|
1011
|
+
def _build_sections_ai(self, blocks: list[dict]) -> list[dict]:
|
|
1012
|
+
"""Build sections using AI batch classification (one API call)."""
|
|
1013
|
+
# First pass: collect all title blocks
|
|
1014
|
+
title_infos: list[dict] = [] # {bi, raw, level}
|
|
1015
|
+
for bi, b in enumerate(blocks):
|
|
1016
|
+
if self._is_title(b):
|
|
1017
|
+
raw = self._extract_text(b) or ""
|
|
1018
|
+
level = self._title_level(b)
|
|
1019
|
+
if level >= 2:
|
|
1020
|
+
ctx = self._collect_context(blocks, bi)
|
|
1021
|
+
title_infos.append({
|
|
1022
|
+
"index": bi,
|
|
1023
|
+
"raw": raw,
|
|
1024
|
+
"level": level,
|
|
1025
|
+
"context": ctx,
|
|
1026
|
+
})
|
|
1027
|
+
|
|
1028
|
+
# Batch classify
|
|
1029
|
+
if title_infos:
|
|
1030
|
+
results = self.classifier.batch_normalize(title_infos)
|
|
1031
|
+
classified: dict[int, tuple[str, str]] = {
|
|
1032
|
+
title_infos[i]["index"]: results[i] for i in range(len(title_infos))
|
|
1033
|
+
}
|
|
1034
|
+
else:
|
|
1035
|
+
classified = {}
|
|
1036
|
+
|
|
1037
|
+
# Second pass: build sections
|
|
1038
|
+
sections: list[dict] = []
|
|
1039
|
+
current: dict | None = None
|
|
1040
|
+
sub_current: dict | None = None
|
|
1041
|
+
pending: list[str] = []
|
|
1042
|
+
|
|
1043
|
+
def _flush_section() -> None:
|
|
1044
|
+
nonlocal current, sub_current
|
|
1045
|
+
if current is not None and (
|
|
1046
|
+
current.get("paragraphs") or current.get("subsections") or pending
|
|
1047
|
+
):
|
|
1048
|
+
if pending:
|
|
1049
|
+
current.setdefault("paragraphs", []).extend(pending)
|
|
1050
|
+
sections.append(current)
|
|
1051
|
+
current = None
|
|
1052
|
+
sub_current = None
|
|
1053
|
+
|
|
1054
|
+
for bi, b in enumerate(blocks):
|
|
1055
|
+
if self._is_title(b):
|
|
1056
|
+
raw = self._extract_text(b) or ""
|
|
1057
|
+
level = self._title_level(b)
|
|
1058
|
+
|
|
1059
|
+
if level <= 1:
|
|
1060
|
+
continue
|
|
1061
|
+
|
|
1062
|
+
is_sub = (
|
|
1063
|
+
current is not None
|
|
1064
|
+
and (
|
|
1065
|
+
level >= 3
|
|
1066
|
+
or bool(re.match(r"^\s*(?:\d+[\.\)]\s*){2,}", raw))
|
|
1067
|
+
)
|
|
1068
|
+
)
|
|
1069
|
+
|
|
1070
|
+
if is_sub and current is not None:
|
|
1071
|
+
subs = current.setdefault("subsections", [])
|
|
1072
|
+
sub_current = {
|
|
1073
|
+
"raw_title": raw,
|
|
1074
|
+
"display_title": raw,
|
|
1075
|
+
"level": level,
|
|
1076
|
+
"paragraphs": [],
|
|
1077
|
+
}
|
|
1078
|
+
subs.append(sub_current)
|
|
1079
|
+
continue
|
|
1080
|
+
|
|
1081
|
+
_flush_section()
|
|
1082
|
+
pending.clear()
|
|
1083
|
+
|
|
1084
|
+
canonical, display = classified.get(
|
|
1085
|
+
bi, self.classifier.normalize(raw)
|
|
1086
|
+
)
|
|
1087
|
+
current = {
|
|
1088
|
+
"canonical_type": canonical,
|
|
1089
|
+
"raw_title": raw,
|
|
1090
|
+
"display_title": display,
|
|
1091
|
+
"level": level,
|
|
1092
|
+
"paragraphs": [],
|
|
1093
|
+
}
|
|
1094
|
+
sub_current = None
|
|
1095
|
+
continue
|
|
1096
|
+
|
|
1097
|
+
text = self._extract_text(b)
|
|
1098
|
+
if not text:
|
|
1099
|
+
continue
|
|
1100
|
+
|
|
1101
|
+
if sub_current is not None:
|
|
1102
|
+
sub_current["paragraphs"].append(text)
|
|
1103
|
+
elif current is not None:
|
|
1104
|
+
current["paragraphs"].append(text)
|
|
1105
|
+
else:
|
|
1106
|
+
pending.append(text)
|
|
1107
|
+
|
|
1108
|
+
if pending:
|
|
1109
|
+
pending.clear()
|
|
1110
|
+
_flush_section()
|
|
1111
|
+
return sections
|
|
1112
|
+
|
|
1113
|
+
# ── section aggregation ───────────────────────────────────
|
|
1114
|
+
|
|
1115
|
+
def _aggregate_sections(self, sections: list[dict]) -> list[dict]:
|
|
1116
|
+
merged: list[dict] = []
|
|
1117
|
+
seen: dict[str, dict] = {}
|
|
1118
|
+
|
|
1119
|
+
for sec in sections:
|
|
1120
|
+
ctype = sec["canonical_type"]
|
|
1121
|
+
if ctype != "other" and ctype in seen:
|
|
1122
|
+
parent = seen[ctype]
|
|
1123
|
+
parent["paragraphs"].extend(sec.get("paragraphs", []))
|
|
1124
|
+
subs = parent.setdefault("subsections", [])
|
|
1125
|
+
subs.append({
|
|
1126
|
+
"raw_title": sec["raw_title"],
|
|
1127
|
+
"display_title": sec["display_title"],
|
|
1128
|
+
"level": sec["level"],
|
|
1129
|
+
"paragraphs": sec.get("paragraphs", []),
|
|
1130
|
+
})
|
|
1131
|
+
elif ctype == "other":
|
|
1132
|
+
merged.append(sec)
|
|
1133
|
+
else:
|
|
1134
|
+
merged.append(sec)
|
|
1135
|
+
seen[ctype] = sec
|
|
1136
|
+
|
|
1137
|
+
# Sort by canonical order
|
|
1138
|
+
order_map = {
|
|
1139
|
+
t: i for i, t in enumerate(self.classifier.canonical_order)
|
|
1140
|
+
}
|
|
1141
|
+
merged.sort(
|
|
1142
|
+
key=lambda s: order_map.get(s["canonical_type"], len(order_map))
|
|
1143
|
+
)
|
|
1144
|
+
return merged
|
|
1145
|
+
|
|
1146
|
+
def _empty(self, json_path: Path) -> dict:
|
|
1147
|
+
return {
|
|
1148
|
+
"source": "mineru",
|
|
1149
|
+
"file": json_path.name,
|
|
1150
|
+
"backend": (
|
|
1151
|
+
"ai"
|
|
1152
|
+
if isinstance(self.classifier, AISectionClassifier)
|
|
1153
|
+
else "regex"
|
|
1154
|
+
),
|
|
1155
|
+
"metadata": {},
|
|
1156
|
+
"sections": [],
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
|
|
1160
|
+
|
|
1161
|
+
#############################################################
|
|
1162
|
+
# 2, Mineru Markdown Export
|
|
1163
|
+
#############################################################
|
|
1164
|
+
|
|
1165
|
+
|
|
1166
|
+
# ── Markdown export ───────────────────────────────────────────────────
|
|
1167
|
+
|
|
1168
|
+
def _slugify(text: str) -> str:
|
|
1169
|
+
"""Create a URL-friendly anchor slug from heading text."""
|
|
1170
|
+
value = re.sub(r"[^a-z0-9\s-]", "", text.lower())
|
|
1171
|
+
value = re.sub(r"\s+", "-", value.strip())
|
|
1172
|
+
value = re.sub(r"-+", "-", value)
|
|
1173
|
+
return value or "paper"
|
|
1174
|
+
|
|
1175
|
+
|
|
1176
|
+
def export_mineru_json_to_md(
|
|
1177
|
+
input_json: str | Path,
|
|
1178
|
+
output_md: str | Path,
|
|
1179
|
+
yaml_cfg: str | Path | None = None,
|
|
1180
|
+
) -> dict:
|
|
1181
|
+
"""Export a structured mineru JSON to a Markdown file for LLM consumption.
|
|
1182
|
+
|
|
1183
|
+
Parameters
|
|
1184
|
+
----------
|
|
1185
|
+
input_json:
|
|
1186
|
+
Path to a JSON file produced by ``MinerUContentParser.parse()``,
|
|
1187
|
+
or a directory containing multiple such files.
|
|
1188
|
+
output_md:
|
|
1189
|
+
Output Markdown file path.
|
|
1190
|
+
yaml_cfg:
|
|
1191
|
+
Optional YAML config specifying which sections to include.
|
|
1192
|
+
Example::
|
|
1193
|
+
|
|
1194
|
+
content_sections:
|
|
1195
|
+
- abstract
|
|
1196
|
+
- introduction
|
|
1197
|
+
- methods
|
|
1198
|
+
- results
|
|
1199
|
+
- discussion
|
|
1200
|
+
|
|
1201
|
+
If not provided, ALL sections are included.
|
|
1202
|
+
|
|
1203
|
+
Returns
|
|
1204
|
+
-------
|
|
1205
|
+
A dict with keys: ``total``, ``output``, ``sections_exported``.
|
|
1206
|
+
"""
|
|
1207
|
+
input_path = Path(input_json)
|
|
1208
|
+
output_path = Path(output_md)
|
|
1209
|
+
|
|
1210
|
+
# ── Collect JSON files ──
|
|
1211
|
+
json_files: list[Path] = []
|
|
1212
|
+
if input_path.is_dir():
|
|
1213
|
+
json_files = sorted(input_path.glob("*.json"))
|
|
1214
|
+
elif input_path.is_file():
|
|
1215
|
+
json_files = [input_path]
|
|
1216
|
+
else:
|
|
1217
|
+
raise FileNotFoundError(f"Input not found: {input_json}")
|
|
1218
|
+
|
|
1219
|
+
# ── Load config ──
|
|
1220
|
+
cfg = _load_yaml_config(yaml_cfg)
|
|
1221
|
+
requested_sections: list[str] | None = None
|
|
1222
|
+
if cfg:
|
|
1223
|
+
requested = cfg.get("content_sections")
|
|
1224
|
+
if isinstance(requested, list) and requested:
|
|
1225
|
+
requested_sections = [s.strip().lower() for s in requested]
|
|
1226
|
+
|
|
1227
|
+
# ── Load papers ──
|
|
1228
|
+
papers: list[dict] = []
|
|
1229
|
+
for jf in json_files:
|
|
1230
|
+
try:
|
|
1231
|
+
with open(jf, "r") as fh:
|
|
1232
|
+
papers.append(json.load(fh))
|
|
1233
|
+
except Exception:
|
|
1234
|
+
continue
|
|
1235
|
+
|
|
1236
|
+
if not papers:
|
|
1237
|
+
raise ValueError("No valid JSON files found")
|
|
1238
|
+
|
|
1239
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
1240
|
+
sections_exported: dict[str, int] = {}
|
|
1241
|
+
|
|
1242
|
+
# ── Build index entries (title → anchor) ──
|
|
1243
|
+
index_entries: list[tuple[str, str]] = []
|
|
1244
|
+
for paper in papers:
|
|
1245
|
+
meta = paper.get("metadata", {})
|
|
1246
|
+
title = meta.get("title", "Untitled").strip()
|
|
1247
|
+
index_entries.append((title, _slugify(title)))
|
|
1248
|
+
|
|
1249
|
+
with open(output_path, "w") as f:
|
|
1250
|
+
# ── Index ──
|
|
1251
|
+
if len(index_entries) > 1:
|
|
1252
|
+
f.write("# Index\n\n")
|
|
1253
|
+
for title, anchor in index_entries:
|
|
1254
|
+
f.write(f"- [{title}](#{anchor})\n")
|
|
1255
|
+
f.write("\n---\n\n")
|
|
1256
|
+
|
|
1257
|
+
# ── Papers ──
|
|
1258
|
+
for i, paper in enumerate(papers):
|
|
1259
|
+
meta = paper.get("metadata", {})
|
|
1260
|
+
title = meta.get("title", "Untitled").strip()
|
|
1261
|
+
authors = meta.get("authors", "")
|
|
1262
|
+
year = meta.get("year", "")
|
|
1263
|
+
doi = meta.get("doi", "")
|
|
1264
|
+
journal = meta.get("journal", "")
|
|
1265
|
+
anchor = _slugify(title)
|
|
1266
|
+
|
|
1267
|
+
# anchor + paper title
|
|
1268
|
+
f.write(f'<a id="{anchor}"></a>\n\n')
|
|
1269
|
+
f.write(f"# {title}\n\n")
|
|
1270
|
+
|
|
1271
|
+
# metadata block
|
|
1272
|
+
if authors:
|
|
1273
|
+
f.write(f"**Authors:** {authors}\n\n")
|
|
1274
|
+
meta_parts = []
|
|
1275
|
+
if year:
|
|
1276
|
+
meta_parts.append(f"**Year:** {year}")
|
|
1277
|
+
if journal:
|
|
1278
|
+
meta_parts.append(f"**Journal:** {journal}")
|
|
1279
|
+
if doi:
|
|
1280
|
+
meta_parts.append(f"**DOI:** {doi}")
|
|
1281
|
+
if meta_parts:
|
|
1282
|
+
f.write(" | ".join(meta_parts) + "\n\n")
|
|
1283
|
+
|
|
1284
|
+
# ── sections ──
|
|
1285
|
+
sections = paper.get("sections", [])
|
|
1286
|
+
for sec in sections:
|
|
1287
|
+
ctype = sec.get("canonical_type", "other")
|
|
1288
|
+
if requested_sections is not None and ctype not in requested_sections:
|
|
1289
|
+
continue
|
|
1290
|
+
|
|
1291
|
+
sections_exported[ctype] = sections_exported.get(ctype, 0) + 1
|
|
1292
|
+
|
|
1293
|
+
display = sec.get("display_title", ctype)
|
|
1294
|
+
f.write(f"## {display}\n\n")
|
|
1295
|
+
|
|
1296
|
+
for para in sec.get("paragraphs", []):
|
|
1297
|
+
if para.strip():
|
|
1298
|
+
f.write(f"{para}\n\n")
|
|
1299
|
+
|
|
1300
|
+
for sub in sec.get("subsections", []):
|
|
1301
|
+
sub_title = sub.get("display_title", "")
|
|
1302
|
+
f.write(f"### {sub_title}\n\n")
|
|
1303
|
+
for para in sub.get("paragraphs", []):
|
|
1304
|
+
if para.strip():
|
|
1305
|
+
f.write(f"{para}\n\n")
|
|
1306
|
+
|
|
1307
|
+
# paper separator
|
|
1308
|
+
if i < len(papers) - 1:
|
|
1309
|
+
f.write("\n---\n\n")
|
|
1310
|
+
|
|
1311
|
+
return {
|
|
1312
|
+
"total": len(papers),
|
|
1313
|
+
"output": str(output_path),
|
|
1314
|
+
"sections_exported": sections_exported,
|
|
1315
|
+
}
|