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,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
+ )