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,392 @@
1
+ r"""BibTeX parsing → BibEntry.
2
+
3
+ bibtexparser 2 only splits the text into blocks (`parse_string` with an empty parse stack). This
4
+ module reads the field values itself, because the library's default middlewares would get them wrong
5
+ in ways nothing reports: they leave a `#` concatenation (`venue # " 2020"`) and a month macro
6
+ (`month = jul`) as raw text, and their LaTeX decoder truncates a URL at its first `%`
7
+ (`parsing.latex`). `read_bib_records` resolves `@string` references, `#` concatenation and BibTeX's
8
+ month macros, then decodes LaTeX (`decode_latex`: {\'e}→é) for clean matching. It never guesses: a
9
+ reference to an undefined `@string`, a duplicate field, or a value pylatexenc cannot read makes the
10
+ entry unparsed, with the reason. Three further non-standard behaviors:
11
+
12
+ 1. **Commented-twin detection.** The pilot has a `%@misc{bagrov2024visual, ...}` block whose
13
+ only-commented header is the arXiv preprint of `kravchenko2026`. bibtexparser reads a `%` line as
14
+ a comment only up to the next `@`, so it may or may not parse such a block, and a fully commented
15
+ one parses into fields named `% title`. Commentedness is therefore decided from the *raw source
16
+ line*, and every such entry is read from the uncommented text and routed to `twins`
17
+ (informational), never the audited list. T1 is solved from the DBs, not from this block.
18
+ 2. **Dropped-entry detection.** bibtexparser reports a block it cannot split as a failed block with
19
+ no key. Every live `@type{key` header in the raw text that produced no entry is returned as an
20
+ `UnparsedEntry` with the reason, so the report can say it was not checked.
21
+ 3. **Duplicate keys.** bibtexparser sets a repeated entry key aside as a failed block. Both entries
22
+ are audited, as written.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import re
28
+ from pathlib import Path
29
+ from typing import NamedTuple
30
+
31
+ import bibtexparser
32
+ from bibtexparser.exceptions import BlockAbortedException
33
+ from bibtexparser.library import Library
34
+ from bibtexparser.model import (
35
+ DuplicateBlockKeyBlock,
36
+ DuplicateFieldKeyBlock,
37
+ Entry,
38
+ ParsingFailedBlock,
39
+ String,
40
+ )
41
+
42
+ from reference_audit.models import BibEntry, Identifiers, UnparsedEntry, entry_type_from_bib
43
+ from reference_audit.parsing.entities import decode_html_entities
44
+ from reference_audit.parsing.identifiers import (
45
+ extract_arxiv_id,
46
+ normalize_doi,
47
+ normalize_google_books_id,
48
+ normalize_isbn13,
49
+ normalize_openalex_id,
50
+ normalize_url,
51
+ )
52
+ from reference_audit.parsing.latex import LatexDecodeError, decode_latex
53
+
54
+ # An @type{key occurrence; we inspect whether a '%' precedes the '@' on the same line.
55
+ _ENTRY_LINE_RE = re.compile(r"(?m)^(?P<pre>[^\n@]*)@(?P<type>\w+)\s*\{\s*(?P<key>[^,\s}]+)")
56
+ # `@string`, `@preamble` and `@comment` blocks are not entries.
57
+ _NON_ENTRY_TYPES = {"string", "preamble", "comment"}
58
+ # BibTeX's predefined month macros (`month = jul`), expanded to the full name as bibtex does.
59
+ _MONTH_MACROS = {
60
+ name[:3].lower(): name
61
+ for name in (
62
+ "January", "February", "March", "April", "May", "June",
63
+ "July", "August", "September", "October", "November", "December",
64
+ )
65
+ }
66
+ # A bare operand of a field value: a `@string` name (or a number, checked first).
67
+ _STRING_NAME_RE = re.compile(r"[^\s\"#%'(),={}]+")
68
+ _NOT_CHECKED = "the entry was not read, so it was not checked"
69
+
70
+
71
+ def _classify_keys(raw: str) -> set[str]:
72
+ """Return keys whose @type{key header appears ONLY in a commented (`%`-prefixed) line."""
73
+ commented: set[str] = set()
74
+ live: set[str] = set()
75
+ for m in _ENTRY_LINE_RE.finditer(raw):
76
+ key = m.group("key")
77
+ pre = m.group("pre")
78
+ # A '%' anywhere before '@' on the line (not escaped) ⇒ commented occurrence.
79
+ if re.search(r"(?<!\\)%", pre):
80
+ commented.add(key)
81
+ else:
82
+ live.add(key)
83
+ return commented - live
84
+
85
+
86
+ def _clean(s: str) -> str:
87
+ # HTML references (`d&apos;Amore`) come from web-scraped metadata; see `parsing.entities`.
88
+ text = decode_html_entities(s.replace("{", "").replace("}", ""))
89
+ return re.sub(r"\s+", " ", text).strip()
90
+
91
+
92
+ def _split_authors(author_field: str) -> list[str]:
93
+ if not author_field:
94
+ return []
95
+ parts = re.split(r"\s+and\s+", author_field.strip())
96
+ return [_clean(p) for p in parts if _clean(p)]
97
+
98
+
99
+ def _year(value: str | None) -> int | None:
100
+ if not value:
101
+ return None
102
+ m = re.search(r"\d{4}", value)
103
+ return int(m.group(0)) if m else None
104
+
105
+
106
+ def _identifiers_from_fields(f: dict[str, str]) -> Identifiers:
107
+ doi = normalize_doi(f.get("doi")) or normalize_doi(f.get("url"))
108
+ arxiv = extract_arxiv_id(
109
+ f.get("eprint"),
110
+ f.get("archiveprefix"),
111
+ fallback_text=f.get("doi") or f.get("url"),
112
+ )
113
+ isbn13 = normalize_isbn13(f.get("isbn"))
114
+ # An openalex.org `url` is a resolvable Work id, not a generic landing page: extract it as a
115
+ # first-class identifier and drop it from `url` so it isn't mistaken for a web @misc page.
116
+ openalex = normalize_openalex_id(f.get("url"))
117
+ # A books.google `url` carries a resolvable volume id — extract it as a first-class identifier
118
+ # (the authoritative key for that exact volume). The URL itself is kept (it is a real landing
119
+ # page, unlike an openalex.org Work URL) so a dead-link check can still see it.
120
+ google_books = normalize_google_books_id(f.get("url"))
121
+ url = None if openalex else normalize_url(f.get("url"))
122
+ pmid = (f.get("pmid") or "").strip() or None
123
+ return Identifiers(
124
+ doi=doi, arxiv_id=arxiv, isbn13=isbn13, url=url, pmid=pmid,
125
+ openalex=openalex, google_books=google_books,
126
+ )
127
+
128
+
129
+ def entry_from_fields(
130
+ key: str,
131
+ bib_type: str,
132
+ f: dict[str, str],
133
+ *,
134
+ commented: bool = False,
135
+ authors: list[str] | None = None,
136
+ ) -> BibEntry:
137
+ """Build a `BibEntry` from a flat `{field: str}` dict — the single construction seam.
138
+
139
+ Any front end that produces BibTeX-shaped fields goes through here (the `.bib` parser below, and
140
+ `pdf.tei` for GROBID's TEI), so identifier normalization, venue selection, cleaning and type
141
+ mapping are defined exactly once.
142
+
143
+ `authors` bypasses the `" and "` split for callers that already have a *list* of names. TEI gives
144
+ us structured `<persName>` elements, and re-joining them into `"A and B"` only to re-split would
145
+ corrupt an organizational author such as "Smith and Sons".
146
+ """
147
+ venue = f.get("journal") or f.get("booktitle") or f.get("howpublished") or ""
148
+ return BibEntry(
149
+ key=key,
150
+ entry_type=entry_type_from_bib(bib_type),
151
+ title=_clean(f.get("title", "")),
152
+ authors=authors if authors is not None else _split_authors(f.get("author", "")),
153
+ year=_year(f.get("year")),
154
+ venue=_clean(venue),
155
+ publisher=_clean(f.get("publisher", "")),
156
+ pages=_clean(f.get("pages", "")),
157
+ ids=_identifiers_from_fields(f),
158
+ raw_fields={k: v for k, v in f.items() if isinstance(v, str)},
159
+ is_commented=commented,
160
+ )
161
+
162
+
163
+ class _FieldError(ValueError):
164
+ """A field value that cannot be read without guessing."""
165
+
166
+
167
+ def _operands(raw: str) -> list[str]:
168
+ """`a # "b" # {c}` → its operands. A `#` inside braces or quotes is text."""
169
+ parts: list[str] = []
170
+ depth, quoted, start = 0, False, 0
171
+ for i, ch in enumerate(raw):
172
+ if ch == "{":
173
+ depth += 1
174
+ elif ch == "}":
175
+ depth -= 1
176
+ elif ch == '"' and depth == 0:
177
+ quoted = not quoted
178
+ elif ch == "#" and depth == 0 and not quoted:
179
+ parts.append(raw[start:i])
180
+ start = i + 1
181
+ parts.append(raw[start:])
182
+ return [p.strip() for p in parts]
183
+
184
+
185
+ def _resolve_value(raw: str, strings: dict[str, str], string_errors: dict[str, str]) -> str:
186
+ """A raw BibTeX value (`{X}`, `"X"`, `2020`, `jul`, `venue # " 2020"`) → its text."""
187
+ out: list[str] = []
188
+ for part in _operands(raw):
189
+ if len(part) >= 2 and (part[0], part[-1]) in (("{", "}"), ('"', '"')):
190
+ out.append(part[1:-1])
191
+ elif part.isdigit():
192
+ out.append(part)
193
+ elif not _STRING_NAME_RE.fullmatch(part):
194
+ raise _FieldError(f"cannot read the value {raw!r}")
195
+ elif part.lower() in string_errors:
196
+ raise _FieldError(f"@string `{part}` could not be read: {string_errors[part.lower()]}")
197
+ elif part.lower() in strings:
198
+ out.append(strings[part.lower()])
199
+ else:
200
+ raise _FieldError(f"undefined @string `{part}`")
201
+ return "".join(out)
202
+
203
+
204
+ def _read_strings(library: Library) -> tuple[dict[str, str], dict[str, str]]:
205
+ """`@string` name (lower-cased; BibTeX names are case-insensitive) → value, and → failure reason.
206
+
207
+ Strings are resolved in file order against the month macros and the strings before them; a
208
+ redefinition wins over the earlier one.
209
+ """
210
+ strings = dict(_MONTH_MACROS)
211
+ errors: dict[str, str] = {}
212
+ for block in library.blocks:
213
+ if isinstance(block, DuplicateBlockKeyBlock):
214
+ block = block.ignore_error_block
215
+ if not isinstance(block, String):
216
+ continue
217
+ name = block.key.lower()
218
+ try:
219
+ strings[name] = _resolve_value(block.value, strings, errors)
220
+ errors.pop(name, None)
221
+ except _FieldError as e:
222
+ strings.pop(name, None)
223
+ errors[name] = str(e)
224
+ return strings, errors
225
+
226
+
227
+ def _read_fields(
228
+ entry: Entry, strings: dict[str, str], string_errors: dict[str, str]
229
+ ) -> dict[str, str]:
230
+ """An entry's fields as `{lower-cased key: decoded text}`; raises `_FieldError` naming the field."""
231
+ fields: dict[str, str] = {}
232
+ for field in entry.fields:
233
+ key = field.key.lower()
234
+ if key in fields: # bibtexparser flags exact repeats; `Title` and `title` are one field too
235
+ raise _FieldError(f"duplicate field `{key}`")
236
+ try:
237
+ fields[key] = decode_latex(_resolve_value(field.value, strings, string_errors))
238
+ except (_FieldError, LatexDecodeError) as e:
239
+ raise _FieldError(f"field `{field.key}`: {e}") from e
240
+ return fields
241
+
242
+
243
+ class BibRecord(NamedTuple):
244
+ """One `.bib` entry as read: values resolved and LaTeX-decoded, field keys lower-cased."""
245
+
246
+ key: str
247
+ entry_type: str # lower-cased, e.g. "article"
248
+ line: int # 1-based line of the `@type{key` header
249
+ fields: dict[str, str]
250
+
251
+
252
+ class BibRead(NamedTuple):
253
+ records: list[BibRecord]
254
+ failed: list[UnparsedEntry] # entries bibtexparser split whose fields cannot be read
255
+ parse_errors: dict[int, str] # 1-based line → why bibtexparser could not split the block there
256
+
257
+
258
+ def _failed(entry: Entry, reason: str) -> UnparsedEntry:
259
+ return UnparsedEntry(
260
+ key=entry.key, entry_type=entry.entry_type.lower(), line=(entry.start_line or 0) + 1,
261
+ reason=f"{reason}; {_NOT_CHECKED}",
262
+ )
263
+
264
+
265
+ def read_bib_records(raw: str) -> BibRead:
266
+ """Read every entry of `.bib` text. An entry that cannot be read is in `failed`, never guessed."""
267
+ library = bibtexparser.parse_string(raw, parse_stack=[])
268
+ strings, string_errors = _read_strings(library)
269
+ records: list[BibRecord] = []
270
+ failed: list[UnparsedEntry] = []
271
+ parse_errors: dict[int, str] = {}
272
+ for block in library.blocks:
273
+ if isinstance(block, DuplicateFieldKeyBlock):
274
+ dupes = ", ".join(f"`{k}`" for k in sorted(block.duplicate_keys))
275
+ failed.append(_failed(block.ignore_error_block, f"duplicate field {dupes}"))
276
+ continue
277
+ # A repeated entry key: bibtexparser sets the later entry aside; it is read like any other.
278
+ entry = block.ignore_error_block if isinstance(block, DuplicateBlockKeyBlock) else block
279
+ if isinstance(entry, Entry):
280
+ # `@article{key}`, or LaTeX such as `\@ifundefined{X}` in a mis-passed `.tex`.
281
+ if not entry.fields:
282
+ failed.append(_failed(entry, "the entry has no fields"))
283
+ continue
284
+ try:
285
+ fields = _read_fields(entry, strings, string_errors)
286
+ except _FieldError as e:
287
+ failed.append(_failed(entry, str(e)))
288
+ continue
289
+ records.append(
290
+ BibRecord(entry.key, entry.entry_type.lower(), (entry.start_line or 0) + 1, fields)
291
+ )
292
+ elif isinstance(block, ParsingFailedBlock) and not isinstance(block, DuplicateBlockKeyBlock):
293
+ err = block.error
294
+ reason = err.abort_reason if isinstance(err, BlockAbortedException) else str(err)
295
+ parse_errors[(block.start_line or 0) + 1] = reason
296
+ return BibRead(records, failed, parse_errors)
297
+
298
+
299
+ def _twin_from_raw(raw: str, key: str) -> BibEntry | None:
300
+ """The commented-only entry `key`, read from the text with its lines' `%` markers removed."""
301
+ m = re.search(r"(?m)^[^\n@]*@\w+\s*\{\s*" + re.escape(key) + r"(?=[,\s}])", raw)
302
+ if not m:
303
+ return None
304
+ # The block ends, at the latest, where the next live entry starts.
305
+ end = next(
306
+ (h.start() for h in _ENTRY_LINE_RE.finditer(raw, m.end())
307
+ if not re.search(r"(?<!\\)%", h.group("pre"))),
308
+ len(raw),
309
+ )
310
+ uncommented = re.sub(r"(?m)^([ \t]*)%+", r"\1", raw[m.start():end])
311
+ read = read_bib_records(uncommented)
312
+ rec = next((r for r in read.records if r.key == key), None)
313
+ if rec is None:
314
+ return None
315
+ return entry_from_fields(key, rec.entry_type, rec.fields, commented=True)
316
+
317
+
318
+ class BibParse(NamedTuple):
319
+ entries: list[BibEntry] # audited
320
+ twins: list[BibEntry] # commented-out entries (informational)
321
+ unparsed: list[UnparsedEntry] # live entries that could not be read (never audited)
322
+
323
+
324
+ def _brace_balance(text: str) -> int:
325
+ """Net `{` minus `}` in `text`, ignoring escaped braces (`\\{`, `\\}`)."""
326
+ unescaped = re.sub(r"\\[{}]", "", text)
327
+ return unescaped.count("{") - unescaped.count("}")
328
+
329
+
330
+ def _unparsed_entries(
331
+ raw: str, read_keys: set[str], commented_only: set[str], parse_errors: dict[int, str]
332
+ ) -> list[UnparsedEntry]:
333
+ """Live entry headers in `raw` that produced no entry, each with the likeliest reason.
334
+
335
+ The block of an entry runs from its header to the next header. A block whose net brace count does
336
+ not balance is reported as such; otherwise the reason is bibtexparser's own for the block at that
337
+ line, or the generic one, never a guess.
338
+ """
339
+ headers = [
340
+ m for m in _ENTRY_LINE_RE.finditer(raw)
341
+ if m.group("type").lower() not in _NON_ENTRY_TYPES
342
+ and not re.search(r"(?<!\\)%", m.group("pre"))
343
+ ]
344
+ out: list[UnparsedEntry] = []
345
+ reported: set[str] = set()
346
+ for i, m in enumerate(headers):
347
+ key = m.group("key")
348
+ if key in read_keys or key in commented_only or key in reported:
349
+ continue
350
+ end = headers[i + 1].start() if i + 1 < len(headers) else len(raw)
351
+ balance = _brace_balance(raw[m.start("type") - 1:end])
352
+ line = raw.count("\n", 0, m.start()) + 1
353
+ if balance > 0:
354
+ reason = f"unbalanced braces ({balance} more '{{' than '}}')"
355
+ elif balance < 0:
356
+ reason = f"unbalanced braces ({-balance} more '}}' than '{{')"
357
+ elif line in parse_errors:
358
+ reason = f"bibtexparser could not parse this entry: {parse_errors[line]}"
359
+ else:
360
+ reason = "bibtexparser could not parse this entry"
361
+ out.append(UnparsedEntry(
362
+ key=key, entry_type=m.group("type").lower(), line=line,
363
+ reason=f"{reason}; {_NOT_CHECKED}",
364
+ ))
365
+ reported.add(key)
366
+ return out
367
+
368
+
369
+ def parse_bib(bib_path: str | Path) -> BibParse:
370
+ """Parse a .bib file. Returns (audited_entries, commented_twins, unparsed_entries)."""
371
+ return parse_bib_text(Path(bib_path).read_text(encoding="utf-8", errors="replace"))
372
+
373
+
374
+ def parse_bib_text(raw: str) -> BibParse:
375
+ """Parse .bib text — a file's, or the records a PDF embeds (`pdf.embedded`)."""
376
+ commented_only = _classify_keys(raw)
377
+ read = read_bib_records(raw)
378
+
379
+ entries = [
380
+ entry_from_fields(r.key, r.entry_type, r.fields)
381
+ for r in read.records if r.key not in commented_only
382
+ ]
383
+ twins: list[BibEntry] = []
384
+ for key in dict.fromkeys(m.group("key") for m in _ENTRY_LINE_RE.finditer(raw)):
385
+ if key in commented_only and (twin := _twin_from_raw(raw, key)) is not None:
386
+ twins.append(twin)
387
+ failed = [u for u in read.failed if u.key not in commented_only]
388
+
389
+ read_keys = {r.key for r in read.records} | {u.key for u in read.failed}
390
+ unparsed = failed + _unparsed_entries(raw, read_keys, commented_only, read.parse_errors)
391
+ unparsed.sort(key=lambda u: u.line)
392
+ return BibParse(entries, twins, unparsed)
@@ -0,0 +1,55 @@
1
+ """Sentence-span extraction for citing contexts — shared by the `.tex` and the PDF/TEI front ends.
2
+
3
+ `parsing.tex.parse_citation_contexts` (LaTeX source) and `pdf.tei.parse_tei` (GROBID output) must
4
+ agree on what "the citing sentence" *is*, or a `CitationContext` would mean something different
5
+ depending on which format the manuscript arrived in — and the alignment check compares them against
6
+ the same abstracts. These three helpers are that single definition. Each front end adds only its own
7
+ noise stripping on top: `_clean_context` for LaTeX macros, nothing for TEI (whose text is already
8
+ plain prose).
9
+
10
+ This module is pure: no I/O, no network, no LaTeX and no XML knowledge.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import re
16
+
17
+ _SENT_END_RE = re.compile(r"[.!?]")
18
+
19
+ # A cleaned context shorter than this (bare macro shells, a lone trailing cite) is extended with its
20
+ # preceding sentence so there is an actual claim to judge.
21
+ MIN_CONTEXT_CHARS = 15
22
+
23
+
24
+ def collapse(span: str) -> str:
25
+ """Collapse whitespace runs and strip — the final step of every context extraction."""
26
+ return re.sub(r"\s+", " ", span).strip()
27
+
28
+
29
+ def sentence_span(text: str, start: int, end: int) -> tuple[int, int]:
30
+ """Bounds of the sentence (or paragraph-bounded fragment) containing text[start:end]. A sentence
31
+ boundary is a `.`/`!`/`?` followed by whitespace; a blank line also bounds it.
32
+
33
+ A zero-width span (`start == end`) is valid and returns the sentence surrounding that offset —
34
+ the TEI front end deletes each citation marker's surface text (`[13]`, `(Chan, 2019)`), which is
35
+ not part of the citing claim, and asks for the sentence around the position where it stood.
36
+ """
37
+ left = 0
38
+ for m in _SENT_END_RE.finditer(text, 0, start):
39
+ e = m.end()
40
+ if e < len(text) and text[e].isspace():
41
+ left = e
42
+ para = text.rfind("\n\n", left, start)
43
+ if para != -1:
44
+ left = para + 2
45
+
46
+ right = len(text)
47
+ for m in _SENT_END_RE.finditer(text, end):
48
+ e = m.end()
49
+ if e >= len(text) or text[e].isspace():
50
+ right = e
51
+ break
52
+ para = text.find("\n\n", end, right)
53
+ if para != -1:
54
+ right = para
55
+ return left, right
@@ -0,0 +1,22 @@
1
+ """HTML character references in bibliographic text.
2
+
3
+ Metadata scraped from the web reaches `.bib` files with its HTML escaping intact: `d&apos;Amore`,
4
+ `Allocation &amp; Regularization`. Left encoded, a cited author `d&apos;Amore` fails the author check
5
+ against the source's `d'Amore`, and a title gains a phantom word "amp". Only complete, `;`-terminated
6
+ references are decoded. `html.unescape` alone would also expand legacy forms without the `;`
7
+ (`&copy` in "R&D &copy" → "©"), which ordinary text can contain.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import html
13
+ import re
14
+
15
+ _REFERENCE_RE = re.compile(r"&(?:#[0-9]+|#[xX][0-9a-fA-F]+|[A-Za-z][A-Za-z0-9]*);")
16
+
17
+
18
+ def decode_html_entities(text: str) -> str:
19
+ """`text` with every `;`-terminated HTML character reference decoded; anything else unchanged."""
20
+ if "&" not in text:
21
+ return text
22
+ return _REFERENCE_RE.sub(lambda m: html.unescape(m.group(0)), text)
@@ -0,0 +1,165 @@
1
+ """Identifier extraction & normalization.
2
+
3
+ DOI/ISBN regexes lifted from `paper-search-mcp/paper_search_mcp/utils.py`; we add
4
+ URL-prefix stripping (fixes wolpert2007's `https://doi.org/...` DOI), ISBN-10→13 conversion,
5
+ and arXiv-id extraction from `eprint`/`archivePrefix`.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+
12
+ # --- DOI ---
13
+ _DOI_CORE_RE = re.compile(r"10\.\d{4,9}/[-._;()/:A-Z0-9]+", re.IGNORECASE)
14
+
15
+
16
+ # @cpt-dod:cpt-referenceaudit-dod-parsing-identifiers:p1
17
+ def normalize_doi(text: str | None) -> str | None:
18
+ """Return a bare, lowercased DOI ('10.xxxx/...') from any URL/prefixed form, or None.
19
+
20
+ DOIs are case-insensitive (ISO 26324); lowercasing makes opaque-token equality trivial.
21
+ """
22
+ if not text:
23
+ return None
24
+ m = _DOI_CORE_RE.search(text)
25
+ if not m:
26
+ return None
27
+ doi = m.group(0).rstrip(".,;)")
28
+ return doi.lower()
29
+
30
+
31
+ # --- ISBN ---
32
+ def _compact_isbn(text: str) -> str:
33
+ return re.sub(r"[\s\-]", "", str(text or "")).upper()
34
+
35
+
36
+ def _isbn10_to_13(isbn10: str) -> str | None:
37
+ core = "978" + isbn10[:9]
38
+ total = sum((1 if i % 2 == 0 else 3) * int(d) for i, d in enumerate(core))
39
+ check = (10 - (total % 10)) % 10
40
+ return core + str(check)
41
+
42
+
43
+ def normalize_isbn13(text: str | None) -> str | None:
44
+ """Return a 13-digit ISBN (converting ISBN-10), or None if not a valid ISBN shape."""
45
+ if not text:
46
+ return None
47
+ compact = _compact_isbn(text)
48
+ if re.fullmatch(r"97[89]\d{10}", compact):
49
+ return compact
50
+ if re.fullmatch(r"\d{9}[\dX]", compact):
51
+ return _isbn10_to_13(compact)
52
+ # try to find an embedded ISBN
53
+ for pat in (r"97[89](?:[\s\-]?\d){10}", r"(?:\d[\s\-]?){9}[\dXx]"):
54
+ m = re.search(pat, text)
55
+ if m:
56
+ return normalize_isbn13(m.group(0))
57
+ return None
58
+
59
+
60
+ # --- arXiv ---
61
+ # Bare ids are only trusted from an `eprint` field or after an explicit arXiv marker —
62
+ # never scraped from an arbitrary DOI (e.g. 10.3389/frobt.2016.00040 is NOT arXiv 2016.00040).
63
+ _ARXIV_NEW_RE = re.compile(r"\b(\d{4}\.\d{4,5})(?:v\d+)?\b")
64
+ _ARXIV_OLD_RE = re.compile(r"\b([a-z\-]+(?:\.[A-Z]{2})?/\d{7})(?:v\d+)?\b", re.IGNORECASE)
65
+ _ARXIV_MARKED_RE = re.compile(
66
+ r"(?:arxiv[._:/]|abs/)\s*/?([a-z\-]*(?:\.[A-Z]{2})?/?\d{4}\.\d{4,5}(?:v\d+)?)",
67
+ re.IGNORECASE,
68
+ )
69
+
70
+
71
+ def _bare_arxiv(text: str) -> str | None:
72
+ m = _ARXIV_NEW_RE.search(text) or _ARXIV_OLD_RE.search(text)
73
+ return _strip_arxiv_version(m.group(1)) if m else None
74
+
75
+
76
+ def extract_arxiv_id(
77
+ eprint: str | None, archive_prefix: str | None = None, *, fallback_text: str | None = None
78
+ ) -> str | None:
79
+ """Pull an arXiv id from a trusted `eprint` field (guarded by archivePrefix=arXiv when
80
+ present), or from text that explicitly mentions arXiv (a 10.48550/arXiv.<id> DOI or an
81
+ arxiv.org/abs/<id> URL). Arbitrary DOIs are never scanned for bare ids."""
82
+ if eprint:
83
+ prefix_ok = (not archive_prefix) or archive_prefix.strip().lower() == "arxiv"
84
+ if prefix_ok:
85
+ got = _bare_arxiv(eprint)
86
+ if got:
87
+ return got
88
+ if fallback_text and re.search(r"arxiv|48550", fallback_text, re.IGNORECASE):
89
+ m = _ARXIV_MARKED_RE.search(fallback_text)
90
+ if m:
91
+ return _strip_arxiv_version(m.group(1).lstrip("/"))
92
+ got = _bare_arxiv(fallback_text) # safe: an arXiv marker is already present
93
+ if got:
94
+ return got
95
+ return None
96
+
97
+
98
+ def _strip_arxiv_version(arxiv_id: str) -> str:
99
+ return re.sub(r"v\d+$", "", arxiv_id.strip())
100
+
101
+
102
+ def arxiv_to_doi(arxiv_id: str) -> str:
103
+ """The DataCite DOI arXiv mints for every submission."""
104
+ return f"10.48550/arxiv.{arxiv_id.lower()}"
105
+
106
+
107
+ def arxiv_submission_year(arxiv_id: str | None) -> int | None:
108
+ """Year an arXiv id was first submitted — encoded in the id itself, so it pins the *original*
109
+ version (v1) independent of any later updates.
110
+
111
+ New scheme `YYMM.NNNNN` (since 2007-04) → 2000+YY. Old scheme `archive/YYMMNNN` (1991-2007) →
112
+ 19YY for YY>=91 else 20YY. Returns None when the id matches neither shape.
113
+ """
114
+ if not arxiv_id:
115
+ return None
116
+ s = _strip_arxiv_version(arxiv_id.strip())
117
+ new = re.match(r"^(\d{2})\d{2}\.\d{4,5}$", s)
118
+ if new:
119
+ return 2000 + int(new.group(1))
120
+ old = re.match(r"^[a-z-]+(?:\.[a-z]{2})?/(\d{2})\d{5}$", s, re.IGNORECASE)
121
+ if old:
122
+ yy = int(old.group(1))
123
+ return 1900 + yy if yy >= 91 else 2000 + yy
124
+ return None
125
+
126
+
127
+ def normalize_url(text: str | None) -> str | None:
128
+ if not text:
129
+ return None
130
+ url = text.strip()
131
+ return url or None
132
+
133
+
134
+ # --- OpenAlex Work id ---
135
+ # A canonical OpenAlex Work id is the token `W<digits>` carried by an openalex.org URL
136
+ # (https://openalex.org/W3034344071) or an API path (api.openalex.org/works/W...). Only trusted
137
+ # when an openalex.org host is present — a bare `W…` token is too ambiguous to scrape blindly.
138
+ _OPENALEX_ID_RE = re.compile(r"\bW\d+\b", re.IGNORECASE)
139
+
140
+
141
+ def normalize_openalex_id(text: str | None) -> str | None:
142
+ """Return a bare, upper-cased OpenAlex Work id ('W3034344071') from an openalex.org URL, or None."""
143
+ if not text or "openalex.org" not in text.lower():
144
+ return None
145
+ m = _OPENALEX_ID_RE.search(text)
146
+ return m.group(0).upper() if m else None
147
+
148
+
149
+ # --- Google Books volume id ---
150
+ # A Google Books volume id is the opaque `id=` token on a books.google.<tld> (or
151
+ # play.google.com/books) URL — e.g. https://books.google.com.sg/books?id=yIV_NMDDIvYC. It is the
152
+ # authoritative key for that exact volume, so the adapter can resolve it with no fuzzy matching.
153
+ # Only trusted when a Google Books host is present — a bare token is too ambiguous to scrape.
154
+ _GBOOKS_ID_RE = re.compile(r"[?&]id=([A-Za-z0-9_-]+)")
155
+
156
+
157
+ def normalize_google_books_id(text: str | None) -> str | None:
158
+ """Return a Google Books volume id from a books.google / play.google books URL, or None."""
159
+ if not text:
160
+ return None
161
+ low = text.lower()
162
+ if "books.google." not in low and "play.google.com/books" not in low:
163
+ return None
164
+ m = _GBOOKS_ID_RE.search(text)
165
+ return m.group(1) if m else None