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,36 @@
|
|
|
1
|
+
"""Format-prioritized retrieval.
|
|
2
|
+
|
|
3
|
+
The default retrieval path in ``fetch_pdf`` is PDF-shaped: it walks a
|
|
4
|
+
fixed list of sources and accepts the first thing with ``%PDF`` magic bytes.
|
|
5
|
+
That is the right default for a human who wants a paper to read.
|
|
6
|
+
|
|
7
|
+
It is the wrong default for feeding a table-extraction pipeline, because what
|
|
8
|
+
breaks there is not character accuracy but row/column/header association -- and
|
|
9
|
+
that association only survives losslessly in markup. This package inverts the
|
|
10
|
+
loop: format tiers outer, sources inner, so a JATS copy from a worse-ranked
|
|
11
|
+
source beats a PDF from a better-ranked one.
|
|
12
|
+
|
|
13
|
+
This package also holds the second, independent opt-in feature that grew out of
|
|
14
|
+
the same machinery: ``supplementary``, reached by ``--pull-supplementary``, which
|
|
15
|
+
fetches everything the authors deposited *alongside* the paper. It is not a tier.
|
|
16
|
+
The tier walk is winner-take-all by design -- one best representation of the full
|
|
17
|
+
text -- and "what else is there" is a set, not a winner, so it runs as a pass
|
|
18
|
+
after retrieval rather than as a rung inside it. It shares this package's HTTP
|
|
19
|
+
client, rate limiter and identifier resolver, which is why it lives here.
|
|
20
|
+
|
|
21
|
+
Nothing in here runs unless one of the opt-in flags is set. With none of them,
|
|
22
|
+
the existing chain executes byte-for-byte as before.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from .tiers import Tier, Ladder, load_ladder
|
|
26
|
+
from .identifiers import IdentifierSet, ResolutionChain
|
|
27
|
+
from .artifact import Artifact
|
|
28
|
+
|
|
29
|
+
__all__ = [
|
|
30
|
+
"Tier",
|
|
31
|
+
"Ladder",
|
|
32
|
+
"load_ladder",
|
|
33
|
+
"IdentifierSet",
|
|
34
|
+
"ResolutionChain",
|
|
35
|
+
"Artifact",
|
|
36
|
+
]
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""Small helpers that more than one module in this package needs.
|
|
2
|
+
|
|
3
|
+
Each of these existed in two to four copies before. That is mostly harmless
|
|
4
|
+
duplication -- except for `localname`, where it was not: both copies need the
|
|
5
|
+
non-string guard for lxml comment and processing-instruction nodes, whose `.tag`
|
|
6
|
+
is a *callable* rather than a name. A copy written without that guard raised
|
|
7
|
+
TypeError on every real publisher page, which is how T2 validation was broken
|
|
8
|
+
until an integration run caught it. One definition, one guard.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import html as _html
|
|
12
|
+
import os
|
|
13
|
+
import unicodedata
|
|
14
|
+
from datetime import datetime, timezone
|
|
15
|
+
from typing import Optional
|
|
16
|
+
|
|
17
|
+
#: Two titles for the same work are the same title at 0.93 and are different
|
|
18
|
+
#: works well below it. The margin is not delicate: a Dataverse deposit and its
|
|
19
|
+
#: own article score 0.99, an unrelated one 0.12. Anything in between is not a
|
|
20
|
+
#: near-miss worth arguing over. `supplement_graph` still carries its own copy
|
|
21
|
+
#: of this number and its own `_squash`; they predate these and have not been
|
|
22
|
+
#: migrated, so the two are free to drift. Anyone touching either should move
|
|
23
|
+
#: that caller over rather than adding a third.
|
|
24
|
+
TITLE_MATCH_RATIO = 0.93
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def localname(tag) -> str:
|
|
28
|
+
"""An element tag without its namespace.
|
|
29
|
+
|
|
30
|
+
TEI is namespaced, JATS usually is not, and lxml hands back comments and
|
|
31
|
+
processing instructions whose `.tag` is a function -- hence the isinstance
|
|
32
|
+
check, which is the whole reason this lives in one place.
|
|
33
|
+
"""
|
|
34
|
+
if not isinstance(tag, str):
|
|
35
|
+
return ""
|
|
36
|
+
return tag.rsplit("}", 1)[-1].lower() if "}" in tag else tag.lower()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def as_int(value, default: int = 0) -> int:
|
|
40
|
+
"""int(value) for values that arrive from JSON as strings, or as None."""
|
|
41
|
+
try:
|
|
42
|
+
return int(value)
|
|
43
|
+
except (TypeError, ValueError):
|
|
44
|
+
return default
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def iso(timestamp: float) -> str:
|
|
48
|
+
"""A UTC ISO-8601 stamp. Used in every audit record this package writes."""
|
|
49
|
+
return datetime.fromtimestamp(timestamp, tz=timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def unlink(path: Optional[str]) -> None:
|
|
53
|
+
"""Delete a path, tolerating its absence. Cleanup must never raise."""
|
|
54
|
+
if not path:
|
|
55
|
+
return
|
|
56
|
+
try:
|
|
57
|
+
os.unlink(path)
|
|
58
|
+
except OSError:
|
|
59
|
+
pass
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def squash(text: str) -> str:
|
|
63
|
+
"""Lowercased alphanumerics only, so punctuation and casing differences
|
|
64
|
+
between a deposit title and a journal title cannot break the match.
|
|
65
|
+
|
|
66
|
+
Dropping *everything* non-alphanumeric is also what makes this usable
|
|
67
|
+
against text extracted from a PDF page: a title broken across a line, or
|
|
68
|
+
hyphenated at the break, squashes to the same string as the title it came
|
|
69
|
+
from. That is load-bearing for `pdf_identity`, not incidental.
|
|
70
|
+
|
|
71
|
+
The NFKD pass is load-bearing for the same caller and for a reason that is
|
|
72
|
+
invisible until it bites: PDF text extraction hands back the *ligature*
|
|
73
|
+
glyphs a typesetter used, so a page reading "identification" arrives as
|
|
74
|
+
"identification" -- one character, U+FB01. Without decomposition that title
|
|
75
|
+
silently fails to match itself, and under reject-always a silent non-match
|
|
76
|
+
is a deleted file. Decomposing first turns the ligature back into "fi". It
|
|
77
|
+
also strips accents to their base letters, so "café" and "cafe" agree.
|
|
78
|
+
|
|
79
|
+
Kept as `str.isalnum()` rather than `[^a-z0-9]`, which is what this was:
|
|
80
|
+
that character class erases every non-Latin script outright. A Cyrillic,
|
|
81
|
+
Greek or CJK title squashed to the empty string, and an empty title matches
|
|
82
|
+
nothing -- so under reject-always every paper with a non-Latin title and no
|
|
83
|
+
DOI printed on the page would have been deleted as somebody else's.
|
|
84
|
+
"""
|
|
85
|
+
# Unescaped first because titles arrive from Crossref carrying HTML
|
|
86
|
+
# entities -- a real title reads "salience & message framing" in the
|
|
87
|
+
# metadata and "salience & message framing" on the page, and the stray
|
|
88
|
+
# "amp" splits the match straight down the middle.
|
|
89
|
+
unescaped = _html.unescape(text or "")
|
|
90
|
+
decomposed = unicodedata.normalize("NFKD", unescaped).lower()
|
|
91
|
+
return "".join(ch for ch in decomposed if ch.isalnum())
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def matched_fraction(needle: str, haystack: str, min_block: int = 4) -> float:
|
|
95
|
+
"""How much of `needle` appears in `haystack`, across ALL matching runs.
|
|
96
|
+
|
|
97
|
+
The right question to ask when matching a title against a page of extracted
|
|
98
|
+
text, and `SequenceMatcher.ratio()` is the wrong one: ratio is symmetric and
|
|
99
|
+
length-sensitive, so a 90-character title compared against 4,000 characters
|
|
100
|
+
of page scores near zero however perfectly the title appears on it.
|
|
101
|
+
|
|
102
|
+
Deliberately gap-tolerant. A single substituted character in the middle of a
|
|
103
|
+
title halves the longest contiguous run while leaving the title obviously
|
|
104
|
+
intact -- measured on a real record, "V717F β-Amyloid Precursor Protein"
|
|
105
|
+
typeset as "V717F b-Amyloid Precursor Protein" scores 0.62 by longest run
|
|
106
|
+
and 0.99 by matched total. Greek letters set in a Latin face are pervasive
|
|
107
|
+
in the life sciences, so that is not an edge case, it is Tuesday.
|
|
108
|
+
|
|
109
|
+
`min_block` is what keeps this honest: without it, a long page of prose
|
|
110
|
+
supplies a matching character somewhere for nearly every letter of any
|
|
111
|
+
title, and everything scores high. Only runs of at least `min_block`
|
|
112
|
+
characters count, so the score still means "this page contains these
|
|
113
|
+
phrases", not "this page contains these letters".
|
|
114
|
+
|
|
115
|
+
Both sides are assumed already squashed.
|
|
116
|
+
"""
|
|
117
|
+
import difflib
|
|
118
|
+
|
|
119
|
+
if not needle or not haystack:
|
|
120
|
+
return 0.0
|
|
121
|
+
matcher = difflib.SequenceMatcher(None, needle, haystack, autojunk=False)
|
|
122
|
+
total = sum(b.size for b in matcher.get_matching_blocks() if b.size >= min_block)
|
|
123
|
+
return min(1.0, total / float(len(needle)))
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def normalise_doi(doi: str) -> str:
|
|
127
|
+
"""A DOI stripped of the several prefixes it arrives wearing."""
|
|
128
|
+
doi = (doi or "").strip().lower()
|
|
129
|
+
for prefix in ("doi:", "https://doi.org/", "http://doi.org/", "doi.org/",
|
|
130
|
+
"https://dx.doi.org/", "http://dx.doi.org/", "dx.doi.org/"):
|
|
131
|
+
if doi.startswith(prefix):
|
|
132
|
+
doi = doi[len(prefix):]
|
|
133
|
+
return doi
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def titles_match(a: str, b: str, ratio: float = TITLE_MATCH_RATIO) -> bool:
|
|
137
|
+
"""Whether two titles name the same work.
|
|
138
|
+
|
|
139
|
+
Containment first -- it is cheap and it is the common case, since one side
|
|
140
|
+
is routinely a subtitle-trimmed or prefix-wrapped form of the other. The
|
|
141
|
+
difflib ratio is only for the spelling-variant tail that containment drops.
|
|
142
|
+
"""
|
|
143
|
+
import difflib
|
|
144
|
+
|
|
145
|
+
left, right = squash(a), squash(b)
|
|
146
|
+
if not left or not right:
|
|
147
|
+
return False
|
|
148
|
+
if left in right or right in left:
|
|
149
|
+
return True
|
|
150
|
+
return difflib.SequenceMatcher(None, left, right).ratio() >= ratio
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""What a source returns, and what a validator says about it.
|
|
2
|
+
|
|
3
|
+
Kept in its own module so sources, validators, the engine and the provenance
|
|
4
|
+
writer can all share these types without importing each other.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import hashlib
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from typing import Any, Dict, List, Optional
|
|
10
|
+
|
|
11
|
+
from .tiers import TIER_EXTENSIONS, Tier
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class ValidationResult:
|
|
16
|
+
"""The outcome of a tier gate, and enough detail to say WHY.
|
|
17
|
+
|
|
18
|
+
`reason` is populated on failure and on success alike: provenance has to
|
|
19
|
+
record which checks passed, not merely that some unnamed check did.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
ok: bool
|
|
23
|
+
reason: str = ""
|
|
24
|
+
checks_passed: List[str] = field(default_factory=list)
|
|
25
|
+
n_chars: int = 0
|
|
26
|
+
n_tables: int = 0
|
|
27
|
+
n_footnotes: int = 0
|
|
28
|
+
n_populated_cells: int = 0
|
|
29
|
+
|
|
30
|
+
def to_dict(self) -> dict:
|
|
31
|
+
return {
|
|
32
|
+
"ok": self.ok,
|
|
33
|
+
"reason": self.reason,
|
|
34
|
+
"checks_passed": list(self.checks_passed),
|
|
35
|
+
"n_chars": self.n_chars,
|
|
36
|
+
"n_tables": self.n_tables,
|
|
37
|
+
"n_footnotes": self.n_footnotes,
|
|
38
|
+
"n_populated_cells": self.n_populated_cells,
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
@classmethod
|
|
42
|
+
def failure(cls, reason: str, **kwargs) -> "ValidationResult":
|
|
43
|
+
return cls(ok=False, reason=reason, **kwargs)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass
|
|
47
|
+
class Artifact:
|
|
48
|
+
"""One retrieved candidate, before or after validation.
|
|
49
|
+
|
|
50
|
+
`tier` is what the source expected to produce. The engine overwrites it with
|
|
51
|
+
the tier the classifier assigns from the bytes themselves -- a source that
|
|
52
|
+
advertises XML and returns an HTML error page is reclassified, not trusted.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
content: bytes
|
|
56
|
+
tier: Tier
|
|
57
|
+
source: str
|
|
58
|
+
url: str
|
|
59
|
+
http_status: int
|
|
60
|
+
served_content_type: str = ""
|
|
61
|
+
identifier_used: str = ""
|
|
62
|
+
license: Optional[str] = None
|
|
63
|
+
extension: Optional[str] = None
|
|
64
|
+
validation: Optional[ValidationResult] = None
|
|
65
|
+
tables_unavailable: bool = False
|
|
66
|
+
declared_tier: Optional[Tier] = None # what the source claimed, if reclassified
|
|
67
|
+
normalization_failures: List[str] = field(default_factory=list)
|
|
68
|
+
extra: Dict[str, Any] = field(default_factory=dict)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def suffix(self) -> str:
|
|
74
|
+
return self.extension or TIER_EXTENSIONS[self.tier]
|
|
75
|
+
|
|
76
|
+
@property
|
|
77
|
+
def content_hash(self) -> str:
|
|
78
|
+
return "sha256:" + hashlib.sha256(self.content).hexdigest()
|
|
79
|
+
|
|
80
|
+
def __len__(self):
|
|
81
|
+
return len(self.content)
|
|
82
|
+
|
|
83
|
+
def __repr__(self):
|
|
84
|
+
return (
|
|
85
|
+
f"<Artifact {self.source} {self.tier.name} "
|
|
86
|
+
f"{len(self.content)}B status={self.http_status}>"
|
|
87
|
+
)
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""Backends for the retrieval agent: one contract, two very different sandboxes.
|
|
2
|
+
|
|
3
|
+
The agent's job is the same either way -- read the paper, work out which
|
|
4
|
+
supplementary files and datasets we do not already have, and get them -- but
|
|
5
|
+
what it is *allowed to do* differs so much between the two that the difference
|
|
6
|
+
is the interesting part.
|
|
7
|
+
|
|
8
|
+
MEASURED 2026-08-22, against Claude Code 2.1.240, because the containment story
|
|
9
|
+
in the design turned out to be wrong and only testing found it:
|
|
10
|
+
|
|
11
|
+
* `--allowed-tools` GRANTS permission, it does not restrict the tool set. A
|
|
12
|
+
subprocess run with `--allowed-tools "Bash(echo:*)"` happily ran
|
|
13
|
+
`cat ../outside_canary.txt` and returned the contents. So did
|
|
14
|
+
`--allowed-tools WebFetch`. Both with and without
|
|
15
|
+
`--permission-mode bypassPermissions`.
|
|
16
|
+
* `--disallowed-tools` DOES remove tools, and is the only lever that worked.
|
|
17
|
+
* A default subprocess inherits the operator's own MCP servers. On the
|
|
18
|
+
machine this was written on that meant Gmail, Google Drive, Google Calendar
|
|
19
|
+
and a brokerage account were in scope for a subprocess whose job is
|
|
20
|
+
downloading spreadsheets. `--strict-mcp-config` with an empty config
|
|
21
|
+
removes them.
|
|
22
|
+
|
|
23
|
+
The consequence: the CLI backend gets WebFetch and nothing else -- no shell, no
|
|
24
|
+
file tools, no MCP -- and therefore *cannot write a file*. So it does not
|
|
25
|
+
download. It navigates, which is the part that actually needs a model (open the
|
|
26
|
+
landing page, find the real file link, decide whether this deposit belongs to
|
|
27
|
+
this paper), and reports URLs. fetchpdf does the transfer, through the same
|
|
28
|
+
capped, hashed, deduplicated path every other provider uses.
|
|
29
|
+
|
|
30
|
+
The OpenRouter backend is the opposite: there is no general-purpose agent
|
|
31
|
+
harness to lock down, because the two tools it can call are ours. `download`
|
|
32
|
+
writes inside the staging directory by construction and enforces the byte cap
|
|
33
|
+
itself. That backend really does download.
|
|
34
|
+
|
|
35
|
+
Both return the same receipt, and the caller cannot tell which one ran by
|
|
36
|
+
looking at the output directory. That is the point.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
from typing import Optional
|
|
40
|
+
|
|
41
|
+
#: The receipt one entry at a time. Keys a backend may set:
|
|
42
|
+
#:
|
|
43
|
+
#: url the direct file URL, or the deposit landing page
|
|
44
|
+
#: kind "supplement" (the paper's own SI) or "dataset" (a deposit)
|
|
45
|
+
#: deposit the repository landing page this file came from, if any
|
|
46
|
+
#: name the filename to record it under, when the URL does not say
|
|
47
|
+
#: why one short line: why this belongs to THIS paper
|
|
48
|
+
#: saved_as staging-relative path, set only by a backend that downloads
|
|
49
|
+
RECEIPT_KEYS = ("url", "kind", "deposit", "name", "why", "saved_as")
|
|
50
|
+
|
|
51
|
+
KIND_SUPPLEMENT = "supplement"
|
|
52
|
+
KIND_DATASET = "dataset"
|
|
53
|
+
|
|
54
|
+
#: Registered backends, resolved by `--llm-backend`.
|
|
55
|
+
BACKENDS = ("claude-cli", "openrouter")
|
|
56
|
+
DEFAULT_BACKEND = "claude-cli"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def get_backend(name: Optional[str] = None):
|
|
60
|
+
"""A backend instance by name, or None when the name is unknown.
|
|
61
|
+
|
|
62
|
+
Imported lazily so a run that never asks for an agent never imports one.
|
|
63
|
+
"""
|
|
64
|
+
name = (name or DEFAULT_BACKEND).strip().lower()
|
|
65
|
+
if name == "claude-cli":
|
|
66
|
+
from .claude_cli import ClaudeCliBackend
|
|
67
|
+
return ClaudeCliBackend()
|
|
68
|
+
if name == "openrouter":
|
|
69
|
+
from .openrouter import OpenRouterBackend
|
|
70
|
+
return OpenRouterBackend()
|
|
71
|
+
return None
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def normalize_receipt(raw, staging: str = "") -> list:
|
|
75
|
+
"""Whatever the model returned, reduced to entries we can act on.
|
|
76
|
+
|
|
77
|
+
Defensive by policy rather than by taste: this is the one place a model's
|
|
78
|
+
free-form output crosses into code that downloads things. An entry without
|
|
79
|
+
a usable URL or staged file is dropped rather than guessed at, and a
|
|
80
|
+
`saved_as` that escapes the staging directory is dropped too -- a path is
|
|
81
|
+
the one field a model could use to reach outside its sandbox.
|
|
82
|
+
"""
|
|
83
|
+
import os
|
|
84
|
+
|
|
85
|
+
if not isinstance(raw, list):
|
|
86
|
+
return []
|
|
87
|
+
staging_real = os.path.realpath(staging) if staging else ""
|
|
88
|
+
entries = []
|
|
89
|
+
for item in raw:
|
|
90
|
+
if not isinstance(item, dict):
|
|
91
|
+
continue
|
|
92
|
+
entry = {k: item.get(k) for k in RECEIPT_KEYS if item.get(k)}
|
|
93
|
+
saved = entry.get("saved_as")
|
|
94
|
+
if saved:
|
|
95
|
+
candidate = os.path.realpath(os.path.join(staging, str(saved)))
|
|
96
|
+
inside = (staging_real and
|
|
97
|
+
(candidate == staging_real or
|
|
98
|
+
candidate.startswith(staging_real + os.sep)))
|
|
99
|
+
if not inside or not os.path.isfile(candidate):
|
|
100
|
+
entry.pop("saved_as", None)
|
|
101
|
+
else:
|
|
102
|
+
entry["saved_as"] = candidate
|
|
103
|
+
url = str(entry.get("url") or "")
|
|
104
|
+
if not entry.get("saved_as") and not url.lower().startswith(("http://", "https://")):
|
|
105
|
+
continue
|
|
106
|
+
kind = str(entry.get("kind") or "").lower()
|
|
107
|
+
entry["kind"] = KIND_DATASET if kind.startswith("data") else KIND_SUPPLEMENT
|
|
108
|
+
entries.append(entry)
|
|
109
|
+
return entries
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""The retrieval agent on the local Claude Code CLI, locked down to WebFetch.
|
|
2
|
+
|
|
3
|
+
Reuses `llm_adjudicate`'s subprocess plumbing -- `find_cli`, `run_cli`,
|
|
4
|
+
`unwrap_result`, `_warn_once` -- so there is one place that knows about the
|
|
5
|
+
Windows .cmd shim, the stdin feed and the JSON envelope.
|
|
6
|
+
|
|
7
|
+
WHY IT DOES NOT DOWNLOAD. See `backends/__init__.py` for the measurements: in
|
|
8
|
+
this CLI, `--allowed-tools` grants and does not restrict, and a default
|
|
9
|
+
subprocess inherits the operator's MCP servers. The only configuration that
|
|
10
|
+
verifiably contained the agent removes every file and shell tool -- which also
|
|
11
|
+
removes its ability to save anything. That is a fair trade. Navigating is the
|
|
12
|
+
part that needs a model; transferring bytes under a cap with a hash is the part
|
|
13
|
+
fetchpdf already does better than a shell command would.
|
|
14
|
+
|
|
15
|
+
The lockdown is a DENYLIST, and denylists rot: a tool added to Claude Code in a
|
|
16
|
+
later release is allowed here until someone adds it below. The two structural
|
|
17
|
+
guards are what this actually rests on -- no MCP config, no settings sources --
|
|
18
|
+
plus the fact that a WebFetch-only agent has nothing to write to.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
import json
|
|
22
|
+
import os
|
|
23
|
+
import tempfile
|
|
24
|
+
from typing import List, Optional
|
|
25
|
+
|
|
26
|
+
from ..llm_adjudicate import (
|
|
27
|
+
DEFAULT_MODEL,
|
|
28
|
+
_warn_once,
|
|
29
|
+
find_cli,
|
|
30
|
+
run_cli,
|
|
31
|
+
unwrap_result,
|
|
32
|
+
)
|
|
33
|
+
from . import normalize_receipt
|
|
34
|
+
|
|
35
|
+
#: The one tool it needs, and every built-in that could read the operator's
|
|
36
|
+
#: disk, spawn another agent, or reach a connected service. Verified against
|
|
37
|
+
#: Claude Code 2.1.240 by asking a locked-down subprocess to list its own
|
|
38
|
+
#: tools: it answered "WebFetch" and nothing else.
|
|
39
|
+
ALLOWED_TOOLS = "WebFetch"
|
|
40
|
+
DISALLOWED_TOOLS = ",".join((
|
|
41
|
+
"Bash", "Read", "Write", "Edit", "NotebookEdit", "Glob", "Grep",
|
|
42
|
+
"Agent", "Skill", "Workflow", "SendMessage", "ListAgents",
|
|
43
|
+
"TaskCreate", "TaskGet", "TaskList", "TaskOutput", "TaskStop", "TaskUpdate",
|
|
44
|
+
"WebSearch", "Monitor", "ScheduleWakeup", "ReportFindings", "ToolSearch",
|
|
45
|
+
"CronCreate", "CronDelete", "CronList", "DesignSync",
|
|
46
|
+
"EnterWorktree", "ExitWorktree", "PushNotification", "RemoteTrigger",
|
|
47
|
+
"ListMcpResourcesTool", "ReadMcpResourceTool", "ReadMcpResourceDirTool",
|
|
48
|
+
))
|
|
49
|
+
|
|
50
|
+
#: Enough turns to open a landing page, follow it to a file listing, and
|
|
51
|
+
#: answer. Measured: a single OSF node lookup takes 2. The cap IS enforced --
|
|
52
|
+
#: a run that exceeds it comes back is_error with no result at all, which is
|
|
53
|
+
#: why this is generous rather than tight.
|
|
54
|
+
MAX_TURNS = 12
|
|
55
|
+
|
|
56
|
+
#: Wall clock for the whole conversation. The adjudicator's 60s is sized for
|
|
57
|
+
#: one classification; this one makes several network round trips.
|
|
58
|
+
TIMEOUT_SECONDS = 300
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class ClaudeCliBackend:
|
|
62
|
+
"""Navigates with WebFetch and reports URLs. Never writes a file."""
|
|
63
|
+
|
|
64
|
+
name = "claude-cli"
|
|
65
|
+
downloads = False
|
|
66
|
+
|
|
67
|
+
def available(self):
|
|
68
|
+
"""`(ok, detail)`. Absence is a reason, never an exception."""
|
|
69
|
+
if find_cli() is None:
|
|
70
|
+
return False, ("Claude Code CLI not found on PATH; install it or "
|
|
71
|
+
"use --llm-backend openrouter")
|
|
72
|
+
return True, "claude"
|
|
73
|
+
|
|
74
|
+
def run(self, brief: str, staging: str, model: str = DEFAULT_MODEL,
|
|
75
|
+
log=None) -> Optional[List[dict]]:
|
|
76
|
+
cli_path = find_cli()
|
|
77
|
+
if cli_path is None:
|
|
78
|
+
ok, detail = self.available()
|
|
79
|
+
_warn_once(detail, log)
|
|
80
|
+
return None
|
|
81
|
+
|
|
82
|
+
with tempfile.TemporaryDirectory() as empty_config_dir:
|
|
83
|
+
config = os.path.join(empty_config_dir, "no_mcp.json")
|
|
84
|
+
with open(config, "w", encoding="utf-8") as handle:
|
|
85
|
+
json.dump({"mcpServers": {}}, handle)
|
|
86
|
+
|
|
87
|
+
stdout = run_cli(
|
|
88
|
+
brief,
|
|
89
|
+
cli_path,
|
|
90
|
+
model=model,
|
|
91
|
+
timeout=TIMEOUT_SECONDS,
|
|
92
|
+
allowed_tools=ALLOWED_TOOLS,
|
|
93
|
+
max_turns=MAX_TURNS,
|
|
94
|
+
extra_argv=[
|
|
95
|
+
# Structural, not a denylist: without these the subprocess
|
|
96
|
+
# inherits every MCP server the operator has connected.
|
|
97
|
+
"--strict-mcp-config", "--mcp-config", config,
|
|
98
|
+
"--setting-sources", "",
|
|
99
|
+
"--disallowed-tools", DISALLOWED_TOOLS,
|
|
100
|
+
# Nothing can prompt in print mode, so an un-preapproved
|
|
101
|
+
# WebFetch would simply be denied and the agent would
|
|
102
|
+
# report nothing. The tool set above is the boundary.
|
|
103
|
+
"--permission-mode", "bypassPermissions",
|
|
104
|
+
],
|
|
105
|
+
cwd=staging,
|
|
106
|
+
log=log,
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
if stdout is None:
|
|
110
|
+
return None
|
|
111
|
+
return normalize_receipt(unwrap_result(stdout, expect=list), staging)
|