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.
- scholarmend/__init__.py +3 -0
- scholarmend/_version.py +1 -0
- scholarmend/cache.py +80 -0
- scholarmend/cli.py +211 -0
- scholarmend/emit.py +129 -0
- scholarmend/http.py +133 -0
- scholarmend/ledger.py +88 -0
- scholarmend/miners/__init__.py +29 -0
- scholarmend/miners/arxiv.py +31 -0
- scholarmend/miners/openreview.py +35 -0
- scholarmend/miners/pmc.py +32 -0
- scholarmend/miners/pmlr.py +39 -0
- scholarmend/miners/proceedings.py +90 -0
- scholarmend/models.py +71 -0
- scholarmend/parse.py +61 -0
- scholarmend/pipeline.py +137 -0
- scholarmend/py.typed +0 -0
- scholarmend/resolvers/__init__.py +0 -0
- scholarmend/resolvers/openreview.py +224 -0
- scholarmend/resolvers/pmc.py +43 -0
- scholarmend/resolvers/pmlr_index.py +79 -0
- scholarmend/resolvers/proceedings_page.py +120 -0
- scholarmend/resolvers/semanticscholar.py +102 -0
- scholarmend-0.1.0.dist-info/METADATA +186 -0
- scholarmend-0.1.0.dist-info/RECORD +28 -0
- scholarmend-0.1.0.dist-info/WHEEL +4 -0
- scholarmend-0.1.0.dist-info/entry_points.txt +2 -0
- scholarmend-0.1.0.dist-info/licenses/LICENSE +21 -0
scholarmend/__init__.py
ADDED
scholarmend/_version.py
ADDED
|
@@ -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
|