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,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()
|