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,958 @@
1
+ """
2
+ Paper Merger Module for pyPaperFlow
3
+
4
+ This module provides functionality to merge PubMed paper metadata and content
5
+ into unified formats optimized for AI analysis.
6
+
7
+ ⚠️ For Pubmed papers ONLY
8
+
9
+ This module provides a minimal, well-documented implementation that
10
+ performs exactly two tasks required by the user:
11
+
12
+ - Merge: scan a PubMed-style folder (pubmed/<year>/<pmid>/...) or a list
13
+ of PMIDs and produce per-paper `{PMID}.json` sidecars plus a single
14
+ merged JSON (or JSONL) file named `<source>_<timestamp>.json`.
15
+
16
+ - Export: read the merged JSON/JSONL and write a single Markdown file.
17
+
18
+ """
19
+
20
+ import os
21
+ import json
22
+ import csv
23
+ import re
24
+ from collections import Counter
25
+ from datetime import datetime
26
+ from typing import *
27
+ import yaml
28
+
29
+ # typical paper sections that we want to export
30
+ SECTION_CANONICAL_ORDER = [
31
+ # "title"
32
+ # "year"
33
+ # "authors"
34
+ 'abstract',
35
+ 'introduction',
36
+ 'results',
37
+ 'discussion',
38
+ 'methods',
39
+ 'conclusion',
40
+ 'supplementary',
41
+ 'availability',
42
+ 'funding',
43
+ 'acknowledgements',
44
+ 'author_contributions',
45
+ 'other',
46
+ ]
47
+
48
+ # lower case aliases for section titles.
49
+ # Each canonical section uses two tiers:
50
+ # - strong: exact phrases that should map immediately
51
+ # - weak: regex patterns, most are "in" matches
52
+ # The two tiers are OR'ed together during normalization.
53
+ SECTION_TITLE_ALIASES = {
54
+ 'abstract': {
55
+ 'strong': {'abstract'},
56
+ 'weak': {r'abstract', r'summary'},
57
+ },
58
+ 'introduction': {
59
+ 'strong': {'introduction', 'intro', 'background'},
60
+ 'weak': {r'introduction', r'intro', r'background'},
61
+ },
62
+ 'results': {
63
+ 'strong': {'results', 'result', 'findings', 'finding'},
64
+ 'weak': {r'result', r'finding'},
65
+ },
66
+ 'discussion': {
67
+ 'strong': {'discussion', 'discussions'},
68
+ 'weak': {r'discussion'},
69
+ },
70
+ 'methods': {
71
+ 'strong': {
72
+ 'methods',
73
+ 'method',
74
+ 'materials and methods',
75
+ 'material and methods',
76
+ 'materials & methods',
77
+ 'methodology',
78
+ },
79
+ 'weak': {
80
+ r'method',
81
+ r'material',
82
+ r'methodology',
83
+ r'materials?\s*(?:and|&)\s*methods?',
84
+ },
85
+ },
86
+ 'conclusion': {
87
+ 'strong': {'conclusion', 'conclusions', 'concluding remarks', 'summary'},
88
+ 'weak': {r'conclusion', r'remark', r'summary'},
89
+ },
90
+ 'supplementary': {
91
+ 'strong': {
92
+ 'supplementary material',
93
+ 'supplementary information',
94
+ 'supplementary data',
95
+ 'supporting information',
96
+ 'supplementary',
97
+ },
98
+ 'weak': {r'supplementary', r'supporting', r'supplement'},
99
+ },
100
+ 'availability': {
101
+ 'strong': {
102
+ 'data availability',
103
+ 'software and data availability',
104
+ 'data and code availability',
105
+ 'data availability statement',
106
+ 'data and code availability.',
107
+ 'availability and implementation',
108
+ 'availability',
109
+ },
110
+ 'weak': {
111
+ r'data',
112
+ r'software',
113
+ r'code',
114
+ r'availability'
115
+ },
116
+ },
117
+ 'funding': {
118
+ 'strong': {'funding'},
119
+ 'weak': {r'funding', r'financial'},
120
+ },
121
+ 'acknowledgements': {
122
+ 'strong': {'acknowledgements', 'acknowledgments', 'acknowledgment'},
123
+ 'weak': {r'acknowledgement', r'thank', r'expression'},
124
+ },
125
+ 'author_contributions': {
126
+ 'strong': {
127
+ 'author contributions',
128
+ 'authors contributions',
129
+ 'author contribution',
130
+ 'authors contribution',
131
+ },
132
+ 'weak': {
133
+ r'author',
134
+ r'contribution',
135
+ r'statement',
136
+ },
137
+ },
138
+ }
139
+
140
+ # display names for section titles (for better readability in the Markdown output)
141
+ # only shown in the Markdown output(final output)
142
+ SECTION_DISPLAY_NAMES = {
143
+ 'abstract': 'Abstract',
144
+ 'introduction': 'Introduction',
145
+ 'results': 'Results',
146
+ 'discussion': 'Discussion',
147
+ 'methods': 'Methods',
148
+ 'conclusion': 'Conclusion',
149
+ 'supplementary': 'Supplementary Material',
150
+ 'availability': 'Data Availability',
151
+ 'funding': 'Funding',
152
+ 'acknowledgements': 'Acknowledgements',
153
+ 'author_contributions': 'Author Contributions',
154
+ 'other': 'Other',
155
+ }
156
+
157
+ def _timestamp() -> str:
158
+ return datetime.now().strftime('%Y-%m-%d_%H-%M-%S')
159
+
160
+
161
+ class PubmedMerger:
162
+ """
163
+ Description
164
+ -----------
165
+ Minimal merger with two public methods: merge_json and export_md.
166
+ JSON/JSONL is for structured data storage and potential downstream use,
167
+ while Markdown is for human-readable output and LLM input(the most important part).
168
+
169
+ Usage:
170
+ merger = PubmedMerger()
171
+ merger.merge_json_from_directory(paper_dir, output_path, pmid_file=None)
172
+ merger.export_md_from_merged_json(merged_json, output_md, yaml_cfg=None)
173
+ """
174
+
175
+ def merge_json_from_directory(
176
+ self,
177
+ paper_dir: str,
178
+ output_json: str,
179
+ pmid_file: Optional[str] = None,
180
+ jsonl: bool = False,
181
+ ) -> Dict[str, Any]:
182
+ """
183
+ Description
184
+ -----------
185
+ Merge per-paper JSONs.
186
+
187
+ - If `pmid_file` is provided, only PMIDs in that file are merged.
188
+ But the premise is that the pmid_file's PMIDs must be present in the directory structure.
189
+ If `pmid_file` is not provided, all discovered PMIDs in `paper_dir` are merged.
190
+ - For each discovered paper we write `{PMID}.json` next to the
191
+ paper's files (sidecar).
192
+ - A single merged JSON (array) or JSONL is written to `output_json`.
193
+
194
+
195
+ Args
196
+ ----
197
+ paper_dir: Directory containing per-paper subdirectories (e.g. pubmed/2023/12345678/, withput pubmed/ containing)
198
+ output_json: Path to the output merged JSON/JSONL file, or a directory to auto-name the output, default is current directory with auto-naming
199
+ pmid_file: Optional path to a file containing a list of PMIDs to merge
200
+ jsonl: If True, write output as JSONL instead of JSON
201
+
202
+ Returns
203
+ -------
204
+ A small stats dict.
205
+
206
+ Example return value:
207
+ {
208
+ 'total': 0, of PMIDs found in directory (after optional filtering by pmid_file)
209
+ 'meta_missing': ['12345678'], of papers that failed to merge in theory (missing meta JSON), we do not count the files failing at merge part
210
+ 'content_missing': ['12345678'], of papers with missing content JSON
211
+ }
212
+ """
213
+
214
+ pmids: Optional[List[str]] = None
215
+ if pmid_file:
216
+ # read PMIDs from file, if provided (filter), list like ['12345678', '23456789', ...]
217
+ pmids = self._read_pmids_from_file(pmid_file)
218
+
219
+ # discover PMIDs and their directories, list like [('12345678', '/path/to/paper_dir/pubmed/2023/12345678'), ...]
220
+ pairs = self._iter_pmid_directories(paper_dir)
221
+
222
+ # define stats
223
+ # list for pmid records
224
+ meta_missing = []
225
+ content_missing = []
226
+ # number of papers to process in total
227
+ total_papers = len(pmids) if pmids else len(pairs)
228
+
229
+ # build selected list filtered by pmid_file (if provided), and load paper data
230
+ papers: List[Dict[str, Any]] = []
231
+ for pmid, pmid_dir in pairs:
232
+ if pmids is not None and pmid not in pmids:
233
+ continue
234
+
235
+ # load paper data from meta JSON and content JSON (if exists)
236
+ # paper is like {
237
+ # 'pmid': '12345678',
238
+ # 'meta': {...}, # loaded from 12345678_meta.json
239
+ # 'content': {...} # loaded from 12345678_content.json
240
+ # }
241
+ paper = self._load_paper_data(pmid_dir, pmid)
242
+ # meta and content are both empty, skip (no useful info to merge)
243
+ if not paper['meta']:
244
+ meta_missing.append(pmid)
245
+ continue
246
+ elif not paper['content']:
247
+ content_missing.append(pmid)
248
+
249
+ # write sidecar {PMID}.json
250
+ try:
251
+ with open(os.path.join(pmid_dir, f"{pmid}.json"), 'w') as fh:
252
+ json.dump(paper, fh, ensure_ascii=False, indent=2)
253
+ except Exception:
254
+ # best-effort: do not fail merge for a single write error
255
+ pass
256
+ papers.append(paper)
257
+
258
+ # resolve output path (if directory or no ext -> auto name)
259
+ # output_json can be a file path or a directory
260
+ out = output_json
261
+ # directory existed or non-existent path without extension (treat as directory)
262
+ # /data2/pyPaperFlow/test/full_paper_test or /data2/pyPaperFlow/test/Not-Exist-Folder/
263
+ if os.path.isdir(output_json) or not os.path.splitext(output_json)[1]:
264
+ # paper_dir is not pubmed/ containing
265
+ source_name = os.path.basename(os.path.normpath(paper_dir))
266
+ suffix = '.jsonl' if jsonl else '.json'
267
+ out = os.path.join(output_json, f"{source_name}_{_timestamp()}{suffix}")
268
+
269
+ # write merged output
270
+ # note that papers are list of dict
271
+ try:
272
+ # ensure output directory exists, default is current directory
273
+ os.makedirs(os.path.dirname(out) or '.', exist_ok=True)
274
+ if jsonl:
275
+ # list of dict -> JSONL with one JSON object per line, one paper per line
276
+ with open(out, 'w') as fh:
277
+ for p in papers:
278
+ fh.write(json.dumps(p, ensure_ascii=False) + '\n')
279
+ else:
280
+ # json
281
+ # here we create pmid: i in papers
282
+ with open(out, 'w') as fh:
283
+ papers_json_dict = {p.get('pmid'): p for p in papers}
284
+ json.dump(papers_json_dict, fh, ensure_ascii=False, indent=2)
285
+ except Exception as e:
286
+ raise RuntimeError(f"Failed to write merged JSON: {e}")
287
+
288
+ # write the stats
289
+ # actually we care more about the content missing than meta missing, cause the later is less likely to happen
290
+ # ⚠️ and we can use the pmids missing content to fetch the article again by DOI module
291
+ stats = {
292
+ 'total': total_papers,
293
+ 'meta_missing': meta_missing,
294
+ 'content_missing': content_missing
295
+ }
296
+ return stats
297
+
298
+ def export_md_from_merged_json(
299
+ self,
300
+ merged_json: str,
301
+ output_md: str,
302
+ yaml_cfg: Optional[str] = None,
303
+ pmid_file: Optional[str] = None,
304
+ ) -> Dict[str, Any]:
305
+ """
306
+ Description
307
+ -----------
308
+ Export a single Markdown file from merged JSON/JSONL.
309
+
310
+ Args
311
+ ----
312
+ merged_json: Path to the merged JSON or JSONL file produced by merge_json_from_directory
313
+ output_md: Path to the output Markdown file
314
+ yaml_cfg: Optional path to a YAML config file specifying which metadata fields and content sections to include in the Markdown output. If not provided, defaults to including 'identity.title' and 'identity.pmid' for metadata, and 'abstract' for content.
315
+
316
+ `yaml_cfg` may specify:
317
+ metadata_fields: ["identity.title", "identity.pmid"]
318
+ content_sections: ["abstract", "introduction"]
319
+
320
+ pmid_file: Optional path to a file containing a list of PMIDs to filter the papers by. If not provided, defaults to including all papers. Note that the PMIDs in this file must be present in the merged JSON for them to be included in the output.
321
+
322
+ Notes
323
+ -----
324
+ - ⚠️ 1. export_md_from_merged_json must be called after merge_json_from_directory, we design it like this to ensure the merged JSON/JSONL file is up-to-date.
325
+ """
326
+
327
+ # papers now is list of dict loaded from merged_json,
328
+ # each dict is like {'pmid': '12345678', 'meta': {...}, 'content': {...} }
329
+ papers = self._load_merged(merged_json)
330
+
331
+ if pmid_file:
332
+ filter_pmids = set(self._read_pmids_from_file(pmid_file))
333
+ # filter papers by pmid_file
334
+ papers = [p for p in papers if str(p.get('pmid', '')) in filter_pmids]
335
+
336
+ cfg = {}
337
+ if yaml_cfg:
338
+ with open(yaml_cfg, 'r') as yf:
339
+ cfg = yaml.safe_load(yf) or {}
340
+
341
+ use_yaml_layout = bool(yaml_cfg)
342
+ # call config yaml first, if not exist, use default values for metadata only
343
+ if use_yaml_layout:
344
+ metadata_fields = cfg.get('metadata_fields', ['identity.title', 'identity.pmid', 'identity.doi', 'content.keywords', 'content.mesh_terms', 'content.pub_types', 'content.abstract'])
345
+ content_sections = cfg.get('content_sections', ['abstract', 'introduction', 'methods', 'discussion', 'conclusion', 'availability'])
346
+ else:
347
+ # No YAML means raw meta + raw content tree. Keep metadata lean to avoid
348
+ # duplicating abstract/content sections that will be rendered below.
349
+ metadata_fields = cfg.get('metadata_fields', ['identity.title', 'identity.pmid', 'identity.doi', 'content.keywords', 'content.mesh_terms', 'content.pub_types'])
350
+ content_sections = []
351
+
352
+ # write markdown
353
+ os.makedirs(os.path.dirname(output_md) or '.', exist_ok=True)
354
+ section_summary = Counter()
355
+ raw_title_summary = Counter()
356
+
357
+ def slugify(text: str) -> str:
358
+ """
359
+ Description
360
+ -----------
361
+ create a slug for markdown anchor from the heading text,
362
+ like 'PMID 12345678 - Title of the Paper' -> 'pmid-12345678-title-of-the-paper'
363
+ """
364
+ value = re.sub(r'[^a-z0-9\s-]', '', text.lower())
365
+ value = re.sub(r'\s+', '-', value.strip())
366
+ value = re.sub(r'-+', '-', value)
367
+ return value or 'paper'
368
+
369
+ def resolve_field(paper: Dict[str, Any], path: str) -> Any:
370
+ value = self._get_by_path(paper.get('meta', {}), path)
371
+ if value is not None:
372
+ return value
373
+
374
+ if path.startswith('content.'):
375
+ tail = path.split('.', 1)[1]
376
+ content = paper.get('content')
377
+ if isinstance(content, dict):
378
+ value = content.get(tail)
379
+ if value is not None:
380
+ return value
381
+
382
+ if tail == 'abstract':
383
+ body_nodes = content.get('body')
384
+ if isinstance(body_nodes, list):
385
+ for node in body_nodes:
386
+ if not isinstance(node, dict):
387
+ continue
388
+ if self._normalize_section_title(node.get('title')) != 'abstract':
389
+ continue
390
+ paragraphs = node.get('content') or []
391
+ if isinstance(paragraphs, str):
392
+ return paragraphs.strip() or None
393
+ if isinstance(paragraphs, list):
394
+ joined = '\n\n'.join(str(item).strip() for item in paragraphs if str(item).strip())
395
+ if joined:
396
+ return joined
397
+ break
398
+
399
+ return None
400
+
401
+ index_entries: List[tuple[str, str, str]] = []
402
+ for p in papers:
403
+ pmid = str(self._get_by_path(p['meta'], 'identity.pmid') or p.get('pmid', 'N/A'))
404
+ title = str(self._get_by_path(p['meta'], 'identity.title') or 'N/A')
405
+ heading_text = f'PMID {pmid} - {title}' if title != 'N/A' else f'PMID {pmid}'
406
+ # anchor for markdown link
407
+ index_entries.append((pmid, title, slugify(heading_text)))
408
+
409
+ with open(output_md, 'w') as f:
410
+ for pmid, title, anchor in index_entries:
411
+ display = f'PMID {pmid} - {title}' if title != 'N/A' else f'PMID {pmid}'
412
+ f.write(f'- [{display}](#{anchor})\n')
413
+
414
+ if index_entries:
415
+ f.write('\n---\n\n')
416
+
417
+ for i, p in enumerate(papers, 1):
418
+ # i for paper index, p for paper dict
419
+ pmid = str(self._get_by_path(p['meta'], 'identity.pmid') or p.get('pmid', 'N/A'))
420
+ title = str(self._get_by_path(p['meta'], 'identity.title') or 'N/A')
421
+ heading_text = f'PMID {pmid} - {title}' if title != 'N/A' else f'PMID {pmid}'
422
+ anchor = slugify(heading_text)
423
+ content = p.get('content') if isinstance(p.get('content'), dict) else {}
424
+ body_nodes = content.get('body') if isinstance(content, dict) else []
425
+ metadata_fields_to_write = list(metadata_fields)
426
+ if not use_yaml_layout and not body_nodes:
427
+ metadata_fields_to_write.append('content.abstract')
428
+
429
+ f.write(f'<a id="{anchor}"></a>\n\n')
430
+ f.write(f'# PMID {pmid} - {title}\n\n')
431
+
432
+ # flat metadata block
433
+ for mf in metadata_fields_to_write:
434
+ label = mf.split('.')[-1].replace('_', ' ').title() # default label is the last part
435
+ val = resolve_field(p, mf)
436
+ if val is None:
437
+ continue
438
+ if isinstance(val, list):
439
+ val = ', '.join(str(x) for x in val)
440
+ f.write(f'## {label}\n\n{val}\n\n')
441
+
442
+ if use_yaml_layout:
443
+ # YAML provided: canonicalize only first-level body nodes.
444
+ section_records = self._extract_section_records(p)
445
+ for record in section_records:
446
+ section_summary[record['canonical_type']] += 1
447
+ raw_title_summary[record['raw_title'] or 'N/A'] += 1
448
+ for record in section_records:
449
+ # skip canonical abstract (handled via metadata/resolve_field)
450
+ if record.get('canonical_type') == 'abstract':
451
+ continue
452
+ # only render sections requested in the YAML config
453
+ if record.get('canonical_type') not in content_sections:
454
+ continue
455
+ self._render_section_records(f, [record], base_level=2)
456
+ else:
457
+ # No YAML provided: render raw meta + raw content tree with no canonical mapping.
458
+ section_records = self._extract_section_records(p)
459
+ for record in section_records:
460
+ section_summary[record['canonical_type']] += 1
461
+ raw_title_summary[record['raw_title'] or 'N/A'] += 1
462
+ self._render_raw_section_nodes(f, body_nodes, level=2)
463
+
464
+ if i < len(papers):
465
+ f.write('\n<!-- PAPER_BREAK -->\n\n---\n\n')
466
+
467
+ return {
468
+ 'total': len(papers),
469
+ 'output': output_md,
470
+ 'section_summary': dict(section_summary),
471
+ 'raw_title_summary': dict(raw_title_summary),
472
+ }
473
+
474
+ # ---- helpers ----
475
+ def _load_merged(self, path: str) -> List[Dict[str, Any]]:
476
+ """
477
+ Description
478
+ -----------
479
+ Load merged JSON/JSONL file.
480
+
481
+ Args
482
+ ----
483
+ path: Path to the merged JSON or JSONL file produced by merge_json_from_directory.
484
+
485
+ Returns
486
+ -------
487
+ A list of paper dicts loaded from the merged file. If the file does not exist or cannot be parsed, returns an empty list.
488
+ Like [ {'pmid': '12345678','meta': {...},'content': {...}},... ]
489
+ """
490
+
491
+ if not os.path.exists(path):
492
+ return []
493
+ try:
494
+ if path.endswith('.json'):
495
+ with open(path, 'r') as fh:
496
+ # data now is a dict of {pmid: paper}
497
+ data = json.load(fh)
498
+ if isinstance(data, dict):
499
+ return list(data.values())
500
+ elif path.endswith('.jsonl'):
501
+ out = []
502
+ with open(path, 'r') as fh:
503
+ for line in fh:
504
+ line = line.strip()
505
+ if not line:
506
+ continue
507
+ try:
508
+ out.append(json.loads(line))
509
+ except Exception:
510
+ continue
511
+ return out
512
+ else:
513
+ raise ValueError("Unsupported file format for merged_json. Only .json and .jsonl are supported.")
514
+ except Exception:
515
+ return []
516
+
517
+ def _get_by_path(self, data: Dict[str, Any], path: str) -> Any:
518
+ """
519
+ Description
520
+ -----------
521
+ Safely get a nested value from a dict using a dot-separated path.
522
+
523
+ Args
524
+ ----
525
+ data: The dict to search.
526
+ path: The dot-separated path to the value to get.
527
+
528
+ Returns
529
+ -------
530
+ The value at the specified path, or None if any part of the path is not found or if the path leads to a non-dict.
531
+ """
532
+ parts = path.split('.')
533
+ cur = data
534
+ for p in parts:
535
+ if not isinstance(cur, dict):
536
+ return None
537
+ cur = cur.get(p)
538
+ if cur is None:
539
+ return None
540
+ return cur
541
+
542
+ def _normalize_section_title(self, title: Any) -> str:
543
+ """
544
+ Description
545
+ -----------
546
+ Normalize section title to a canonical form.
547
+
548
+ Args
549
+ ----
550
+ title: The raw section title to normalize. (key of the node in the tree: title)
551
+
552
+ Returns
553
+ -------
554
+ A canonical section type string, like 'abstract', 'introduction', 'methods', etc. If the title cannot be matched to any known section, returns 'other'.
555
+ """
556
+
557
+ canonical_type, _ = self._match_section_title_from_cursor(title, 0)
558
+ return canonical_type or 'other'
559
+
560
+ def _match_section_title_from_cursor(self, title: Any, start_index: int = 0) -> tuple[Optional[str], int]:
561
+ """
562
+ Description
563
+ -----------
564
+ Match a title against canonical sections starting from a given cursor.
565
+
566
+ Returns
567
+ -------
568
+ A pair of (canonical_type, next_cursor). If no match is found, returns
569
+ (None, start_index) so the caller can keep the cursor in place.
570
+ """
571
+
572
+ # normalize the title
573
+ text = re.sub(r'\s+', ' ', str(title or '').strip())
574
+ text = text.strip(' .:-—–\t\n\r')
575
+ lower = text.lower()
576
+
577
+ section_names = [name for name in SECTION_CANONICAL_ORDER if name != 'other']
578
+ if start_index < 0:
579
+ start_index = 0
580
+
581
+ # 1) exact alias table first: cheapest and safest.
582
+ for index in range(start_index, len(section_names)):
583
+ canonical = section_names[index]
584
+ aliases = SECTION_TITLE_ALIASES.get(canonical, {})
585
+ strong_aliases = aliases.get('strong', set())
586
+ weak_aliases = aliases.get('weak', set())
587
+ if lower == canonical or lower in strong_aliases:
588
+ return canonical, index + 1
589
+
590
+ for pattern in weak_aliases:
591
+ if re.search(pattern, lower, flags=re.IGNORECASE):
592
+ return canonical, index + 1
593
+
594
+ return None, start_index
595
+
596
+ def _display_section_title(self, canonical_type: str) -> str:
597
+ return SECTION_DISPLAY_NAMES.get(canonical_type, canonical_type.replace('_', ' ').title())
598
+
599
+ def _escape_md_table(self, text: Any) -> str:
600
+ value = str(text or '')
601
+ return value.replace('|', '\\|').replace('\n', ' ').strip()
602
+
603
+ def _candidate_body_nodes(self, paper: Dict[str, Any]) -> List[Dict[str, Any]]:
604
+ """
605
+ Description
606
+ -----------
607
+ Find the body nodes in the paper dictionary.
608
+
609
+ Args
610
+ ----
611
+ paper: The paper dictionary.
612
+
613
+ Returns
614
+ -------
615
+ A list of body nodes.
616
+ """
617
+
618
+ # papers now is list of dict loaded from merged_json,
619
+ # each dict is like {'pmid': '12345678', 'meta': {...}, 'content': {...} }
620
+ candidates = [
621
+ paper.get('content'),
622
+ paper.get('meta'),
623
+ (paper.get('meta') or {}).get('content') if isinstance(paper.get('meta'), dict) else None,
624
+ ]
625
+
626
+ for candidate in candidates:
627
+ if isinstance(candidate, list) and candidate:
628
+ return candidate
629
+ if isinstance(candidate, dict):
630
+ for key in ('body', 'sections', 'content'):
631
+ nested = candidate.get(key)
632
+ if isinstance(nested, list) and nested:
633
+ return nested
634
+
635
+ return []
636
+
637
+ def _extract_section_records(
638
+ self,
639
+ paper: Dict[str, Any],
640
+ ) -> List[Dict[str, Any]]:
641
+ body_nodes = self._candidate_body_nodes(paper)
642
+
643
+ def copy_raw_tree(nodes: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
644
+ copied: List[Dict[str, Any]] = []
645
+ for node in nodes or []:
646
+ if not isinstance(node, dict):
647
+ continue
648
+
649
+ copied.append({
650
+ 'title': node.get('title'),
651
+ 'content': node.get('content'),
652
+ 'subsections': copy_raw_tree(node.get('subsections') or []),
653
+ })
654
+ return copied
655
+
656
+ records: List[Dict[str, Any]] = []
657
+ cursor = 0
658
+ for node in body_nodes or []:
659
+ if not isinstance(node, dict):
660
+ continue
661
+
662
+ raw_title = str(node.get('title') or 'N/A').strip()
663
+ canonical_type, next_cursor = self._match_section_title_from_cursor(raw_title, cursor)
664
+ if canonical_type is None:
665
+ canonical_type = 'other'
666
+ else:
667
+ cursor = next_cursor
668
+
669
+ paragraphs = node.get('content') or []
670
+ if isinstance(paragraphs, str):
671
+ paragraphs = [paragraphs]
672
+ paragraphs = [str(item).strip() for item in paragraphs if str(item).strip()]
673
+
674
+ records.append({
675
+ 'raw_title': raw_title,
676
+ 'canonical_type': canonical_type,
677
+ 'display_title': self._display_section_title(canonical_type),
678
+ 'path': raw_title,
679
+ 'depth': 0,
680
+ 'paragraphs': paragraphs,
681
+ 'paragraph_count': len(paragraphs),
682
+ 'children': copy_raw_tree(node.get('subsections') or []),
683
+ })
684
+
685
+ return records
686
+
687
+
688
+ def _flatten_section_records(self, records: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
689
+ flattened: List[Dict[str, Any]] = []
690
+
691
+ def recurse(items: List[Dict[str, Any]]) -> None:
692
+ for item in items or []:
693
+ flattened.append(item)
694
+ recurse(item.get('children', []))
695
+
696
+ recurse(records)
697
+ return flattened
698
+
699
+ def _render_raw_section_nodes(
700
+ self,
701
+ handle: Any,
702
+ sections: List[Dict[str, Any]],
703
+ level: int = 2,
704
+ ) -> None:
705
+ for sec in sections or []:
706
+ if not isinstance(sec, dict):
707
+ continue
708
+
709
+ title = str(sec.get('title') or 'No Title').strip() or 'No Title'
710
+ heading_level = '#' * max(2, min(level, 6))
711
+ handle.write(f'\n{heading_level} {title}\n\n')
712
+
713
+ content = sec.get('content') or []
714
+ if isinstance(content, str):
715
+ content = [content]
716
+ for paragraph in content:
717
+ clean_para = str(paragraph).replace('\n', ' ').strip()
718
+ if clean_para:
719
+ handle.write(f' {clean_para}\n\n')
720
+
721
+ self._render_raw_section_nodes(handle, sec.get('subsections', []), level=level + 1)
722
+
723
+ def _order_section_records(
724
+ self,
725
+ records: List[Dict[str, Any]],
726
+ section_order: List[str],
727
+ ) -> List[Dict[str, Any]]:
728
+ order_index = {name: idx for idx, name in enumerate(section_order or SECTION_CANONICAL_ORDER)}
729
+
730
+ def sort_key(item: Dict[str, Any]) -> tuple[int, int]:
731
+ return (order_index.get(item.get('canonical_type', 'other'), len(order_index)), item.get('depth', 0))
732
+
733
+ # Preserve intra-paper order as much as possible while keeping common sections first.
734
+ return sorted(records, key=sort_key)
735
+
736
+ def _aggregate_section_records(self, records: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
737
+ """
738
+ Aggregate multiple section records that share the same canonical_type into
739
+ a single record. This preserves the first-seen ordering from `records`
740
+ and concatenates paragraphs and children for non-'other' canonical types.
741
+ 'other' sections are kept separate to preserve their distinct titles.
742
+ """
743
+ aggregated: List[Dict[str, Any]] = []
744
+ seen: Dict[str, Dict[str, Any]] = {}
745
+
746
+ for rec in records or []:
747
+ ctype = rec.get('canonical_type', 'other') or 'other'
748
+ if ctype != 'other' and ctype in seen:
749
+ target = seen[ctype]
750
+ target_pars = target.setdefault('paragraphs', [])
751
+ target_children = target.setdefault('children', [])
752
+ target_pars.extend(rec.get('paragraphs', []) or [])
753
+ target_children.extend(rec.get('children', []) or [])
754
+ target['paragraph_count'] = target.get('paragraph_count', 0) + rec.get('paragraph_count', 0)
755
+ else:
756
+ # shallow copy to avoid mutating the original record list
757
+ new_rec = {
758
+ 'raw_title': rec.get('raw_title'),
759
+ 'canonical_type': rec.get('canonical_type'),
760
+ 'display_title': rec.get('display_title'),
761
+ 'path': rec.get('path'),
762
+ 'depth': rec.get('depth', 0),
763
+ 'paragraphs': list(rec.get('paragraphs', []) or []),
764
+ 'paragraph_count': rec.get('paragraph_count', 0),
765
+ 'children': list(rec.get('children', []) or []),
766
+ }
767
+ aggregated.append(new_rec)
768
+ if ctype != 'other':
769
+ seen[ctype] = new_rec
770
+
771
+ return aggregated
772
+
773
+ def _render_section_records(
774
+ self,
775
+ handle: Any,
776
+ records: List[Dict[str, Any]],
777
+ base_level: int = 2,
778
+ prev_heading: Optional[str] = None
779
+ ) -> None:
780
+ for record in records or []:
781
+ level = base_level + int(record.get('depth', 0))
782
+ heading_level = '#' * max(2, min(level, 6))
783
+ display_title = record.get('display_title') or self._display_section_title(record.get('canonical_type', 'other'))
784
+ raw_title = record.get('raw_title') or ''
785
+ if record.get('canonical_type') == 'other' and raw_title:
786
+ heading = raw_title
787
+ else:
788
+ heading = display_title
789
+
790
+ heading_line = f'{heading_level} {heading}'.strip()
791
+ # skip writing the heading if it's identical to the previous written heading
792
+ if heading_line != prev_heading:
793
+ handle.write(f'{heading_line}\n\n')
794
+ prev_heading = heading_line
795
+
796
+ for paragraph in record.get('paragraphs', []):
797
+ handle.write(f'{paragraph}\n\n')
798
+ if record.get('children'):
799
+ # children remain raw tree nodes and are appended under this top-level section.
800
+ self._render_raw_section_nodes(handle, record['children'], level=level + 1)
801
+
802
+ def _read_pmids_from_file(self, file_path: str) -> List[str]:
803
+ """
804
+ Description
805
+ -----------
806
+ Read PMIDs from a file.
807
+
808
+ Args
809
+ ----
810
+ file_path: Path to a text or csv file containing PMIDs.
811
+
812
+ Returns
813
+ -------
814
+ A list of PMIDs as strings. If the file does not exist, returns an empty list.
815
+ """
816
+
817
+ pmids: List[str] = []
818
+ if not os.path.exists(file_path):
819
+ return pmids
820
+ # for csv file
821
+ if file_path.endswith('.csv'):
822
+ with open(file_path, 'r', encoding='utf-8') as f:
823
+ for row in csv.reader(f):
824
+ if row:
825
+ pmids.append(str(row[0]).strip())
826
+ return pmids
827
+ # for tsv or plain text file
828
+ with open(file_path, 'r', encoding='utf-8') as f:
829
+ for line in f:
830
+ v = line.strip()
831
+ if v:
832
+ pmids.append(v)
833
+ return pmids
834
+
835
+ def _iter_pmid_directories(self, paper_dir: str) -> List[tuple[str, str]]:
836
+ """
837
+ Description
838
+ -----------
839
+ Yield (pmid, pmid_dir) pairs for two common layouts.
840
+
841
+ Args
842
+ ----
843
+ paper_dir: Root directory to scan for PMIDs. Only accepts paper_dir(without pubmed)/pubmed/<year>/<pmid>/
844
+
845
+ Returns
846
+ -------
847
+ A list of (pmid, pmid_dir) tuples for discovered PMIDs, like ('12345678', '/path/to/paper_dir/pubmed/2023/12345678'). If no PMIDs are found, returns an empty list.
848
+ """
849
+
850
+ pairs: List[tuple[str, str]] = []
851
+ if not os.path.isdir(paper_dir):
852
+ return pairs
853
+
854
+ # paper_dir(without pubmed)/pubmed/<year>/<pmid>/
855
+ # like /data2/pyPaperFlow/test/full_paper_test'
856
+ if 'pubmed' in os.listdir(paper_dir):
857
+ paper_dir = os.path.join(paper_dir, 'pubmed')
858
+
859
+ # year subfolders within pubmed/
860
+ entries = sorted(os.listdir(paper_dir))
861
+ # year subfolders
862
+ for e in entries:
863
+ year_dir = os.path.join(paper_dir, e)
864
+ if not os.path.isdir(year_dir):
865
+ continue
866
+ for pm in sorted(os.listdir(year_dir)):
867
+ pm_dir = os.path.join(year_dir, pm)
868
+ # pm is PMID
869
+ if os.path.isdir(pm_dir) and pm.isdigit():
870
+ pairs.append((pm, pm_dir))
871
+ return pairs
872
+
873
+ def _resolve_paper_files(self, pmid_dir: str, pmid: str) -> Dict[str, Optional[str]]:
874
+ """
875
+ Description
876
+ -----------
877
+ Resolve file paths for a given PMID directory.
878
+
879
+ Args
880
+ ----
881
+ pmid_dir: Directory containing the paper's files (e.g. pubmed/2023/12345678/)
882
+ pmid: The PMID of the paper (e.g. '12345678')
883
+ ('12345678', '/path/to/paper_dir/pubmed/2023/12345678')
884
+
885
+ Returns
886
+ -------
887
+ A dict with keys 'meta_json', 'content_json', 'content_md' and values as the resolved file paths or None if not found.
888
+
889
+ """
890
+
891
+ # simple detection of common file names
892
+ candidates = {
893
+ 'meta_json': f'{pmid}_meta.json',
894
+ 'content_json': f'{pmid}_content.json',
895
+ 'content_md': f'{pmid}_content.md',
896
+ }
897
+ resolved = {}
898
+ for k, name in candidates.items():
899
+ # in most cases, the meta JSON exits but content JSON/MD may be missing, so we do not fail if they are not found. We just set them to None in the resolved dict.
900
+ resolved[k] = None
901
+ # ('12345678', '/path/to/paper_dir/pubmed/2023/12345678') / '12345678_meta.json'
902
+ p = os.path.join(pmid_dir, name)
903
+ if os.path.exists(p):
904
+ # {'meta_json': '/path/to/paper_dir/pubmed/2023/12345678/12345678_meta.json', ...}
905
+ resolved[k] = p
906
+ return resolved
907
+
908
+ def _load_paper_data(self, pmid_dir: str, pmid: str) -> Optional[Dict[str, Any]]:
909
+ """
910
+ Description
911
+ -----------
912
+ Load paper data from a PMID directory.
913
+
914
+ Args
915
+ ----
916
+ pmid_dir: Directory containing the paper's files (e.g. pubmed/2023/12345678/)
917
+ pmid: The PMID of the paper (e.g. '12345678')
918
+ ('12345678', '/path/to/paper_dir/pubmed/2023/12345678')
919
+
920
+ Returns
921
+ -------
922
+ A dict containing the paper's data, with keys 'meta' and 'content'.
923
+ 'meta' is loaded from the meta JSON file if it exists, otherwise an empty dict.
924
+ 'content' is loaded from the content JSON file if it exists, otherwise an empty dict.
925
+ """
926
+
927
+ # paths is a dict like {'meta_json': '/path/to/paper_dir/pubmed/2023/12345678/12345678_meta.json', 'content_json': None, 'content_md': None}
928
+ paths = self._resolve_paper_files(pmid_dir, pmid)
929
+
930
+ # meta and meta_json
931
+ meta = paths.get('meta_json', None)
932
+ if meta:
933
+ try:
934
+ with open(meta, 'r') as fh:
935
+ meta_json = json.load(fh)
936
+ except Exception:
937
+ meta_json = {}
938
+ else:
939
+ meta_json = {}
940
+
941
+ # content and content_json
942
+ content = paths.get('content_json', None)
943
+ if content:
944
+ try:
945
+ with open(content, 'r') as fh:
946
+ content_json = json.load(fh)
947
+ except Exception:
948
+ content_json = {}
949
+ else:
950
+ content_json = {}
951
+
952
+ # Compose a small canonical dict
953
+ paper = {
954
+ 'pmid': pmid,
955
+ 'meta': meta_json,
956
+ 'content': content_json
957
+ }
958
+ return paper