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,330 @@
1
+ """Render an AuditReport as JSON (machine, use-case c) or human-readable text."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from reference_audit.models import AlignmentFinding, AuditReport, EntryAudit, FieldFinding
6
+
7
+
8
+ def render_json(report: AuditReport) -> str:
9
+ return report.model_dump_json(indent=2)
10
+
11
+
12
+ def _ids_str(audit: EntryAudit) -> str:
13
+ ids = audit.entry.ids
14
+ parts = []
15
+ if ids.doi:
16
+ parts.append(f"doi:{ids.doi}")
17
+ if ids.arxiv_id:
18
+ parts.append(f"arXiv:{ids.arxiv_id}")
19
+ if ids.isbn13:
20
+ parts.append(f"isbn:{ids.isbn13}")
21
+ if ids.openalex:
22
+ parts.append(f"openalex:{ids.openalex}")
23
+ if ids.url and not ids.doi:
24
+ parts.append(f"url:{ids.url}")
25
+ return " ".join(parts) if parts else "(no identifier)"
26
+
27
+
28
+ _VERDICT_GLYPH = {"exactly_one": "✓", "none": "✗", "multiple": "?"}
29
+
30
+
31
+ def _verdict_line(audit) -> str | None:
32
+ v = audit.verdict
33
+ if v is None:
34
+ return None
35
+ glyph = _VERDICT_GLYPH.get(v.kind, "·")
36
+ label = {"exactly_one": "exactly one match", "none": "NO MATCH (possible hallucination)",
37
+ "multiple": "MULTIPLE matches"}.get(v.kind, v.kind)
38
+ line = f" {glyph} {label} ({v.confidence}) — {v.rationale}"
39
+ if v.kind == "exactly_one" and v.artifacts and v.artifacts[0].best_record:
40
+ br = v.artifacts[0].best_record
41
+ if br.ids.doi:
42
+ line += f"\n matched: doi:{br.ids.doi} ({br.source})"
43
+ elif br.ids.isbn13:
44
+ line += f"\n matched: isbn:{br.ids.isbn13} ({br.source})"
45
+ elif br.ids.openalex:
46
+ line += f"\n matched: openalex:{br.ids.openalex} ({br.source})"
47
+ elif br.ids.url:
48
+ line += f"\n matched: url:{br.ids.url} ({br.source})"
49
+ return line
50
+
51
+
52
+ def _nit_line(f: FieldFinding) -> str:
53
+ """One formatting nit (a cosmetic, not-a-mistake field difference) spelled out."""
54
+ tag = "(LLM) " if f.via_llm else ""
55
+ src = f" [{', '.join(f.sources)}]" if f.sources else ""
56
+ return f" · {tag}formatting nit in '{f.field}'='{f.bib_value}' — {f.detail}{src}"
57
+
58
+
59
+ def _formatting_nits(a: EntryAudit) -> list[FieldFinding]:
60
+ return [f for f in a.field_findings if f.status == "formatting"]
61
+
62
+
63
+ def _alignment_advisories(a: EntryAudit) -> list[AlignmentFinding]:
64
+ """Advisory alignment notes shown inline (not_in_abstract). Contradicted and unverifiable are
65
+ surfaced as entry issues by the pipeline; supported findings are machine-only (JSON)."""
66
+ return [f for f in a.alignment_findings if f.status == "not_in_abstract"]
67
+
68
+
69
+ def _alignment_line(f: AlignmentFinding) -> str:
70
+ where = f" #{f.ordinal + 1}" if f.ordinal else ""
71
+ return (
72
+ f" · citation{where} not confirmed by the cited abstract "
73
+ f"(claim: “{f.context_text}”) — {f.detail}"
74
+ )
75
+
76
+
77
+ def _has_issues(a: EntryAudit, *, network: bool) -> bool:
78
+ """Does this entry need attention? An issue, or (once the network ran) a verdict that is not a
79
+ clean single match — a possible hallucination, an ambiguous multi-match, or an unresolved entry
80
+ we could not check. Formatting nits alone do NOT count as issues (they are cosmetic)."""
81
+ if a.issues:
82
+ return True
83
+ if network:
84
+ v = a.verdict
85
+ if v is None or v.kind in ("none", "multiple"):
86
+ return True
87
+ return False
88
+
89
+
90
+ def _entry_block(a: EntryAudit, *, pdf: bool = False, citedness_known: bool = True) -> list[str]:
91
+ """The full report block for one entry: header, ids, issues, formatting nits, verdict.
92
+
93
+ For a PDF-derived entry the key (`b12`) is a GROBID-assigned id and means nothing to a reader, so
94
+ the printed reference number is shown alongside it, and a reference GROBID could not title is
95
+ identified by the raw text it read — otherwise the entry would be unlocatable in the source PDF.
96
+ """
97
+ e = a.entry
98
+ if not citedness_known:
99
+ flag = "citedness unknown"
100
+ else:
101
+ flag = "cited" if e.cited else "UNCITED"
102
+ ref_no = e.raw_fields.get("grobid_ref_index") if pdf else None
103
+ where = f"ref #{ref_no}, {flag}" if ref_no else flag
104
+ title = e.title
105
+ if not title:
106
+ raw = (e.raw_fields.get("grobid_raw") or "").strip()
107
+ title = f'(no title — GROBID read: "{raw[:160]}")' if raw else "(no title)"
108
+ block = [
109
+ f"[{e.entry_type.value}] {e.key} ({where})",
110
+ f" {title}",
111
+ f" ids: {_ids_str(a)}",
112
+ ]
113
+ for issue in a.issues:
114
+ block.append(f" ⚠ {issue}")
115
+ for f in _formatting_nits(a):
116
+ block.append(_nit_line(f))
117
+ for f in _alignment_advisories(a):
118
+ block.append(_alignment_line(f))
119
+ vline = _verdict_line(a)
120
+ if vline:
121
+ block.append(vline)
122
+ return block
123
+
124
+
125
+ def _category_section(
126
+ bucket: list[EntryAudit],
127
+ heading: str,
128
+ empty_message: str,
129
+ *,
130
+ pdf: bool = False,
131
+ citedness_known: bool = True,
132
+ ) -> list[str]:
133
+ """A named report category: the full entry block for each member, or — when none qualify — a
134
+ single explicit line saying so (a clean run states it plainly rather than going silent)."""
135
+ if not bucket:
136
+ return [empty_message, ""]
137
+ out = [heading.format(n=len(bucket)), ""]
138
+ for a in bucket:
139
+ out.extend(_entry_block(a, pdf=pdf, citedness_known=citedness_known))
140
+ out.append("")
141
+ return out
142
+
143
+
144
+ def render_text(report: AuditReport) -> str:
145
+ s = report.summary
146
+ verdicts = s.get("verdicts")
147
+ pdf = report.input_kind == "pdf"
148
+ # For a PDF, cited/uncited depends on GROBID having linked in-text markers to bibliography items.
149
+ # When it linked none, saying "0 uncited" would assert something we do not know.
150
+ citedness_known = report.citation_linking == "available"
151
+ lines: list[str] = []
152
+ if verdicts:
153
+ header = {
154
+ "pdf": "Reference audit — references extracted from PDF",
155
+ "pdf_embedded": "Reference audit — references embedded in PDF",
156
+ }.get(report.input_kind, "Reference audit")
157
+ else:
158
+ header = "Reference audit — parse summary (no network)"
159
+ lines.append(header)
160
+ counts = [f" {s.get('total_entries', 0)} entries"]
161
+ if citedness_known:
162
+ counts.append(f"{s.get('cited', 0)} cited")
163
+ counts.append(f"{s.get('uncited', 0)} uncited")
164
+ else:
165
+ counts.append("citedness unknown")
166
+ counts.append(f"{s.get('entries_with_issues', 0)} with issues")
167
+ if s.get("unparsed"):
168
+ counts.append(f"{s['unparsed']} unparseable")
169
+ if report.input_kind == "tex_bib":
170
+ # A PDF has no commented-out entries, so the count is not zero — it is meaningless.
171
+ counts.append(f"{s.get('commented_twins', 0)} commented twins")
172
+ lines.append(" · ".join(counts))
173
+ if verdicts:
174
+ lines.append(
175
+ f" verdicts: {verdicts.get('exactly_one', 0)} matched"
176
+ f" · {verdicts.get('none', 0)} no-match"
177
+ f" · {verdicts.get('multiple', 0)} ambiguous"
178
+ f" · {verdicts.get('unresolved', 0)} unresolved"
179
+ )
180
+ if s.get("source_backend") == "clickhouse":
181
+ lines.append(" sources: Semantic Scholar, OpenAlex and DBLP read from local ClickHouse")
182
+ coverage = s.get("source_coverage") or {}
183
+ if coverage:
184
+ lines.append(
185
+ " local snapshots reach: "
186
+ + ", ".join(f"{name} {end}" for name, end in sorted(coverage.items()))
187
+ )
188
+ align = [f for a in report.entries for f in a.alignment_findings]
189
+ if align:
190
+ by_status: dict[str, int] = {}
191
+ for f in align:
192
+ by_status[f.status] = by_status.get(f.status, 0) + 1
193
+ lines.append(
194
+ f" citation alignment: {len(align)} checked"
195
+ f" · {by_status.get('contradicted', 0)} contradicted"
196
+ f" · {by_status.get('supported', 0)} supported"
197
+ f" · {by_status.get('not_in_abstract', 0)} not-in-abstract"
198
+ f" · {by_status.get('unverifiable', 0)} unverifiable"
199
+ )
200
+ by_type = s.get("by_type", {})
201
+ if by_type:
202
+ lines.append(" types: " + ", ".join(f"{k}={v}" for k, v in sorted(by_type.items())))
203
+ lines.append("")
204
+
205
+ # Caveats about the input itself, before any finding — so nothing below is read as an authoring
206
+ # error when it may be an extraction artifact.
207
+ if report.notes:
208
+ lines.append("INPUT NOTES:")
209
+ for note in report.notes:
210
+ lines.append(f" · {note}")
211
+ lines.append("")
212
+
213
+ # Entries the .bib parser could not read were never checked. They lead the findings: a reader who
214
+ # skipped this section would take the report as covering the whole bibliography.
215
+ if report.unparsed:
216
+ lines.append(
217
+ f"UNPARSEABLE .bib ENTRIES ({len(report.unparsed)}) — could not be read, so not checked:"
218
+ )
219
+ lines.append("")
220
+ for u in report.unparsed:
221
+ kind = f"[{u.entry_type}] " if u.entry_type else ""
222
+ lines.append(f"{kind}{u.key} (line {u.line})")
223
+ lines.append(f" ⚠ {u.reason}")
224
+ lines.append("")
225
+
226
+ # Group entries so the reader sees the gravest first. The two headline categories lead, each its
227
+ # own section: CAPITAL OFFENCES (conclusive hallucinations — verdict `none`, no real document
228
+ # corresponds) and UNABLE TO VERIFY (we could not conclusively rule a hallucination out — the
229
+ # verdict was left None by a transient network/LLM error, an unfamiliar .bib type, a dead link,
230
+ # or an adjudication we could not settle). Both are verdict-aware, so they apply only once the
231
+ # network has run; in --no-network mode there are no verdicts and every entry falls through to
232
+ # the issue/nit/clean split. The remaining entries — at least one artifact positively identified
233
+ # — are then split into issues, formatting-nits-only, and clean.
234
+ network = verdicts is not None
235
+ capital_offences: list[EntryAudit] = []
236
+ unable_to_verify: list[EntryAudit] = []
237
+ with_issues: list[EntryAudit] = []
238
+ nits_only: list[EntryAudit] = []
239
+ clean: list[EntryAudit] = []
240
+ for a in report.entries:
241
+ if network and a.verdict is not None and a.verdict.kind == "none":
242
+ capital_offences.append(a)
243
+ elif network and a.verdict is None:
244
+ unable_to_verify.append(a)
245
+ elif _has_issues(a, network=network):
246
+ with_issues.append(a)
247
+ elif _formatting_nits(a) or _alignment_advisories(a):
248
+ nits_only.append(a)
249
+ else:
250
+ clean.append(a)
251
+
252
+ if network:
253
+ lines.extend(_category_section(
254
+ capital_offences,
255
+ "CAPITAL OFFENCES ({n}) — hallucinated citations (no real document corresponds):",
256
+ "CAPITAL OFFENCES — No hallucinated citations",
257
+ pdf=pdf,
258
+ citedness_known=citedness_known,
259
+ ))
260
+ lines.extend(_category_section(
261
+ unable_to_verify,
262
+ "UNABLE TO VERIFY ({n}) — could not conclusively rule out a hallucination "
263
+ "(network/LLM error, unfamiliar entry type, dead link, …):",
264
+ "UNABLE TO VERIFY — For all other references at least one matching artifact "
265
+ "was positively identified",
266
+ pdf=pdf,
267
+ citedness_known=citedness_known,
268
+ ))
269
+
270
+ groups = [
271
+ (with_issues, "ISSUES ({n}) — other problems to review:"),
272
+ (nits_only, "FORMATTING NITS & ADVISORIES ({n}) — cosmetic field fixes or citation notes:"),
273
+ (clean, "NO ISSUES ({n}) — verified, nothing to fix:"),
274
+ ]
275
+ for bucket, heading in groups:
276
+ if not bucket:
277
+ continue
278
+ lines.append(heading.format(n=len(bucket)))
279
+ lines.append("")
280
+ for a in bucket:
281
+ lines.extend(_entry_block(a, pdf=pdf, citedness_known=citedness_known))
282
+ lines.append("")
283
+
284
+ if report.cited_but_missing:
285
+ if pdf:
286
+ # A TEI marker points into this same document's own bibliography, so a target with no
287
+ # entry means the extracted TEI is internally inconsistent — a GROBID fault, not the
288
+ # author's dangling citation.
289
+ lines.append(
290
+ "CITATION MARKERS POINTING AT NO REFERENCE (GROBID produced inconsistent TEI):"
291
+ )
292
+ for k in report.cited_but_missing:
293
+ lines.append(f" marker → {k} → no such bibliography entry")
294
+ else:
295
+ lines.append("CITED BUT MISSING FROM .bib (error — dangling citation):")
296
+ for k in report.cited_but_missing:
297
+ lines.append(f" \\cite{{{k}}} → no bib entry")
298
+ lines.append("")
299
+
300
+ if report.missing_includes:
301
+ lines.append(
302
+ "UNRESOLVED \\input/\\include (citation coverage incomplete — "
303
+ "'uncited' below may be cited inside these):"
304
+ )
305
+ for name in report.missing_includes:
306
+ lines.append(f" {name}")
307
+ lines.append("")
308
+
309
+ if report.uncited:
310
+ if pdf:
311
+ # GROBID's marker→bibliography linking is imperfect, so an "uncited" reference here may
312
+ # simply be one whose in-text markers it failed to attach. Say so rather than reporting a
313
+ # linking failure as an authoring error.
314
+ lines.append(
315
+ "UNCITED (no in-text citation marker was linked to it by GROBID — this may be a "
316
+ "linking failure rather than a genuinely uncited reference):"
317
+ )
318
+ else:
319
+ caveat = " — may be cited in missing includes" if report.missing_includes else ""
320
+ lines.append(f"UNCITED (in .bib, never \\cite/\\nocite — info{caveat}):")
321
+ lines.append(" " + ", ".join(report.uncited))
322
+ lines.append("")
323
+
324
+ if report.commented_twins:
325
+ lines.append("COMMENTED ENTRIES (informational — possible preprint/version twins):")
326
+ for k in report.commented_twins:
327
+ lines.append(f" {k}")
328
+ lines.append("")
329
+
330
+ return "\n".join(lines).rstrip() + "\n"
@@ -0,0 +1,6 @@
1
+ """Modular database-adapter layer.
2
+
3
+ Each adapter exposes two sharply different methods (see `base.SourceAdapter`):
4
+ `lookup_by_id` (privileged, ≤1 high-precision record) and `search_by_metadata` (recall, top-k).
5
+ Disambiguation happens centrally in `reference_audit.matching`, never inside an adapter.
6
+ """
@@ -0,0 +1,81 @@
1
+ """arXiv adapter — preprint authority (kumar2024automating; preprint side of T1).
2
+
3
+ Uses the arXiv Atom API. Parsed with stdlib ElementTree (no DTD/entity processing by default).
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import xml.etree.ElementTree as ET
9
+
10
+ import httpx
11
+
12
+ from reference_audit.models import (
13
+ BibEntry,
14
+ EntryType,
15
+ Identifiers,
16
+ SourceQueryResult,
17
+ SourceRecord,
18
+ )
19
+ from reference_audit.parsing.identifiers import arxiv_to_doi
20
+ from reference_audit.sources.base import SourceAdapter
21
+ from reference_audit.sources.http import TransientHTTPError, get_text
22
+
23
+ _BASE = "http://export.arxiv.org/api/query"
24
+ _ATOM = "{http://www.w3.org/2005/Atom}"
25
+
26
+
27
+ def _entry_to_record(entry: ET.Element) -> SourceRecord:
28
+ raw_id = (entry.findtext(f"{_ATOM}id") or "").strip()
29
+ arxiv_id = raw_id.rsplit("/abs/", 1)[-1] if "/abs/" in raw_id else raw_id
30
+ arxiv_id = arxiv_id.split("v")[0] if arxiv_id and "v" in arxiv_id.split("/")[-1] else arxiv_id
31
+ title = " ".join((entry.findtext(f"{_ATOM}title") or "").split())
32
+ authors = [
33
+ " ".join((a.findtext(f"{_ATOM}name") or "").split())
34
+ for a in entry.findall(f"{_ATOM}author")
35
+ ]
36
+ authors = [a for a in authors if a]
37
+ published = (entry.findtext(f"{_ATOM}published") or "").strip()
38
+ year = int(published[:4]) if published[:4].isdigit() else None
39
+ return SourceRecord(
40
+ source="arxiv",
41
+ source_native_id=arxiv_id,
42
+ title=title,
43
+ authors=authors,
44
+ year=year,
45
+ ids=Identifiers(arxiv_id=arxiv_id or None, doi=arxiv_to_doi(arxiv_id) if arxiv_id else None),
46
+ is_preprint=True,
47
+ version_links=[raw_id] if raw_id else [],
48
+ raw={"id": raw_id},
49
+ )
50
+
51
+
52
+ class ArxivAdapter(SourceAdapter):
53
+ name = "arxiv"
54
+ handles = {EntryType.MISC, EntryType.ARTICLE, EntryType.INPROCEEDINGS}
55
+ # The arXiv API terms: no more than one request every three seconds, over a single connection.
56
+ # At 3 requests/s, a third of a 112-entry HALLMARK chunk came back unresolved on arXiv 429s.
57
+ rate_per_sec = 1 / 3
58
+ max_in_flight = 1
59
+
60
+ async def _query(self, params: dict, query_kind: str) -> SourceQueryResult:
61
+ try:
62
+ # get_text rate-limits and retries 429/5xx with exponential backoff, then reraises
63
+ # TransientHTTPError once retries are exhausted (mapped to .error below).
64
+ _, text = await get_text(self.client, self.rate_limiter, _BASE, params=params)
65
+ root = ET.fromstring(text)
66
+ except (httpx.HTTPError, TransientHTTPError, ET.ParseError) as exc:
67
+ return SourceQueryResult(source=self.name, query_kind=query_kind, error=str(exc))
68
+ records = [_entry_to_record(e) for e in root.findall(f"{_ATOM}entry")]
69
+ records = [r for r in records if r.title]
70
+ return SourceQueryResult(source=self.name, query_kind=query_kind, records=records)
71
+
72
+ async def lookup_by_id(self, ids: Identifiers) -> SourceQueryResult:
73
+ if not ids.arxiv_id:
74
+ return SourceQueryResult(source=self.name, query_kind="id", records=[])
75
+ return await self._query({"id_list": ids.arxiv_id, "max_results": 1}, "id")
76
+
77
+ async def search_by_metadata(self, entry: BibEntry, limit: int = 10) -> SourceQueryResult:
78
+ if not entry.title:
79
+ return SourceQueryResult(source=self.name, query_kind="metadata", records=[])
80
+ params = {"search_query": f'ti:"{entry.title}"', "max_results": limit}
81
+ return await self._query(params, "metadata")
@@ -0,0 +1,54 @@
1
+ """SourceAdapter ABC — the modular adapter contract."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from abc import ABC
6
+ from datetime import date
7
+
8
+ from reference_audit.models import BibEntry, EntryType, Identifiers, SourceQueryResult
9
+ from reference_audit.sources.http import MonotonicRateLimiter, new_client
10
+
11
+
12
+ class SourceAdapter(ABC):
13
+ """One scholarly source.
14
+
15
+ `lookup_by_id` is privileged precision (≤1 record from a DOI/ISBN/arXiv endpoint); its result
16
+ is still scored downstream so a hallucinated-but-resolving id fails the title+author gate.
17
+ `search_by_metadata` is recall (top-k); it returns the WHOLE candidate list — central matching
18
+ decides, never the adapter.
19
+ """
20
+
21
+ name: str = "source"
22
+ handles: set[EntryType] = set()
23
+ rate_per_sec: float = 5.0
24
+ # Requests the source allows in flight at once, when it documents a cap (None = no cap). A slot
25
+ # is held for a whole request, retries included (`MonotonicRateLimiter`).
26
+ max_in_flight: int | None = None
27
+ # Where the data comes from: the public API ("api"), or a local mirror of the same database
28
+ # ("clickhouse"). Two backends of one source share `name` (routing, field priorities and reports
29
+ # are about the database, not the transport), so cached responses are keyed by `cache_source`.
30
+ backend: str = "api"
31
+ # The newest date a local snapshot covers, set by `preflight`. None for a live API: a work too
32
+ # new for a snapshot cannot be found there, and the report says so next to a `none` verdict.
33
+ coverage_end: date | None = None
34
+
35
+ def __init__(self, *, client=None, limiter: MonotonicRateLimiter | None = None):
36
+ self.client = client or new_client()
37
+ self.rate_limiter = limiter or MonotonicRateLimiter(self.rate_per_sec, self.max_in_flight)
38
+
39
+ @property
40
+ def cache_source(self) -> str:
41
+ """The source key this adapter's responses are cached under — never shared across backends."""
42
+ return self.name if self.backend == "api" else f"{self.name}@{self.backend}"
43
+
44
+ async def preflight(self) -> None:
45
+ """Raise if this source cannot be queried at all, before any entry is audited (no-op here)."""
46
+
47
+ async def lookup_by_id(self, ids: Identifiers) -> SourceQueryResult: # noqa: ARG002
48
+ return SourceQueryResult(source=self.name, query_kind="id", records=[])
49
+
50
+ async def search_by_metadata(self, entry: BibEntry, limit: int = 10) -> SourceQueryResult: # noqa: ARG002
51
+ return SourceQueryResult(source=self.name, query_kind="metadata", records=[])
52
+
53
+ async def aclose(self) -> None:
54
+ await self.client.aclose()