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