reference-audit 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.
- reference_audit/__init__.py +8 -0
- reference_audit/alignmentcheck.py +136 -0
- reference_audit/bookcheck.py +98 -0
- reference_audit/cache/__init__.py +1 -0
- reference_audit/cache/db.py +108 -0
- reference_audit/cache/store.py +128 -0
- reference_audit/cli.py +190 -0
- reference_audit/config.py +179 -0
- reference_audit/fieldcheck.py +599 -0
- reference_audit/inputs.py +96 -0
- reference_audit/llm/__init__.py +6 -0
- reference_audit/llm/client.py +85 -0
- reference_audit/llm/prompts.py +206 -0
- reference_audit/llm/schemas.py +38 -0
- reference_audit/matching/__init__.py +6 -0
- reference_audit/matching/adjudicate.py +63 -0
- reference_audit/matching/features.py +185 -0
- reference_audit/matching/names.py +238 -0
- reference_audit/matching/pool.py +344 -0
- reference_audit/matching/sameobject.py +150 -0
- reference_audit/matching/scoring.py +75 -0
- reference_audit/matching/verdict.py +56 -0
- reference_audit/matching/webcheck.py +189 -0
- reference_audit/models.py +412 -0
- reference_audit/parsing/__init__.py +1 -0
- reference_audit/parsing/bib.py +392 -0
- reference_audit/parsing/context.py +55 -0
- reference_audit/parsing/entities.py +22 -0
- reference_audit/parsing/identifiers.py +165 -0
- reference_audit/parsing/latex.py +83 -0
- reference_audit/parsing/tex.py +159 -0
- reference_audit/pdf/__init__.py +16 -0
- reference_audit/pdf/embedded.py +117 -0
- reference_audit/pdf/grobid.py +234 -0
- reference_audit/pdf/tei.py +470 -0
- reference_audit/pipeline.py +1692 -0
- reference_audit/report.py +330 -0
- reference_audit/sources/__init__.py +6 -0
- reference_audit/sources/arxiv.py +81 -0
- reference_audit/sources/base.py +54 -0
- reference_audit/sources/clickhouse.py +575 -0
- reference_audit/sources/crossref.py +90 -0
- reference_audit/sources/dblp.py +140 -0
- reference_audit/sources/google_books.py +101 -0
- reference_audit/sources/http.py +260 -0
- reference_audit/sources/normalize.py +464 -0
- reference_audit/sources/openalex.py +93 -0
- reference_audit/sources/openlibrary.py +158 -0
- reference_audit/sources/publisher.py +186 -0
- reference_audit/sources/registry.py +117 -0
- reference_audit/sources/render.py +115 -0
- reference_audit/sources/semantic_scholar.py +68 -0
- reference_audit/sources/titlewords.py +31 -0
- reference_audit/sources/web.py +256 -0
- reference_audit/versioning.py +125 -0
- reference_audit-0.1.0.dist-info/METADATA +715 -0
- reference_audit-0.1.0.dist-info/RECORD +60 -0
- reference_audit-0.1.0.dist-info/WHEEL +4 -0
- reference_audit-0.1.0.dist-info/entry_points.txt +2 -0
- reference_audit-0.1.0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,575 @@
|
|
|
1
|
+
"""Local ClickHouse backend for Semantic Scholar, OpenAlex and DBLP.
|
|
2
|
+
|
|
3
|
+
The same three databases the API adapters query, read from a local ClickHouse mirror instead (the S2
|
|
4
|
+
Academic Graph dump in `s2ag`, the OpenAlex snapshot in `openalex`, the DBLP dump in `kb`). There are
|
|
5
|
+
no rate limits and no third-party outages, so a large batch (a benchmark split, a whole conference)
|
|
6
|
+
audits in minutes. Selected per run with `source_backend = "clickhouse"`. The API adapters remain the
|
|
7
|
+
default.
|
|
8
|
+
|
|
9
|
+
**Same source, different transport.** Each adapter keeps its API sibling's `name` (`semantic_scholar`,
|
|
10
|
+
`openalex`, `dblp`), and its rows are shaped like the API's JSON and passed through the *same*
|
|
11
|
+
normalizer wherever one exists. Routing, field priorities, identity pinning and reports therefore treat
|
|
12
|
+
both backends alike. Cached responses are kept apart by `cache_source`, and verdicts by the cache's
|
|
13
|
+
`backend` key.
|
|
14
|
+
|
|
15
|
+
**Title search** uses a full-text `text` index on each table's `title` (lower-cased, split on
|
|
16
|
+
non-alphanumerics), with all searchable words required (`hasAllTokens`) and the shortest titles first.
|
|
17
|
+
The same contract as DBLP's SPARQL word search, so the exact title ranks ahead of the longer titles
|
|
18
|
+
that quote it. Among titles of equal length the most-cited comes first, as an API's relevance ranking
|
|
19
|
+
would put it (the 2017 "Attention Is All You Need" ahead of a later repost with the same title). Without the index the search is a scan of hundreds of millions of rows, so `preflight`
|
|
20
|
+
refuses to run without it, quoting the statements that build it.
|
|
21
|
+
|
|
22
|
+
All-words matching is strict: one misspelt or extra word in the cited title hides the real record.
|
|
23
|
+
So when no all-words hit is a near-exact title (`title_ratio` below `title_accept`) and the title has
|
|
24
|
+
at least `RELAXED_MIN_WORDS` distinct words, the search is repeated with each word left out in turn
|
|
25
|
+
(one query, the leave-one-out conditions OR-ed, still served by the index). Those hits rank by edit
|
|
26
|
+
distance to the cited title, then citations, and come ahead of the all-words hits. Two wrong words
|
|
27
|
+
still find nothing; the APIs' relevance search is more forgiving than that.
|
|
28
|
+
|
|
29
|
+
**Coverage.** `preflight` also reads how far each snapshot reaches (`coverage_end`): DBLP's latest
|
|
30
|
+
dump date, OpenAlex's newest `updated_date`, and the newest publication date in the S2 dump. The
|
|
31
|
+
pipeline names it next to a `none` verdict for an entry from that year or later.
|
|
32
|
+
|
|
33
|
+
**Failures are errors, never "not found":** an unreachable server, a timeout or a query error becomes
|
|
34
|
+
`SourceQueryResult.error`, which the pipeline reports and retries next run.
|
|
35
|
+
|
|
36
|
+
What the local data cannot supply, compared with the APIs:
|
|
37
|
+
- OpenAlex has no `locations` table locally, so a Work's version links are its primary location and
|
|
38
|
+
its best open-access location only. The preprint↔published merge then relies on those and on the
|
|
39
|
+
identifier links the other sources supply.
|
|
40
|
+
- The DBLP dump table has no landing-page URL (`ee`) and no pages.
|
|
41
|
+
- Coverage ends at each snapshot's ingest date. A work newer than the snapshot is not found locally.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
from __future__ import annotations
|
|
45
|
+
|
|
46
|
+
import asyncio
|
|
47
|
+
import json
|
|
48
|
+
import threading
|
|
49
|
+
from datetime import date, datetime
|
|
50
|
+
from typing import Any
|
|
51
|
+
|
|
52
|
+
from reference_audit.config import AuditConfig
|
|
53
|
+
from reference_audit.matching.features import title_ratio
|
|
54
|
+
from reference_audit.models import (
|
|
55
|
+
BibEntry,
|
|
56
|
+
EntryType,
|
|
57
|
+
Identifiers,
|
|
58
|
+
SourceQueryResult,
|
|
59
|
+
SourceRecord,
|
|
60
|
+
)
|
|
61
|
+
from reference_audit.sources.base import SourceAdapter
|
|
62
|
+
from reference_audit.sources.normalize import (
|
|
63
|
+
dblp_dump_row_to_record,
|
|
64
|
+
openalex_work_to_record,
|
|
65
|
+
s2_paper_to_record,
|
|
66
|
+
)
|
|
67
|
+
from reference_audit.sources.titlewords import title_search_words
|
|
68
|
+
|
|
69
|
+
# The full-text title index every searched table must carry (see README, "Local ClickHouse backend").
|
|
70
|
+
TITLE_INDEX = "idx_title_text"
|
|
71
|
+
TITLE_INDEX_DDL = (
|
|
72
|
+
"ALTER TABLE {table} ADD INDEX IF NOT EXISTS " + TITLE_INDEX + " title "
|
|
73
|
+
"TYPE text(tokenizer = splitByNonAlpha, preprocessor = lower(title));\n"
|
|
74
|
+
"ALTER TABLE {table} MATERIALIZE INDEX " + TITLE_INDEX + ";"
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
# The leave-one-word-out retry needs at least this many distinct title words: with fewer, the words
|
|
79
|
+
# left would match far too many titles to rank the right one into the first page.
|
|
80
|
+
RELAXED_MIN_WORDS = 4
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _all_words_condition(words: list[str]) -> tuple[str, dict]:
|
|
84
|
+
return "hasAllTokens(title, {w:Array(String)})", {"w": words}
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _leave_one_out_condition(words: list[str]) -> tuple[str, dict]:
|
|
88
|
+
"""Every distinct word but one must match, for each word in turn."""
|
|
89
|
+
distinct = list(dict.fromkeys(words))
|
|
90
|
+
subsets = [distinct[:i] + distinct[i + 1:] for i in range(len(distinct))]
|
|
91
|
+
sql = " OR ".join(f"hasAllTokens(title, {{w{i}:Array(String)}})" for i in range(len(subsets)))
|
|
92
|
+
return f"({sql})", {f"w{i}": sub for i, sub in enumerate(subsets)}
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _as_date(value: Any) -> date | None:
|
|
96
|
+
if isinstance(value, datetime):
|
|
97
|
+
return value.date()
|
|
98
|
+
if isinstance(value, date):
|
|
99
|
+
return value
|
|
100
|
+
if isinstance(value, str) and value:
|
|
101
|
+
try:
|
|
102
|
+
return date.fromisoformat(value[:10])
|
|
103
|
+
except ValueError:
|
|
104
|
+
return None
|
|
105
|
+
return None
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class ClickHouseUnavailableError(RuntimeError):
|
|
109
|
+
"""The local ClickHouse backend cannot serve this run: unreachable, or missing a title index."""
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class ClickHouseQueryError(Exception):
|
|
113
|
+
"""One query failed (timeout, server error, transport). Mapped to a source *error*."""
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class ClickHouseConnection:
|
|
117
|
+
"""One client for the run, shared by the three adapters, with bounded concurrency.
|
|
118
|
+
|
|
119
|
+
`clickhouse_connect`'s client is synchronous, so queries run in worker threads. A semaphore keeps
|
|
120
|
+
at most `concurrency` of them in flight, because the pipeline starts every entry at once. Session
|
|
121
|
+
ids are off: queries from one client share a session otherwise, and a session refuses concurrent
|
|
122
|
+
queries.
|
|
123
|
+
"""
|
|
124
|
+
|
|
125
|
+
def __init__(self, config: AuditConfig):
|
|
126
|
+
self.config = config
|
|
127
|
+
self._client: Any = None
|
|
128
|
+
self._client_lock = threading.Lock()
|
|
129
|
+
self._semaphore = asyncio.Semaphore(max(1, config.clickhouse_concurrency))
|
|
130
|
+
self._ready: asyncio.Future | None = None
|
|
131
|
+
self._closed = False
|
|
132
|
+
|
|
133
|
+
def _get_client(self):
|
|
134
|
+
with self._client_lock:
|
|
135
|
+
if self._client is None:
|
|
136
|
+
import clickhouse_connect
|
|
137
|
+
|
|
138
|
+
self._client = clickhouse_connect.get_client(
|
|
139
|
+
host=self.config.clickhouse_host,
|
|
140
|
+
port=self.config.clickhouse_port,
|
|
141
|
+
username=self.config.clickhouse_user,
|
|
142
|
+
password=self.config.clickhouse_password,
|
|
143
|
+
autogenerate_session_id=False,
|
|
144
|
+
connect_timeout=10,
|
|
145
|
+
send_receive_timeout=int(self.config.clickhouse_query_timeout) + 30,
|
|
146
|
+
)
|
|
147
|
+
return self._client
|
|
148
|
+
|
|
149
|
+
def _query_sync(self, sql: str, parameters: dict | None) -> list[dict]:
|
|
150
|
+
settings = {
|
|
151
|
+
"max_execution_time": int(self.config.clickhouse_query_timeout),
|
|
152
|
+
# make max_execution_time a wall-clock limit rather than a projected-speed check
|
|
153
|
+
"timeout_before_checking_execution_speed": 0,
|
|
154
|
+
"readonly": 2,
|
|
155
|
+
}
|
|
156
|
+
result = self._get_client().query(sql, parameters=parameters, settings=settings)
|
|
157
|
+
return list(result.named_results())
|
|
158
|
+
|
|
159
|
+
async def query(self, sql: str, parameters: dict | None = None) -> list[dict]:
|
|
160
|
+
"""Rows as dicts. Any failure raises ClickHouseQueryError (caller maps it to a source error)."""
|
|
161
|
+
async with self._semaphore:
|
|
162
|
+
try:
|
|
163
|
+
return await asyncio.to_thread(self._query_sync, sql, parameters)
|
|
164
|
+
except Exception as exc: # transport, timeout, server-side error — all an *error*
|
|
165
|
+
raise ClickHouseQueryError(f"clickhouse: {type(exc).__name__}: {exc}") from exc
|
|
166
|
+
|
|
167
|
+
async def ensure_ready(self, tables: list[str]) -> None:
|
|
168
|
+
"""Check reachability and the title indexes once per run; every adapter awaits the same check."""
|
|
169
|
+
if self._ready is None:
|
|
170
|
+
self._ready = asyncio.ensure_future(self._check(tables))
|
|
171
|
+
await self._ready
|
|
172
|
+
|
|
173
|
+
async def _check(self, tables: list[str]) -> None:
|
|
174
|
+
cfg = self.config
|
|
175
|
+
where = f"{cfg.clickhouse_host}:{cfg.clickhouse_port}"
|
|
176
|
+
try:
|
|
177
|
+
await self.query("SELECT 1")
|
|
178
|
+
except ClickHouseQueryError as exc:
|
|
179
|
+
raise ClickHouseUnavailableError(
|
|
180
|
+
f"source_backend=clickhouse, but the ClickHouse server at {where} cannot be queried "
|
|
181
|
+
f"({exc}). Start it, fix CLICKHOUSE_* in .env, or run with the API backend "
|
|
182
|
+
"(SOURCE_BACKEND=api / --backend api)."
|
|
183
|
+
) from exc
|
|
184
|
+
problems: list[str] = []
|
|
185
|
+
for table in tables:
|
|
186
|
+
database, name = table.split(".", 1)
|
|
187
|
+
rows = await self.query(
|
|
188
|
+
"SELECT type FROM system.data_skipping_indices "
|
|
189
|
+
"WHERE database = {db:String} AND table = {t:String} AND name = {i:String}",
|
|
190
|
+
{"db": database, "t": name, "i": TITLE_INDEX},
|
|
191
|
+
)
|
|
192
|
+
if not rows or rows[0]["type"] != "text":
|
|
193
|
+
problems.append(f"{table} has no full-text index {TITLE_INDEX}. Build it with:\n"
|
|
194
|
+
+ TITLE_INDEX_DDL.format(table=table))
|
|
195
|
+
continue
|
|
196
|
+
pending = await self.query(
|
|
197
|
+
"SELECT count() AS n FROM system.mutations WHERE database = {db:String} "
|
|
198
|
+
"AND table = {t:String} AND NOT is_done AND position(command, {i:String}) > 0",
|
|
199
|
+
{"db": database, "t": name, "i": TITLE_INDEX},
|
|
200
|
+
)
|
|
201
|
+
if pending and pending[0]["n"]:
|
|
202
|
+
problems.append(
|
|
203
|
+
f"{table}: {TITLE_INDEX} is still being built (a MATERIALIZE INDEX mutation is "
|
|
204
|
+
"running); parts without it would be scanned in full. Wait for it to finish "
|
|
205
|
+
"(system.mutations)."
|
|
206
|
+
)
|
|
207
|
+
if problems:
|
|
208
|
+
raise ClickHouseUnavailableError(
|
|
209
|
+
f"source_backend=clickhouse, but the server at {where} cannot serve title search:\n"
|
|
210
|
+
+ "\n".join(problems)
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
async def aclose(self) -> None:
|
|
214
|
+
if self._closed:
|
|
215
|
+
return
|
|
216
|
+
self._closed = True
|
|
217
|
+
client, self._client = self._client, None
|
|
218
|
+
if client is not None:
|
|
219
|
+
await asyncio.to_thread(client.close)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _error(name: str, kind: str, exc: Exception) -> SourceQueryResult:
|
|
223
|
+
return SourceQueryResult(source=name, query_kind=kind, error=str(exc))
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _no_words(name: str, title: str) -> SourceQueryResult:
|
|
227
|
+
# Nothing the word index can match. An empty result would read as "the database has no such
|
|
228
|
+
# paper" when it was never actually asked.
|
|
229
|
+
return SourceQueryResult(
|
|
230
|
+
source=name, query_kind="metadata",
|
|
231
|
+
error=f"title has no searchable word for the title index: {title!r}",
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
class _ClickHouseAdapter(SourceAdapter):
|
|
236
|
+
"""Shared plumbing: the connection, the per-run preflight, title search, and closing.
|
|
237
|
+
|
|
238
|
+
A subclass supplies `_title_rows` (one page of title hits, each with `id` and title `t`),
|
|
239
|
+
`_records` (those hits as `SourceRecord`s, in order), the two orderings, and `coverage_sql`.
|
|
240
|
+
"""
|
|
241
|
+
|
|
242
|
+
backend = "clickhouse"
|
|
243
|
+
rate_per_sec = 1000.0 # local server; concurrency is bounded by the connection's semaphore
|
|
244
|
+
strict_order = "" # ORDER BY of the all-words search
|
|
245
|
+
relaxed_order = "" # ORDER BY of the leave-one-out search; may use {cited:String}
|
|
246
|
+
|
|
247
|
+
def __init__(self, connection: ClickHouseConnection, **kw):
|
|
248
|
+
super().__init__(**kw)
|
|
249
|
+
self.ch = connection
|
|
250
|
+
|
|
251
|
+
def search_tables(self) -> list[str]:
|
|
252
|
+
raise NotImplementedError
|
|
253
|
+
|
|
254
|
+
def coverage_sql(self) -> str:
|
|
255
|
+
"""One row, one column `d`: the newest date this snapshot covers."""
|
|
256
|
+
raise NotImplementedError
|
|
257
|
+
|
|
258
|
+
async def preflight(self) -> None:
|
|
259
|
+
await self.ch.ensure_ready(self.search_tables())
|
|
260
|
+
try:
|
|
261
|
+
rows = await self.ch.query(self.coverage_sql())
|
|
262
|
+
except ClickHouseQueryError as exc:
|
|
263
|
+
raise ClickHouseUnavailableError(
|
|
264
|
+
f"source_backend=clickhouse: cannot read how far the local {self.name} snapshot "
|
|
265
|
+
f"reaches ({exc})"
|
|
266
|
+
) from exc
|
|
267
|
+
self.coverage_end = _as_date(rows[0]["d"]) if rows else None
|
|
268
|
+
if self.coverage_end is None or self.coverage_end.year < 2000: # 1970: an unset value
|
|
269
|
+
raise ClickHouseUnavailableError(
|
|
270
|
+
f"source_backend=clickhouse: the local {self.name} snapshot reports no usable "
|
|
271
|
+
f"coverage date ({rows[0]['d'] if rows else 'no rows'}); is the table empty?"
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
async def _title_rows(self, condition: str, params: dict, order: str, limit: int) -> list[dict]:
|
|
275
|
+
raise NotImplementedError
|
|
276
|
+
|
|
277
|
+
async def _records(self, rows: list[dict]) -> list[SourceRecord]:
|
|
278
|
+
raise NotImplementedError
|
|
279
|
+
|
|
280
|
+
async def search_by_metadata(self, entry: BibEntry, limit: int = 10) -> SourceQueryResult:
|
|
281
|
+
if not entry.title:
|
|
282
|
+
return SourceQueryResult(source=self.name, query_kind="metadata", records=[])
|
|
283
|
+
words = title_search_words(entry.title)
|
|
284
|
+
if not words:
|
|
285
|
+
return _no_words(self.name, entry.title)
|
|
286
|
+
try:
|
|
287
|
+
rows = await self._title_rows(*_all_words_condition(words), self.strict_order, limit)
|
|
288
|
+
near_exact = any(
|
|
289
|
+
title_ratio(entry.title, r["t"]) >= self.ch.config.title_accept for r in rows
|
|
290
|
+
)
|
|
291
|
+
if not near_exact and len(set(words)) >= RELAXED_MIN_WORDS:
|
|
292
|
+
condition, params = _leave_one_out_condition(words)
|
|
293
|
+
params["cited"] = entry.title.lower()
|
|
294
|
+
relaxed = await self._title_rows(condition, params, self.relaxed_order, limit)
|
|
295
|
+
seen = {r["id"] for r in relaxed}
|
|
296
|
+
rows = (relaxed + [r for r in rows if r["id"] not in seen])[:limit]
|
|
297
|
+
records = await self._records(rows)
|
|
298
|
+
except ClickHouseQueryError as exc:
|
|
299
|
+
return _error(self.name, "metadata", exc)
|
|
300
|
+
return SourceQueryResult(source=self.name, query_kind="metadata", records=records)
|
|
301
|
+
|
|
302
|
+
async def aclose(self) -> None:
|
|
303
|
+
await super().aclose()
|
|
304
|
+
await self.ch.aclose()
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
# ── Semantic Scholar (S2 Academic Graph dump) ─────────────────────────────────────────────────────
|
|
308
|
+
_S2_ID_TYPES = (("doi", "doi"), ("arxiv_id", "arxiv"), ("pmid", "pubmed"))
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
class ClickHouseSemanticScholarAdapter(_ClickHouseAdapter):
|
|
312
|
+
name = "semantic_scholar"
|
|
313
|
+
handles = {EntryType.ARTICLE, EntryType.INPROCEEDINGS, EntryType.MISC}
|
|
314
|
+
|
|
315
|
+
@property
|
|
316
|
+
def db(self) -> str:
|
|
317
|
+
return self.ch.config.clickhouse_s2_db
|
|
318
|
+
|
|
319
|
+
strict_order = "length(t), c DESC, id"
|
|
320
|
+
relaxed_order = "editDistance(lower(t), {cited:String}), c DESC, id"
|
|
321
|
+
|
|
322
|
+
def search_tables(self) -> list[str]:
|
|
323
|
+
return [f"{self.db}.papers"]
|
|
324
|
+
|
|
325
|
+
def coverage_sql(self) -> str:
|
|
326
|
+
# The S2 dump has no snapshot date; its newest (non-future) publication date stands in.
|
|
327
|
+
return (
|
|
328
|
+
f"SELECT max(publicationdate) AS d FROM {self.db}.papers "
|
|
329
|
+
"WHERE publicationdate <= toString(today())"
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
async def lookup_by_id(self, ids: Identifiers) -> SourceQueryResult:
|
|
333
|
+
# The API looks up DOI, else arXiv, else PMID; so does this (the same precedence).
|
|
334
|
+
key = next(((t, getattr(ids, f)) for f, t in _S2_ID_TYPES if getattr(ids, f)), None)
|
|
335
|
+
if key is None:
|
|
336
|
+
return SourceQueryResult(source=self.name, query_kind="id", records=[])
|
|
337
|
+
id_type, value = key
|
|
338
|
+
try:
|
|
339
|
+
rows = await self.ch.query(
|
|
340
|
+
f"SELECT DISTINCT corpusid FROM {self.db}.paper_external_ids "
|
|
341
|
+
"WHERE id_type = {t:String} AND id_val = {v:String} LIMIT 5",
|
|
342
|
+
{"t": id_type, "v": value.lower()},
|
|
343
|
+
)
|
|
344
|
+
records = await self._papers([r["corpusid"] for r in rows])
|
|
345
|
+
except ClickHouseQueryError as exc:
|
|
346
|
+
return _error(self.name, "id", exc)
|
|
347
|
+
return SourceQueryResult(source=self.name, query_kind="id", records=records)
|
|
348
|
+
|
|
349
|
+
async def _title_rows(self, condition: str, params: dict, order: str, limit: int) -> list[dict]:
|
|
350
|
+
return await self.ch.query(
|
|
351
|
+
f"SELECT corpusid AS id, any(title) AS t, max(ifNull(citationcount, 0)) AS c "
|
|
352
|
+
f"FROM {self.db}.papers WHERE {condition} "
|
|
353
|
+
f"GROUP BY id ORDER BY {order} LIMIT {{n:UInt32}}",
|
|
354
|
+
{**params, "n": limit},
|
|
355
|
+
)
|
|
356
|
+
|
|
357
|
+
async def _records(self, rows: list[dict]) -> list[SourceRecord]:
|
|
358
|
+
return await self._papers([r["id"] for r in rows])
|
|
359
|
+
|
|
360
|
+
async def _papers(self, corpusids: list[int]) -> list[SourceRecord]:
|
|
361
|
+
"""Full records for `corpusids`, in the given order (newest ingest of each)."""
|
|
362
|
+
if not corpusids:
|
|
363
|
+
return []
|
|
364
|
+
papers = await self.ch.query(
|
|
365
|
+
"SELECT corpusid, title, venue, year, externalids, citationcount, "
|
|
366
|
+
"arrayMap(a -> ifNull(a.name, ''), authors) AS author_names "
|
|
367
|
+
f"FROM {self.db}.papers WHERE corpusid IN {{ids:Array(UInt64)}} "
|
|
368
|
+
"ORDER BY corpusid, ingest_seq DESC LIMIT 1 BY corpusid",
|
|
369
|
+
{"ids": corpusids},
|
|
370
|
+
)
|
|
371
|
+
abstracts = await self.ch.query(
|
|
372
|
+
f"SELECT corpusid, abstract FROM {self.db}.abstracts "
|
|
373
|
+
"WHERE corpusid IN {ids:Array(UInt64)} LIMIT 1 BY corpusid",
|
|
374
|
+
{"ids": corpusids},
|
|
375
|
+
)
|
|
376
|
+
abstract_of = {r["corpusid"]: r["abstract"] for r in abstracts}
|
|
377
|
+
by_id = {r["corpusid"]: r for r in papers}
|
|
378
|
+
return [
|
|
379
|
+
s2_paper_to_record(s2_row_to_api_paper(by_id[c], abstract_of.get(c, "")))
|
|
380
|
+
for c in corpusids if c in by_id
|
|
381
|
+
]
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def s2_row_to_api_paper(row: dict, abstract: str) -> dict:
|
|
385
|
+
"""An `s2ag.papers` row in the S2 Graph API's paper shape, for `s2_paper_to_record`."""
|
|
386
|
+
try:
|
|
387
|
+
external = json.loads(row.get("externalids") or "{}")
|
|
388
|
+
except ValueError:
|
|
389
|
+
external = {}
|
|
390
|
+
return {
|
|
391
|
+
"paperId": str(row["corpusid"]),
|
|
392
|
+
"title": row.get("title") or "",
|
|
393
|
+
"year": row.get("year"),
|
|
394
|
+
"venue": row.get("venue") or "",
|
|
395
|
+
"authors": [{"name": n} for n in row.get("author_names") or [] if n],
|
|
396
|
+
"externalIds": {k: v for k, v in external.items() if v is not None},
|
|
397
|
+
"citationCount": row.get("citationcount") or 0,
|
|
398
|
+
"abstract": abstract or "",
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
# ── OpenAlex (snapshot) ───────────────────────────────────────────────────────────────────────────
|
|
403
|
+
_OPENALEX_SLIM_COLUMNS = (
|
|
404
|
+
"id, doi, ids, title, publication_year, type, cited_by_count, "
|
|
405
|
+
"arrayMap(a -> ifNull(a.author.display_name, ''), authorships) AS author_names, "
|
|
406
|
+
"primary_location.landing_page_url AS landing_page_url, primary_location.pdf_url AS pdf_url, "
|
|
407
|
+
"primary_location.source.display_name AS source_name, primary_location.source.type AS source_type"
|
|
408
|
+
)
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
class ClickHouseOpenAlexAdapter(_ClickHouseAdapter):
|
|
412
|
+
name = "openalex"
|
|
413
|
+
handles = {
|
|
414
|
+
EntryType.ARTICLE,
|
|
415
|
+
EntryType.INPROCEEDINGS,
|
|
416
|
+
EntryType.MISC,
|
|
417
|
+
EntryType.BOOK,
|
|
418
|
+
EntryType.INCOLLECTION,
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
@property
|
|
422
|
+
def db(self) -> str:
|
|
423
|
+
return self.ch.config.clickhouse_openalex_db
|
|
424
|
+
|
|
425
|
+
strict_order = "length(t), c DESC, id"
|
|
426
|
+
relaxed_order = "editDistance(lower(t), {cited:String}), c DESC, id"
|
|
427
|
+
|
|
428
|
+
def search_tables(self) -> list[str]:
|
|
429
|
+
return [f"{self.db}.works_slim"]
|
|
430
|
+
|
|
431
|
+
def coverage_sql(self) -> str:
|
|
432
|
+
return f"SELECT max(updated_date) AS d FROM {self.db}.works"
|
|
433
|
+
|
|
434
|
+
async def lookup_by_id(self, ids: Identifiers) -> SourceQueryResult:
|
|
435
|
+
# The API's precedence: DOI, else arXiv (via its DataCite DOI), else a cited Work id.
|
|
436
|
+
if ids.doi:
|
|
437
|
+
column, value = "doi", f"https://doi.org/{ids.doi.lower()}"
|
|
438
|
+
elif ids.arxiv_id:
|
|
439
|
+
column, value = "doi", f"https://doi.org/10.48550/arxiv.{ids.arxiv_id.lower()}"
|
|
440
|
+
elif ids.openalex:
|
|
441
|
+
column, value = "id", f"https://openalex.org/{ids.openalex}"
|
|
442
|
+
else:
|
|
443
|
+
return SourceQueryResult(source=self.name, query_kind="id", records=[])
|
|
444
|
+
try:
|
|
445
|
+
rows = await self.ch.query(
|
|
446
|
+
f"SELECT id FROM {self.db}.works_slim WHERE {column} = {{v:String}} LIMIT 5",
|
|
447
|
+
{"v": value},
|
|
448
|
+
)
|
|
449
|
+
records = await self._works(list(dict.fromkeys(r["id"] for r in rows)))
|
|
450
|
+
except ClickHouseQueryError as exc:
|
|
451
|
+
return _error(self.name, "id", exc)
|
|
452
|
+
return SourceQueryResult(source=self.name, query_kind="id", records=records)
|
|
453
|
+
|
|
454
|
+
async def _title_rows(self, condition: str, params: dict, order: str, limit: int) -> list[dict]:
|
|
455
|
+
return await self.ch.query(
|
|
456
|
+
f"SELECT id, any(title) AS t, max(ifNull(cited_by_count, 0)) AS c "
|
|
457
|
+
f"FROM {self.db}.works_slim WHERE {condition} "
|
|
458
|
+
f"GROUP BY id ORDER BY {order} LIMIT {{n:UInt32}}",
|
|
459
|
+
{**params, "n": limit},
|
|
460
|
+
)
|
|
461
|
+
|
|
462
|
+
async def _records(self, rows: list[dict]) -> list[SourceRecord]:
|
|
463
|
+
return await self._works([r["id"] for r in rows])
|
|
464
|
+
|
|
465
|
+
async def _works(self, work_ids: list[str]) -> list[SourceRecord]:
|
|
466
|
+
"""Full records for `work_ids`, in the given order: the slim table for the work, the full
|
|
467
|
+
`works` table for pages (`biblio`) and the abstract."""
|
|
468
|
+
if not work_ids:
|
|
469
|
+
return []
|
|
470
|
+
slim = await self.ch.query(
|
|
471
|
+
f"SELECT {_OPENALEX_SLIM_COLUMNS} FROM {self.db}.works_slim "
|
|
472
|
+
"WHERE id IN {ids:Array(String)} ORDER BY id, updated_date DESC LIMIT 1 BY id",
|
|
473
|
+
{"ids": work_ids},
|
|
474
|
+
)
|
|
475
|
+
full = await self.ch.query(
|
|
476
|
+
"SELECT id, biblio.volume AS volume, biblio.issue AS issue, "
|
|
477
|
+
"biblio.first_page AS first_page, biblio.last_page AS last_page, "
|
|
478
|
+
"best_oa_location.landing_page_url AS oa_landing_page_url, "
|
|
479
|
+
"best_oa_location.pdf_url AS oa_pdf_url, "
|
|
480
|
+
f"abstract_inverted_index FROM {self.db}.works "
|
|
481
|
+
"WHERE id IN {ids:Array(String)} ORDER BY id, version DESC LIMIT 1 BY id",
|
|
482
|
+
{"ids": work_ids},
|
|
483
|
+
)
|
|
484
|
+
extra = {r["id"]: r for r in full}
|
|
485
|
+
by_id = {r["id"]: r for r in slim}
|
|
486
|
+
return [
|
|
487
|
+
openalex_work_to_record(openalex_row_to_api_work(by_id[w], extra.get(w)))
|
|
488
|
+
for w in work_ids if w in by_id
|
|
489
|
+
]
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
def _best_oa_location(full: dict, primary: dict) -> list[dict]:
|
|
493
|
+
"""The `works` row's best open-access location, unless it is missing or is the primary one."""
|
|
494
|
+
landing, pdf = full.get("oa_landing_page_url"), full.get("oa_pdf_url")
|
|
495
|
+
if not (landing or pdf):
|
|
496
|
+
return []
|
|
497
|
+
if (landing, pdf) == (primary.get("landing_page_url"), primary.get("pdf_url")):
|
|
498
|
+
return []
|
|
499
|
+
return [{"landing_page_url": landing, "pdf_url": pdf}]
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
def openalex_row_to_api_work(slim: dict, full: dict | None) -> dict:
|
|
503
|
+
"""A `works_slim` row (+ its `works` biblio/abstract) in the OpenAlex API's Work shape."""
|
|
504
|
+
full = full or {}
|
|
505
|
+
try:
|
|
506
|
+
abstract_index = json.loads(full.get("abstract_inverted_index") or "null")
|
|
507
|
+
except ValueError:
|
|
508
|
+
abstract_index = None
|
|
509
|
+
primary = {
|
|
510
|
+
"landing_page_url": slim.get("landing_page_url"),
|
|
511
|
+
"pdf_url": slim.get("pdf_url"),
|
|
512
|
+
"source": {"display_name": slim.get("source_name"), "type": slim.get("source_type")},
|
|
513
|
+
}
|
|
514
|
+
return {
|
|
515
|
+
"id": slim["id"],
|
|
516
|
+
"doi": slim.get("doi"),
|
|
517
|
+
"ids": dict(slim.get("ids") or {}),
|
|
518
|
+
"title": slim.get("title") or "",
|
|
519
|
+
"publication_year": slim.get("publication_year"),
|
|
520
|
+
"type": slim.get("type"),
|
|
521
|
+
"authorships": [{"author": {"display_name": n}} for n in slim.get("author_names") or [] if n],
|
|
522
|
+
"primary_location": primary,
|
|
523
|
+
# No `locations` table locally: the primary and the best open-access location are all
|
|
524
|
+
# that is known. The latter is often the arXiv copy of a published paper.
|
|
525
|
+
"locations": [primary, *_best_oa_location(full, primary)],
|
|
526
|
+
"biblio": {k: full.get(k) for k in ("volume", "issue", "first_page", "last_page")},
|
|
527
|
+
"cited_by_count": slim.get("cited_by_count") or 0,
|
|
528
|
+
"abstract_inverted_index": abstract_index if isinstance(abstract_index, dict) else None,
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
|
|
532
|
+
# ── DBLP (dump) ───────────────────────────────────────────────────────────────────────────────────
|
|
533
|
+
class ClickHouseDblpAdapter(_ClickHouseAdapter):
|
|
534
|
+
name = "dblp"
|
|
535
|
+
handles = {EntryType.ARTICLE, EntryType.INPROCEEDINGS, EntryType.MISC}
|
|
536
|
+
|
|
537
|
+
@property
|
|
538
|
+
def db(self) -> str:
|
|
539
|
+
return self.ch.config.clickhouse_dblp_db
|
|
540
|
+
|
|
541
|
+
strict_order = "length(title), key, ingested_at DESC"
|
|
542
|
+
relaxed_order = "editDistance(lower(title), {cited:String}), key, ingested_at DESC"
|
|
543
|
+
|
|
544
|
+
def search_tables(self) -> list[str]:
|
|
545
|
+
return [f"{self.db}.dblp_publication"]
|
|
546
|
+
|
|
547
|
+
def coverage_sql(self) -> str:
|
|
548
|
+
return f"SELECT max(dump_date) AS d FROM {self.db}.dblp_publication"
|
|
549
|
+
|
|
550
|
+
async def _title_rows(self, condition: str, params: dict, order: str, limit: int) -> list[dict]:
|
|
551
|
+
table = f"{self.db}.dblp_publication"
|
|
552
|
+
# The table keeps one partition per dump; only the latest dump is the current DBLP.
|
|
553
|
+
rows = await self.ch.query(
|
|
554
|
+
"SELECT key, kind, publtype, title, year, venue, authors, dois "
|
|
555
|
+
f"FROM {table} WHERE dump_date = (SELECT max(dump_date) FROM {table}) "
|
|
556
|
+
f"AND {condition} ORDER BY {order} LIMIT 1 BY key LIMIT {{n:UInt32}}",
|
|
557
|
+
{**params, "n": limit},
|
|
558
|
+
)
|
|
559
|
+
return [{**r, "id": r["key"], "t": r["title"]} for r in rows]
|
|
560
|
+
|
|
561
|
+
async def _records(self, rows: list[dict]) -> list[SourceRecord]:
|
|
562
|
+
return [
|
|
563
|
+
dblp_dump_row_to_record({k: v for k, v in r.items() if k not in ("id", "t")})
|
|
564
|
+
for r in rows
|
|
565
|
+
]
|
|
566
|
+
|
|
567
|
+
|
|
568
|
+
def build_clickhouse_adapters(config: AuditConfig) -> list[SourceAdapter]:
|
|
569
|
+
"""The three local adapters, sharing one connection."""
|
|
570
|
+
connection = ClickHouseConnection(config)
|
|
571
|
+
return [
|
|
572
|
+
ClickHouseOpenAlexAdapter(connection),
|
|
573
|
+
ClickHouseSemanticScholarAdapter(connection),
|
|
574
|
+
ClickHouseDblpAdapter(connection),
|
|
575
|
+
]
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Crossref adapter — gold DOI metadata + the relation (version) graph.
|
|
2
|
+
|
|
3
|
+
Polite pool via `mailto`. `lookup_by_id` resolves a DOI directly; `search_by_metadata` does a
|
|
4
|
+
bibliographic + author query and returns the whole top-k (central matching disambiguates).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from reference_audit.models import (
|
|
10
|
+
BibEntry,
|
|
11
|
+
EntryType,
|
|
12
|
+
Identifiers,
|
|
13
|
+
SourceQueryResult,
|
|
14
|
+
SourceRecord,
|
|
15
|
+
)
|
|
16
|
+
from reference_audit.sources.base import SourceAdapter
|
|
17
|
+
from reference_audit.sources.http import TransientHTTPError, get_json
|
|
18
|
+
from reference_audit.sources.normalize import crossref_item_to_record
|
|
19
|
+
|
|
20
|
+
_BASE = "https://api.crossref.org/works"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class CrossrefAdapter(SourceAdapter):
|
|
24
|
+
name = "crossref"
|
|
25
|
+
handles = {
|
|
26
|
+
EntryType.ARTICLE,
|
|
27
|
+
EntryType.INPROCEEDINGS,
|
|
28
|
+
EntryType.BOOK,
|
|
29
|
+
EntryType.INCOLLECTION,
|
|
30
|
+
}
|
|
31
|
+
rate_per_sec = 10.0
|
|
32
|
+
# The polite pool's concurrency limit (crossref.org, REST API "Access and authentication").
|
|
33
|
+
# Without it a batch of metadata searches, each taking seconds, is answered with 429s.
|
|
34
|
+
max_in_flight = 3
|
|
35
|
+
|
|
36
|
+
def __init__(self, mailto: str = "reference-audit@example.org", **kw):
|
|
37
|
+
super().__init__(**kw)
|
|
38
|
+
self.mailto = mailto
|
|
39
|
+
|
|
40
|
+
async def lookup_by_id(self, ids: Identifiers) -> SourceQueryResult:
|
|
41
|
+
if not ids.doi:
|
|
42
|
+
return SourceQueryResult(source=self.name, query_kind="id", records=[])
|
|
43
|
+
# Crossref takes the raw DOI in the path (literal slashes); httpx encodes the rest.
|
|
44
|
+
url = f"{_BASE}/{ids.doi}"
|
|
45
|
+
try:
|
|
46
|
+
status, data = await get_json(
|
|
47
|
+
self.client, self.rate_limiter, url, params={"mailto": self.mailto}
|
|
48
|
+
)
|
|
49
|
+
except TransientHTTPError as exc:
|
|
50
|
+
return SourceQueryResult(source=self.name, query_kind="id", error=str(exc))
|
|
51
|
+
if status == 404 or not data:
|
|
52
|
+
return SourceQueryResult(source=self.name, query_kind="id", records=[])
|
|
53
|
+
item = data.get("message") or {}
|
|
54
|
+
return SourceQueryResult(
|
|
55
|
+
source=self.name, query_kind="id", records=[crossref_item_to_record(item)]
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
async def search_by_metadata(self, entry: BibEntry, limit: int = 10) -> SourceQueryResult:
|
|
59
|
+
params: dict[str, str | int] = {"rows": limit, "mailto": self.mailto, "select": _SELECT}
|
|
60
|
+
if entry.title:
|
|
61
|
+
params["query.bibliographic"] = entry.title
|
|
62
|
+
if entry.authors:
|
|
63
|
+
params["query.author"] = " ".join(entry.authors)
|
|
64
|
+
try:
|
|
65
|
+
status, data = await get_json(self.client, self.rate_limiter, _BASE, params=params)
|
|
66
|
+
except TransientHTTPError as exc:
|
|
67
|
+
return SourceQueryResult(source=self.name, query_kind="metadata", error=str(exc))
|
|
68
|
+
items = ((data or {}).get("message") or {}).get("items") or []
|
|
69
|
+
records: list[SourceRecord] = [crossref_item_to_record(it) for it in items]
|
|
70
|
+
return SourceQueryResult(source=self.name, query_kind="metadata", records=records)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
_SELECT = ",".join(
|
|
74
|
+
[
|
|
75
|
+
"DOI",
|
|
76
|
+
"title",
|
|
77
|
+
"author",
|
|
78
|
+
"container-title",
|
|
79
|
+
"issued",
|
|
80
|
+
"published",
|
|
81
|
+
"volume",
|
|
82
|
+
"issue",
|
|
83
|
+
"page",
|
|
84
|
+
"publisher",
|
|
85
|
+
"type",
|
|
86
|
+
"ISBN",
|
|
87
|
+
"relation",
|
|
88
|
+
"is-referenced-by-count",
|
|
89
|
+
]
|
|
90
|
+
)
|