scholarmend 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.
@@ -0,0 +1,3 @@
1
+ from ._version import __version__
2
+
3
+ __all__ = ["__version__"]
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
scholarmend/cache.py ADDED
@@ -0,0 +1,80 @@
1
+ """A content-addressed response store, committed to the repository.
2
+
3
+ Reproducibility is the point. A systematic review is reported once and defended
4
+ for years; a rerun has to produce what the first run produced. Every cached file
5
+ records the key alongside the payload so that the store stays auditable by
6
+ reading it, rather than only by replaying it.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import hashlib
12
+ import json
13
+ import os
14
+ import sys
15
+ import tempfile
16
+ from collections.abc import Callable
17
+ from pathlib import Path
18
+
19
+
20
+ class CacheMiss(RuntimeError):
21
+ """Offline mode was asked for something the cache does not hold."""
22
+
23
+
24
+ class Cache:
25
+ def __init__(self, root: Path, offline: bool = False) -> None:
26
+ self.root = Path(root)
27
+ self.offline = offline
28
+
29
+ def _path(self, key: str) -> Path:
30
+ # Hashing keeps arbitrary keys -- titles, URLs -- safe as filenames.
31
+ digest = hashlib.sha256(key.encode("utf-8")).hexdigest()
32
+ return self.root / digest[:2] / f"{digest}.json"
33
+
34
+ def get(self, key: str) -> dict | None:
35
+ path = self._path(key)
36
+ if not path.exists():
37
+ return None
38
+ try:
39
+ return json.loads(path.read_text(encoding="utf-8"))["payload"]
40
+ except (json.JSONDecodeError, KeyError, UnicodeDecodeError) as error:
41
+ # A damaged entry is a miss, loudly. Raising instead would abort a
42
+ # 2,413-record run over one unreadable file; staying silent would
43
+ # hide a cache the repository is supposed to be able to audit.
44
+ print(f"cache entry {path} is unreadable ({error}); treating as a miss",
45
+ file=sys.stderr)
46
+ return None
47
+
48
+ def put(self, key: str, payload: dict) -> None:
49
+ path = self._path(key)
50
+ path.parent.mkdir(parents=True, exist_ok=True)
51
+ document = {"key": key, "payload": payload}
52
+ text = json.dumps(document, indent=1, sort_keys=True, ensure_ascii=False)
53
+ # Write-then-rename, because a run interrupted mid-write used to leave
54
+ # truncated JSON behind, and the next run raised JSONDecodeError on a
55
+ # file it had written itself. The temporary file shares the directory
56
+ # so that the replace is atomic on one filesystem.
57
+ descriptor, temporary = tempfile.mkstemp(
58
+ dir=path.parent, prefix=path.name, suffix=".tmp"
59
+ )
60
+ try:
61
+ with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
62
+ handle.write(text)
63
+ os.replace(temporary, path)
64
+ except BaseException:
65
+ Path(temporary).unlink(missing_ok=True)
66
+ raise
67
+
68
+ def fetch(self, key: str, loader: Callable[[], dict]) -> dict:
69
+ """Cached value, or ``loader()`` stored and returned."""
70
+ hit = self.get(key)
71
+ if hit is not None:
72
+ return hit
73
+ if self.offline:
74
+ raise CacheMiss(
75
+ f"{key!r} is not cached and --offline forbids fetching it. "
76
+ f"Run once without --offline to populate the cache."
77
+ )
78
+ payload = loader()
79
+ self.put(key, payload)
80
+ return payload
scholarmend/cli.py ADDED
@@ -0,0 +1,211 @@
1
+ """Command line entry point."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import os
8
+ import sys
9
+ from pathlib import Path
10
+
11
+ from .cache import Cache, CacheMiss
12
+ from .emit import project_ris, to_json
13
+ from .http import HttpError
14
+ from .parse import parse_file
15
+ from .pipeline import RESOLVED_FIELDS, resolve_record
16
+ from .resolvers.openreview import AuthError, OpenReviewResolver, login
17
+ from .resolvers.pmc import PmcResolver
18
+ from .resolvers.pmlr_index import PmlrIndexResolver
19
+ from .resolvers.proceedings_page import ProceedingsPageResolver
20
+ from .resolvers.semanticscholar import SemanticScholarResolver
21
+
22
+
23
+ def _build_parser() -> argparse.ArgumentParser:
24
+ parser = argparse.ArgumentParser(
25
+ prog="scholarmend",
26
+ description="Recover true venue, year and track for Google Scholar RIS exports.",
27
+ )
28
+ parser.add_argument("--input", required=True, type=Path,
29
+ help="a .ris file, or a directory of them")
30
+ parser.add_argument("--out", required=True, type=Path, help="directory to write outputs to")
31
+ parser.add_argument("--cache", default=Path(".scholarmend-cache"), type=Path,
32
+ help="response cache; commit it for reproducibility")
33
+ parser.add_argument("--offline", action="store_true",
34
+ help="never call the network; fail on a cache miss")
35
+ parser.add_argument("--abstracts", action="store_true",
36
+ help="replace Scholar's AB snippet with the full abstract, from the "
37
+ "proceedings page, OpenReview or Semantic Scholar")
38
+ return parser
39
+
40
+
41
+ def _report(documents: list[dict], unresolved: list[tuple[str, list[str]]],
42
+ abstracts_requested: bool = False) -> str:
43
+ """What a human still has to look at, with the structurally impossible removed.
44
+
45
+ A field no source in this run could supply -- doi, which nothing resolved
46
+ for any of the 2,413 records -- accused every record of a failure no human
47
+ could act on, and buried the genuinely unresolved venues and years under
48
+ 1,854 lines of noise. Emptiness across the whole corpus is the evidence
49
+ that no source exists for it, so the field is named once and then left out
50
+ of the per-record list.
51
+ """
52
+ supplied = {field for field in RESOLVED_FIELDS
53
+ if any(doc["fields"][field].get("value") for doc in documents)}
54
+ absent = [f for f in RESOLVED_FIELDS if f not in supplied]
55
+
56
+ rows = [(title, [f for f in missing if f in supplied]) for title, missing in unresolved]
57
+ rows = [(title, missing) for title, missing in rows if missing]
58
+ # Venue and year are the deliverable; a record missing one of them comes
59
+ # first, so that truncating the list cannot hide it.
60
+ rows.sort(key=lambda row: not ({"venue", "year"} & set(row[1])))
61
+
62
+ counts = " ".join(
63
+ f"{field}={sum(1 for _, missing in rows if field in missing)}" for field in RESOLVED_FIELDS
64
+ )
65
+ lines = [
66
+ f"records: {len(documents)}",
67
+ f"records with unresolved fields: {len(rows)}",
68
+ f"unresolved by field: {counts}",
69
+ ]
70
+ if absent:
71
+ lines.append(
72
+ f"no source in this run could supply: {','.join(absent)} (excluded above and below)"
73
+ )
74
+ # Scholar's AB always "resolves", to its own snippet, so the count above
75
+ # cannot show these. They are the records a screener reads a fragment for.
76
+ snippets = [doc["title"] for doc in documents
77
+ if abstracts_requested and doc["fields"]["abstract"].get("source") == "scholar"]
78
+ if abstracts_requested:
79
+ lines.append(f"abstract still Scholar's snippet: {len(snippets)}")
80
+ lines.append("")
81
+ for title, missing in rows[:200]:
82
+ lines.append(f" unresolved {','.join(missing):28s} {title[:70]}")
83
+ if snippets:
84
+ lines.append("")
85
+ for title in snippets:
86
+ lines.append(f" snippet {title[:70]}")
87
+ return "\n".join(lines) + "\n"
88
+
89
+
90
+ def main(argv: list[str] | None = None) -> int:
91
+ args = _build_parser().parse_args(argv)
92
+
93
+ # A single file is how venuetriage's clean.ris -- the Covidence upload,
94
+ # already deduplicated and triaged -- gets its abstracts: its directory
95
+ # also holds removed.ris, which must not be merged into the upload.
96
+ files = [args.input] if args.input.is_file() else sorted(args.input.glob("*.ris"))
97
+ if not files:
98
+ print(f"no .ris files in {args.input}", file=sys.stderr)
99
+ return 2
100
+
101
+ cache = Cache(args.cache, offline=args.offline)
102
+ degraded = False
103
+
104
+ user = os.environ.get("SCHOLARMEND_OPENREVIEW_USER")
105
+ password = os.environ.get("SCHOLARMEND_OPENREVIEW_PASSWORD")
106
+ credentialed = bool(user and password) and not args.offline
107
+ warm = args.cache.exists()
108
+ token: dict[str, str | None] = {}
109
+
110
+ def openreview_token() -> str | None:
111
+ """Log in on the first genuine cache miss, and never before.
112
+
113
+ A committed cache holds every answer tier 2 needs, so a warm run must
114
+ make no API call at all -- including the login the run used to perform
115
+ unconditionally. Deferring it here is what makes the README's "makes no
116
+ API calls" true of the path a third party actually runs.
117
+ """
118
+ nonlocal degraded
119
+ if not credentialed:
120
+ return None
121
+ if "value" not in token:
122
+ try:
123
+ token["value"] = login(str(user), str(password))
124
+ except Exception as error: # noqa: BLE001 - degrade, never crash the run
125
+ print(f"OpenReview login failed, continuing on tier 1: {error}", file=sys.stderr)
126
+ degraded = True
127
+ token["value"] = None
128
+ return token["value"]
129
+
130
+ if not credentialed and not args.offline:
131
+ if warm:
132
+ # The reproducibility path: a committed cache and no account. Tier 2
133
+ # is not disabled here -- only a forum the cache does not hold will
134
+ # degrade this run, one record at a time.
135
+ print("no OpenReview credentials; tier 2 served from the cache", file=sys.stderr)
136
+ else:
137
+ print("no OpenReview credentials and no cache; tier 2 disabled", file=sys.stderr)
138
+ degraded = True
139
+
140
+ # Gate on whether an answer is reachable at all, not on credentials alone.
141
+ openreview = (
142
+ OpenReviewResolver(cache, openreview_token)
143
+ if (credentialed or warm or args.offline)
144
+ else None
145
+ )
146
+ pmlr_index = PmlrIndexResolver(cache)
147
+ pmc = PmcResolver(cache)
148
+ semanticscholar = SemanticScholarResolver(cache, os.environ.get("SCHOLARMEND_S2_KEY"))
149
+ proceedings_page = ProceedingsPageResolver(cache)
150
+
151
+ abstract_errors: list[str] = []
152
+
153
+ def abstract_failed(error: Exception) -> None:
154
+ """An abstract lookup failed: keep the snippet, and say so once."""
155
+ nonlocal degraded
156
+ if not abstract_errors:
157
+ print(f"some abstracts not recovered, Scholar's snippet kept: {error}",
158
+ file=sys.stderr)
159
+ abstract_errors.append(str(error))
160
+ degraded = True
161
+
162
+ documents, projections, unresolved = [], [], []
163
+ for path in files:
164
+ for record in parse_file(path):
165
+ try:
166
+ ledger = resolve_record(record, openreview=openreview,
167
+ pmlr_index=pmlr_index, pmc=pmc,
168
+ semanticscholar=semanticscholar,
169
+ proceedings_page=proceedings_page,
170
+ abstracts=args.abstracts,
171
+ on_error=abstract_failed)
172
+ except (AuthError, CacheMiss, HttpError) as error:
173
+ # Degrade, never abort. One unreachable lookup must not cost
174
+ # the other 2,412 records their tier-1 resolution, which needs
175
+ # no network at all. The run reports itself partial via exit 1.
176
+ if not degraded:
177
+ print(f"falling back to tier 1 for some records: {error}", file=sys.stderr)
178
+ degraded = True
179
+ # The proceedings page needs no key the failed lookup could
180
+ # have supplied, so the abstract is still worth trying.
181
+ ledger = resolve_record(record, openreview=None, pmlr_index=None,
182
+ pmc=None, semanticscholar=None,
183
+ proceedings_page=proceedings_page,
184
+ abstracts=args.abstracts,
185
+ on_error=abstract_failed)
186
+ documents.append(to_json(record, ledger))
187
+ projections.append(project_ris(record, ledger))
188
+ unresolved.append((record.title, [f for f in RESOLVED_FIELDS
189
+ if ledger.is_unresolved(f)]))
190
+
191
+ args.out.mkdir(parents=True, exist_ok=True)
192
+ (args.out / "resolved.json").write_text(
193
+ json.dumps(documents, indent=1, sort_keys=True, ensure_ascii=False), encoding="utf-8"
194
+ )
195
+ # utf-8-sig, because parse_file reads utf-8-sig: without the BOM the file
196
+ # round-trips record by record but not as a file.
197
+ (args.out / "mended.ris").write_text("".join(projections), encoding="utf-8-sig")
198
+
199
+ (args.out / "report.txt").write_text(_report(documents, unresolved, args.abstracts), encoding="utf-8")
200
+
201
+ recovered = sum(1 for doc in documents
202
+ if doc["fields"]["abstract"].get("source") not in (None, "scholar"))
203
+ print(f"wrote {len(documents)} records to {args.out}")
204
+ if args.abstracts:
205
+ print(f"{recovered} with a recovered abstract; "
206
+ f"{len(abstract_errors)} abstract lookups failed")
207
+ return 1 if degraded else 0
208
+
209
+
210
+ if __name__ == "__main__": # pragma: no cover
211
+ raise SystemExit(main())
scholarmend/emit.py ADDED
@@ -0,0 +1,129 @@
1
+ """Canonical JSON, and the RIS projection derived from it.
2
+
3
+ The JSON is the record. RIS cannot represent provenance, so it is emitted as a
4
+ projection for the tools that need it -- Covidence, and venuetriage -- by
5
+ rewriting only the lines whose values scholarmend corrected, and inserting a
6
+ line for a corrected field the record never carried. Every other byte of the
7
+ original record survives, which is what makes the projection safe to feed to a
8
+ parser that was written against Scholar's exact output.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import re
14
+
15
+ from .ledger import PRECEDENCE, Ledger
16
+ from .models import Record
17
+ from .pipeline import RESOLVED_FIELDS
18
+
19
+ # Which RIS tag carries each resolved field. Only these are ever rewritten.
20
+ #
21
+ # Authors are deliberately absent. Rewriting AU means deleting N lines and
22
+ # inserting M -- restructuring a repeated field -- which is a different act from
23
+ # substituting one line or inserting one missing line, and puts the round-trip
24
+ # guarantee -- the projection's whole safety argument -- at risk. Resolved
25
+ # authors live in the canonical JSON, which is the record; RIS is a projection
26
+ # for tools that cannot read it.
27
+ #
28
+ # Abstracts are present because AB is not that case: every corpus record has
29
+ # exactly one AB line, and every abstract claim is collapsed to one line, so a
30
+ # recovered abstract is a one-line substitution like PY or JF. Screening in
31
+ # Covidence reads AB, and Scholar's is a snippet on every record.
32
+ _TAG_FOR = {"year": "PY", "venue": "JF", "abstract": "AB"}
33
+
34
+
35
+ def to_json(record: Record, ledger: Ledger) -> dict:
36
+ """The canonical record: winning values, and every claim behind them."""
37
+ fields: dict[str, dict[str, object]] = {}
38
+ for field in RESOLVED_FIELDS:
39
+ claim = ledger.resolve(field)
40
+ if claim is None:
41
+ fields[field] = {"value": None, "unresolved": True}
42
+ else:
43
+ fields[field] = {
44
+ "value": claim.value,
45
+ "source": claim.source,
46
+ "tier": claim.tier,
47
+ "confidence": claim.confidence,
48
+ "evidence": claim.evidence,
49
+ }
50
+ # Every field the precedence table knows about, not a hand-listed subset.
51
+ # The subset dropped pmlr_volume, pmc_id and arxiv_id from the canonical
52
+ # JSON entirely -- losing the evidence on exactly the records a human has
53
+ # to adjudicate, which are the ones no resolver settled.
54
+ claims = [
55
+ {"field": c.field, "value": c.value, "source": c.source,
56
+ "tier": c.tier, "confidence": c.confidence, "evidence": c.evidence}
57
+ for field in sorted(set(PRECEDENCE) | set(RESOLVED_FIELDS))
58
+ for c in ledger.claims(field)
59
+ ]
60
+ return {
61
+ "title": record.title,
62
+ "source_file": record.source_file,
63
+ "fields": fields,
64
+ "claims": claims,
65
+ "conflicts": ledger.conflicts(),
66
+ }
67
+
68
+
69
+ def _line(tag: str, value: str) -> str:
70
+ """One RIS line, preserving Scholar's ``2025///`` year shape."""
71
+ if tag == "PY":
72
+ return f"PY - {value}///"
73
+ # One value, one line, whatever a resolver handed over: a newline here
74
+ # would split the field into a continuation line no downstream parser
75
+ # agrees on how to read.
76
+ return f"{tag} - {' '.join(value.split())}"
77
+
78
+
79
+ def _insert_at(lines: list[str]) -> int:
80
+ """Where a missing tag's line goes: immediately before ``ER - ``.
81
+
82
+ A record with no ``ER`` line at all is malformed, but refusing to emit it
83
+ would be a false drop -- the failure mode the design calls silent and
84
+ unrecoverable -- so the line is appended after the last non-blank line
85
+ instead, which keeps the record's trailing newline where it was.
86
+ """
87
+ for index, line in enumerate(lines):
88
+ if line.startswith("ER -"):
89
+ return index
90
+ last = max((i for i, line in enumerate(lines) if line.strip()), default=-1)
91
+ return last + 1
92
+
93
+
94
+ def project_ris(record: Record, ledger: Ledger) -> str:
95
+ """``record.raw`` with corrected fields rewritten and nothing else touched.
96
+
97
+ A tag that already has a line is substituted in place. A tag that has none
98
+ is inserted immediately before ``ER - ``: 331 corpus records -- every one
99
+ of them from OpenReview -- carry no ``PY`` line at all, which is precisely
100
+ the population tier 2 exists to supply a year for, and substitution alone
101
+ would drop every year it resolved for them.
102
+
103
+ Inserting one line is not the same as restructuring a multi-line field.
104
+ ``AU`` stays out of ``_TAG_FOR`` for exactly that reason.
105
+ """
106
+ corrections = {}
107
+ for field, tag in _TAG_FOR.items():
108
+ claim = ledger.resolve(field)
109
+ if claim is not None and claim.source != "scholar":
110
+ corrections[tag] = claim.value
111
+ if not corrections:
112
+ return record.raw
113
+
114
+ out = []
115
+ substituted = set()
116
+ for line in record.raw.split("\n"):
117
+ match = re.match(r"^([A-Z][A-Z0-9]) - ", line)
118
+ line_tag: str | None = match.group(1) if match else None
119
+ if line_tag in corrections:
120
+ substituted.add(line_tag)
121
+ out.append(_line(line_tag, corrections[line_tag]))
122
+ else:
123
+ out.append(line)
124
+
125
+ missing = [tag for tag in _TAG_FOR.values() if tag in corrections and tag not in substituted]
126
+ if missing:
127
+ index = _insert_at(out)
128
+ out[index:index] = [_line(tag, corrections[tag]) for tag in missing]
129
+ return "\n".join(out)
scholarmend/http.py ADDED
@@ -0,0 +1,133 @@
1
+ """A small JSON-over-HTTP client.
2
+
3
+ stdlib only, matching refaudit's decision to carry no runtime dependencies:
4
+ this gets installed in a hurry, close to a deadline, often on a machine someone
5
+ else administers.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import time
12
+ import urllib.error
13
+ import urllib.request
14
+
15
+
16
+ class HttpError(RuntimeError):
17
+ """A request that failed. ``status`` and ``body`` are set for an HTTP
18
+ refusal, so a resolver can tell "not on this API" from "not visible to
19
+ you" without parsing the message."""
20
+
21
+ def __init__(self, message: str, status: int | None = None, body: str = "") -> None:
22
+ super().__init__(message)
23
+ self.status = status
24
+ self.body = body
25
+
26
+
27
+ # The largest window any server here advertises is one hour. Cap the wait so a
28
+ # malformed or hostile header cannot park a run indefinitely.
29
+ MAX_RATELIMIT_WAIT = 3700.0
30
+
31
+
32
+ def _advertised_wait(headers) -> float | None:
33
+ """The pause the server itself asked for, or ``None`` if it asked for none.
34
+
35
+ ``ratelimit-reset`` is what OpenReview sends; ``retry-after`` is what
36
+ several other servers send instead, in its seconds form. Either is a far
37
+ better estimate than exponential backoff, which on a real 429 burns three
38
+ attempts in three seconds and then gives up while the window still has
39
+ fifty minutes to run.
40
+ """
41
+ if headers is None:
42
+ return None
43
+ for name in ("ratelimit-reset", "retry-after"):
44
+ raw = headers.get(name)
45
+ if raw is None:
46
+ continue
47
+ text = str(raw).strip()
48
+ if text.replace(".", "", 1).isdigit():
49
+ return min(max(float(text), 0.0), MAX_RATELIMIT_WAIT) + 1.0
50
+ return None
51
+
52
+
53
+ def _respect_rate_limit(response, sleep=time.sleep) -> None:
54
+ """Pause when the server says the budget is spent.
55
+
56
+ OpenReview advertises ``ratelimit-policy: 500;w=3600`` -- 500 requests an
57
+ hour, per token. Tier 2 needs 527 forum lookups, so an unbroken run runs
58
+ out 27 calls from the end. Waiting for the window to roll over turns that
59
+ from a failed run into a slow one, and because every response is cached,
60
+ the wait is paid once ever rather than once per run.
61
+ """
62
+ remaining = response.headers.get("ratelimit-remaining")
63
+ if remaining is None or not remaining.strip().isdigit():
64
+ return
65
+ if int(remaining) > 0:
66
+ return
67
+ reset = response.headers.get("ratelimit-reset", "")
68
+ seconds = float(reset) if reset.strip().replace(".", "", 1).isdigit() else 60.0
69
+ sleep(min(max(seconds, 0.0), MAX_RATELIMIT_WAIT) + 1.0)
70
+
71
+
72
+ def _read_body(error: urllib.error.HTTPError) -> str:
73
+ try:
74
+ return error.read(2000).decode("utf-8", "replace")
75
+ except (OSError, AttributeError): # a closed or absent response body
76
+ return ""
77
+
78
+
79
+ def get_json(
80
+ url: str,
81
+ headers: dict[str, str] | None = None,
82
+ timeout: float = 20.0,
83
+ attempts: int = 3,
84
+ sleep=time.sleep,
85
+ ) -> dict:
86
+ """GET ``url`` and parse JSON, retrying on transient failure. See ``get_text``."""
87
+ return json.loads(get_text(url, headers, timeout, attempts, sleep, parse=json.loads))
88
+
89
+
90
+ def get_text(
91
+ url: str,
92
+ headers: dict[str, str] | None = None,
93
+ timeout: float = 20.0,
94
+ attempts: int = 3,
95
+ sleep=time.sleep,
96
+ parse=None,
97
+ ) -> str:
98
+ """GET ``url`` as text, retrying on transient failure.
99
+
100
+ 429 and 5xx are retried; 4xx other than 429 is raised at once, because
101
+ retrying a refusal only spends someone else's rate limit. A 429 waits for
102
+ as long as the server's own headers say the window has left, rather than
103
+ for the exponential backoff the other failures get: OpenReview's window is
104
+ an hour wide, so backing off for one second and then two and then giving up
105
+ fails the run while doing nothing to let the budget recover.
106
+
107
+ ``parse``, if given, is applied inside the retry loop only to validate the
108
+ body, so that a truncated JSON response is retried like any other
109
+ transient failure.
110
+ """
111
+ last: Exception | None = None
112
+ for attempt in range(attempts):
113
+ wait: float | None = None
114
+ request = urllib.request.Request(url, headers=headers or {})
115
+ try:
116
+ with urllib.request.urlopen(request, timeout=timeout) as response:
117
+ body = response.read().decode("utf-8")
118
+ if parse is not None:
119
+ parse(body)
120
+ _respect_rate_limit(response, sleep)
121
+ return body
122
+ except urllib.error.HTTPError as error:
123
+ last = error
124
+ if error.code != 429 and error.code < 500:
125
+ raise HttpError(f"{error.code} from {url}", status=error.code,
126
+ body=_read_body(error)) from error
127
+ if error.code == 429:
128
+ wait = _advertised_wait(getattr(error, "headers", None))
129
+ except (urllib.error.URLError, TimeoutError, json.JSONDecodeError) as error:
130
+ last = error
131
+ if attempt < attempts - 1:
132
+ sleep(2.0**attempt if wait is None else wait)
133
+ raise HttpError(f"giving up on {url}: {last!r}")
scholarmend/ledger.py ADDED
@@ -0,0 +1,88 @@
1
+ """Claim accumulation and resolution.
2
+
3
+ The precedence table is the whole policy, and it is data. A source listed for
4
+ a field may answer it, best first; a source *absent* from a field's tuple is
5
+ excluded from answering it at all. That single mechanism expresses both
6
+ ordering and exclusion -- notably that preprint-derived sources may supply an
7
+ abstract and a full author list, which preprints genuinely have, but must never
8
+ supply a venue or a year, which they get wrong by construction.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from collections import defaultdict
14
+
15
+ from .models import Claim
16
+
17
+ PRECEDENCE: dict[str, tuple[str, ...]] = {
18
+ # Scholar loses all 1,264 year disagreements measured on the corpus.
19
+ "year": ("proceedings_url", "openreview_api", "pmlr_index", "semanticscholar", "scholar"),
20
+ # The OpenReview venueid was correct in 92 of 92 hand-checked cases.
21
+ "venue": ("openreview_api", "proceedings_url", "pmlr_index", "semanticscholar", "scholar"),
22
+ "track": ("openreview_api", "proceedings_url"),
23
+ # pmlr_index emits "PMLR v318" as a venue_id too. Listing only
24
+ # openreview_api here made resolve("venue_id") return None for every
25
+ # PMLR record, discarding the identity evidence a human adjudicating
26
+ # one of them needs.
27
+ "venue_id": ("openreview_api", "pmlr_index"),
28
+ "version": ("proceedings_url", "openreview_api", "pmlr_index", "arxiv_url"),
29
+ # Preprint sources are welcome here: Scholar truncates 71% of author lists.
30
+ "authors": ("openreview_api", "semanticscholar", "openalex", "arxiv_url", "scholar"),
31
+ # Scholar's AB is a search snippet on every record, so it answers last.
32
+ # The proceedings page is the published abstract itself; it outranks the
33
+ # OpenReview note, which a camera-ready revision can leave behind.
34
+ "abstract": ("proceedings_page", "openreview_api", "semanticscholar", "openalex",
35
+ "arxiv_url", "scholar"),
36
+ "doi": ("openreview_api", "semanticscholar", "openalex", "arxiv_url"),
37
+ # Keys, not answers: these carry an identifier from tier 1 to tier 2.
38
+ "forum_id": ("openreview_url",),
39
+ "pmlr_volume": ("pmlr_url", "pmc_api"),
40
+ "arxiv_id": ("arxiv_url",),
41
+ "pmc_id": ("pmc_url",),
42
+ }
43
+
44
+
45
+ class Ledger:
46
+ """Every claim made about one record, and the policy for choosing between them."""
47
+
48
+ def __init__(self) -> None:
49
+ self._claims: dict[str, list[Claim]] = defaultdict(list)
50
+
51
+ def add(self, claim: Claim) -> None:
52
+ if claim not in self._claims[claim.field]:
53
+ self._claims[claim.field].append(claim)
54
+
55
+ def claims(self, field: str) -> list[Claim]:
56
+ """Every claim for ``field``, including ones excluded by precedence."""
57
+ return list(self._claims.get(field, []))
58
+
59
+ def resolve(self, field: str) -> Claim | None:
60
+ """The winning claim, or ``None`` if no permitted source answered."""
61
+ order = PRECEDENCE.get(field)
62
+ if not order:
63
+ return None
64
+ for source in order:
65
+ for claim in self._claims.get(field, []):
66
+ if claim.source == source:
67
+ return claim
68
+ return None
69
+
70
+ def is_unresolved(self, field: str) -> bool:
71
+ return self.resolve(field) is None
72
+
73
+ def conflicts(self) -> list[str]:
74
+ """Fields where two sources *at the same tier* disagree.
75
+
76
+ Cross-tier disagreement is the normal case -- Scholar against a mined
77
+ URL happens 1,264 times in the corpus -- so flagging it would drown the
78
+ report. Same-tier disagreement means two equally trusted sources cannot
79
+ both be right, which is worth a human's attention.
80
+ """
81
+ out = []
82
+ for field, claims in self._claims.items():
83
+ by_tier: dict[int, set[str]] = defaultdict(set)
84
+ for claim in claims:
85
+ by_tier[claim.tier].add(claim.value)
86
+ if any(len(values) > 1 for values in by_tier.values()):
87
+ out.append(field)
88
+ return sorted(out)
@@ -0,0 +1,29 @@
1
+ """The miner registry.
2
+
3
+ Order matters only for readability; miners are disjoint by host, so at most one
4
+ answers any given URL.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from collections.abc import Callable, Iterable
10
+
11
+ from ..models import Claim
12
+ from . import arxiv, openreview, pmc, pmlr, proceedings
13
+
14
+ ALL: tuple[Callable[[str], list[Claim]], ...] = (
15
+ proceedings.mine,
16
+ openreview.mine,
17
+ pmlr.mine,
18
+ arxiv.mine,
19
+ pmc.mine,
20
+ )
21
+
22
+
23
+ def mine_all(urls: Iterable[str]) -> list[Claim]:
24
+ """Every claim every miner can make about every URL on a record."""
25
+ claims: list[Claim] = []
26
+ for url in urls:
27
+ for miner in ALL:
28
+ claims.extend(miner(url))
29
+ return claims