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.
- fetchpdf/__init__.py +49 -0
- fetchpdf/__main__.py +6 -0
- fetchpdf/_env.py +63 -0
- fetchpdf/_http.py +40 -0
- fetchpdf/fetch_abstract_from_doi.py +549 -0
- fetchpdf/fetch_metadata_from_doi.py +225 -0
- fetchpdf/fetchpdf.py +7339 -0
- fetchpdf/retrieval/__init__.py +36 -0
- fetchpdf/retrieval/_util.py +150 -0
- fetchpdf/retrieval/artifact.py +87 -0
- fetchpdf/retrieval/backends/__init__.py +109 -0
- fetchpdf/retrieval/backends/claude_cli.py +111 -0
- fetchpdf/retrieval/backends/openrouter.py +244 -0
- fetchpdf/retrieval/cache.py +123 -0
- fetchpdf/retrieval/chunk.py +195 -0
- fetchpdf/retrieval/classify.py +110 -0
- fetchpdf/retrieval/context.py +69 -0
- fetchpdf/retrieval/corresponding.py +349 -0
- fetchpdf/retrieval/engine.py +666 -0
- fetchpdf/retrieval/extract_images.py +593 -0
- fetchpdf/retrieval/fulltext_scan.py +472 -0
- fetchpdf/retrieval/goals.py +145 -0
- fetchpdf/retrieval/http.py +669 -0
- fetchpdf/retrieval/identifiers.py +149 -0
- fetchpdf/retrieval/ladder.json +362 -0
- fetchpdf/retrieval/linked_artifacts.py +144 -0
- fetchpdf/retrieval/llm_adjudicate.py +278 -0
- fetchpdf/retrieval/llm_agent_retrieval.py +495 -0
- fetchpdf/retrieval/normalize.py +301 -0
- fetchpdf/retrieval/pdf_identity.py +533 -0
- fetchpdf/retrieval/pdf_text.py +242 -0
- fetchpdf/retrieval/provenance.py +185 -0
- fetchpdf/retrieval/ratelimit.py +113 -0
- fetchpdf/retrieval/repository_waf.py +152 -0
- fetchpdf/retrieval/request_drafts.py +304 -0
- fetchpdf/retrieval/resolve.py +481 -0
- fetchpdf/retrieval/sources/__init__.py +15 -0
- fetchpdf/retrieval/sources/arxiv.py +102 -0
- fetchpdf/retrieval/sources/core_text.py +64 -0
- fetchpdf/retrieval/sources/crossref_tdm.py +193 -0
- fetchpdf/retrieval/sources/epmc.py +133 -0
- fetchpdf/retrieval/sources/landing.py +34 -0
- fetchpdf/retrieval/sources/legacy_pdf.py +99 -0
- fetchpdf/retrieval/sources/pmc.py +59 -0
- fetchpdf/retrieval/sources/preprint.py +101 -0
- fetchpdf/retrieval/sources/publisher_html.py +154 -0
- fetchpdf/retrieval/sources/supplements.py +183 -0
- fetchpdf/retrieval/supplement_atypon.py +269 -0
- fetchpdf/retrieval/supplement_dspace.py +187 -0
- fetchpdf/retrieval/supplement_graph.py +842 -0
- fetchpdf/retrieval/supplement_index.py +820 -0
- fetchpdf/retrieval/supplement_pmc.py +351 -0
- fetchpdf/retrieval/supplement_publishers.py +374 -0
- fetchpdf/retrieval/supplementary.py +1770 -0
- fetchpdf/retrieval/tiers.py +213 -0
- fetchpdf/retrieval/to_markdown.py +661 -0
- fetchpdf/retrieval/validate.py +368 -0
- fetchpdf/retrieval/verify.py +149 -0
- fetchpdf-0.1.0.dist-info/METADATA +1392 -0
- fetchpdf-0.1.0.dist-info/RECORD +64 -0
- fetchpdf-0.1.0.dist-info/WHEEL +5 -0
- fetchpdf-0.1.0.dist-info/entry_points.txt +5 -0
- fetchpdf-0.1.0.dist-info/licenses/LICENSE +21 -0
- fetchpdf-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,495 @@
|
|
|
1
|
+
"""Layer 2: a model reads the paper and goes after what the APIs did not get.
|
|
2
|
+
|
|
3
|
+
Layer 1 is eighteen enumerators and four link services, and it is very good at
|
|
4
|
+
the material publishers and repositories declare through an API. What it cannot
|
|
5
|
+
do is read. MEASURED across four corpora on 2026-08-22: those services returned
|
|
6
|
+
**3,005 `related` links that were never routed** and 280 `owned` ones of which
|
|
7
|
+
264 were BioStudies mirrors of supplements already in hand -- while
|
|
8
|
+
`fulltext_scan`, which does nothing but read the article's own prose, produced
|
|
9
|
+
83 OSF files from ten records, and the link indexes had surfaced *none* of the
|
|
10
|
+
fourteen deposits those articles named themselves.
|
|
11
|
+
|
|
12
|
+
So this provider runs LAST, after every free deterministic route, and it is
|
|
13
|
+
given three things layer 1 already knows and a model does not:
|
|
14
|
+
|
|
15
|
+
1. the paper's text -- the availability statements, weighted toward them;
|
|
16
|
+
2. what we already hold, so it does not fetch a second copy;
|
|
17
|
+
3. what the link services found and declined, with their reasons. That list
|
|
18
|
+
is the single richest input here: 3,005 candidates somebody already
|
|
19
|
+
decided were probably not ours, among which the actual replication
|
|
20
|
+
packages are hiding.
|
|
21
|
+
|
|
22
|
+
WHAT IT MAY NOT DO. It proposes; the existing gates dispose. Nothing it returns
|
|
23
|
+
skips `_should_route` or `_deposit_claims_another_article`, nothing it saves
|
|
24
|
+
skips `_commit`'s size cap, sha256, deduplication and challenge-page sniff, and
|
|
25
|
+
nothing it does can change whether the record succeeded. Every failure path --
|
|
26
|
+
no backend, no key, a timeout, a receipt that will not parse -- logs once and
|
|
27
|
+
returns an empty list, leaving layer 1's result exactly as it was.
|
|
28
|
+
|
|
29
|
+
The two backends differ in whether the model downloads or only navigates; see
|
|
30
|
+
`backends/__init__.py`, where the measurements that forced that split are
|
|
31
|
+
recorded.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
import os
|
|
35
|
+
import re
|
|
36
|
+
import shutil
|
|
37
|
+
import tempfile
|
|
38
|
+
import threading
|
|
39
|
+
from typing import List, Optional
|
|
40
|
+
|
|
41
|
+
from .backends import KIND_DATASET, get_backend
|
|
42
|
+
from .supplement_index import ROLE_SUPPLEMENT, SupplementFile
|
|
43
|
+
|
|
44
|
+
#: Records that have already had an agent call this process. A ceiling is the
|
|
45
|
+
#: only thing standing between an overnight corpus run and an unbounded bill,
|
|
46
|
+
#: and it has to be counted where the spending happens rather than where the
|
|
47
|
+
#: batch is planned -- workers run concurrently, so the count is shared and
|
|
48
|
+
#: guarded.
|
|
49
|
+
_SPENT = {"records": 0}
|
|
50
|
+
_SPENT_LOCK = threading.Lock()
|
|
51
|
+
|
|
52
|
+
#: What layer 2 actually did, per record, across this run. A run that says only
|
|
53
|
+
#: how many files it found lets "the agent was never called" and "the agent
|
|
54
|
+
#: looked and there was nothing" print identically -- and on these corpora the
|
|
55
|
+
#: second is the common case, so the first would hide behind it indefinitely.
|
|
56
|
+
_OUTCOMES: dict = {}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _note(outcome: str) -> None:
|
|
60
|
+
with _SPENT_LOCK:
|
|
61
|
+
_OUTCOMES[outcome] = _OUTCOMES.get(outcome, 0) + 1
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def run_report() -> dict:
|
|
65
|
+
"""`{outcome: count}` for the whole run, or empty if it never ran."""
|
|
66
|
+
with _SPENT_LOCK:
|
|
67
|
+
return dict(_OUTCOMES)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def reset_record_budget() -> None:
|
|
71
|
+
"""Forget how many records have been billed. For tests and long sessions."""
|
|
72
|
+
with _SPENT_LOCK:
|
|
73
|
+
_SPENT["records"] = 0
|
|
74
|
+
_OUTCOMES.clear()
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _claim_record_budget(limit: int) -> bool:
|
|
78
|
+
"""Take one slot, or report that the ceiling is reached. Never blocks."""
|
|
79
|
+
if not limit or limit <= 0:
|
|
80
|
+
return True
|
|
81
|
+
with _SPENT_LOCK:
|
|
82
|
+
if _SPENT["records"] >= limit:
|
|
83
|
+
return False
|
|
84
|
+
_SPENT["records"] += 1
|
|
85
|
+
return True
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
#: How much of the paper the model is shown. Bounded because cost scales with
|
|
89
|
+
#: it and because the answer lives in a few hundred characters of availability
|
|
90
|
+
#: prose, not in the methods section.
|
|
91
|
+
BRIEF_TEXT_CHARS = 14000
|
|
92
|
+
|
|
93
|
+
#: Characters kept around each availability statement.
|
|
94
|
+
DAS_WINDOW = 1800
|
|
95
|
+
|
|
96
|
+
#: Head of the document, for title and abstract: the model has to know which
|
|
97
|
+
#: paper it is judging ownership against.
|
|
98
|
+
HEAD_CHARS = 2500
|
|
99
|
+
|
|
100
|
+
#: Availability phrases, RANKED. The rank is the whole point: a real data
|
|
101
|
+
#: availability statement lives in the back matter, and a long paper mentions
|
|
102
|
+
#: "supplementary material" and "deposited" a dozen times before it gets
|
|
103
|
+
#: there. Filling the budget in document order therefore spends it on passing
|
|
104
|
+
#: mentions and drops the statement -- measured on
|
|
105
|
+
#: 10.1057/s41599-024-02881-1, where 11 phrases matched, the real statement sat
|
|
106
|
+
#: at character 49,385 of 60,736, and the brief the model received did not
|
|
107
|
+
#: contain the GitHub repository the authors named.
|
|
108
|
+
#:
|
|
109
|
+
#: So windows are chosen by score and only then put back into document order.
|
|
110
|
+
_AVAILABILITY_RANKED = (
|
|
111
|
+
# A section heading. Nothing else in a paper says this.
|
|
112
|
+
(3, re.compile(r"(data\s+and\s+code\s+availability|"
|
|
113
|
+
r"data\s+availability|code\s+availability|"
|
|
114
|
+
r"availability\s+of\s+(?:data|code)|"
|
|
115
|
+
r"data,\s*materials,?\s*and\s*(?:code|software)\s*availability)", re.I)),
|
|
116
|
+
# A deposit being claimed, wherever it is said.
|
|
117
|
+
(2, re.compile(r"(deposited|publicly\s+available|freely\s+available|"
|
|
118
|
+
r"available\s+(?:at|from|on|in)\s+(?:the\s+)?"
|
|
119
|
+
r"(?:https?|www|github|osf|zenodo|dryad|figshare|dataverse)|"
|
|
120
|
+
r"data\s+sharing|open\s+(?:data|science)\s+framework)", re.I)),
|
|
121
|
+
# Weakest: the word appears. Worth reading if there is budget left.
|
|
122
|
+
(1, re.compile(r"(supplementary\s+(?:material|information|data|file)|"
|
|
123
|
+
r"supporting\s+information|accession)", re.I)),
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
_TAGS = re.compile(r"<[^>]+>")
|
|
127
|
+
_WHITESPACE = re.compile(r"[ \t]+")
|
|
128
|
+
|
|
129
|
+
_INSTRUCTIONS = """\
|
|
130
|
+
You are finding supplementary files and datasets that belong to ONE paper, so
|
|
131
|
+
they can be archived alongside it.
|
|
132
|
+
|
|
133
|
+
Rules, in order of importance:
|
|
134
|
+
|
|
135
|
+
1. ONLY this paper's own material. A dataset the paper CITES belongs to
|
|
136
|
+
somebody else and must not be collected. A software library the authors
|
|
137
|
+
used is not their code. A preregistration is not the analysis data.
|
|
138
|
+
If you are not sure it is theirs, leave it out and say so in "why".
|
|
139
|
+
2. Do not re-fetch anything in ALREADY OBTAINED below.
|
|
140
|
+
3. Prefer a direct file URL. If you only have a landing page, open it and find
|
|
141
|
+
the file list before answering.
|
|
142
|
+
4. Ignore anything that is not a file: registry entries, accession numbers with
|
|
143
|
+
no download, "available on request".
|
|
144
|
+
|
|
145
|
+
%(action)s
|
|
146
|
+
|
|
147
|
+
Answer with a JSON array and nothing else. One object per file:
|
|
148
|
+
|
|
149
|
+
[{"url": "<direct file url>",
|
|
150
|
+
"kind": "supplement" | "dataset",
|
|
151
|
+
"deposit": "<landing page it came from, or empty>",
|
|
152
|
+
"name": "<filename>",
|
|
153
|
+
"why": "<one short line: why this is THIS paper's>"%(saved_as)s}]
|
|
154
|
+
|
|
155
|
+
An empty array is the right answer when the paper deposited nothing new. It is
|
|
156
|
+
the common case -- say it plainly rather than reaching for something.
|
|
157
|
+
"""
|
|
158
|
+
|
|
159
|
+
_ACTION_NAVIGATE = """\
|
|
160
|
+
You cannot save files. Use WebFetch to open pages and REPORT the URLs you
|
|
161
|
+
find; they will be downloaded for you.\
|
|
162
|
+
"""
|
|
163
|
+
|
|
164
|
+
_ACTION_DOWNLOAD = """\
|
|
165
|
+
Use `download` to save each file into your working directory, then report it.
|
|
166
|
+
Use `fetch_url` to open landing pages first.\
|
|
167
|
+
"""
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def build_brief(text: str, identity: str, obtained, declined,
|
|
171
|
+
downloads: bool) -> str:
|
|
172
|
+
"""The prompt: instructions, what we hold, what was declined, the paper."""
|
|
173
|
+
action = _ACTION_DOWNLOAD if downloads else _ACTION_NAVIGATE
|
|
174
|
+
saved_as = ',\n "saved_as": "<the filename you saved>"' if downloads else ""
|
|
175
|
+
parts = [_INSTRUCTIONS % {"action": action, "saved_as": saved_as}]
|
|
176
|
+
|
|
177
|
+
parts.append(f"\nPAPER: {identity}")
|
|
178
|
+
|
|
179
|
+
if obtained:
|
|
180
|
+
parts.append("\nALREADY OBTAINED (do not fetch again):")
|
|
181
|
+
parts.extend(f" - {line}" for line in obtained[:60])
|
|
182
|
+
else:
|
|
183
|
+
parts.append("\nALREADY OBTAINED: nothing.")
|
|
184
|
+
|
|
185
|
+
if declined:
|
|
186
|
+
parts.append(
|
|
187
|
+
"\nLINKS THE INDEXES FOUND AND DID NOT COLLECT. Each was judged "
|
|
188
|
+
"not to be this paper's own material, on metadata alone. Some of "
|
|
189
|
+
"them are wrong. The paper's text is better evidence than the "
|
|
190
|
+
"index was:")
|
|
191
|
+
parts.extend(f" - {line}" for line in declined[:40])
|
|
192
|
+
|
|
193
|
+
parts.append("\nPAPER TEXT:\n")
|
|
194
|
+
parts.append(text)
|
|
195
|
+
return "\n".join(parts)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def select_text(text: str, limit: int = BRIEF_TEXT_CHARS) -> str:
|
|
199
|
+
"""The head of the paper plus every availability statement in it.
|
|
200
|
+
|
|
201
|
+
A whole article is mostly methods and results, and none of that says where
|
|
202
|
+
the data went. Sending all of it would multiply the bill by the length of
|
|
203
|
+
the paper for no gain, so the windows that matter are cut out and joined --
|
|
204
|
+
with a marker, so the model is never told this is continuous prose.
|
|
205
|
+
"""
|
|
206
|
+
if not text:
|
|
207
|
+
return ""
|
|
208
|
+
flat = _WHITESPACE.sub(" ", _TAGS.sub(" ", text))
|
|
209
|
+
if len(flat) <= limit:
|
|
210
|
+
return flat
|
|
211
|
+
|
|
212
|
+
# The head is not optional and does not compete for budget: the model has
|
|
213
|
+
# to know which paper it is judging ownership against.
|
|
214
|
+
head = flat[:min(HEAD_CHARS, limit)]
|
|
215
|
+
remaining = limit - len(head)
|
|
216
|
+
if remaining <= 0:
|
|
217
|
+
return head
|
|
218
|
+
|
|
219
|
+
scored = []
|
|
220
|
+
for score, pattern in _AVAILABILITY_RANKED:
|
|
221
|
+
for match in pattern.finditer(flat, len(head)):
|
|
222
|
+
scored.append((score, match.start()))
|
|
223
|
+
if not scored:
|
|
224
|
+
# Nothing said "availability" anywhere. The back matter is still where
|
|
225
|
+
# a statement would be, so read the end rather than more of the front.
|
|
226
|
+
return head + "\n[...]\n" + flat[-remaining:]
|
|
227
|
+
|
|
228
|
+
# Strongest first, and later beats earlier at equal strength: a real
|
|
229
|
+
# statement sits in the back matter, and the front-matter mention of the
|
|
230
|
+
# same words is the abstract talking about somebody else's data.
|
|
231
|
+
scored.sort(key=lambda pair: (-pair[0], -pair[1]))
|
|
232
|
+
|
|
233
|
+
chosen: List[list] = []
|
|
234
|
+
budget = remaining
|
|
235
|
+
for _score, position in scored:
|
|
236
|
+
if budget <= 0:
|
|
237
|
+
break
|
|
238
|
+
start = max(len(head), position - DAS_WINDOW // 3)
|
|
239
|
+
end = min(len(flat), position + DAS_WINDOW)
|
|
240
|
+
for span in chosen: # already covered?
|
|
241
|
+
if start >= span[0] and end <= span[1]:
|
|
242
|
+
start = end = 0
|
|
243
|
+
break
|
|
244
|
+
if end <= start:
|
|
245
|
+
continue
|
|
246
|
+
length = min(end - start, budget)
|
|
247
|
+
chosen.append([start, start + length])
|
|
248
|
+
budget -= length
|
|
249
|
+
|
|
250
|
+
# Back into document order before it is shown to anyone. Overlaps merge,
|
|
251
|
+
# so an availability section matched by three patterns is read once.
|
|
252
|
+
merged: List[list] = []
|
|
253
|
+
for start, end in sorted(chosen):
|
|
254
|
+
if merged and start <= merged[-1][1]:
|
|
255
|
+
merged[-1][1] = max(merged[-1][1], end)
|
|
256
|
+
else:
|
|
257
|
+
merged.append([start, end])
|
|
258
|
+
|
|
259
|
+
return "\n[...]\n".join([head] + [flat[a:b] for a, b in merged])
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _identity(ids) -> str:
|
|
263
|
+
bits = [f"DOI {ids.doi}" if getattr(ids, "doi", None) else "",
|
|
264
|
+
f"PMID {ids.pmid}" if getattr(ids, "pmid", None) else "",
|
|
265
|
+
f"PMCID {ids.pmcid}" if getattr(ids, "pmcid", None) else ""]
|
|
266
|
+
return ", ".join(b for b in bits if b) or "unidentified record"
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _obtained_lines(ctx) -> List[str]:
|
|
270
|
+
lines = []
|
|
271
|
+
for entry in ctx.scratch.get("enumerated_so_far") or []:
|
|
272
|
+
name = getattr(entry, "name", "") or ""
|
|
273
|
+
provider = getattr(entry, "provider", "") or ""
|
|
274
|
+
lines.append(f"{name} [{provider}]" if provider else name)
|
|
275
|
+
return lines
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _declined_lines(ctx) -> List[str]:
|
|
279
|
+
lines = []
|
|
280
|
+
bucket = ctx.scratch.get("linked_artifacts") or {}
|
|
281
|
+
for link in bucket.get("links") or []:
|
|
282
|
+
if link.get("routed_to_download"):
|
|
283
|
+
continue
|
|
284
|
+
lines.append(
|
|
285
|
+
f"{link.get('target_pid', '')} "
|
|
286
|
+
f"({link.get('classified', '?')}, {link.get('relation', '?')}) "
|
|
287
|
+
f"{(link.get('title') or '')[:90]}")
|
|
288
|
+
return lines
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def enumerate_llm_agent(ids, ctx) -> List[SupplementFile]:
|
|
292
|
+
"""E20: the last word, and only when asked for.
|
|
293
|
+
|
|
294
|
+
Runs after every deterministic provider, so everything it is told about
|
|
295
|
+
what we already hold is true by the time it is told.
|
|
296
|
+
"""
|
|
297
|
+
if not getattr(ctx, "llm_agent_retrieval", False):
|
|
298
|
+
return []
|
|
299
|
+
|
|
300
|
+
from .fulltext_scan import record_text
|
|
301
|
+
from .linked_artifacts import record_query
|
|
302
|
+
|
|
303
|
+
stem = os.path.splitext(ctx.save_path)[0] if ctx.save_path else ""
|
|
304
|
+
if not stem:
|
|
305
|
+
return []
|
|
306
|
+
|
|
307
|
+
backend = get_backend(getattr(ctx, "llm_backend", None))
|
|
308
|
+
if backend is None:
|
|
309
|
+
ctx.log(f" llm_agent: unknown backend "
|
|
310
|
+
f"{getattr(ctx, 'llm_backend', None)!r}")
|
|
311
|
+
_note("unknown_backend")
|
|
312
|
+
return []
|
|
313
|
+
ok, detail = backend.available()
|
|
314
|
+
if not ok:
|
|
315
|
+
record_query(ctx, service="llm_agent", url="(local model call)",
|
|
316
|
+
status="unavailable", detail=detail)
|
|
317
|
+
ctx.log(f" llm_agent: {detail}")
|
|
318
|
+
_note("backend_unavailable")
|
|
319
|
+
return []
|
|
320
|
+
|
|
321
|
+
if not _claim_record_budget(getattr(ctx, "max_llm_records", 0) or 0):
|
|
322
|
+
# Recorded, not just skipped. "We chose not to spend on this record"
|
|
323
|
+
# and "we looked and there was nothing" are different facts, and a
|
|
324
|
+
# sidecar that cannot tell them apart makes the second one up.
|
|
325
|
+
record_query(ctx, service="llm_agent", url="(model call)",
|
|
326
|
+
status="skipped",
|
|
327
|
+
detail="--max-llm-records ceiling reached for this run")
|
|
328
|
+
ctx.log(" llm_agent: --max-llm-records reached; not calling")
|
|
329
|
+
_note("skipped_ceiling")
|
|
330
|
+
return []
|
|
331
|
+
|
|
332
|
+
source = record_text(stem, log=ctx.log)
|
|
333
|
+
if source is None:
|
|
334
|
+
# Not a failure of the agent: there is no text on disk to read. Said
|
|
335
|
+
# out loud, because "no full text here" and "nothing was deposited"
|
|
336
|
+
# are answers to different questions.
|
|
337
|
+
record_query(ctx, service="llm_agent", url="(local full text)",
|
|
338
|
+
status="no_text", detail="no readable full text on disk")
|
|
339
|
+
ctx.log(" llm_agent: no readable full text for this record")
|
|
340
|
+
_note("no_full_text")
|
|
341
|
+
return []
|
|
342
|
+
|
|
343
|
+
_path, text = source
|
|
344
|
+
brief = build_brief(
|
|
345
|
+
select_text(text), _identity(ids),
|
|
346
|
+
_obtained_lines(ctx), _declined_lines(ctx), backend.downloads)
|
|
347
|
+
|
|
348
|
+
# NOT a `with`: a downloading backend's files must stay readable until the
|
|
349
|
+
# pipeline has committed them, and that happens long after this provider
|
|
350
|
+
# returns. Deleting on the way out races the ingest and loses every file
|
|
351
|
+
# the agent fetched -- silently, because a missing `file://` source reads
|
|
352
|
+
# as an unreachable download. `cleanup_scratch` is called by
|
|
353
|
+
# `pull_for_record` once every file is committed, exactly as the Atypon
|
|
354
|
+
# provider's scratch dirs are.
|
|
355
|
+
staging = tempfile.mkdtemp(prefix="fetchpdf-agent-")
|
|
356
|
+
ctx.scratch.setdefault("_llm_agent_scratch_dirs", []).append(staging)
|
|
357
|
+
|
|
358
|
+
receipt = _call(backend, brief, staging, ctx)
|
|
359
|
+
if receipt is None:
|
|
360
|
+
record_query(ctx, service="llm_agent", url="(model call)",
|
|
361
|
+
status="error", detail="backend returned nothing")
|
|
362
|
+
_note("call_failed")
|
|
363
|
+
return []
|
|
364
|
+
record_query(ctx, service="llm_agent", url="(model call)", status="ok",
|
|
365
|
+
detail=f"{len(receipt)} file(s) proposed via {backend.name}")
|
|
366
|
+
|
|
367
|
+
_note("proposed_something" if receipt else "ran_found_nothing")
|
|
368
|
+
files: List[SupplementFile] = []
|
|
369
|
+
for index, entry in enumerate(receipt):
|
|
370
|
+
routed = _route(entry, ids, ctx)
|
|
371
|
+
if routed is None:
|
|
372
|
+
routed = [_as_supplement_file(entry, ids, index)]
|
|
373
|
+
files.extend(routed)
|
|
374
|
+
_record(ctx, entry, routed)
|
|
375
|
+
return files
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
#: Repository landing pages the existing enumerators already know how to walk.
|
|
379
|
+
#: A model that answers "the data are at https://osf.io/abcde/" is right, and
|
|
380
|
+
#: downloading that URL gets an HTML page -- measured: the agent found the
|
|
381
|
+
#: GitHub repository this paper's own regex could not (the PDF broke the URL
|
|
382
|
+
#: across a line), proposed the repo page, and the pipeline correctly refused
|
|
383
|
+
#: it as "served HTML". Right refusal, wrong question. The repo has an
|
|
384
|
+
#: enumerator; use it.
|
|
385
|
+
_REPOSITORY_HINTS = ("osf.io", "zenodo.org", "10.5281/zenodo", "datadryad.org",
|
|
386
|
+
"10.5061/dryad", "figshare.com", "10.6084",
|
|
387
|
+
"10.7910/dvn", "dataverse")
|
|
388
|
+
_GITHUB_HINT = "github.com/"
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def _route(entry: dict, ids, ctx):
|
|
392
|
+
"""The existing repository enumerators, when the URL names a repository.
|
|
393
|
+
|
|
394
|
+
Returns None when this is an ordinary file URL and should just be
|
|
395
|
+
downloaded. Routing matters for more than politeness: `_files_in_repository`
|
|
396
|
+
is where a deposit meets `_should_route` and
|
|
397
|
+
`_deposit_claims_another_article`, so a model's proposal is judged by the
|
|
398
|
+
same gate as an index's.
|
|
399
|
+
"""
|
|
400
|
+
if entry.get("saved_as"):
|
|
401
|
+
return None # already a file, on disk
|
|
402
|
+
from .supplement_graph import _files_in_repository, _github_tarball
|
|
403
|
+
|
|
404
|
+
target = (entry.get("deposit") or "") or entry.get("url") or ""
|
|
405
|
+
lowered = target.lower()
|
|
406
|
+
if _GITHUB_HINT in lowered:
|
|
407
|
+
repo = lowered.split(_GITHUB_HINT, 1)[1].strip("/")
|
|
408
|
+
repo = "/".join(repo.split("/")[:2])
|
|
409
|
+
if repo.count("/") == 1:
|
|
410
|
+
return _github_tarball(repo, ctx) or []
|
|
411
|
+
return None
|
|
412
|
+
if any(hint in lowered for hint in _REPOSITORY_HINTS):
|
|
413
|
+
return _files_in_repository(target, ids, ctx, via="llm_agent") or []
|
|
414
|
+
return None
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def _record(ctx, entry: dict, routed) -> None:
|
|
418
|
+
"""Every proposal in the sidecar, downloaded or not.
|
|
419
|
+
|
|
420
|
+
A model's suggestion that came to nothing is still the record of what was
|
|
421
|
+
considered, and the sidecar is where a reader checks whether a paper's
|
|
422
|
+
deposit was seen and rejected or never seen at all.
|
|
423
|
+
"""
|
|
424
|
+
from .linked_artifacts import CLASS_OWNED, record_link
|
|
425
|
+
|
|
426
|
+
target = entry.get("url") or entry.get("deposit") or ""
|
|
427
|
+
record_link(
|
|
428
|
+
ctx, service="llm_agent", target_pid=target,
|
|
429
|
+
target_type=("software" if _GITHUB_HINT in target.lower()
|
|
430
|
+
else "dataset" if entry.get("kind") == KIND_DATASET
|
|
431
|
+
else "supplement"),
|
|
432
|
+
relation="NamedInFullText", classified=CLASS_OWNED,
|
|
433
|
+
title=(entry.get("why") or "")[:300],
|
|
434
|
+
publisher=entry.get("deposit") or "",
|
|
435
|
+
provider_name="llm_agent", routed=bool(routed),
|
|
436
|
+
)
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def cleanup_scratch(ctx) -> None:
|
|
440
|
+
"""Remove the staging dirs the retrieval agent downloaded into.
|
|
441
|
+
|
|
442
|
+
Same contract as the Atypon provider's: called once the pipeline has copied
|
|
443
|
+
the files to their real homes, never on the way out of enumeration.
|
|
444
|
+
"""
|
|
445
|
+
for path in ctx.scratch.pop("_llm_agent_scratch_dirs", []) or []:
|
|
446
|
+
shutil.rmtree(path, ignore_errors=True)
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def _call(backend, brief: str, staging: str, ctx) -> Optional[List[dict]]:
|
|
450
|
+
model = getattr(ctx, "llm_model", None) or None
|
|
451
|
+
try:
|
|
452
|
+
if getattr(backend, "downloads", False):
|
|
453
|
+
return backend.run(brief, staging, model=model, log=ctx.log,
|
|
454
|
+
http=ctx.http)
|
|
455
|
+
return backend.run(brief, staging, model=model, log=ctx.log)
|
|
456
|
+
except Exception as error: # a helper may never fail a run
|
|
457
|
+
ctx.log(f" llm_agent: {type(error).__name__}: {str(error)[:150]}")
|
|
458
|
+
return None
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def _as_supplement_file(entry: dict, ids, index: int) -> SupplementFile:
|
|
462
|
+
"""One receipt entry as something `_Run.take` can act on.
|
|
463
|
+
|
|
464
|
+
A staged file becomes a `file://` URL rather than a special case:
|
|
465
|
+
`HttpClient.download` already adopts those under the same cap, the same
|
|
466
|
+
sha256 and the same complete-or-absent guarantee as a network transfer, so
|
|
467
|
+
a downloaded-by-the-agent file and a downloaded-by-us file travel one code
|
|
468
|
+
path from here on.
|
|
469
|
+
|
|
470
|
+
The real source URL rides in `extra`, because the `file://` path stops
|
|
471
|
+
meaning anything the moment the staging directory is removed -- and the
|
|
472
|
+
manifest is what a reader consults to ask where a file came from.
|
|
473
|
+
"""
|
|
474
|
+
staged = entry.get("saved_as")
|
|
475
|
+
url = f"file://{staged}" if staged else entry["url"]
|
|
476
|
+
name = (entry.get("name")
|
|
477
|
+
or (os.path.basename(staged) if staged else "")
|
|
478
|
+
or os.path.basename(entry["url"].split("?", 1)[0])
|
|
479
|
+
or f"llm_agent_{index + 1}")
|
|
480
|
+
kind = entry.get("kind")
|
|
481
|
+
return SupplementFile(
|
|
482
|
+
name=name,
|
|
483
|
+
url=url,
|
|
484
|
+
provider=("fulltext_scan:llm_agent" if kind == KIND_DATASET
|
|
485
|
+
else "llm_agent"),
|
|
486
|
+
listing_index=index,
|
|
487
|
+
role=ROLE_SUPPLEMENT,
|
|
488
|
+
origin_doi=getattr(ids, "doi", None),
|
|
489
|
+
label=(entry.get("why") or "")[:200],
|
|
490
|
+
extra={
|
|
491
|
+
"source_url": entry.get("url") or "",
|
|
492
|
+
"deposit": entry.get("deposit") or "",
|
|
493
|
+
"llm_agent": True,
|
|
494
|
+
},
|
|
495
|
+
)
|