fetchpdf 0.1.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.
Files changed (64) hide show
  1. fetchpdf/__init__.py +49 -0
  2. fetchpdf/__main__.py +6 -0
  3. fetchpdf/_env.py +63 -0
  4. fetchpdf/_http.py +40 -0
  5. fetchpdf/fetch_abstract_from_doi.py +549 -0
  6. fetchpdf/fetch_metadata_from_doi.py +225 -0
  7. fetchpdf/fetchpdf.py +7339 -0
  8. fetchpdf/retrieval/__init__.py +36 -0
  9. fetchpdf/retrieval/_util.py +150 -0
  10. fetchpdf/retrieval/artifact.py +87 -0
  11. fetchpdf/retrieval/backends/__init__.py +109 -0
  12. fetchpdf/retrieval/backends/claude_cli.py +111 -0
  13. fetchpdf/retrieval/backends/openrouter.py +244 -0
  14. fetchpdf/retrieval/cache.py +123 -0
  15. fetchpdf/retrieval/chunk.py +195 -0
  16. fetchpdf/retrieval/classify.py +110 -0
  17. fetchpdf/retrieval/context.py +69 -0
  18. fetchpdf/retrieval/corresponding.py +349 -0
  19. fetchpdf/retrieval/engine.py +666 -0
  20. fetchpdf/retrieval/extract_images.py +593 -0
  21. fetchpdf/retrieval/fulltext_scan.py +472 -0
  22. fetchpdf/retrieval/goals.py +145 -0
  23. fetchpdf/retrieval/http.py +669 -0
  24. fetchpdf/retrieval/identifiers.py +149 -0
  25. fetchpdf/retrieval/ladder.json +362 -0
  26. fetchpdf/retrieval/linked_artifacts.py +144 -0
  27. fetchpdf/retrieval/llm_adjudicate.py +278 -0
  28. fetchpdf/retrieval/llm_agent_retrieval.py +495 -0
  29. fetchpdf/retrieval/normalize.py +301 -0
  30. fetchpdf/retrieval/pdf_identity.py +533 -0
  31. fetchpdf/retrieval/pdf_text.py +242 -0
  32. fetchpdf/retrieval/provenance.py +185 -0
  33. fetchpdf/retrieval/ratelimit.py +113 -0
  34. fetchpdf/retrieval/repository_waf.py +152 -0
  35. fetchpdf/retrieval/request_drafts.py +304 -0
  36. fetchpdf/retrieval/resolve.py +481 -0
  37. fetchpdf/retrieval/sources/__init__.py +15 -0
  38. fetchpdf/retrieval/sources/arxiv.py +102 -0
  39. fetchpdf/retrieval/sources/core_text.py +64 -0
  40. fetchpdf/retrieval/sources/crossref_tdm.py +193 -0
  41. fetchpdf/retrieval/sources/epmc.py +133 -0
  42. fetchpdf/retrieval/sources/landing.py +34 -0
  43. fetchpdf/retrieval/sources/legacy_pdf.py +99 -0
  44. fetchpdf/retrieval/sources/pmc.py +59 -0
  45. fetchpdf/retrieval/sources/preprint.py +101 -0
  46. fetchpdf/retrieval/sources/publisher_html.py +154 -0
  47. fetchpdf/retrieval/sources/supplements.py +183 -0
  48. fetchpdf/retrieval/supplement_atypon.py +269 -0
  49. fetchpdf/retrieval/supplement_dspace.py +187 -0
  50. fetchpdf/retrieval/supplement_graph.py +842 -0
  51. fetchpdf/retrieval/supplement_index.py +820 -0
  52. fetchpdf/retrieval/supplement_pmc.py +351 -0
  53. fetchpdf/retrieval/supplement_publishers.py +374 -0
  54. fetchpdf/retrieval/supplementary.py +1770 -0
  55. fetchpdf/retrieval/tiers.py +213 -0
  56. fetchpdf/retrieval/to_markdown.py +661 -0
  57. fetchpdf/retrieval/validate.py +368 -0
  58. fetchpdf/retrieval/verify.py +149 -0
  59. fetchpdf-0.1.0.dist-info/METADATA +1392 -0
  60. fetchpdf-0.1.0.dist-info/RECORD +64 -0
  61. fetchpdf-0.1.0.dist-info/WHEEL +5 -0
  62. fetchpdf-0.1.0.dist-info/entry_points.txt +5 -0
  63. fetchpdf-0.1.0.dist-info/licenses/LICENSE +21 -0
  64. fetchpdf-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,472 @@
1
+ """Find the paper's own data deposits by reading the paper.
2
+
3
+ Link services (ScholeXplorer, Europe PMC datalinks) answer "what does an index
4
+ say is connected to this DOI". That misses a great deal: across 13 full-text
5
+ records in a real corpus they surfaced zero of the 14 OSF deposits the articles
6
+ themselves name in their own prose. The article is the better source, and it is
7
+ already on disk -- so this provider costs no network requests at all.
8
+
9
+ The catch is precision. A bare URL regex over full text is only about HALF
10
+ right, because the same page also contains:
11
+
12
+ * preregistrations "preregistration: https://osf.io/2efnw"
13
+ * OTHER papers' deposits a reference-list entry citing someone's archive
14
+ * preprint DOIs 10.31234/osf.io/8r9p7 is PsyArXiv, not a deposit
15
+
16
+ Downloading those is the "dirty directories" failure: gigabytes of somebody
17
+ else's material filed under this paper. So three rules run over the prose around
18
+ each URL, and a candidate is accepted only when the sentence actually claims a
19
+ deposit. On the 14 hand-labelled OSF IDs this scores 7/7 accepted and 7/7
20
+ refused -- though those rules were written after reading those examples, so
21
+ treat the number as a regression baseline rather than a validated accuracy.
22
+
23
+ What this deliberately does NOT do is guess. A candidate the rules cannot settle
24
+ is returned as UNCERTAIN rather than silently dropped, so the caller can pay a
25
+ model to adjudicate it (see llm_adjudicate) or record it unresolved. Losing a
26
+ deposit silently is the one failure mode worth engineering against: an extra
27
+ download is visible and cheap, a missing one is invisible.
28
+ """
29
+
30
+ import os
31
+ import re
32
+ from dataclasses import dataclass, field
33
+ from typing import Dict, List, Optional
34
+
35
+ #: Verdicts. UNCERTAIN is a real answer, not a failure -- it is the queue that
36
+ #: optional LLM adjudication drains.
37
+ ACCEPT = "accept"
38
+ REFUSE = "refuse"
39
+ UNCERTAIN = "uncertain"
40
+
41
+ #: How much prose around a URL is read. 300 chars each way covers the sentence
42
+ #: and its neighbour, which is where "deposited"/"preregistration" live, without
43
+ #: dragging in the next paragraph's unrelated claims.
44
+ CONTEXT_CHARS = 300
45
+
46
+ #: Repositories worth resolving. Keyed by the name _files_in_repository expects.
47
+ #: GitHub is matched but handled separately -- see _GITHUB below.
48
+ _REPO_PATTERNS = (
49
+ ("osf", re.compile(r"osf\.io/([a-z0-9]{5})\b", re.I)),
50
+ ("zenodo", re.compile(r"zenodo\.org/record[s]?/(\d+)", re.I)),
51
+ ("zenodo", re.compile(r"\b(10\.5281/zenodo\.\d+)", re.I)),
52
+ ("dryad", re.compile(r"\b(10\.5061/dryad\.[a-z0-9./]+)", re.I)),
53
+ ("dataverse", re.compile(r"\b(10\.7910/dvn/[a-z0-9]+)", re.I)),
54
+ ("figshare", re.compile(r"figshare\.com/[^\s\"'<>)]*?(\d{6,})", re.I)),
55
+ ("github", re.compile(r"github\.com/([A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+)", re.I)),
56
+ )
57
+
58
+ #: Rule 1 -- the sentence has to claim a deposit. Without one of these the URL
59
+ #: is just a URL: mentioned in passing, in a footnote, or somebody else's.
60
+ _DEPOSIT_LANGUAGE = re.compile(
61
+ r"(deposited|data\s+availability|code\s+availability|are\s+available|"
62
+ r"is\s+available|publicly\s+available|available\s+(at|on|from)|"
63
+ r"analysis\s+(code|scripts)|materials\s+and\s+data|data\s+and\s+(code|materials)|"
64
+ r"online\s+archive|can\s+be\s+(found|accessed)|have\s+been\s+shared|"
65
+ r"osf\s+project)",
66
+ re.I,
67
+ )
68
+
69
+ #: Rule 2 -- a preregistration is not data. Papers routinely link both, and the
70
+ #: prereg is the one that is NOT the analysis dataset.
71
+ #:
72
+ #: This must match the URL's PURPOSE, not merely the word appearing nearby. A
73
+ #: deposit's contents are often listed as "preregistrations, the analysis code,
74
+ #: anonymized data ... available as an OSF project at <url>" -- one link
75
+ #: holding everything, including a prereg. Matching a bare "prereg" there
76
+ #: refused a real deposit. So require the word to be attached to the link:
77
+ #: "preregistration: <url>", "preregistered at <url>", "(preregistration <url>)".
78
+ _PREREG_LANGUAGE = re.compile(
79
+ r"(prereg\w*\s*(:|at|is|was|available|link|page|here)?\s*[\(\[]?\s*$|"
80
+ r"pre-regist\w*\s*(:|at)?\s*[\(\[]?\s*$|"
81
+ r"analysis\s+plan\s*[\(\[:]?\s*$|"
82
+ r"registered\s+report\s*[\(\[:]?\s*$)",
83
+ re.I,
84
+ )
85
+
86
+ #: A deposit that merely CONTAINS a preregistration among other things is still
87
+ #: a deposit. When both signals fire, this one wins.
88
+ _MULTI_ARTIFACT_DEPOSIT = re.compile(
89
+ r"(all\s+materials|materials\s+includ\w+|includ\w+\s+.{0,80}"
90
+ r"(analysis\s+code|anonymized\s+data|data\s+and\s+code)|"
91
+ r"(analysis\s+code|anonymi[sz]ed\s+data)\s*,)",
92
+ re.I,
93
+ )
94
+
95
+ #: Rule 3 -- inside a reference/citation the deposit belongs to the work being
96
+ #: CITED, not to this paper. JATS marks these structurally, which is why this
97
+ #: is reliable rather than a guess.
98
+ _CITATION_CONTEXT = re.compile(
99
+ r"(<ref\b|</ref>|<mixed-citation|<element-citation|citation-string|"
100
+ r"ref-list|<nlm-citation|\[Preprint\])",
101
+ re.I,
102
+ )
103
+
104
+ #: Preprint-server DOIs that embed "osf.io" but name a PREPRINT, not a deposit.
105
+ #: Without this, 10.31234/osf.io/8r9p7 is scraped as OSF id "8r9p7" and
106
+ #: downloaded. 10.31219 is OSF Preprints' own prefix and was missing: a live
107
+ #: corpus reference list carries "OSF https://doi.org/10.31219/osf.io/k268q",
108
+ #: which this scan would otherwise have fetched as somebody else's deposit.
109
+ _PREPRINT_DOI = re.compile(r"10\.(?:31234|31219)/", re.I)
110
+
111
+ #: Rule 3 boost -- a URL inside a data-availability section is as strong a
112
+ #: signal as prose gets. Not required (only 4 of 13 records have such a
113
+ #: section), but it upgrades an otherwise uncertain candidate.
114
+ _DAS_SECTION = re.compile(
115
+ r"(data[\s-]*availability|code[\s-]*availability|"
116
+ r"data,\s*materials,?\s*and\s*software\s*availability|<ack\b)",
117
+ re.I,
118
+ )
119
+
120
+ #: Well-known tool orgs. A backstop under both the rules and any LLM verdict:
121
+ #: these are libraries the authors USED, never the paper's own deposit.
122
+ _TOOL_ORGS = frozenset({
123
+ "stan-dev", "tidyverse", "numpy", "scipy", "pandas-dev", "rstudio",
124
+ "matplotlib", "scikit-learn", "statsmodels", "pytorch", "tensorflow",
125
+ "jupyter", "ipython", "r-lib", "rstan", "pymc-devs", "huggingface",
126
+ "apache", "microsoft", "google", "facebook", "openai",
127
+ })
128
+
129
+
130
+ @dataclass
131
+ class Candidate:
132
+ """One repository reference found in a paper, and what we decided about it."""
133
+
134
+ repo: str # "osf", "zenodo", "github", ...
135
+ ident: str # the id/DOI as matched
136
+ url: str # normalized, resolvable
137
+ verdict: str = UNCERTAIN
138
+ reason: str = ""
139
+ context: str = "" # prose that drove the verdict; audit trail
140
+ in_das: bool = False
141
+ adjudicated_by: Optional[str] = None # set when an LLM overrode the rules
142
+ extra: Dict[str, str] = field(default_factory=dict)
143
+
144
+ @property
145
+ def key(self) -> str:
146
+ return f"{self.repo}:{self.ident.lower()}"
147
+
148
+
149
+ _TAG = re.compile(r"<[^>]*>")
150
+ #: The window is a slice, so its final tag is usually CUT OFF mid-attribute
151
+ #: ('<ext-link xlink:href="') and has no closing bracket for _TAG to match.
152
+ #: That unterminated remnant is exactly what sits between the prereg word and
153
+ #: the URL, so it has to go too or the anchored test can never fire.
154
+ _OPEN_TAG_TAIL = re.compile(r"<[^<>]*$")
155
+ _URL_TAIL = re.compile(r"https?:(//)?\S*$", re.I)
156
+
157
+
158
+ def _strip_tags(fragment: str) -> str:
159
+ """Prose only: closed tags, the trailing half-tag, and any dangling URL.
160
+
161
+ All three matter for the anchored prereg test, which asks whether the word
162
+ is attached to THIS link rather than merely present in the paragraph.
163
+ """
164
+ text = _TAG.sub(" ", fragment)
165
+ text = _OPEN_TAG_TAIL.sub(" ", text)
166
+ text = " ".join(text.split())
167
+ return _URL_TAIL.sub("", text).rstrip()
168
+
169
+
170
+ def _normalize(repo: str, ident: str) -> str:
171
+ """A URL the downstream enumerators can actually resolve."""
172
+ if repo == "osf":
173
+ return f"https://osf.io/{ident}/"
174
+ if repo == "zenodo":
175
+ if ident.lower().startswith("10.5281"):
176
+ return f"https://doi.org/{ident}"
177
+ # As the DOI, not the web URL: the downstream enumerator matches
178
+ # r"zenodo\.(\d+)", which a /records/<id> path does not satisfy.
179
+ return f"https://doi.org/10.5281/zenodo.{ident}"
180
+ if repo == "dryad":
181
+ return f"https://doi.org/{ident}"
182
+ if repo == "dataverse":
183
+ return f"https://doi.org/{ident}"
184
+ if repo == "figshare":
185
+ # Same reason as Zenodo: the enumerator matches r"figshare\.(\d+)",
186
+ # which a /articles/<id> path does not satisfy.
187
+ return f"https://doi.org/10.6084/m9.figshare.{ident}"
188
+ if repo == "github":
189
+ return f"https://github.com/{ident}"
190
+ return ident
191
+
192
+
193
+ def _judge(repo: str, ident: str, context: str, in_das: bool,
194
+ in_citation: bool = False, before: str = ""):
195
+ """Apply the three rules to one candidate's surrounding prose.
196
+
197
+ `in_citation` and `before` are both computed from what PRECEDES the URL,
198
+ not from the whole window -- see _inside_citation for why that distinction
199
+ is load-bearing. `before` is the ~120 chars immediately before the link.
200
+ """
201
+ # Rule 3a: a PsyArXiv DOI is a preprint that merely contains "osf.io".
202
+ if _PREPRINT_DOI.search(context):
203
+ return REFUSE, "PsyArXiv preprint DOI, not a data deposit"
204
+
205
+ # Rule 3b: inside a reference, the deposit belongs to the cited work.
206
+ if in_citation:
207
+ return REFUSE, "appears inside a reference/citation entry"
208
+
209
+ # Rule 2: a preregistration is not the analysis data -- but only when the
210
+ # word is attached to THIS link (`before` is the text immediately preceding
211
+ # the URL), and not when the sentence describes a multi-artifact deposit
212
+ # that merely includes a prereg alongside the data.
213
+ if _PREREG_LANGUAGE.search(before) and not _MULTI_ARTIFACT_DEPOSIT.search(context):
214
+ return REFUSE, "preregistration, not a data deposit"
215
+
216
+ if repo == "github":
217
+ owner = ident.split("/", 1)[0].lower()
218
+ if owner in _TOOL_ORGS:
219
+ return REFUSE, f"{owner} is a known tool/library org"
220
+ # GitHub is where the rules are weakest: "we used X" and "our code is
221
+ # at X" look alike outside a DAS. Only a data-availability section is
222
+ # strong enough on its own; everything else goes to adjudication.
223
+ if in_das and _DEPOSIT_LANGUAGE.search(context):
224
+ return ACCEPT, "code repository named in a data/code availability section"
225
+ return UNCERTAIN, "GitHub link outside a data-availability section"
226
+
227
+ # Rule 1: for deposit repositories, deposit language is the signal.
228
+ if _DEPOSIT_LANGUAGE.search(context):
229
+ where = "data-availability section" if in_das else "deposit language nearby"
230
+ return ACCEPT, f"deposit repository with {where}"
231
+
232
+ return UNCERTAIN, "repository URL with no deposit language nearby"
233
+
234
+
235
+ #: Markers that OPEN and CLOSE a reference entry. Direction matters, so these
236
+ #: are separate from the loose _CITATION_CONTEXT scan.
237
+ _CITATION_OPEN = re.compile(r"<(ref|mixed-citation|element-citation|nlm-citation)\b", re.I)
238
+ _CITATION_CLOSE = re.compile(r"</(ref|mixed-citation|element-citation|nlm-citation)>", re.I)
239
+
240
+
241
+ def _inside_citation(text: str, position: int) -> bool:
242
+ """True when the URL at `position` sits inside a reference entry.
243
+
244
+ Looking for "<ref" anywhere in a symmetric window is wrong, and wrongly
245
+ refused a real deposit: PNAS puts the data-availability sentence
246
+ immediately before <ref-list>, so a citation tag 200 characters AFTER the
247
+ URL disqualified prose that plainly said "publicly available online as an
248
+ OSF project at ...".
249
+
250
+ What actually matters is whether the nearest citation delimiter BEFORE the
251
+ URL is an opening tag. If it is, we are inside an entry; if it is a closing
252
+ tag (or there is none), we are in body prose.
253
+ """
254
+ window = text[max(0, position - 4000):position]
255
+ last_open = None
256
+ for match in _CITATION_OPEN.finditer(window):
257
+ last_open = match.start()
258
+ last_close = None
259
+ for match in _CITATION_CLOSE.finditer(window):
260
+ last_close = match.start()
261
+ if last_open is None:
262
+ return False
263
+ if last_close is None:
264
+ return True
265
+ return last_open > last_close
266
+
267
+
268
+ #: A bibliography heading standing alone on its line. Markdown hashes and a
269
+ #: leading section number are allowed because that is how a PDF-to-text pass
270
+ #: and a converted body render one. Deliberately NOT matched inside markup: in
271
+ #: JATS the same word arrives as <title>References</title>, which is
272
+ #: _inside_citation's job and is decided structurally rather than by a heading.
273
+ _REFERENCE_HEADING = re.compile(
274
+ r"^[ \t]{0,3}(?:#{1,6}[ \t]*)?(?:\d{0,2}[.)][ \t]*)?"
275
+ r"(references|bibliography|works\s+cited|literature\s+cited|"
276
+ r"reference\s+list)[ \t]*:?[ \t]*$",
277
+ re.I | re.M,
278
+ )
279
+
280
+
281
+ def _after_reference_heading(text: str, position: int) -> bool:
282
+ """True when the URL at `position` sits in the bibliography of plain text.
283
+
284
+ The plain-text sibling of `_inside_citation`, and it exists because that
285
+ function is built entirely on JATS tags: on a PDF or a markdown body it
286
+ returns False at every position, so every reference-list URL becomes a live
287
+ candidate. That is the "dirty directories" failure -- somebody else's
288
+ deposit downloaded and filed under this paper.
289
+
290
+ POSITION-BASED, NOT TRUNCATING, for the reason
291
+ `test_deposit_before_a_reference_list_is_accepted` records: availability
292
+ sentences sit immediately before the bibliography, so cutting the document
293
+ at the heading discards the very prose this scan exists to read.
294
+
295
+ And a data-availability heading BETWEEN the bibliography and the URL
296
+ re-opens the document. Journals that put availability statements in back
297
+ matter after the references are common enough that a blanket "everything
298
+ after References is a citation" rule would refuse the strongest signal
299
+ there is.
300
+ """
301
+ last_end = None
302
+ for match in _REFERENCE_HEADING.finditer(text, 0, position):
303
+ last_end = match.end()
304
+ if last_end is None:
305
+ return False
306
+ return not _DAS_SECTION.search(text[last_end:position])
307
+
308
+
309
+ def _section_is_das(text: str, position: int) -> bool:
310
+ """True when the URL sits under a data/code-availability heading.
311
+
312
+ Looks back a bounded distance for the nearest heading-ish marker rather
313
+ than parsing JATS: the scan has to work on HTML too, where the structure
314
+ differs, and a bounded window is the same answer for far less machinery.
315
+ """
316
+ window = text[max(0, position - 1200):position]
317
+ return bool(_DAS_SECTION.search(window))
318
+
319
+
320
+ def scan_text(text: str) -> List[Candidate]:
321
+ """Every repository reference in one document, judged.
322
+
323
+ Deduplicated by (repo, id): a deposit named five times is one candidate,
324
+ and it keeps the STRONGEST verdict any of its mentions earned -- a URL
325
+ given properly in the data-availability statement is not disqualified by
326
+ also appearing in the reference list.
327
+ """
328
+ if not text:
329
+ return []
330
+
331
+ best: Dict[str, Candidate] = {}
332
+ rank = {ACCEPT: 2, UNCERTAIN: 1, REFUSE: 0}
333
+
334
+ for repo, pattern in _REPO_PATTERNS:
335
+ for match in pattern.finditer(text):
336
+ ident = match.group(1)
337
+ if not ident:
338
+ continue
339
+ if repo == "github":
340
+ # Strip a trailing ".git" and any path fragment beyond owner/repo.
341
+ ident = ident.rsplit(".git", 1)[0]
342
+ start = match.start()
343
+ context = text[max(0, start - CONTEXT_CHARS):start + CONTEXT_CHARS]
344
+ context = " ".join(context.split())
345
+ in_das = _section_is_das(text, start)
346
+ # Tags are stripped before the "is the prereg word attached to
347
+ # THIS link" test: in JATS the URL lives inside
348
+ # <ext-link xlink:href="..."> so 60+ characters of markup sit
349
+ # between "preregistration:" and the link, and an anchored match
350
+ # against the raw text never fires.
351
+ before = _strip_tags(text[max(0, start - 200):start])
352
+ verdict, reason = _judge(
353
+ repo, ident, context, in_das,
354
+ in_citation=(_inside_citation(text, start)
355
+ or _after_reference_heading(text, start)),
356
+ before=before)
357
+
358
+ candidate = Candidate(
359
+ repo=repo, ident=ident, url=_normalize(repo, ident),
360
+ verdict=verdict, reason=reason, context=context[:600],
361
+ in_das=in_das,
362
+ )
363
+ previous = best.get(candidate.key)
364
+ if previous is None or rank[verdict] > rank[previous.verdict]:
365
+ best[candidate.key] = candidate
366
+
367
+ return sorted(best.values(), key=lambda c: (c.repo, c.ident))
368
+
369
+
370
+ #: Markup artifacts whose text is worth scanning. These come first because
371
+ #: they carry the structural citation signal rule 3 depends on -- in markup we
372
+ #: know we are inside a reference entry, on plain text we can only infer it.
373
+ #: Suffixes rather than extensions: the concat below is literal, so
374
+ #: `.fulltext.html` and a converted `_from_pdf_body.md` are reached the same
375
+ #: way a bare `.xml` is.
376
+ _SCANNABLE = (".xml", ".nxml", ".html", ".htm", ".fulltext.html",
377
+ "_from_pdf_body.md", "_from_xml.md", "_from_html.md")
378
+
379
+ #: Read only if no markup was found. A PDF's text is the same prose with the
380
+ #: structure thrown away, so scanning it alongside the XML would add nothing
381
+ #: but a second chance to misjudge the reference list.
382
+ _SCANNABLE_PDF = (".pdf",)
383
+
384
+
385
+ def _read_markup(stem: str):
386
+ """`(path, text)` for every markup artifact on disk for this record."""
387
+ for suffix in _SCANNABLE:
388
+ path = stem + suffix
389
+ if not os.path.exists(path):
390
+ continue
391
+ try:
392
+ with open(path, encoding="utf-8", errors="replace") as handle:
393
+ yield path, handle.read()
394
+ except OSError:
395
+ continue
396
+
397
+
398
+ def _read_pdf(stem: str, log=None):
399
+ """`(path, text)` for the record's PDF, when there is text to be had.
400
+
401
+ Silence here would be the project's own worst failure mode -- a machine
402
+ with no PDF engine would report "scanned, nothing found" for every
403
+ PDF-only record. So the two ways this comes back empty are logged with
404
+ different sentences: no engine on this machine, versus no text layer in
405
+ this document.
406
+ """
407
+ from .pdf_text import engine_status, pdf_text
408
+
409
+ for suffix in _SCANNABLE_PDF:
410
+ path = stem + suffix
411
+ if not os.path.exists(path):
412
+ continue
413
+ engine, detail = engine_status()
414
+ if engine is None:
415
+ if log:
416
+ log(f" fulltext_scan: {detail}")
417
+ return
418
+ text = pdf_text(path)
419
+ if not text:
420
+ if log:
421
+ log(f" fulltext_scan: {os.path.basename(path)} has no text "
422
+ f"layer ({engine} read nothing)")
423
+ return
424
+ yield path, text
425
+
426
+
427
+ def record_text(stem: str, log=None):
428
+ """`(path, text)` for the one source `scan_record` would read, or None.
429
+
430
+ Public because the retrieval agent needs exactly the same text and exactly
431
+ the same markup-beats-PDF choice. Two readers disagreeing about what "the
432
+ paper" is would be a subtle way for the agent to judge prose the scan never
433
+ saw.
434
+ """
435
+ for path, text in _read_markup(stem):
436
+ return path, text
437
+ for path, text in _read_pdf(stem, log=log):
438
+ return path, text
439
+ return None
440
+
441
+
442
+ def scan_record(stem: str, log=None) -> List[Candidate]:
443
+ """Scan whatever full text is already on disk for one record.
444
+
445
+ `stem` is the output path stem (no extension), matching how the rest of the
446
+ supplementary subsystem addresses a record.
447
+
448
+ Markup wins when both exist. The PDF is the fallback, not a supplement to
449
+ the XML, because it is the same prose with the citation structure stripped:
450
+ scanning both would only give the weaker reading a second vote.
451
+ """
452
+ candidates: Dict[str, Candidate] = {}
453
+ rank = {ACCEPT: 2, UNCERTAIN: 1, REFUSE: 0}
454
+
455
+ sources = list(_read_markup(stem))
456
+ if not sources:
457
+ sources = list(_read_pdf(stem, log=log))
458
+
459
+ for _path, text in sources:
460
+ for candidate in scan_text(text):
461
+ previous = candidates.get(candidate.key)
462
+ if previous is None or rank[candidate.verdict] > rank[previous.verdict]:
463
+ candidates[candidate.key] = candidate
464
+ return sorted(candidates.values(), key=lambda c: (c.repo, c.ident))
465
+
466
+
467
+ def accepted(candidates: List[Candidate]) -> List[Candidate]:
468
+ return [c for c in candidates if c.verdict == ACCEPT]
469
+
470
+
471
+ def uncertain(candidates: List[Candidate]) -> List[Candidate]:
472
+ return [c for c in candidates if c.verdict == UNCERTAIN]
@@ -0,0 +1,145 @@
1
+ """What a single walk of the ladder is trying to collect.
2
+
3
+ The tier walk was winner-take-all: best format, write it, stop. That is right when
4
+ a consumer reads one artifact, and wrong for the ones that read two.
5
+ agent_for_forensic_metascience takes (body_md, main_pdf, si_pdfs) -- it needs the
6
+ PDF for supplements, figures and the rendered page -- while the reason to want XML
7
+ alongside it is that the glyph-corruption class its tests quarantine papers over
8
+ (broken ToUnicode CMaps mapping '-' to '2' and '=' to '5') is a PDF-only disease
9
+ that markup does not have.
10
+
11
+ So a walk now fills a set of GOALS rather than returning the first hit. The default
12
+ is a one-goal set, which reduces to exactly the old behaviour -- there is one code
13
+ path, not a flag-selected fork.
14
+
15
+ A goal names the tiers that can fill it, in preference order. Tiers, not sources:
16
+ which sources serve a tier stays in ladder.json, so reordering sources there needs
17
+ no change here.
18
+ """
19
+
20
+ from dataclasses import dataclass, field
21
+ from typing import List, Optional, Sequence
22
+
23
+ from .tiers import Tier
24
+
25
+
26
+ @dataclass
27
+ class Goal:
28
+ """One slot to fill, and what filled it."""
29
+
30
+ name: str
31
+ tiers: Sequence[Tier]
32
+ path: Optional[str] = None
33
+ artifact: object = None # retrieval.artifact.Artifact once filled
34
+ preexisting: bool = False # found on disk rather than fetched now
35
+
36
+ @property
37
+ def filled(self) -> bool:
38
+ return self.path is not None
39
+
40
+ def accepts(self, tier: Tier) -> bool:
41
+ return tier in self.tiers
42
+
43
+ def fill(self, path: str, artifact=None, preexisting: bool = False) -> None:
44
+ self.path = path
45
+ self.artifact = artifact
46
+ self.preexisting = preexisting
47
+
48
+
49
+ @dataclass
50
+ class GoalSet:
51
+ """The goals for one walk, plus the questions the engine asks of them."""
52
+
53
+ goals: List[Goal] = field(default_factory=list)
54
+
55
+ def __iter__(self):
56
+ return iter(self.goals)
57
+
58
+ def __len__(self):
59
+ return len(self.goals)
60
+
61
+ @property
62
+ def complete(self) -> bool:
63
+ return all(g.filled for g in self.goals)
64
+
65
+ @property
66
+ def any_filled(self) -> bool:
67
+ return any(g.filled for g in self.goals)
68
+
69
+ def unfilled(self) -> List[Goal]:
70
+ return [g for g in self.goals if not g.filled]
71
+
72
+ def goal_for(self, tier: Tier) -> Optional[Goal]:
73
+ """The first UNFILLED goal this tier can fill.
74
+
75
+ Unfilled matters: without it a second XML artifact would overwrite the
76
+ first, and the walk would never move on to the PDF.
77
+ """
78
+ for goal in self.goals:
79
+ if not goal.filled and goal.accepts(tier):
80
+ return goal
81
+ return None
82
+
83
+ def by_name(self, name: str) -> Optional[Goal]:
84
+ return next((g for g in self.goals if g.name == name), None)
85
+
86
+ def wanted_tiers(self) -> List[Tier]:
87
+ """Every tier any unfilled goal would still accept."""
88
+ wanted = []
89
+ for goal in self.unfilled():
90
+ for tier in goal.tiers:
91
+ if tier not in wanted:
92
+ wanted.append(tier)
93
+ return wanted
94
+
95
+ def filled_paths(self) -> List[str]:
96
+ return [g.path for g in self.goals if g.filled]
97
+
98
+ def summary(self) -> str:
99
+ """'xml+pdf', or 'pdf (structured: none)' -- for the per-record log line."""
100
+ got = [g.name for g in self.goals if g.filled]
101
+ missing = [g.name for g in self.goals if not g.filled]
102
+ if not missing:
103
+ return "+".join(got)
104
+ return "{} ({}: none)".format("+".join(got) or "nothing", ",".join(missing))
105
+
106
+
107
+ #: The structured half of the collect: best markup available, XML before HTML.
108
+ #: T3 LaTeX is deliberately NOT here -- a source tarball is not something a
109
+ #: table-extraction pass can read without a TeX toolchain, so it stays a
110
+ #: winner-take-all tier rather than a slot to fill alongside the PDF.
111
+ _STRUCTURED_TIERS = (Tier.T1_XML, Tier.T2_HTML)
112
+
113
+
114
+ def single_best(tiers: Sequence[Tier]) -> GoalSet:
115
+ """The default: one goal that any tier in the ladder can fill.
116
+
117
+ Equivalent to the original stop-at-first-hit walk, expressed as a goal set so
118
+ the engine has one loop instead of two.
119
+ """
120
+ return GoalSet([Goal(name="best", tiers=tuple(tiers))])
121
+
122
+
123
+ def structured_and_pdf(tiers: Sequence[Tier]) -> GoalSet:
124
+ """--get-xml-or-html: a structured copy AND the PDF.
125
+
126
+ Ordered structured-first so that when both are still open, a validated T1
127
+ artifact is filed as the structured goal rather than being consumed by a
128
+ broader one.
129
+ """
130
+ structured = [t for t in _STRUCTURED_TIERS if t in tiers]
131
+ pdf = [t for t in (Tier.T5_PDF,) if t in tiers]
132
+ goals = []
133
+ if structured:
134
+ goals.append(Goal(name="structured", tiers=tuple(structured)))
135
+ if pdf:
136
+ goals.append(Goal(name="pdf", tiers=tuple(pdf)))
137
+ # A ladder restricted to neither (nothing but T6/T7, say) would give an empty
138
+ # goal set and a walk that accepts nothing. Fall back to the default rather
139
+ # than silently failing every record.
140
+ return GoalSet(goals) if goals else single_best(tiers)
141
+
142
+
143
+ def build(tiers: Sequence[Tier], get_xml_or_html: bool = False) -> GoalSet:
144
+ """The goal set for this walk."""
145
+ return structured_and_pdf(tiers) if get_xml_or_html else single_best(tiers)