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,599 @@
|
|
|
1
|
+
"""Step 3 (correctness): are the .bib *fields* right?
|
|
2
|
+
|
|
3
|
+
Once step 1 has matched an entry to exactly one real artifact (by a strong identifier), the work's
|
|
4
|
+
identity is settled — but individual fields can still be wrong: a dropped word in a journal name, a
|
|
5
|
+
split typo ('Un iversity'), a placeholder issue number, a garbled page range. This module compares
|
|
6
|
+
each field of the entry against the canonical record and labels it `ok` / `formatting` / `error` /
|
|
7
|
+
`uncertain` / `unverifiable`.
|
|
8
|
+
|
|
9
|
+
The classifier is an intelligent combination of two layers:
|
|
10
|
+
|
|
11
|
+
1. **Per-field deterministic rules** (`deterministic_field_checks`, pure, no network). These own the
|
|
12
|
+
cases a rule can settle: numeric fields (year/volume/issue) compare by value; page ranges are
|
|
13
|
+
normalized so that dash style (`--` vs en-dash vs the malformed `185-–197`) never reads as a
|
|
14
|
+
wrong page; empty/placeholder fields are flagged outright; exact and case/accent-folded string
|
|
15
|
+
matches resolve to `ok`/`formatting`.
|
|
16
|
+
2. **An LLM check** for the genuinely ambiguous string differences a rule cannot judge — is
|
|
17
|
+
'Annual Review Condensed Matter Physics' a wrong name (dropped 'of') or just a variant? is
|
|
18
|
+
'J. Theor. Biol.' the same journal as 'Journal of Theoretical Biology'? `resolve_field_findings`
|
|
19
|
+
escalates exactly those, caching each decision.
|
|
20
|
+
|
|
21
|
+
Per the reliability contract, an unconfirmable field becomes `unverifiable`/`uncertain` and is
|
|
22
|
+
reported — never silently passed. Field checks are advisory: they never change the step-1 verdict.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import asyncio
|
|
28
|
+
import re
|
|
29
|
+
from dataclasses import dataclass, field as dataclass_field
|
|
30
|
+
|
|
31
|
+
from anyascii import anyascii
|
|
32
|
+
|
|
33
|
+
from reference_audit.cache.store import AuditCache, prompt_hash
|
|
34
|
+
from reference_audit.config import AuditConfig
|
|
35
|
+
from reference_audit.llm.client import LLMClient, LLMError
|
|
36
|
+
from reference_audit.llm.prompts import FIELD_CHECK_SYSTEM, field_check_user
|
|
37
|
+
from reference_audit.matching.features import is_repository_venue
|
|
38
|
+
from reference_audit.matching.names import check_cited_authors, omitted_authors
|
|
39
|
+
from reference_audit.models import (
|
|
40
|
+
BibEntry,
|
|
41
|
+
EntryType,
|
|
42
|
+
FieldFinding,
|
|
43
|
+
FieldJudgment,
|
|
44
|
+
MatchedArtifact,
|
|
45
|
+
SourceRecord,
|
|
46
|
+
)
|
|
47
|
+
from reference_audit.parsing.identifiers import arxiv_submission_year
|
|
48
|
+
from reference_audit.versioning import cited_arxiv_id
|
|
49
|
+
|
|
50
|
+
_BOOK_TYPES = {EntryType.BOOK, EntryType.INCOLLECTION}
|
|
51
|
+
|
|
52
|
+
# Values that mean "no real value" even though the field is present (sotnikov `number={}`,
|
|
53
|
+
# goldenfeld `number={-}`). Compared after stripping braces/whitespace and lowercasing.
|
|
54
|
+
_PLACEHOLDERS = {"", "-", "--", "–", "—", "n/a", "n.a.", "na", "none", "null", "tbd", "?", "..."}
|
|
55
|
+
|
|
56
|
+
# Source preference for sourcing a canonical field value: the publisher of record first, then the
|
|
57
|
+
# rich registration-grade bibliographic records.
|
|
58
|
+
_SOURCE_RANK = {
|
|
59
|
+
"publisher": 0,
|
|
60
|
+
"crossref": 1,
|
|
61
|
+
"openalex": 2,
|
|
62
|
+
"dblp": 3,
|
|
63
|
+
"openlibrary": 4,
|
|
64
|
+
"semantic_scholar": 5,
|
|
65
|
+
"arxiv": 6,
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
_DASH_RUN = re.compile(r"\s*[-–—‐]+\s*")
|
|
69
|
+
_RANGE = re.compile(r"\w-\w")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _strip(s: str | None) -> str:
|
|
73
|
+
"""Collapse whitespace and drop LaTeX braces, preserving case/accents."""
|
|
74
|
+
return re.sub(r"\s+", " ", (s or "").replace("{", "").replace("}", "")).strip()
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _fold(s: str | None) -> str:
|
|
78
|
+
"""Case/accent/punctuation-insensitive form for equivalence. Braces are *removed* (so the
|
|
79
|
+
protection in '{L}enia' rejoins 'Lenia') before other punctuation collapses to spaces."""
|
|
80
|
+
bare = (s or "").replace("{", "").replace("}", "")
|
|
81
|
+
return re.sub(r"\s+", " ", re.sub(r"[^a-z0-9 ]", " ", anyascii(bare).lower())).strip()
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _is_placeholder(raw: str) -> bool:
|
|
85
|
+
return _strip(raw).lower() in _PLACEHOLDERS
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _num(s: str) -> str:
|
|
89
|
+
"""Normalize an integer-valued field (drop leading zeros); fall back to a folded string."""
|
|
90
|
+
t = _strip(s)
|
|
91
|
+
return str(int(t)) if t.isdigit() else _fold(t)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _norm_pages(p: str) -> str:
|
|
95
|
+
"""Canonical page form for comparison: any dash run → single '-', folded ('E8678--E8687'→
|
|
96
|
+
'e8678-e8687'; '139–-158'→'139-158'; '40'→'40')."""
|
|
97
|
+
return _DASH_RUN.sub("-", _strip(p)).casefold()
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _pages_clean_range(raw: str) -> bool:
|
|
101
|
+
"""True if a page *range* is written with the canonical BibTeX '--' and nothing odd."""
|
|
102
|
+
s = _strip(raw)
|
|
103
|
+
if "–" in s or "—" in s: # contains an en/em dash → not the canonical '--'
|
|
104
|
+
return False
|
|
105
|
+
return bool(re.search(r"\w--\w", s))
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
# ── canonical value sourcing ─────────────────────────────────────────────────
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _is_preprint_copy(rec: SourceRecord) -> bool:
|
|
112
|
+
"""A preprint or repository copy of the work, rather than its journal/conference version."""
|
|
113
|
+
doi = rec.ids.doi or ""
|
|
114
|
+
return rec.is_preprint or doi.startswith("10.48550/arxiv") or is_repository_venue(rec.venue)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def cites_preprint(entry: BibEntry) -> bool:
|
|
118
|
+
"""The entry cites the preprint: its venue is a preprint server or repository, or it names no
|
|
119
|
+
venue but an arXiv id. Anything else cites a journal or conference version."""
|
|
120
|
+
if entry.venue:
|
|
121
|
+
return is_repository_venue(entry.venue)
|
|
122
|
+
return cited_arxiv_id(entry) is not None
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class _Ordered(list):
|
|
126
|
+
"""Records in comparison order. `tiers[i]` is record i's (other kind, other year) group, or None
|
|
127
|
+
for a pooled fallback record; `_canonical` votes within the first group that has a value."""
|
|
128
|
+
|
|
129
|
+
tiers: list
|
|
130
|
+
|
|
131
|
+
def where(self, keep) -> _Ordered:
|
|
132
|
+
out = _Ordered(r for r in self if keep(r))
|
|
133
|
+
out.tiers = [t for r, t in zip(self, self.tiers, strict=True) if keep(r)]
|
|
134
|
+
return out
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _ordered_records(entry: BibEntry, artifact: MatchedArtifact) -> _Ordered:
|
|
138
|
+
"""The matched work's source records, the version the entry cites first.
|
|
139
|
+
|
|
140
|
+
A pooled record compiles one view across a work's versions, but an entry cites one version: the
|
|
141
|
+
arXiv preprint (its year, its title) or the published paper, and of a conference paper and its
|
|
142
|
+
later journal version, one of the two. So the fields are compared against the pooled record's
|
|
143
|
+
members: first those of the kind the entry cites (preprint or published), then those from the
|
|
144
|
+
cited year, then by source authority. The pooled records follow, as a fallback.
|
|
145
|
+
"""
|
|
146
|
+
members = [m for r in artifact.records for m in (r.members or [r])]
|
|
147
|
+
wants_preprint = cites_preprint(entry)
|
|
148
|
+
|
|
149
|
+
def key(r: SourceRecord) -> tuple:
|
|
150
|
+
other_kind = _is_preprint_copy(r) != wants_preprint
|
|
151
|
+
other_year = entry.year is not None and r.year != entry.year
|
|
152
|
+
return (other_kind, other_year, _SOURCE_RANK.get(r.source, 9))
|
|
153
|
+
|
|
154
|
+
keyed = sorted(((key(r), r) for r in members), key=lambda kr: kr[0])
|
|
155
|
+
fallbacks = [r for r in artifact.records if r.members]
|
|
156
|
+
out = _Ordered([r for _, r in keyed] + fallbacks)
|
|
157
|
+
out.tiers = [k[:2] for k, _ in keyed] + [None] * len(fallbacks)
|
|
158
|
+
return out
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _rec_sources(rec: SourceRecord) -> list[str]:
|
|
162
|
+
"""Sources behind a (possibly pooled) record — the merge set if present, else its own source."""
|
|
163
|
+
merged_from = rec.raw.get("merged_from") if isinstance(rec.raw, dict) else None
|
|
164
|
+
return list(merged_from) if merged_from else [rec.source]
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _canonical(records: list[SourceRecord], getter) -> tuple[str, list[str]]:
|
|
168
|
+
"""The canonical value of a field, plus every source that agrees on it.
|
|
169
|
+
|
|
170
|
+
Within the first group of records (see `_Ordered`) that has a value, the value most sources agree
|
|
171
|
+
on wins, the more authoritative one on a tie. One source's defect is outvoted: OpenAlex gives
|
|
172
|
+
arXiv:2212.08073, Constitutional AI, the title "Affective Coherence Monitoring for
|
|
173
|
+
Transformer-Based Language Models", where S2, arXiv and DBLP have the real one.
|
|
174
|
+
"""
|
|
175
|
+
def has(r: SourceRecord) -> bool:
|
|
176
|
+
return bool((getter(r) or "").strip())
|
|
177
|
+
|
|
178
|
+
first = next((i for i, r in enumerate(records) if has(r)), None)
|
|
179
|
+
if first is None:
|
|
180
|
+
return "", []
|
|
181
|
+
if records[first].members: # a pooled fallback record: its own compiled value and merge set
|
|
182
|
+
return getter(records[first]).strip(), _rec_sources(records[first])
|
|
183
|
+
tiers = getattr(records, "tiers", None)
|
|
184
|
+
group = [
|
|
185
|
+
r for i, r in enumerate(records)
|
|
186
|
+
if has(r) and not r.members and (tiers is None or tiers[i] == tiers[first])
|
|
187
|
+
]
|
|
188
|
+
support: dict[str, set[str]] = {}
|
|
189
|
+
for r in group:
|
|
190
|
+
support.setdefault(_fold(getter(r)), set()).add(r.source)
|
|
191
|
+
best = max(group, key=lambda r: (len(support[_fold(getter(r))]), -group.index(r)))
|
|
192
|
+
value = getter(best).strip()
|
|
193
|
+
agree = {r.source for r in records if not r.members and _fold(getter(r)) == _fold(value)}
|
|
194
|
+
return value, sorted(agree)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
# ── per-field deterministic comparison ───────────────────────────────────────
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
@dataclass
|
|
201
|
+
class _Check:
|
|
202
|
+
"""A field comparison before (optional) LLM escalation. `needs_llm` marks the ambiguous
|
|
203
|
+
string differences a rule cannot settle on its own."""
|
|
204
|
+
|
|
205
|
+
field: str
|
|
206
|
+
bib_value: str
|
|
207
|
+
canonical_value: str
|
|
208
|
+
sources: list[str] = dataclass_field(default_factory=list)
|
|
209
|
+
status: str = "ok" # ok | formatting | error | unverifiable | needs_llm
|
|
210
|
+
detail: str = ""
|
|
211
|
+
needs_llm: bool = False
|
|
212
|
+
|
|
213
|
+
def finding(self, *, via_llm: bool = False) -> FieldFinding:
|
|
214
|
+
return FieldFinding(
|
|
215
|
+
field=self.field,
|
|
216
|
+
bib_value=self.bib_value,
|
|
217
|
+
canonical_value=self.canonical_value,
|
|
218
|
+
sources=self.sources,
|
|
219
|
+
status="uncertain" if self.status == "needs_llm" else self.status,
|
|
220
|
+
detail=self.detail,
|
|
221
|
+
via_llm=via_llm,
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _string_field(name: str, bib_raw: str, canonical: str, sources: list[str]) -> _Check:
|
|
226
|
+
"""Exact → ok; case/accent-fold equal → formatting; otherwise escalate to the LLM."""
|
|
227
|
+
bib = _strip(bib_raw)
|
|
228
|
+
chk = _Check(name, bib, canonical, sources)
|
|
229
|
+
if not canonical:
|
|
230
|
+
chk.status, chk.detail = "unverifiable", "no source returned this field to check against"
|
|
231
|
+
return chk
|
|
232
|
+
if bib == _strip(canonical):
|
|
233
|
+
return chk
|
|
234
|
+
if _fold(bib) == _fold(canonical):
|
|
235
|
+
chk.status = "formatting"
|
|
236
|
+
chk.detail = f"only capitalization/punctuation/accents differ from canonical '{canonical}'"
|
|
237
|
+
return chk
|
|
238
|
+
chk.status, chk.needs_llm = "needs_llm", True
|
|
239
|
+
return chk
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _venue_check(entry: BibEntry, records: _Ordered) -> _Check | None:
|
|
243
|
+
if not entry.venue:
|
|
244
|
+
return None
|
|
245
|
+
wants_preprint = cites_preprint(entry)
|
|
246
|
+
# A cited journal or conference is compared against a record that names one; a preprint server
|
|
247
|
+
# is the canonical venue only when no source has anything else.
|
|
248
|
+
published = records.where(lambda r: not is_repository_venue(r.venue))
|
|
249
|
+
canonical, sources = _canonical(records if wants_preprint else published, lambda r: r.venue)
|
|
250
|
+
if not canonical and not wants_preprint:
|
|
251
|
+
canonical, sources = _canonical(records, lambda r: r.venue)
|
|
252
|
+
if canonical:
|
|
253
|
+
chk = _Check("journal/venue", _strip(entry.venue), canonical, sources)
|
|
254
|
+
chk.status = "unverifiable"
|
|
255
|
+
chk.detail = (
|
|
256
|
+
f"every source has only a preprint/repository copy ('{canonical}'); the published "
|
|
257
|
+
"venue could not be confirmed"
|
|
258
|
+
)
|
|
259
|
+
return chk
|
|
260
|
+
return _string_field("journal/venue", entry.venue, canonical, sources)
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _title_check(entry: BibEntry, records: list[SourceRecord]) -> _Check | None:
|
|
264
|
+
if not entry.title:
|
|
265
|
+
return None
|
|
266
|
+
canonical, sources = _canonical(records, lambda r: r.title)
|
|
267
|
+
chk = _Check("title", _strip(entry.title), canonical, sources)
|
|
268
|
+
if not canonical:
|
|
269
|
+
chk.status, chk.detail = "unverifiable", "no source returned a title to check against"
|
|
270
|
+
return chk
|
|
271
|
+
if _fold(entry.title) == _fold(canonical):
|
|
272
|
+
return chk # braces/case/accents folded away → ok
|
|
273
|
+
chk.status, chk.needs_llm = "needs_llm", True
|
|
274
|
+
return chk
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _year_check(entry: BibEntry, records: list[SourceRecord]) -> _Check | None:
|
|
278
|
+
if entry.year is None:
|
|
279
|
+
return None
|
|
280
|
+
value, sources = _canonical(records, lambda r: str(r.year) if r.year else "")
|
|
281
|
+
canonical_year = int(value) if value else None
|
|
282
|
+
chk = _Check("year", str(entry.year), value, sources)
|
|
283
|
+
if canonical_year is None:
|
|
284
|
+
chk.status, chk.detail = "unverifiable", "no source returned a year to check against"
|
|
285
|
+
return chk
|
|
286
|
+
delta = abs(entry.year - canonical_year)
|
|
287
|
+
if delta == 0:
|
|
288
|
+
return chk
|
|
289
|
+
# Preprint cited by its arXiv id: the .bib year is the original version's submission year
|
|
290
|
+
# (encoded in the id). arXiv updates push the canonical/latest year forward, so a *later*
|
|
291
|
+
# canonical year is a newer VERSION of the same preprint, not a wrong year — the record is valid
|
|
292
|
+
# for the version cited (the newer-version advisory is emitted by versioning.better_version_notes).
|
|
293
|
+
# A cited year that does NOT equal the id-encoded submission year falls through and is still flagged.
|
|
294
|
+
arxiv_id = cited_arxiv_id(entry)
|
|
295
|
+
if arxiv_id is not None and canonical_year > entry.year:
|
|
296
|
+
submitted = arxiv_submission_year(arxiv_id)
|
|
297
|
+
if submitted is not None and entry.year == submitted:
|
|
298
|
+
chk.detail = (
|
|
299
|
+
f"matches the cited arXiv version ({submitted}); canonical {canonical_year} is a "
|
|
300
|
+
"later version of the same preprint"
|
|
301
|
+
)
|
|
302
|
+
return chk # status stays 'ok' — valid for the version cited
|
|
303
|
+
if entry.entry_type in _BOOK_TYPES:
|
|
304
|
+
# Books have edition-specific years; the match may be a different printing, so a year gap
|
|
305
|
+
# is a prompt to verify the edition — not, on its own, a bib mistake.
|
|
306
|
+
chk.status = "uncertain"
|
|
307
|
+
chk.detail = f"differs from matched edition ({canonical_year}); verify the edition/printing"
|
|
308
|
+
elif delta == 1:
|
|
309
|
+
chk.status = "uncertain"
|
|
310
|
+
chk.detail = f"differs by one from canonical {canonical_year} (often online vs print year)"
|
|
311
|
+
else:
|
|
312
|
+
chk.status = "error"
|
|
313
|
+
chk.detail = f"year {entry.year} does not match canonical {canonical_year}"
|
|
314
|
+
return chk
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def _author_check(
|
|
318
|
+
entry: BibEntry, records: list[SourceRecord], partial_authors: str
|
|
319
|
+
) -> _Check | None:
|
|
320
|
+
"""Every cited author must be an author of the matched work in at least one source record.
|
|
321
|
+
|
|
322
|
+
A cited list that names only some of the work's authors, without saying so ('and others'), is
|
|
323
|
+
reported per `partial_authors`: `ignore` (not reported), `warn` (uncertain) or `error`.
|
|
324
|
+
"""
|
|
325
|
+
if not entry.authors:
|
|
326
|
+
return None
|
|
327
|
+
cited = "; ".join(entry.authors)
|
|
328
|
+
with_authors = [r for r in records if r.authors]
|
|
329
|
+
if not with_authors:
|
|
330
|
+
chk = _Check("author", cited, "", [])
|
|
331
|
+
chk.status, chk.detail = "unverifiable", "no source returned an author list to check against"
|
|
332
|
+
return chk
|
|
333
|
+
carrier = with_authors[0] # the cited version's list (see _ordered_records)
|
|
334
|
+
chk = _Check("author", cited, "; ".join(carrier.authors), _rec_sources(carrier))
|
|
335
|
+
missing, unchecked = check_cited_authors(entry.authors, [r.authors for r in with_authors])
|
|
336
|
+
if missing or unchecked:
|
|
337
|
+
chk.sources = sorted({s for r in with_authors for s in _rec_sources(r)})
|
|
338
|
+
if missing:
|
|
339
|
+
chk.status = "error"
|
|
340
|
+
chk.detail = f"not an author of the matched work in any source: {', '.join(missing)}"
|
|
341
|
+
return chk
|
|
342
|
+
if unchecked:
|
|
343
|
+
longest = max(len(r.authors) for r in with_authors)
|
|
344
|
+
chk.status = "unverifiable"
|
|
345
|
+
chk.detail = (
|
|
346
|
+
f"every source lists only the first {longest} of the {len(entry.authors)} cited "
|
|
347
|
+
f"authors, so {', '.join(unchecked)} could not be checked"
|
|
348
|
+
)
|
|
349
|
+
return chk
|
|
350
|
+
if partial_authors == "ignore":
|
|
351
|
+
return chk
|
|
352
|
+
omitted = omitted_authors(entry.authors, carrier.authors)
|
|
353
|
+
if omitted:
|
|
354
|
+
chk.status = "error" if partial_authors == "error" else "uncertain"
|
|
355
|
+
shown = ", ".join(omitted[:5]) + (", …" if len(omitted) > 5 else "")
|
|
356
|
+
chk.detail = (
|
|
357
|
+
f"the cited list omits {len(omitted)} of the work's {len(carrier.authors)} authors "
|
|
358
|
+
f"({shown}) without marking it shortened ('and others')"
|
|
359
|
+
)
|
|
360
|
+
return chk
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def _numeric_field(
|
|
364
|
+
name: str, bib_raw: str | None, canonical: str, sources: list[str]
|
|
365
|
+
) -> _Check | None:
|
|
366
|
+
"""volume / issue: present-but-placeholder → error; numeric/string compare otherwise."""
|
|
367
|
+
if bib_raw is None:
|
|
368
|
+
return None # field absent from the entry — nothing to check
|
|
369
|
+
chk = _Check(name, _strip(bib_raw), canonical, sources)
|
|
370
|
+
if _is_placeholder(bib_raw):
|
|
371
|
+
chk.status = "error"
|
|
372
|
+
chk.detail = f"{name} is empty or a placeholder ('{_strip(bib_raw)}')"
|
|
373
|
+
if canonical:
|
|
374
|
+
chk.detail += f"; canonical {name} is '{canonical}'"
|
|
375
|
+
return chk
|
|
376
|
+
if not canonical:
|
|
377
|
+
chk.status, chk.detail = "unverifiable", f"no source returned {name} to check against"
|
|
378
|
+
return chk
|
|
379
|
+
if _num(bib_raw) != _num(canonical):
|
|
380
|
+
chk.status = "error"
|
|
381
|
+
chk.detail = f"{name} '{_strip(bib_raw)}' does not match canonical '{canonical}'"
|
|
382
|
+
return chk
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def _pages_check(entry: BibEntry, records: list[SourceRecord]) -> _Check | None:
|
|
386
|
+
if not entry.pages:
|
|
387
|
+
return None
|
|
388
|
+
canonical, sources = _canonical(records, lambda r: r.pages)
|
|
389
|
+
chk = _Check("pages", _strip(entry.pages), canonical, sources)
|
|
390
|
+
if not canonical:
|
|
391
|
+
chk.status, chk.detail = "unverifiable", "no source returned pages to check against"
|
|
392
|
+
return chk
|
|
393
|
+
if _norm_pages(entry.pages) != _norm_pages(canonical):
|
|
394
|
+
chk.status = "error"
|
|
395
|
+
cano, ent = _norm_pages(canonical), _norm_pages(entry.pages)
|
|
396
|
+
if "-" not in cano and "-" in ent and ent.split("-", 1)[0] == cano:
|
|
397
|
+
# canonical is a single article number (e.g. an article-numbered proceedings); the entry
|
|
398
|
+
# turned it into a range, inventing an end page that no source confirms.
|
|
399
|
+
chk.detail = (
|
|
400
|
+
f"canonical lists a single article number '{canonical}'; the entry's range "
|
|
401
|
+
f"'{_strip(entry.pages)}' adds an end page no source confirms"
|
|
402
|
+
)
|
|
403
|
+
else:
|
|
404
|
+
chk.detail = f"pages '{_strip(entry.pages)}' do not match canonical '{canonical}'"
|
|
405
|
+
return chk
|
|
406
|
+
# Same page numbers: only the separator style may differ.
|
|
407
|
+
if _RANGE.search(_norm_pages(entry.pages)) and not _pages_clean_range(entry.pages):
|
|
408
|
+
chk.status = "formatting"
|
|
409
|
+
chk.detail = f"non-standard page separator in '{_strip(entry.pages)}'; use '--'"
|
|
410
|
+
return chk
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def deterministic_field_checks(
|
|
414
|
+
entry: BibEntry,
|
|
415
|
+
artifact: MatchedArtifact,
|
|
416
|
+
*,
|
|
417
|
+
skip_fields: frozenset[str] = frozenset(),
|
|
418
|
+
partial_authors: str = "warn",
|
|
419
|
+
) -> list[_Check]:
|
|
420
|
+
"""All per-field rule outcomes for a matched entry (pure, no network/LLM).
|
|
421
|
+
|
|
422
|
+
`skip_fields` drops named fields — used for books, whose `year`/`publisher` are edition-specific
|
|
423
|
+
and verified separately against the cited Open Library edition (see `check_book_edition_fields`),
|
|
424
|
+
not against the pooled artifact (which may hold a newer edition).
|
|
425
|
+
"""
|
|
426
|
+
records = _ordered_records(entry, artifact)
|
|
427
|
+
checks: list[_Check | None] = [
|
|
428
|
+
_title_check(entry, records),
|
|
429
|
+
_author_check(entry, records, partial_authors),
|
|
430
|
+
_venue_check(entry, records),
|
|
431
|
+
_year_check(entry, records),
|
|
432
|
+
]
|
|
433
|
+
|
|
434
|
+
vol_canon, vol_src = _canonical(records, lambda r: r.volume)
|
|
435
|
+
checks.append(_numeric_field("volume", entry.raw_fields.get("volume"), vol_canon, vol_src))
|
|
436
|
+
|
|
437
|
+
# The entry may carry the issue under the standard `number` or the variant `issue` field.
|
|
438
|
+
number_raw = entry.raw_fields.get("number")
|
|
439
|
+
if number_raw is None:
|
|
440
|
+
number_raw = entry.raw_fields.get("issue")
|
|
441
|
+
iss_canon, iss_src = _canonical(records, lambda r: r.issue)
|
|
442
|
+
checks.append(_numeric_field("number", number_raw, iss_canon, iss_src))
|
|
443
|
+
|
|
444
|
+
checks.append(_pages_check(entry, records))
|
|
445
|
+
|
|
446
|
+
if entry.publisher:
|
|
447
|
+
canonical, sources = _canonical(records, lambda r: r.publisher)
|
|
448
|
+
checks.append(_string_field("publisher", entry.publisher, canonical, sources))
|
|
449
|
+
|
|
450
|
+
return [c for c in checks if c is not None and c.field not in skip_fields]
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
# ── LLM escalation ───────────────────────────────────────────────────────────
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
def _apply_judgment(chk: _Check, judgment: FieldJudgment) -> FieldFinding:
|
|
457
|
+
finding = chk.finding(via_llm=True)
|
|
458
|
+
finding.detail = judgment.reason
|
|
459
|
+
if judgment.classification == "formatting_variant":
|
|
460
|
+
finding.status = "formatting"
|
|
461
|
+
elif judgment.classification == "error" and judgment.confidence in ("high", "medium"):
|
|
462
|
+
finding.status = "error"
|
|
463
|
+
else: # low-confidence error or explicit uncertain → don't over-claim
|
|
464
|
+
finding.status = "uncertain"
|
|
465
|
+
return finding
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
async def _judge_field(
|
|
469
|
+
entry: BibEntry,
|
|
470
|
+
chk: _Check,
|
|
471
|
+
llm: LLMClient | None,
|
|
472
|
+
cache: AuditCache | None,
|
|
473
|
+
context: SourceRecord,
|
|
474
|
+
) -> FieldFinding:
|
|
475
|
+
if llm is None:
|
|
476
|
+
finding = chk.finding()
|
|
477
|
+
finding.status = "uncertain"
|
|
478
|
+
finding.detail = (
|
|
479
|
+
f"differs from canonical '{chk.canonical_value}'; LLM unavailable, verify manually"
|
|
480
|
+
)
|
|
481
|
+
return finding
|
|
482
|
+
user = field_check_user(chk.field, chk.bib_value, chk.canonical_value, chk.sources, context)
|
|
483
|
+
p_hash = prompt_hash(FIELD_CHECK_SYSTEM + "\n" + user)
|
|
484
|
+
if cache is not None:
|
|
485
|
+
cached = cache.get_llm_decision(p_hash, "field_check")
|
|
486
|
+
if cached is not None:
|
|
487
|
+
return _apply_judgment(chk, FieldJudgment.model_validate_json(cached))
|
|
488
|
+
try:
|
|
489
|
+
judgment = await llm.structured(FIELD_CHECK_SYSTEM, user, FieldJudgment, "field_check")
|
|
490
|
+
except LLMError as exc:
|
|
491
|
+
finding = chk.finding()
|
|
492
|
+
finding.status = "uncertain"
|
|
493
|
+
finding.detail = f"differs from canonical '{chk.canonical_value}'; LLM check failed ({exc})"
|
|
494
|
+
return finding
|
|
495
|
+
if cache is not None:
|
|
496
|
+
cache.put_llm_decision(p_hash, "field_check", judgment.model_dump_json())
|
|
497
|
+
return _apply_judgment(chk, judgment)
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
async def resolve_field_findings(
|
|
501
|
+
entry: BibEntry,
|
|
502
|
+
artifact: MatchedArtifact,
|
|
503
|
+
llm: LLMClient | None,
|
|
504
|
+
config: AuditConfig,
|
|
505
|
+
cache: AuditCache | None,
|
|
506
|
+
*,
|
|
507
|
+
skip_fields: frozenset[str] = frozenset(),
|
|
508
|
+
) -> list[FieldFinding]:
|
|
509
|
+
"""Full step-3 result for one matched entry: deterministic rules + LLM tie-break, in order."""
|
|
510
|
+
checks = deterministic_field_checks(
|
|
511
|
+
entry, artifact, skip_fields=skip_fields, partial_authors=config.partial_authors
|
|
512
|
+
)
|
|
513
|
+
# The LLM is shown the matched work as the database records it (the cited version, with the
|
|
514
|
+
# canonical title), never the entry itself: told that the entry's title is the confirmed one, it
|
|
515
|
+
# waved through a different title as formatting.
|
|
516
|
+
records = _ordered_records(entry, artifact)
|
|
517
|
+
title, _ = _canonical(records, lambda r: r.title)
|
|
518
|
+
context = next((r for r in records if _fold(r.title) == _fold(title)), records[0])
|
|
519
|
+
escalated = await asyncio.gather(
|
|
520
|
+
*(_judge_field(entry, c, llm, cache, context) for c in checks if c.needs_llm)
|
|
521
|
+
)
|
|
522
|
+
escalated_iter = iter(escalated)
|
|
523
|
+
return [next(escalated_iter) if c.needs_llm else c.finding() for c in checks]
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
def _book_publisher_check(entry: BibEntry, canonical: str, sources: list[str]) -> _Check:
|
|
527
|
+
"""Publisher check against the cited edition's publisher.
|
|
528
|
+
|
|
529
|
+
A folded-substring relationship ('W. A. Benjamin' ⊆ 'W. A. Benjamin, Advanced Book Program') is a
|
|
530
|
+
shortened-but-correct imprint name → `formatting`, settled without the LLM. A genuinely different
|
|
531
|
+
string (a typo like 'Princeton Un iversity Press', or a different publisher) still escalates so it
|
|
532
|
+
is judged, not silently passed.
|
|
533
|
+
"""
|
|
534
|
+
bib = _strip(entry.publisher)
|
|
535
|
+
chk = _Check("publisher", bib, canonical, sources)
|
|
536
|
+
if not canonical:
|
|
537
|
+
chk.status, chk.detail = "unverifiable", "the matched Open Library edition lists no publisher"
|
|
538
|
+
return chk
|
|
539
|
+
if bib == _strip(canonical):
|
|
540
|
+
return chk
|
|
541
|
+
a, b = _fold(bib), _fold(canonical)
|
|
542
|
+
if a and b and (a in b or b in a):
|
|
543
|
+
chk.status = "formatting"
|
|
544
|
+
chk.detail = f"a shortened form of the edition's publisher '{canonical}'"
|
|
545
|
+
return chk
|
|
546
|
+
chk.status, chk.needs_llm = "needs_llm", True
|
|
547
|
+
return chk
|
|
548
|
+
|
|
549
|
+
|
|
550
|
+
async def check_book_edition_fields(
|
|
551
|
+
entry: BibEntry,
|
|
552
|
+
matched_edition: SourceRecord,
|
|
553
|
+
llm: LLMClient | None,
|
|
554
|
+
config: AuditConfig, # noqa: ARG001 — symmetry with resolve_field_findings; reserved
|
|
555
|
+
cache: AuditCache | None,
|
|
556
|
+
) -> list[FieldFinding]:
|
|
557
|
+
"""Verify a book's `year`/`publisher` against the *cited* Open Library edition.
|
|
558
|
+
|
|
559
|
+
Grounding the canonical values in the matched edition (rather than the pooled artifact, which may
|
|
560
|
+
carry a newer reprint) means the original edition is no longer flagged against a later one. A
|
|
561
|
+
genuine publisher typo (e.g. 'Princeton Un iversity Press') still escalates to the LLM and is
|
|
562
|
+
caught.
|
|
563
|
+
"""
|
|
564
|
+
records = [matched_edition]
|
|
565
|
+
checks: list[_Check] = []
|
|
566
|
+
year_chk = _year_check(entry, records)
|
|
567
|
+
if year_chk is not None:
|
|
568
|
+
checks.append(year_chk)
|
|
569
|
+
if entry.publisher:
|
|
570
|
+
canonical, sources = _canonical(records, lambda r: r.publisher)
|
|
571
|
+
checks.append(_book_publisher_check(entry, canonical, sources))
|
|
572
|
+
return [
|
|
573
|
+
await _judge_field(entry, c, llm, cache, matched_edition) if c.needs_llm else c.finding()
|
|
574
|
+
for c in checks
|
|
575
|
+
]
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
def consulted_sources(artifact: MatchedArtifact) -> list[str]:
|
|
579
|
+
"""Every source actually behind the matched artifact's records — i.e. exactly what the field
|
|
580
|
+
checks compared against. Used to report an unverifiable field as "not present in {these
|
|
581
|
+
sources}", never as the unprovable claim that no authoritative source has it."""
|
|
582
|
+
return sorted({s for r in artifact.records for s in _rec_sources(r)})
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
def finding_note(f: FieldFinding) -> str:
|
|
586
|
+
"""One-line human summary for the text report (only actionable findings are surfaced)."""
|
|
587
|
+
src = f" [{', '.join(f.sources)}]" if f.sources else ""
|
|
588
|
+
tag = "(LLM) " if f.via_llm else ""
|
|
589
|
+
if f.status == "error":
|
|
590
|
+
return (
|
|
591
|
+
f"{tag}field '{f.field}' looks wrong: '{f.bib_value}' vs canonical "
|
|
592
|
+
f"'{f.canonical_value}'{src} — {f.detail}"
|
|
593
|
+
)
|
|
594
|
+
if f.status == "uncertain":
|
|
595
|
+
return (
|
|
596
|
+
f"{tag}field '{f.field}' needs review: '{f.bib_value}' vs canonical "
|
|
597
|
+
f"'{f.canonical_value}'{src} — {f.detail}"
|
|
598
|
+
)
|
|
599
|
+
return f"field '{f.field}'='{f.bib_value}' could not be verified — {f.detail}"
|