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.
@@ -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
+ }