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,470 @@
|
|
|
1
|
+
r"""GROBID TEI XML → `BibEntry` + `CitationContext`. Pure: no I/O, no network.
|
|
2
|
+
|
|
3
|
+
Two things are recovered from one TEI document:
|
|
4
|
+
|
|
5
|
+
1. **The reference list** — `<back>//<listBibl>/<biblStruct>`, one per printed bibliography entry.
|
|
6
|
+
Each becomes a `BibEntry` via `parsing.bib.entry_from_fields`, the same construction seam the
|
|
7
|
+
`.bib` parser uses, so identifier normalization and venue/type selection are defined once.
|
|
8
|
+
2. **The in-text citations** — `<ref type="bibr" target="#bN">` markers inside body `<p>`s, resolved
|
|
9
|
+
against the `biblStruct` `xml:id`s to give per-reference `CitationContext`s.
|
|
10
|
+
|
|
11
|
+
What this module refuses to do, per the project's reliability rules: it never guesses a link for an
|
|
12
|
+
unresolved marker, never invents a key, never returns a partial list from malformed XML, and records
|
|
13
|
+
*why* a reference could not be extracted instead of quietly emitting an empty record.
|
|
14
|
+
|
|
15
|
+
XML is parsed with the stdlib `xml.etree.ElementTree`, matching `sources.arxiv`. The input is TEI
|
|
16
|
+
serialized by our own GROBID from its internal model — not attacker-controlled markup passed through
|
|
17
|
+
from the PDF — so the entity-expansion hardening `defusedxml` adds has nothing to act on here.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import re
|
|
23
|
+
from xml.etree import ElementTree as ET
|
|
24
|
+
|
|
25
|
+
from pydantic import BaseModel, Field
|
|
26
|
+
|
|
27
|
+
from reference_audit.models import BibEntry, CitationContext
|
|
28
|
+
from reference_audit.parsing.bib import entry_from_fields
|
|
29
|
+
from reference_audit.parsing.context import MIN_CONTEXT_CHARS, collapse, sentence_span
|
|
30
|
+
|
|
31
|
+
TEI_NS = "http://www.tei-c.org/ns/1.0"
|
|
32
|
+
XML_NS = "http://www.w3.org/XML/1998/namespace"
|
|
33
|
+
|
|
34
|
+
_TEI = f"{{{TEI_NS}}}"
|
|
35
|
+
_XML_ID = f"{{{XML_NS}}}id"
|
|
36
|
+
|
|
37
|
+
# A `level="m"` venue naming a proceedings volume rather than a book.
|
|
38
|
+
_PROCEEDINGS_RE = re.compile(
|
|
39
|
+
r"\b(?:proc\.?|proceedings|conference|conf\.?|workshop|symposium|symp\.?|meeting)\b",
|
|
40
|
+
re.IGNORECASE,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
_DOI_URL_PREFIX_RE = re.compile(r"(?i)^\s*(?:https?://)?(?:dx\.)?doi\.org/")
|
|
44
|
+
_DOI_SHAPE_RE = re.compile(r"(?i)^10\.\d{4,9}/\S+$")
|
|
45
|
+
# Text that follows a DOI in a printed reference and is not part of it. Used to bound a DOI recovered
|
|
46
|
+
# from the raw reference string after whitespace removal has glued the next word onto it.
|
|
47
|
+
_DOI_STOP_RE = re.compile(r"(?i)(URL|https?://|ISBN|ISSN|Accessed|arXiv|In:|pp\.)")
|
|
48
|
+
# How far a line-break repair may extend a DOI. Real suffixes that get split are short (".ch9",
|
|
49
|
+
# ".v37i12.26698", ".2007.06.007"); anything longer means the extension has run past the end of the
|
|
50
|
+
# DOI into surrounding prose — which happens when GROBID merges two references into one record, where
|
|
51
|
+
# the "surrounding prose" is the whole next reference. Without this bound the repair turns a correct
|
|
52
|
+
# DOI into a fabricated one, which the compiled-PDF oracle caught.
|
|
53
|
+
_DOI_MAX_EXTENSION = 30
|
|
54
|
+
_DOI_MAX_LENGTH = 80
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class TeiParseError(ValueError):
|
|
58
|
+
"""The TEI could not be parsed as XML at all.
|
|
59
|
+
|
|
60
|
+
Distinct from "parsed but empty": a truncated or malformed document must never yield a partial
|
|
61
|
+
reference list that would then be reported as if it were the whole bibliography.
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class TeiReferences(BaseModel):
|
|
66
|
+
"""Everything one TEI document yields, before any network identification.
|
|
67
|
+
|
|
68
|
+
The counters exist so the caller can tell the difference between "this document cites nothing"
|
|
69
|
+
and "GROBID could not link this document's citations" — reported as unknown, never as zero.
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
entries: list[BibEntry] = Field(default_factory=list)
|
|
73
|
+
contexts: dict[str, list[CitationContext]] = Field(default_factory=dict)
|
|
74
|
+
# `<ref target="#bN">` pointing at an id no `biblStruct` declares — malformed TEI, normally empty.
|
|
75
|
+
dangling_targets: list[str] = Field(default_factory=list)
|
|
76
|
+
# Markers GROBID recognized as citations but could not attach to a bibliography item. Counted so
|
|
77
|
+
# they can be reported; never guessed into a key.
|
|
78
|
+
unresolved_markers: int = 0
|
|
79
|
+
# Total `type="bibr"` markers seen in the body. Zero means marker→bibliography linking produced
|
|
80
|
+
# nothing at all, so citedness for this document is unknown rather than "nothing is cited".
|
|
81
|
+
markers_seen: int = 0
|
|
82
|
+
# key → reasons this specific reference came out incomplete, surfaced as per-entry issues.
|
|
83
|
+
extraction_failures: dict[str, list[str]] = Field(default_factory=dict)
|
|
84
|
+
|
|
85
|
+
def linked_keys(self) -> set[str]:
|
|
86
|
+
return set(self.contexts)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _text(el: ET.Element | None) -> str:
|
|
90
|
+
"""All text under an element, whitespace-collapsed (TEI wraps prose in inline markup)."""
|
|
91
|
+
if el is None:
|
|
92
|
+
return ""
|
|
93
|
+
return collapse(" ".join(el.itertext()))
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _person_name(persname: ET.Element) -> str:
|
|
97
|
+
"""A `<persName>` rendered in BibTeX's own `"Surname, Forename(s)"` convention.
|
|
98
|
+
|
|
99
|
+
TEI labels the parts explicitly (`<surname>`, `<forename>`), so this is a faithful re-ordering
|
|
100
|
+
rather than a guess — and it matters beyond cosmetics: `compute_content_hash` hashes the author
|
|
101
|
+
strings as written, so emitting the printed order ("Andrey Bagrov") instead of the `.bib` order
|
|
102
|
+
("Bagrov, Andrey") would give the same work two different content hashes depending on whether it
|
|
103
|
+
was read from a PDF or a `.bib`, and the two inputs could not share a cached verdict.
|
|
104
|
+
|
|
105
|
+
With no `<surname>` to key on (an organizational author, or a name GROBID could not split) the
|
|
106
|
+
parts are joined in document order — the only honest option.
|
|
107
|
+
"""
|
|
108
|
+
surname = collapse(" ".join(el.text or "" for el in persname.findall(f"{_TEI}surname")))
|
|
109
|
+
forenames = [
|
|
110
|
+
collapse(el.text or "")
|
|
111
|
+
for el in persname.findall(f"{_TEI}forename")
|
|
112
|
+
if collapse(el.text or "")
|
|
113
|
+
]
|
|
114
|
+
if surname:
|
|
115
|
+
return f"{surname}, {' '.join(forenames)}" if forenames else surname
|
|
116
|
+
parts = [collapse(child.text or "") for child in persname if collapse(child.text or "")]
|
|
117
|
+
return " ".join(parts)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _resolve_doi(extracted: str, raw: str) -> tuple[str | None, str | None]:
|
|
121
|
+
"""Return (doi, note) for a DOI GROBID read, extending it if the PDF broke it across a line.
|
|
122
|
+
|
|
123
|
+
A DOI printed across a line break loses its tail: GROBID reads `10.1016/j.cognition` from a
|
|
124
|
+
reference whose text layer says `10.101 6/j.cognition.2007.06.007`. That prefix is not a shorter
|
|
125
|
+
form of the right DOI — it is a *different* identifier that resolves to another document or to
|
|
126
|
+
nothing, which is exactly the kind of confidently-wrong answer this tool exists to prevent.
|
|
127
|
+
|
|
128
|
+
The rule is deliberately extend-only. We look for the longest DOI-shaped token in the reference
|
|
129
|
+
text (whitespace removed, so a mid-DOI line break closes up) that *begins with* what GROBID
|
|
130
|
+
returned, and take it only if it is strictly longer. So the outcome is either GROBID's own value or
|
|
131
|
+
a superset of it — never a competing DOI, and never a discarded one. A repair is reported, so a
|
|
132
|
+
reader can check it against the PDF.
|
|
133
|
+
|
|
134
|
+
Getting this wrong in the other direction is easy and was caught by the compiled-PDF oracle: an
|
|
135
|
+
earlier version treated the sentence-ending period after a *complete* DOI as evidence of
|
|
136
|
+
truncation and threw good identifiers away.
|
|
137
|
+
"""
|
|
138
|
+
doi = _DOI_URL_PREFIX_RE.sub("", extracted).strip().rstrip(".,;:)]")
|
|
139
|
+
if not doi or not raw:
|
|
140
|
+
return (doi or None), None
|
|
141
|
+
|
|
142
|
+
despaced = re.sub(r"[\s‐‑]+", "", raw)
|
|
143
|
+
at = despaced.lower().find(doi.lower())
|
|
144
|
+
if at == -1:
|
|
145
|
+
return doi, None
|
|
146
|
+
|
|
147
|
+
# Only characters that can legitimately continue a DOI, so a following ", 2020" or "; see" ends
|
|
148
|
+
# the token rather than being absorbed into it.
|
|
149
|
+
ext = re.match(r"[0-9A-Za-z._()/:\-]*", despaced[at + len(doi) :]).group(0)
|
|
150
|
+
stop = _DOI_STOP_RE.search(ext)
|
|
151
|
+
if stop:
|
|
152
|
+
ext = ext[: stop.start()]
|
|
153
|
+
if len(ext) > _DOI_MAX_EXTENSION:
|
|
154
|
+
return doi, None # ran past the DOI into prose — trust what GROBID read
|
|
155
|
+
candidate = (doi + ext).rstrip(".,;:)]")
|
|
156
|
+
|
|
157
|
+
if (
|
|
158
|
+
len(candidate) > len(doi)
|
|
159
|
+
and len(candidate) <= _DOI_MAX_LENGTH
|
|
160
|
+
and _DOI_SHAPE_RE.match(candidate)
|
|
161
|
+
):
|
|
162
|
+
return candidate, (
|
|
163
|
+
f"GROBID's DOI was cut short by a line break in the PDF (it read '{doi}'); extended to "
|
|
164
|
+
f"'{candidate}' from the reference text — verify against the PDF if the match looks wrong"
|
|
165
|
+
)
|
|
166
|
+
return doi, None
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _idno(bs: ET.Element, kind: str) -> str:
|
|
170
|
+
for el in bs.iter(f"{_TEI}idno"):
|
|
171
|
+
if (el.get("type") or "").lower() == kind.lower():
|
|
172
|
+
value = _text(el)
|
|
173
|
+
if value:
|
|
174
|
+
return value
|
|
175
|
+
return ""
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _biblscope(bs: ET.Element, unit: str) -> str:
|
|
179
|
+
"""A `<biblScope unit="...">`: prefer the `@from`-`@to` range, else the element text."""
|
|
180
|
+
for el in bs.iter(f"{_TEI}biblScope"):
|
|
181
|
+
if (el.get("unit") or "") != unit:
|
|
182
|
+
continue
|
|
183
|
+
lo, hi = el.get("from"), el.get("to")
|
|
184
|
+
if lo and hi:
|
|
185
|
+
return f"{lo}--{hi}"
|
|
186
|
+
if lo:
|
|
187
|
+
return lo
|
|
188
|
+
value = _text(el)
|
|
189
|
+
if value:
|
|
190
|
+
return value
|
|
191
|
+
return ""
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _year(bs: ET.Element) -> str:
|
|
195
|
+
"""The publication year, preferring an explicitly `type="published"` date."""
|
|
196
|
+
for want_published in (True, False):
|
|
197
|
+
for el in bs.iter(f"{_TEI}date"):
|
|
198
|
+
if want_published and (el.get("type") or "") != "published":
|
|
199
|
+
continue
|
|
200
|
+
when = (el.get("when") or "").strip()
|
|
201
|
+
if when[:4].isdigit():
|
|
202
|
+
return when[:4]
|
|
203
|
+
text = _text(el)
|
|
204
|
+
m = re.search(r"\b(1\d{3}|20\d{2}|21\d{2})\b", text)
|
|
205
|
+
if m:
|
|
206
|
+
return m.group(1)
|
|
207
|
+
return ""
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _titles(bs: ET.Element) -> tuple[str, str, str]:
|
|
211
|
+
"""Split the `<title>` elements into (title, journal, booktitle).
|
|
212
|
+
|
|
213
|
+
`<analytic>/<title>` is the work's own title whenever it is present. The `<monogr>` titles then
|
|
214
|
+
depend on whether an analytic title exists, and getting this wrong breaks matching in both
|
|
215
|
+
directions:
|
|
216
|
+
|
|
217
|
+
* `level="j"` is a journal name — always a venue, never a work title.
|
|
218
|
+
* `level="m"` is a monograph. WITH an analytic title the reference is a contribution *inside* that
|
|
219
|
+
monograph, so the monograph is the venue (`booktitle`) — this is what stops a book-series name
|
|
220
|
+
from being read as a chapter's title. WITHOUT one, the reference *is* the monograph: a cited
|
|
221
|
+
book has no analytic title, and treating its title as a venue would leave the entry title-less
|
|
222
|
+
and unmatchable, which is exactly what the compiled-PDF oracle caught.
|
|
223
|
+
"""
|
|
224
|
+
analytic = _text(bs.find(f"{_TEI}analytic/{_TEI}title"))
|
|
225
|
+
title = analytic
|
|
226
|
+
journal = booktitle = ""
|
|
227
|
+
for el in bs.findall(f"{_TEI}monogr/{_TEI}title"):
|
|
228
|
+
value = _text(el)
|
|
229
|
+
if not value:
|
|
230
|
+
continue
|
|
231
|
+
level = el.get("level") or ""
|
|
232
|
+
if level == "j":
|
|
233
|
+
journal = journal or value
|
|
234
|
+
elif level == "m":
|
|
235
|
+
if analytic:
|
|
236
|
+
booktitle = booktitle or value
|
|
237
|
+
elif not title:
|
|
238
|
+
title = value
|
|
239
|
+
elif not title:
|
|
240
|
+
title = value
|
|
241
|
+
return title, journal, booktitle
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _infer_bib_type(bs: ET.Element, fields: dict[str, str]) -> str:
|
|
245
|
+
"""Infer a BibTeX entry type from TEI structure.
|
|
246
|
+
|
|
247
|
+
TEI carries no entry type, but `entry_type` decides which source adapters a reference is routed
|
|
248
|
+
to (`sources.registry.route_entry`) and which deterministic issues apply. Leaving everything
|
|
249
|
+
UNKNOWN would silently send books to article aggregators, so structure is used where it is
|
|
250
|
+
unambiguous and UNKNOWN is returned when it is not — UNKNOWN routes to the general article-ish
|
|
251
|
+
adapter set, which is the right default for an unclassifiable reference.
|
|
252
|
+
"""
|
|
253
|
+
if fields.get("journal"):
|
|
254
|
+
return "article"
|
|
255
|
+
booktitle = fields.get("booktitle", "")
|
|
256
|
+
if bs.find(f".//{_TEI}meeting") is not None or _PROCEEDINGS_RE.search(booktitle):
|
|
257
|
+
return "inproceedings"
|
|
258
|
+
has_book_signal = bool(fields.get("isbn") or fields.get("publisher"))
|
|
259
|
+
has_analytic = bool(_text(bs.find(f"{_TEI}analytic/{_TEI}title")))
|
|
260
|
+
if has_book_signal and not has_analytic:
|
|
261
|
+
return "book"
|
|
262
|
+
if has_analytic and booktitle:
|
|
263
|
+
return "incollection"
|
|
264
|
+
if fields.get("url") and not booktitle and not has_book_signal:
|
|
265
|
+
return "misc"
|
|
266
|
+
return ""
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _fields_from_biblstruct(bs: ET.Element) -> tuple[dict[str, str], list[str], list[str]]:
|
|
270
|
+
"""One `<biblStruct>` → (BibTeX-shaped fields, author list, extraction failures)."""
|
|
271
|
+
title, journal, booktitle = _titles(bs)
|
|
272
|
+
authors = [
|
|
273
|
+
name
|
|
274
|
+
for name in (_person_name(p) for p in bs.iter(f"{_TEI}persName"))
|
|
275
|
+
if name
|
|
276
|
+
]
|
|
277
|
+
raw = _text(bs.find(f"{_TEI}note[@type='raw_reference']"))
|
|
278
|
+
|
|
279
|
+
fields: dict[str, str] = {}
|
|
280
|
+
if title:
|
|
281
|
+
fields["title"] = title
|
|
282
|
+
if journal:
|
|
283
|
+
fields["journal"] = journal
|
|
284
|
+
if booktitle:
|
|
285
|
+
fields["booktitle"] = booktitle
|
|
286
|
+
year = _year(bs)
|
|
287
|
+
if year:
|
|
288
|
+
fields["year"] = year
|
|
289
|
+
for name, value in (
|
|
290
|
+
("publisher", _text(bs.find(f".//{_TEI}imprint/{_TEI}publisher"))),
|
|
291
|
+
("pages", _biblscope(bs, "page")),
|
|
292
|
+
("volume", _biblscope(bs, "volume")),
|
|
293
|
+
("number", _biblscope(bs, "issue")),
|
|
294
|
+
("isbn", _idno(bs, "ISBN")),
|
|
295
|
+
("pmid", _idno(bs, "PMID")),
|
|
296
|
+
):
|
|
297
|
+
if value:
|
|
298
|
+
fields[name] = value
|
|
299
|
+
|
|
300
|
+
# A DOI is stored already bare. `pipeline._parse_issues` advises "consider citing the bare DOI"
|
|
301
|
+
# when raw_fields["doi"] looks like a URL — sound advice for a .bib the author can fix, but for a
|
|
302
|
+
# PDF the `https://doi.org/...` form is GROBID's rendering of the printed reference, not an
|
|
303
|
+
# authoring choice. Normalizing here makes that advice structurally unable to misfire.
|
|
304
|
+
doi_note: str | None = None
|
|
305
|
+
if _idno(bs, "DOI"):
|
|
306
|
+
doi, doi_note = _resolve_doi(_idno(bs, "DOI"), raw)
|
|
307
|
+
if doi:
|
|
308
|
+
fields["doi"] = doi
|
|
309
|
+
arxiv = _idno(bs, "arXiv")
|
|
310
|
+
if arxiv:
|
|
311
|
+
fields["eprint"] = arxiv
|
|
312
|
+
fields["archiveprefix"] = "arXiv"
|
|
313
|
+
# `<ptr>` is not reliably tagged `type="url"` — GROBID emits a bare `<ptr target="https://…"/>`
|
|
314
|
+
# for the URL printed in a reference. Keying on the attribute (as the sciwrite-lint client does)
|
|
315
|
+
# silently drops those, and with them the Google Books volume ids that are often the only
|
|
316
|
+
# identifier a cited book has. So accept any `<ptr>` whose target is an http(s) URL.
|
|
317
|
+
for ptr in bs.iter(f"{_TEI}ptr"):
|
|
318
|
+
target = (ptr.get("target") or "").strip()
|
|
319
|
+
if target.lower().startswith(("http://", "https://")):
|
|
320
|
+
fields["url"] = target
|
|
321
|
+
break
|
|
322
|
+
if raw:
|
|
323
|
+
fields["grobid_raw"] = raw
|
|
324
|
+
|
|
325
|
+
failures: list[str] = []
|
|
326
|
+
if doi_note:
|
|
327
|
+
failures.append(doi_note)
|
|
328
|
+
quoted = f' (raw: "{raw[:200]}")' if raw else " (GROBID captured no raw reference string either)"
|
|
329
|
+
if not title:
|
|
330
|
+
failures.append(f"GROBID extracted no title for this reference{quoted}")
|
|
331
|
+
if not authors:
|
|
332
|
+
failures.append(
|
|
333
|
+
f"GROBID extracted no author list for this reference{quoted} — "
|
|
334
|
+
"matching is title-only and may be ambiguous"
|
|
335
|
+
)
|
|
336
|
+
return fields, authors, failures
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def _keys_for(bibl_structs: list[ET.Element]) -> list[str]:
|
|
340
|
+
"""Per-`biblStruct` entry keys, from the TEI `xml:id` that `<ref target="#...">` points at.
|
|
341
|
+
|
|
342
|
+
A PDF has no BibTeX keys, so the id GROBID assigned is the only stable handle — and it is exactly
|
|
343
|
+
what the in-text markers reference, which is what makes citation linking possible. Missing ids
|
|
344
|
+
fall back to position; duplicates get a suffix so no two entries collide (which would silently
|
|
345
|
+
merge two references in the report).
|
|
346
|
+
"""
|
|
347
|
+
keys: list[str] = []
|
|
348
|
+
seen: dict[str, int] = {}
|
|
349
|
+
for i, bs in enumerate(bibl_structs):
|
|
350
|
+
key = (bs.get(_XML_ID) or "").strip() or f"b{i}"
|
|
351
|
+
count = seen.get(key, 0)
|
|
352
|
+
seen[key] = count + 1
|
|
353
|
+
keys.append(key if count == 0 else f"{key}-{count + 1}")
|
|
354
|
+
return keys
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def _flatten_paragraph(p_el: ET.Element) -> tuple[str, list[tuple[int, str | None]]]:
|
|
358
|
+
r"""Plain text of one `<p>` with citation markers REMOVED, plus (offset, target) for each.
|
|
359
|
+
|
|
360
|
+
The marker's own surface text (`[13]`, `(Chan, 2019)`) is deliberately dropped: it is not part of
|
|
361
|
+
the citing claim, exactly as the LaTeX path substitutes the `\cite` macro away before extracting a
|
|
362
|
+
sentence. The offset where it stood is kept so `sentence_span` can be asked for the surrounding
|
|
363
|
+
sentence via a zero-width span.
|
|
364
|
+
|
|
365
|
+
Whitespace is NOT collapsed here — that would invalidate the offsets. The extracted span is
|
|
366
|
+
collapsed at the end, as the LaTeX path does.
|
|
367
|
+
"""
|
|
368
|
+
buf: list[str] = []
|
|
369
|
+
markers: list[tuple[int, str | None]] = []
|
|
370
|
+
pos = 0
|
|
371
|
+
|
|
372
|
+
def emit(s: str | None) -> None:
|
|
373
|
+
nonlocal pos
|
|
374
|
+
if s:
|
|
375
|
+
buf.append(s)
|
|
376
|
+
pos += len(s)
|
|
377
|
+
|
|
378
|
+
emit(p_el.text)
|
|
379
|
+
for child in p_el:
|
|
380
|
+
if child.tag == f"{_TEI}ref" and (child.get("type") or "") == "bibr":
|
|
381
|
+
target = (child.get("target") or "").lstrip("#").strip() or None
|
|
382
|
+
markers.append((pos, target))
|
|
383
|
+
else:
|
|
384
|
+
emit(" ".join(child.itertext()))
|
|
385
|
+
emit(child.tail)
|
|
386
|
+
return "".join(buf), markers
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def _body_paragraphs(root: ET.Element) -> list[ET.Element]:
|
|
390
|
+
"""Prose `<p>`s of the article body, each visited exactly once, in document order.
|
|
391
|
+
|
|
392
|
+
Descends only through `<div>`, so the abstract, figure captions, tables and the `<back>`
|
|
393
|
+
bibliography are excluded. Note `div.findall(p)` rather than `div.iter(ref)`: iterating refs from
|
|
394
|
+
each div would visit a marker in a nested div once per ancestor, inflating every ordinal.
|
|
395
|
+
"""
|
|
396
|
+
body = root.find(f".//{_TEI}text/{_TEI}body")
|
|
397
|
+
if body is None:
|
|
398
|
+
return []
|
|
399
|
+
paragraphs = list(body.findall(f"{_TEI}p"))
|
|
400
|
+
for div in body.iter(f"{_TEI}div"):
|
|
401
|
+
paragraphs.extend(div.findall(f"{_TEI}p"))
|
|
402
|
+
return paragraphs
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def parse_tei(xml_text: str) -> TeiReferences:
|
|
406
|
+
"""Map one GROBID TEI document to entries + citing contexts.
|
|
407
|
+
|
|
408
|
+
Raises `TeiParseError` if the XML is malformed. An empty `entries` list is a legitimate (if
|
|
409
|
+
unusable) result the caller must handle — it means GROBID found no bibliography.
|
|
410
|
+
"""
|
|
411
|
+
try:
|
|
412
|
+
root = ET.fromstring(xml_text)
|
|
413
|
+
except ET.ParseError as exc:
|
|
414
|
+
raise TeiParseError(f"GROBID returned XML that could not be parsed: {exc}") from exc
|
|
415
|
+
|
|
416
|
+
bibl_structs = [
|
|
417
|
+
bs
|
|
418
|
+
for listbibl in root.iter(f"{_TEI}listBibl")
|
|
419
|
+
for bs in listbibl.findall(f"{_TEI}biblStruct")
|
|
420
|
+
]
|
|
421
|
+
keys = _keys_for(bibl_structs)
|
|
422
|
+
|
|
423
|
+
result = TeiReferences()
|
|
424
|
+
id_to_key: dict[str, str] = {}
|
|
425
|
+
for index, (bs, key) in enumerate(zip(bibl_structs, keys, strict=True)):
|
|
426
|
+
fields, authors, failures = _fields_from_biblstruct(bs)
|
|
427
|
+
fields["grobid_ref_index"] = str(index + 1) # 1-based, as printed
|
|
428
|
+
fields["grobid_tei_id"] = (bs.get(_XML_ID) or "").strip()
|
|
429
|
+
entry = entry_from_fields(
|
|
430
|
+
key, _infer_bib_type(bs, fields), fields, authors=authors
|
|
431
|
+
)
|
|
432
|
+
result.entries.append(entry)
|
|
433
|
+
if failures:
|
|
434
|
+
result.extraction_failures[key] = failures
|
|
435
|
+
tei_id = (bs.get(_XML_ID) or "").strip()
|
|
436
|
+
if tei_id:
|
|
437
|
+
id_to_key.setdefault(tei_id, key)
|
|
438
|
+
|
|
439
|
+
counts: dict[str, int] = {}
|
|
440
|
+
for p_el in _body_paragraphs(root):
|
|
441
|
+
text, markers = _flatten_paragraph(p_el)
|
|
442
|
+
for pos, target in markers:
|
|
443
|
+
result.markers_seen += 1
|
|
444
|
+
if target is None:
|
|
445
|
+
result.unresolved_markers += 1
|
|
446
|
+
continue
|
|
447
|
+
key = id_to_key.get(target)
|
|
448
|
+
if key is None:
|
|
449
|
+
result.dangling_targets.append(target)
|
|
450
|
+
continue
|
|
451
|
+
left, right = sentence_span(text, pos, pos)
|
|
452
|
+
cleaned = collapse(text[left:right])
|
|
453
|
+
if len(cleaned) < MIN_CONTEXT_CHARS:
|
|
454
|
+
# fold in the preceding sentence so there is an actual claim to judge
|
|
455
|
+
prev_left, _ = sentence_span(text, max(left - 2, 0), max(left - 1, 0))
|
|
456
|
+
cleaned = collapse(text[prev_left:right])
|
|
457
|
+
if not cleaned:
|
|
458
|
+
# No prose around the marker (a bare citation in a caption-like fragment). Counting it
|
|
459
|
+
# as unresolved is honest; emitting an empty context would give the LLM nothing to
|
|
460
|
+
# judge while looking like a real citing claim.
|
|
461
|
+
result.unresolved_markers += 1
|
|
462
|
+
continue
|
|
463
|
+
ordinal = counts.get(key, 0)
|
|
464
|
+
counts[key] = ordinal + 1
|
|
465
|
+
result.contexts.setdefault(key, []).append(
|
|
466
|
+
CitationContext(key=key, text=cleaned, ordinal=ordinal, command="bibr")
|
|
467
|
+
)
|
|
468
|
+
|
|
469
|
+
result.dangling_targets = sorted(set(result.dangling_targets))
|
|
470
|
+
return result
|