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.
Files changed (60) hide show
  1. reference_audit/__init__.py +8 -0
  2. reference_audit/alignmentcheck.py +136 -0
  3. reference_audit/bookcheck.py +98 -0
  4. reference_audit/cache/__init__.py +1 -0
  5. reference_audit/cache/db.py +108 -0
  6. reference_audit/cache/store.py +128 -0
  7. reference_audit/cli.py +190 -0
  8. reference_audit/config.py +179 -0
  9. reference_audit/fieldcheck.py +599 -0
  10. reference_audit/inputs.py +96 -0
  11. reference_audit/llm/__init__.py +6 -0
  12. reference_audit/llm/client.py +85 -0
  13. reference_audit/llm/prompts.py +206 -0
  14. reference_audit/llm/schemas.py +38 -0
  15. reference_audit/matching/__init__.py +6 -0
  16. reference_audit/matching/adjudicate.py +63 -0
  17. reference_audit/matching/features.py +185 -0
  18. reference_audit/matching/names.py +238 -0
  19. reference_audit/matching/pool.py +344 -0
  20. reference_audit/matching/sameobject.py +150 -0
  21. reference_audit/matching/scoring.py +75 -0
  22. reference_audit/matching/verdict.py +56 -0
  23. reference_audit/matching/webcheck.py +189 -0
  24. reference_audit/models.py +412 -0
  25. reference_audit/parsing/__init__.py +1 -0
  26. reference_audit/parsing/bib.py +392 -0
  27. reference_audit/parsing/context.py +55 -0
  28. reference_audit/parsing/entities.py +22 -0
  29. reference_audit/parsing/identifiers.py +165 -0
  30. reference_audit/parsing/latex.py +83 -0
  31. reference_audit/parsing/tex.py +159 -0
  32. reference_audit/pdf/__init__.py +16 -0
  33. reference_audit/pdf/embedded.py +117 -0
  34. reference_audit/pdf/grobid.py +234 -0
  35. reference_audit/pdf/tei.py +470 -0
  36. reference_audit/pipeline.py +1692 -0
  37. reference_audit/report.py +330 -0
  38. reference_audit/sources/__init__.py +6 -0
  39. reference_audit/sources/arxiv.py +81 -0
  40. reference_audit/sources/base.py +54 -0
  41. reference_audit/sources/clickhouse.py +575 -0
  42. reference_audit/sources/crossref.py +90 -0
  43. reference_audit/sources/dblp.py +140 -0
  44. reference_audit/sources/google_books.py +101 -0
  45. reference_audit/sources/http.py +260 -0
  46. reference_audit/sources/normalize.py +464 -0
  47. reference_audit/sources/openalex.py +93 -0
  48. reference_audit/sources/openlibrary.py +158 -0
  49. reference_audit/sources/publisher.py +186 -0
  50. reference_audit/sources/registry.py +117 -0
  51. reference_audit/sources/render.py +115 -0
  52. reference_audit/sources/semantic_scholar.py +68 -0
  53. reference_audit/sources/titlewords.py +31 -0
  54. reference_audit/sources/web.py +256 -0
  55. reference_audit/versioning.py +125 -0
  56. reference_audit-0.1.0.dist-info/METADATA +715 -0
  57. reference_audit-0.1.0.dist-info/RECORD +60 -0
  58. reference_audit-0.1.0.dist-info/WHEEL +4 -0
  59. reference_audit-0.1.0.dist-info/entry_points.txt +2 -0
  60. 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
+ )