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,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}"