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