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,278 @@
|
|
|
1
|
+
"""Ask a local Claude Code CLI to settle the candidates the rules cannot.
|
|
2
|
+
|
|
3
|
+
fulltext_scan's rules answer most cases for free and deterministically, which
|
|
4
|
+
is what a forensic tool wants: a run should be re-derivable. What they cannot
|
|
5
|
+
settle is genuinely semantic --
|
|
6
|
+
|
|
7
|
+
"see Table 1 here with two new conceptual replication studies; OSF: <url>"
|
|
8
|
+
|
|
9
|
+
-- is that deposit this paper's, or the replications it discusses? No pattern
|
|
10
|
+
decides that; a reader does. So UNCERTAIN candidates are handed to a model.
|
|
11
|
+
|
|
12
|
+
Three deliberate constraints:
|
|
13
|
+
|
|
14
|
+
1. **Only rejections and uncertainties are sent.** Recall is only ever lost in
|
|
15
|
+
a refusal, so the model may only ever PROMOTE a candidate to download. A
|
|
16
|
+
bad verdict then costs one extra deposit, never a silently missing one.
|
|
17
|
+
2. **This is optional.** Missing CLI, non-zero exit, timeout, garbage output --
|
|
18
|
+
all mean "no verdict", and the rules stand. A download run must never fail
|
|
19
|
+
because a helper is absent.
|
|
20
|
+
3. **The subprocess gets no tools.** `--allowed-tools ""` with `--max-turns 1`
|
|
21
|
+
makes it a pure text call. Without that we would be pointing an agent with
|
|
22
|
+
filesystem access at the user's machine to answer a yes/no question.
|
|
23
|
+
|
|
24
|
+
Shelling out to the CLI rather than an SDK is deliberate: no new dependency, no
|
|
25
|
+
API key handling, and it reuses whatever auth the user already has.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
import json
|
|
29
|
+
import os
|
|
30
|
+
import re
|
|
31
|
+
import shutil
|
|
32
|
+
import subprocess
|
|
33
|
+
import sys
|
|
34
|
+
import tempfile
|
|
35
|
+
import threading
|
|
36
|
+
from typing import List, Optional
|
|
37
|
+
|
|
38
|
+
from .fulltext_scan import ACCEPT, REFUSE, UNCERTAIN, Candidate
|
|
39
|
+
|
|
40
|
+
#: Default model. Haiku is the right tier for a one-sentence classification,
|
|
41
|
+
#: and measured at ~2.3s / $0.009 per call.
|
|
42
|
+
DEFAULT_MODEL = "haiku"
|
|
43
|
+
|
|
44
|
+
#: Per-call ceiling. The measured call is ~2.3s; 60s is generous enough to
|
|
45
|
+
#: absorb a cold start without letting a hung subprocess stall a batch.
|
|
46
|
+
TIMEOUT_SECONDS = 60
|
|
47
|
+
|
|
48
|
+
#: One warning per process, not per record -- a 500-record batch should not
|
|
49
|
+
#: emit 500 identical notices. Mirrors the CORE session kill-switch.
|
|
50
|
+
_WARNED = False
|
|
51
|
+
_WARN_LOCK = threading.Lock()
|
|
52
|
+
|
|
53
|
+
_PROMPT = """You are classifying one URL found in a scientific paper's full text.
|
|
54
|
+
|
|
55
|
+
Decide whether the URL is THIS paper's own data/code deposit, or something else
|
|
56
|
+
(a preregistration, a different paper's deposit, or a third-party tool/library).
|
|
57
|
+
|
|
58
|
+
URL: {url}
|
|
59
|
+
REPOSITORY: {repo}
|
|
60
|
+
SURROUNDING TEXT: {context}
|
|
61
|
+
|
|
62
|
+
Answer with JSON only, no prose, no code fence:
|
|
63
|
+
{{"own_deposit": true or false, "confidence": "high" or "low", "reason": "<12 words"}}
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _warn_once(message: str, log=None) -> None:
|
|
68
|
+
global _WARNED
|
|
69
|
+
with _WARN_LOCK:
|
|
70
|
+
if _WARNED:
|
|
71
|
+
return
|
|
72
|
+
_WARNED = True
|
|
73
|
+
text = f"⚠️ {message} Falling back to rule-based classification."
|
|
74
|
+
if log is not None:
|
|
75
|
+
log(text)
|
|
76
|
+
else:
|
|
77
|
+
# Yellow, matching the CORE / news-item notices elsewhere.
|
|
78
|
+
print(f"\033[93m{text}\033[0m")
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def reset_warning_state() -> None:
|
|
82
|
+
"""Exists for tests; a batch is one process."""
|
|
83
|
+
global _WARNED
|
|
84
|
+
with _WARN_LOCK:
|
|
85
|
+
_WARNED = False
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def find_cli() -> Optional[str]:
|
|
89
|
+
"""Absolute path to the Claude Code CLI, or None.
|
|
90
|
+
|
|
91
|
+
shutil.which is the correct detector on all three platforms: on Windows it
|
|
92
|
+
consults PATHEXT and returns the claude.cmd/.exe shim the npm installer
|
|
93
|
+
creates. Returning the FULL PATH matters -- see _build_command.
|
|
94
|
+
"""
|
|
95
|
+
return shutil.which("claude")
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _build_command(cli_path: str, model: str, allowed_tools: str = "",
|
|
99
|
+
max_turns: int = 1, extra_argv=()):
|
|
100
|
+
"""argv for a JSON-output call. Tool-less and single-turn by default.
|
|
101
|
+
|
|
102
|
+
`allowed_tools` and `max_turns` are parameters rather than something a
|
|
103
|
+
caller appends, because both CLI flags are variadic: passing
|
|
104
|
+
`--allowed-tools ""` and then `--allowed-tools "WebFetch"` is not a
|
|
105
|
+
last-one-wins override, it is two values whose combination nobody
|
|
106
|
+
intended. The retrieval agent needs different values for both, so it sets
|
|
107
|
+
them here.
|
|
108
|
+
|
|
109
|
+
Windows: subprocess.run(["claude", ...]) raises WinError 193 when the
|
|
110
|
+
target is a .cmd/.bat shim, because CreateProcess cannot execute a batch
|
|
111
|
+
file directly. Running it through cmd.exe is the fix. We do NOT set
|
|
112
|
+
shell=True generally -- on POSIX that would route the prompt through a
|
|
113
|
+
shell, and the prompt contains arbitrary article prose.
|
|
114
|
+
"""
|
|
115
|
+
argv = [
|
|
116
|
+
cli_path,
|
|
117
|
+
"-p",
|
|
118
|
+
"--model", model,
|
|
119
|
+
"--output-format", "json",
|
|
120
|
+
"--allowed-tools", allowed_tools,
|
|
121
|
+
"--max-turns", str(max_turns),
|
|
122
|
+
]
|
|
123
|
+
argv.extend(extra_argv)
|
|
124
|
+
if os.name == "nt" and cli_path.lower().endswith((".cmd", ".bat")):
|
|
125
|
+
return ["cmd", "/c"] + argv
|
|
126
|
+
return argv
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def run_cli(prompt: str, cli_path: str, model: str = DEFAULT_MODEL,
|
|
130
|
+
timeout: int = None, allowed_tools: str = "", max_turns: int = 1,
|
|
131
|
+
extra_argv=(), cwd: str = None, log=None) -> Optional[str]:
|
|
132
|
+
"""One CLI call. Returns raw stdout, or None if anything went wrong.
|
|
133
|
+
|
|
134
|
+
Extracted from `adjudicate_one` so the retrieval agent can reuse the parts
|
|
135
|
+
that are not about verdicts: the stdin feed, the Windows shim, the
|
|
136
|
+
tempdir cwd, and the four ways this degrades. Every failure returns None
|
|
137
|
+
and warns once -- a helper that is absent, slow or broken must never be
|
|
138
|
+
able to fail a record.
|
|
139
|
+
"""
|
|
140
|
+
try:
|
|
141
|
+
# The prompt goes over STDIN, never argv: Windows caps a command line
|
|
142
|
+
# near 8191 chars and applies its own quoting rules, and article prose
|
|
143
|
+
# carries quotes, newlines and non-ASCII punctuation.
|
|
144
|
+
#
|
|
145
|
+
# cwd defaults to a temp dir so the subprocess cannot pick up an
|
|
146
|
+
# unrelated project's CLAUDE.md from the corpus directory.
|
|
147
|
+
with tempfile.TemporaryDirectory() as scratch:
|
|
148
|
+
completed = subprocess.run(
|
|
149
|
+
_build_command(cli_path, model, allowed_tools,
|
|
150
|
+
max_turns, extra_argv),
|
|
151
|
+
input=prompt,
|
|
152
|
+
capture_output=True,
|
|
153
|
+
text=True,
|
|
154
|
+
encoding="utf-8", # explicit: Windows defaults to cp1252,
|
|
155
|
+
errors="replace", # which mangles curly quotes/en dashes
|
|
156
|
+
timeout=timeout or TIMEOUT_SECONDS,
|
|
157
|
+
cwd=cwd or scratch,
|
|
158
|
+
)
|
|
159
|
+
except subprocess.TimeoutExpired:
|
|
160
|
+
_warn_once(f"Claude CLI timed out after {timeout or TIMEOUT_SECONDS}s.", log)
|
|
161
|
+
return None
|
|
162
|
+
except OSError as error:
|
|
163
|
+
_warn_once(f"Claude CLI could not be run ({error}).", log)
|
|
164
|
+
return None
|
|
165
|
+
|
|
166
|
+
if completed.returncode != 0:
|
|
167
|
+
_warn_once(
|
|
168
|
+
f"Claude CLI exited {completed.returncode}: "
|
|
169
|
+
f"{(completed.stderr or '').strip()[:120]}",
|
|
170
|
+
log,
|
|
171
|
+
)
|
|
172
|
+
return None
|
|
173
|
+
return completed.stdout
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
_FENCE = re.compile(r"^\s*```(?:json)?\s*|\s*```\s*$", re.I)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def unwrap_result(stdout: str, expect=dict):
|
|
180
|
+
"""Pull the model's JSON out of the CLI's JSON envelope.
|
|
181
|
+
|
|
182
|
+
Two layers: the CLI wraps everything in {"result": "..."} , and the model
|
|
183
|
+
tends to fence its answer in ```json even when told not to. Both observed
|
|
184
|
+
against the real CLI, so both are handled rather than assumed away.
|
|
185
|
+
|
|
186
|
+
`expect` is the type the caller requires -- a verdict is one object, a
|
|
187
|
+
retrieval receipt is a list of them -- and anything else returns None
|
|
188
|
+
rather than a shape the caller then has to re-check.
|
|
189
|
+
"""
|
|
190
|
+
try:
|
|
191
|
+
envelope = json.loads(stdout)
|
|
192
|
+
except (ValueError, TypeError):
|
|
193
|
+
return None
|
|
194
|
+
if envelope.get("is_error"):
|
|
195
|
+
return None
|
|
196
|
+
result = envelope.get("result")
|
|
197
|
+
if not isinstance(result, str):
|
|
198
|
+
return None
|
|
199
|
+
return parse_json_body(result, expect=expect)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def parse_json_body(text: str, expect=dict):
|
|
203
|
+
"""The model's JSON out of its prose, fences and all. None if not `expect`.
|
|
204
|
+
|
|
205
|
+
Shared with the OpenRouter backend, which gets the same fenced-JSON habit
|
|
206
|
+
without the CLI's envelope around it.
|
|
207
|
+
"""
|
|
208
|
+
body = _FENCE.sub("", (text or "").strip())
|
|
209
|
+
try:
|
|
210
|
+
parsed = json.loads(body)
|
|
211
|
+
except ValueError:
|
|
212
|
+
# Last resort: the first {...} or [...] block in the text.
|
|
213
|
+
pattern = r"\[.*\]" if expect is list else r"\{.*\}"
|
|
214
|
+
match = re.search(pattern, body, re.S)
|
|
215
|
+
if not match:
|
|
216
|
+
return None
|
|
217
|
+
try:
|
|
218
|
+
parsed = json.loads(match.group(0))
|
|
219
|
+
except ValueError:
|
|
220
|
+
return None
|
|
221
|
+
return parsed if isinstance(parsed, expect) else None
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _parse_verdict(stdout: str) -> Optional[dict]:
|
|
225
|
+
"""The adjudicator's one-object answer. Kept as the name its tests use."""
|
|
226
|
+
return unwrap_result(stdout, expect=dict)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def adjudicate_one(candidate: Candidate, cli_path: str,
|
|
230
|
+
model: str = DEFAULT_MODEL, log=None) -> Optional[dict]:
|
|
231
|
+
"""One yes/no verdict, or None if anything at all went wrong."""
|
|
232
|
+
prompt = _PROMPT.format(
|
|
233
|
+
url=candidate.url, repo=candidate.repo,
|
|
234
|
+
context=(candidate.context or "")[:600],
|
|
235
|
+
)
|
|
236
|
+
stdout = run_cli(prompt, cli_path, model=model, log=log)
|
|
237
|
+
if stdout is None:
|
|
238
|
+
return None
|
|
239
|
+
return _parse_verdict(stdout)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def adjudicate(candidates: List[Candidate], model: str = DEFAULT_MODEL,
|
|
243
|
+
log=None, enabled: bool = True) -> List[Candidate]:
|
|
244
|
+
"""Let a model promote refused/uncertain candidates it judges to be owned.
|
|
245
|
+
|
|
246
|
+
Mutates and returns the same Candidate objects, stamping `adjudicated_by`
|
|
247
|
+
so a model-influenced decision stays distinguishable from a rules-only one
|
|
248
|
+
in the sidecar. Candidates the rules ACCEPTED are never sent: rule
|
|
249
|
+
precision on accepts is already the strong side, so asking again can only
|
|
250
|
+
downgrade a correct answer while adding cost.
|
|
251
|
+
"""
|
|
252
|
+
if not enabled or not candidates:
|
|
253
|
+
return candidates
|
|
254
|
+
|
|
255
|
+
cli_path = find_cli()
|
|
256
|
+
if not cli_path:
|
|
257
|
+
_warn_once(
|
|
258
|
+
"--llm-adjudicate-artifacts was requested but the Claude Code CLI "
|
|
259
|
+
"was not found on PATH.", log,
|
|
260
|
+
)
|
|
261
|
+
return candidates
|
|
262
|
+
|
|
263
|
+
for candidate in candidates:
|
|
264
|
+
if candidate.verdict == ACCEPT:
|
|
265
|
+
continue
|
|
266
|
+
verdict = adjudicate_one(candidate, cli_path, model=model, log=log)
|
|
267
|
+
if not verdict:
|
|
268
|
+
continue # rules stand
|
|
269
|
+
candidate.adjudicated_by = model
|
|
270
|
+
reason = str(verdict.get("reason") or "")[:120]
|
|
271
|
+
if verdict.get("own_deposit") is True:
|
|
272
|
+
candidate.verdict = ACCEPT
|
|
273
|
+
candidate.reason = f"LLM: {reason}" if reason else "LLM: own deposit"
|
|
274
|
+
else:
|
|
275
|
+
# Only ever confirms a non-download; never turns an accept away.
|
|
276
|
+
candidate.verdict = REFUSE
|
|
277
|
+
candidate.reason = f"LLM: {reason}" if reason else "LLM: not this paper's"
|
|
278
|
+
return candidates
|