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,83 @@
1
+ r"""LaTeX markup in `.bib` field values → plain Unicode text.
2
+
3
+ bibtexparser 2 decodes no LaTeX itself (its `LatexDecodingMiddleware` is a thin pylatexenc wrapper).
4
+ Its default decoder reads a field value as a LaTeX *document*, which a BibTeX value is not:
5
+
6
+ * a bare `%` starts a comment, so `https://x.org/a%20b` loses everything after `a`;
7
+ * `&` is a table column separator, so `d'Amore` becomes `d apos;Amore` and the HTML reference
8
+ can no longer be decoded (`parsing.entities`);
9
+ * `~` is a non-breaking space, so `https://x.org/~user` is corrupted;
10
+ * `--`, `---`, ``` `` ``` and `''` become typographic dashes and quotes, so `pages = {65--92}`
11
+ reads `65–92`.
12
+
13
+ Here those characters are literal: `%` is escaped before parsing and the context has no "specials".
14
+ What is decoded is the markup that changes how a reference reads in a database: accents
15
+ (`{\'e}` → é, `\"{o}` → ö, `\ss` → ß), text-mode macros (`\textit{Deep}` → Deep, `\&` → &,
16
+ `\url{x}` → x, `\href{x}{X}` → `X <x>`) and symbols inside math (`$\alpha$` → `$α$`).
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import re
22
+
23
+ from pylatexenc import latexwalker
24
+ from pylatexenc.latex2text import LatexNodes2Text, MacroTextSpec, get_default_latex_context_db
25
+ from pylatexenc.macrospec import LatexContextDb, MacroSpec
26
+
27
+ # A `%` that is not already escaped as `\%`.
28
+ _BARE_PERCENT_RE = re.compile(r"(?<!\\)%")
29
+
30
+
31
+ class LatexDecodeError(ValueError):
32
+ """pylatexenc failed on a value; the field cannot be read as text."""
33
+
34
+
35
+ def _text_context() -> LatexContextDb:
36
+ """pylatexenc's default macro → text rules, without its specials (`&`, `~`, `--`, quotes)."""
37
+ base = get_default_latex_context_db()
38
+ db = LatexContextDb()
39
+ # `\url{x}` is the URL itself, not pylatexenc's `<x>`.
40
+ db.add_context_category("bibtex", macros=[MacroTextSpec("url", simplify_repl="%s")])
41
+ for cat in base.categories():
42
+ db.add_context_category(
43
+ cat,
44
+ macros=list(base.iter_macro_specs([cat])),
45
+ environments=list(base.iter_environment_specs([cat])),
46
+ )
47
+ db.set_unknown_macro_spec(base.unknown_macro_spec)
48
+ db.set_unknown_environment_spec(base.unknown_environment_spec)
49
+ return db
50
+
51
+
52
+ def _parse_context() -> LatexContextDb:
53
+ """The walker's default argument specs, plus `\\href{url}{text}`, which it does not know.
54
+
55
+ Without it `\\href` is parsed with no arguments and its text rule raises an `IndexError`.
56
+ """
57
+ db = latexwalker.get_default_latex_context_db()
58
+ db.add_context_category("bibtex", prepend=True, macros=[MacroSpec("href", "{{")])
59
+ return db
60
+
61
+
62
+ _TEXT = LatexNodes2Text(
63
+ latex_context=_text_context(), math_mode="with-delimiters", keep_braced_groups=False
64
+ )
65
+ _PARSE_CONTEXT = _parse_context()
66
+
67
+
68
+ def decode_latex(value: str) -> str:
69
+ """`value` with its LaTeX markup decoded to Unicode; `%`, `&`, `~` and dashes stay literal.
70
+
71
+ Raises `LatexDecodeError` when pylatexenc fails on the value.
72
+ """
73
+ if "\\" not in value and "{" not in value and "$" not in value:
74
+ return value
75
+ escaped = _BARE_PERCENT_RE.sub(r"\\%", value)
76
+ try:
77
+ walker = latexwalker.LatexWalker(
78
+ escaped, latex_context=_PARSE_CONTEXT, tolerant_parsing=True
79
+ )
80
+ nodes, _, _ = walker.get_latex_nodes()
81
+ return _TEXT.nodelist_to_text(nodes)
82
+ except Exception as e: # noqa: BLE001 — any pylatexenc failure means the value is unreadable
83
+ raise LatexDecodeError(f"{type(e).__name__}: {e}") from e
@@ -0,0 +1,159 @@
1
+ """LaTeX citation extraction.
2
+
3
+ `strip_comments`/`strip_verbatim` are lifted from `sciwrite-lint/tex_parser.py`. We extend
4
+ cite extraction to include `\\nocite` (chan2019lenia is nocite-only in the pilot) and to
5
+ resolve `\\input`/`\\include` so multi-file manuscripts aren't miscounted.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+ from pathlib import Path
12
+
13
+ from reference_audit.models import CitationContext
14
+ from reference_audit.parsing.context import MIN_CONTEXT_CHARS, collapse, sentence_span
15
+
16
+ _VERBATIM_ENVS = ("lstlisting", "verbatim", "minted", "Verbatim", "alltt")
17
+ _VERBATIM_RE = re.compile(
18
+ r"\\begin\{(" + "|".join(re.escape(e) for e in _VERBATIM_ENVS) + r")\}.*?\\end\{\1\}",
19
+ re.DOTALL,
20
+ )
21
+ _VERB_INLINE_RE = re.compile(r"\\verb(.)(.*?)\1")
22
+
23
+ # \cite, \citep, \citet, \citeyearpar, \citeauthor, \citealt, \citealp, \nocite, ...
24
+ _CITE_RE = re.compile(r"\\(?:no)?cite[a-zA-Z]*\s*(?:\[[^\]]*\])*\s*\{([^}]*)\}")
25
+ _NOCITE_RE = re.compile(r"\\nocite\s*\{([^}]*)\}")
26
+ _INPUT_RE = re.compile(r"\\(?:input|include)\s*\{([^}]+)\}")
27
+
28
+ # Same as _CITE_RE but capturing the command name and (optional) pre/post bracket args, so context
29
+ # extraction can name the command and skip \nocite (which carries no citing prose).
30
+ _CITE_CMD_RE = re.compile(
31
+ r"\\(?P<cmd>(?:no)?cite[a-zA-Z]*)\s*(?:\[[^\]]*\])*\s*\{(?P<keys>[^}]*)\}"
32
+ )
33
+ _LATEX_CMD_RE = re.compile(r"\\[a-zA-Z]+\*?")
34
+
35
+
36
+ def strip_comments(text: str) -> str:
37
+ r"""Remove LaTeX comments (% to end of line), preserving escaped \%."""
38
+ out_lines: list[str] = []
39
+ for line in text.split("\n"):
40
+ buf: list[str] = []
41
+ i = 0
42
+ while i < len(line):
43
+ if line[i] == "%" and (i == 0 or line[i - 1] != "\\"):
44
+ break
45
+ buf.append(line[i])
46
+ i += 1
47
+ out_lines.append("".join(buf))
48
+ return "\n".join(out_lines)
49
+
50
+
51
+ def strip_verbatim(text: str) -> str:
52
+ r"""Blank verbatim-like environments and inline \verb|...| (so cites inside code don't count)."""
53
+ text = _VERBATIM_RE.sub(lambda m: "\n" * m.group(0).count("\n"), text)
54
+ text = _VERB_INLINE_RE.sub("", text)
55
+ return text
56
+
57
+
58
+ def _resolve_input(name: str, base_dir: Path) -> Path | None:
59
+ candidate = (base_dir / name).expanduser()
60
+ for p in (candidate, candidate.with_suffix(".tex")):
61
+ if p.suffix == "":
62
+ p = p.with_suffix(".tex")
63
+ if p.is_file():
64
+ return p
65
+ return None
66
+
67
+
68
+ def _read_with_includes(path: Path, seen: set[Path], missing: list[str]) -> str:
69
+ """Concatenate a tex file with its \\input/\\include targets (depth-first, cycle-safe).
70
+
71
+ Unresolved include targets are appended to `missing` so the caller can warn that
72
+ citation coverage is incomplete.
73
+ """
74
+ path = path.resolve()
75
+ if path in seen or not path.is_file():
76
+ return ""
77
+ seen.add(path)
78
+ text = path.read_text(encoding="utf-8", errors="replace")
79
+ text = strip_comments(text) # strip before resolving includes (commented \input ignored)
80
+
81
+ def _expand(m: re.Match[str]) -> str:
82
+ name = m.group(1).strip()
83
+ target = _resolve_input(name, path.parent)
84
+ if target is None:
85
+ missing.append(name)
86
+ return ""
87
+ return _read_with_includes(target, seen, missing)
88
+
89
+ return _INPUT_RE.sub(_expand, text)
90
+
91
+
92
+ def parse_cited_keys(tex_path: str | Path) -> tuple[set[str], bool, list[str]]:
93
+ """Return (cited_keys, nocite_star, missing_includes).
94
+
95
+ `cited_keys` is the union of all `\\cite*` and `\\nocite` keys across the main file and
96
+ its resolvable includes. `nocite_star` is True if `\\nocite{*}` appears (cite-everything).
97
+ `missing_includes` lists `\\input`/`\\include` targets that could not be found on disk.
98
+ """
99
+ path = Path(tex_path)
100
+ missing: list[str] = []
101
+ text = _read_with_includes(path, set(), missing)
102
+ text = strip_verbatim(text)
103
+
104
+ nocite_star = bool(re.search(r"\\nocite\s*\{\s*\*\s*\}", text))
105
+
106
+ keys: set[str] = set()
107
+ for m in _CITE_RE.finditer(text):
108
+ for raw in m.group(1).split(","):
109
+ key = raw.strip()
110
+ if key and key != "*":
111
+ keys.add(key)
112
+ return keys, nocite_star, sorted(set(missing))
113
+
114
+
115
+ def _clean_context(span: str) -> str:
116
+ """Strip LaTeX noise from a raw sentence span so what remains is the readable citing claim:
117
+ drop the cite macros entirely, strip other `\\command` control words (keeping their braced
118
+ argument *text*), and collapse braces/ties/whitespace."""
119
+ span = _CITE_CMD_RE.sub(" ", span) # the citation macro is not part of the claim
120
+ span = _LATEX_CMD_RE.sub(" ", span) # \emph, \textbf, ... — keep the argument text, drop the name
121
+ span = span.replace("{", " ").replace("}", " ").replace("~", " ").replace("\\", " ")
122
+ return collapse(span)
123
+
124
+
125
+ def parse_citation_contexts(tex_path: str | Path) -> dict[str, list[CitationContext]]:
126
+ """Per-key citing contexts: the sentence(s) around each `\\cite`-family occurrence — the *reason*
127
+ each work is cited — extracted offline from the `.tex` and its resolvable includes.
128
+
129
+ `\\nocite` is skipped (it carries no citing prose). A key cited several times yields several
130
+ contexts, each with a 0-based `ordinal` in document order. When a citation's own sentence is too
131
+ short to carry a claim (a bare trailing cite), the preceding sentence is folded in. Keys sharing
132
+ one sentence each get that whole sentence (v1 is sentence-granular, not clause-granular).
133
+ """
134
+ path = Path(tex_path)
135
+ text = _read_with_includes(path, set(), [])
136
+ text = strip_verbatim(text)
137
+
138
+ contexts: dict[str, list[CitationContext]] = {}
139
+ counts: dict[str, int] = {}
140
+ for m in _CITE_CMD_RE.finditer(text):
141
+ cmd = m.group("cmd")
142
+ if cmd.startswith("nocite"):
143
+ continue
144
+ left, right = sentence_span(text, m.start(), m.end())
145
+ cleaned = _clean_context(text[left:right])
146
+ if len(cleaned) < MIN_CONTEXT_CHARS:
147
+ # fold in the preceding sentence so there is an actual claim to judge
148
+ prev_left, _ = sentence_span(text, max(left - 2, 0), max(left - 1, 0))
149
+ cleaned = _clean_context(text[prev_left:right])
150
+ for raw in m.group("keys").split(","):
151
+ key = raw.strip()
152
+ if not key or key == "*":
153
+ continue
154
+ ordinal = counts.get(key, 0)
155
+ counts[key] = ordinal + 1
156
+ contexts.setdefault(key, []).append(
157
+ CitationContext(key=key, text=cleaned, ordinal=ordinal, command=cmd)
158
+ )
159
+ return contexts
@@ -0,0 +1,16 @@
1
+ """PDF input: GROBID turns a PDF into TEI XML, and a pure mapper turns that TEI into the same
2
+ models the `.bib`/`.tex` front end produces.
3
+
4
+ The split is deliberate and is enforced by `cpt-referenceaudit-constraint-no-network-parse`:
5
+
6
+ * `pdf.grobid` — the ONLY module here that performs network I/O. It talks to one operator-supplied GROBID
7
+ service and nothing else.
8
+ * `pdf.tei` — pure. Given the TEI text it returns `BibEntry` and `CitationContext` records, so
9
+ extraction correctness is verifiable offline from recorded TEI, exactly as the source adapters are
10
+ verified from recorded responses.
11
+
12
+ `pdf.embedded` reads a PDF that carries the BibTeX records of its cited works (the AI4X template
13
+ makes such PDFs); then neither GROBID nor `pdf.tei` is involved. It reads the local file only.
14
+
15
+ `parsing` must not import `pdf.grobid`.
16
+ """
@@ -0,0 +1,117 @@
1
+ r"""BibTeX records that a PDF carries for its own references.
2
+
3
+ A conference template can make the PDF carry the authored records of its cited works, so that they
4
+ are read rather than re-extracted from the typeset text. The AI4X template does: its bibliography
5
+ style (`ai4x.bst`) writes each cited entry's BibTeX record into the `.bbl`, and its class embeds them
6
+ as the PDF attachment `cited-references.bib.hex`:
7
+
8
+ * only the cited entries (BibTeX sees nothing else), in the printed order;
9
+ * only the fields printed in the reference list plus identifiers (DOI, ISBN, arXiv id, …), with
10
+ `@string` macros and `crossref` already resolved by BibTeX;
11
+ * hex-encoded, because the records pass through TeX on the way to the attachment and a raw `%`, `#`
12
+ or `\` in a field would break it. Whitespace is insignificant: BibTeX and TeX wrap the lines.
13
+
14
+ The records are what the author compiled, not necessarily what is printed — a hand-edited `.bbl`
15
+ can make them differ — so `title_printed` compares each one with the PDF's reference list.
16
+
17
+ Local file I/O only: nothing here touches the network.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import re
23
+ from dataclasses import dataclass
24
+ from pathlib import Path
25
+
26
+ from anyascii import anyascii
27
+ from pypdf import PdfReader
28
+
29
+ ATTACHMENT_NAME = "cited-references.bib.hex"
30
+
31
+ # The last heading of the reference list; the text after it is what the titles are looked up in.
32
+ _REFERENCES_HEADING_RE = re.compile(r"(?im)^\s*(?:references|bibliography)\s*$")
33
+ # What breaks a title into separately-checked segments: math, LaTeX commands, escaped characters.
34
+ _TITLE_BREAK_RE = re.compile(r"\$[^$]*\$|\\[a-zA-Z]+\*?|\\.")
35
+ # A title segment shorter than this is too unspecific to look up.
36
+ _MIN_SEGMENT_LETTERS = 4
37
+
38
+
39
+ @dataclass(frozen=True)
40
+ class EmbeddedReferences:
41
+ """The outcome of looking for the attachment.
42
+
43
+ `bib_text` is the decoded `.bib`, or None. `problem` says why there is none when that is not the
44
+ ordinary case of a PDF without the attachment: the PDF could not be read, or the attachment is
45
+ malformed. Either way the caller falls back to extracting the references, and reports `problem`.
46
+ """
47
+
48
+ bib_text: str | None = None
49
+ problem: str | None = None
50
+
51
+
52
+ def read_embedded_references(pdf_path: str | Path) -> EmbeddedReferences:
53
+ """Find and decode the `cited-references.bib.hex` attachment of a PDF."""
54
+ path = Path(pdf_path)
55
+ try:
56
+ attachments = PdfReader(path).attachments
57
+ payloads = attachments.get(ATTACHMENT_NAME, [])
58
+ except Exception as exc: # noqa: BLE001 — pypdf raises many types for a damaged PDF
59
+ return EmbeddedReferences(
60
+ problem=f"could not look for embedded BibTeX records in {path.name}: "
61
+ f"{type(exc).__name__}: {exc}"
62
+ )
63
+ if not payloads:
64
+ return EmbeddedReferences()
65
+ if len(payloads) > 1:
66
+ return EmbeddedReferences(
67
+ problem=f"{path.name} embeds {len(payloads)} attachments named {ATTACHMENT_NAME}, so it "
68
+ "is ambiguous which holds the references"
69
+ )
70
+ try:
71
+ return EmbeddedReferences(bib_text=decode_hex_payload(payloads[0]))
72
+ except ValueError as exc:
73
+ return EmbeddedReferences(problem=f"the {ATTACHMENT_NAME} attachment of {path.name} {exc}")
74
+
75
+
76
+ def decode_hex_payload(payload: bytes) -> str:
77
+ """Hex with arbitrary whitespace → text. Raises ValueError (worded as a predicate) if malformed."""
78
+ digits = re.sub(rb"\s+", b"", payload)
79
+ if not digits:
80
+ raise ValueError("is empty")
81
+ if re.fullmatch(rb"[0-9a-fA-F]+", digits) is None:
82
+ raise ValueError("is not hex-encoded")
83
+ if len(digits) % 2:
84
+ raise ValueError("has an odd number of hex digits (truncated)")
85
+ # A .bib in another encoding still parses; the replaced bytes are what `parse_bib` would get too.
86
+ return bytes.fromhex(digits.decode("ascii")).decode("utf-8", errors="replace")
87
+
88
+
89
+ def reference_list_text(pdf_path: str | Path) -> str:
90
+ """The PDF's text after its last References/Bibliography heading (all of it, if there is none).
91
+
92
+ Raises whatever pypdf raises when the text cannot be extracted; the caller reports it.
93
+ """
94
+ reader = PdfReader(Path(pdf_path))
95
+ text = "\n".join(page.extract_text() or "" for page in reader.pages)
96
+ headings = list(_REFERENCES_HEADING_RE.finditer(text))
97
+ return text[headings[-1].end():] if headings else text
98
+
99
+
100
+ def _letters(text: str) -> str:
101
+ """Lower-case ASCII letters only: hyphenation, line breaks, ligatures and accents fall away."""
102
+ return re.sub(r"[^a-z]", "", anyascii(text).lower())
103
+
104
+
105
+ def title_printed(title: str, printed_text: str) -> bool | None:
106
+ """Whether an embedded record's title appears in the printed reference list.
107
+
108
+ The title is split at math and LaTeX commands, whose typeset form differs from the source, and
109
+ every remaining segment must appear in the printed text. None when no segment is long enough to
110
+ be looked up — a title like "$\\alpha$" cannot be checked this way.
111
+ """
112
+ printed = _letters(printed_text)
113
+ segments = [s for s in map(_letters, _TITLE_BREAK_RE.split(title))
114
+ if len(s) >= _MIN_SEGMENT_LETTERS]
115
+ if not segments:
116
+ return None
117
+ return all(s in printed for s in segments)
@@ -0,0 +1,234 @@
1
+ """GROBID client: PDF → TEI XML. The only module in this package that performs I/O.
2
+
3
+ GROBID is an external service this package does NOT manage. `AuditConfig.grobid_url` points at an
4
+ operator-supplied instance (a local container by default); if it is not answering, that is reported
5
+ with the command to start it — never worked around, and never allowed to degrade into an empty
6
+ reference list.
7
+
8
+ Modeled on `inspirations/sciwrite-lint/sciwrite_lint/pdf/grobid.py`, with three deliberate
9
+ divergences documented at their call sites: no `consolidateCitations`, no module-global client or
10
+ health cache, and no PDF-producer blacklist in the failure diagnosis.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import shutil
16
+ import subprocess
17
+ from pathlib import Path
18
+
19
+ import httpx
20
+ from loguru import logger
21
+
22
+ from reference_audit.sources.http import (
23
+ DEFAULT_USER_AGENT,
24
+ TransientHTTPError,
25
+ is_transient_status,
26
+ post_multipart,
27
+ )
28
+
29
+ GROBID_IMAGE = "docker.io/grobid/grobid:0.9.1-crf"
30
+ GROBID_START_HINT = (
31
+ # `--replace` swaps out an existing `grobid` container, e.g. one left from an older image.
32
+ f"podman run -d --replace --name grobid -p 8070:8070 {GROBID_IMAGE}\n"
33
+ " (first start takes ~30 s while the models load; then re-run this command)"
34
+ )
35
+
36
+ FULLTEXT_ENDPOINT = "/api/processFulltextDocument"
37
+ ALIVE_ENDPOINT = "/api/isalive"
38
+
39
+ # Pinned request parameters. These are part of the extraction contract, not tuning knobs: the
40
+ # committed TEI fixtures and the TEI mapper both depend on them, and `tests/pdf_fixtures.py` asserts
41
+ # it sends the same set.
42
+ #
43
+ # `consolidateCitations=0` is the load-bearing one. With `1` (which the sciwrite-lint client uses)
44
+ # GROBID looks each reference up in Crossref and REWRITES it with the matched canonical record. That
45
+ # would silently repair the exact defect this tool exists to detect — a garbled or hallucinated
46
+ # reference would arrive already corrected — and would smuggle a third-party network dependency into
47
+ # what is otherwise a call to a local service.
48
+ #
49
+ # `includeRawCitations=1` keeps `<note type="raw_reference">`, which is how a reference GROBID could
50
+ # not structure is still reported to a human instead of appearing as a blank record.
51
+ FULLTEXT_PARAMS = {
52
+ "consolidateHeader": "0",
53
+ "consolidateCitations": "0",
54
+ "includeRawCitations": "1",
55
+ }
56
+
57
+ _ALIVE_TIMEOUT = 5.0
58
+ # GROBID throughput is roughly 1-3 MB/s and slower under concurrent load, so the timeout scales with
59
+ # document size rather than being a flat ceiling that a large report would always trip: a base budget
60
+ # plus a per-megabyte allowance, capped so we refuse to wait indefinitely.
61
+ _TIMEOUT_BASE_S = 60.0
62
+ _TIMEOUT_PER_MB_S = 10.0
63
+ _TIMEOUT_CEILING_S = 600.0
64
+
65
+ # Markers GROBID puts in a 500 body when it ran fine but could not parse THIS document.
66
+ _UNPARSEABLE_MARKERS = ("NO_BLOCKS", "empty content", "no text", "could not parse")
67
+
68
+
69
+ class GrobidError(RuntimeError):
70
+ """The PDF could not be turned into a reference list. Always fatal for the run.
71
+
72
+ There is no partial result to fall back to: without TEI there are no records to process
73
+ independently, so this is the one place where a failure is whole-run rather than per-record.
74
+ """
75
+
76
+
77
+ class GrobidUnavailableError(GrobidError):
78
+ """The GROBID service is not answering at all."""
79
+
80
+
81
+ class GrobidRequestError(GrobidError):
82
+ """GROBID answered, but with an error we could not get past (5xx/transport after retries)."""
83
+
84
+
85
+ class PdfUnparseableError(GrobidError):
86
+ """GROBID is healthy but cannot parse *this* PDF (image-only, or unrecognizable structure).
87
+
88
+ Kept distinct from `GrobidRequestError` so the message tells the user to fix the input rather
89
+ than the service, and so it is never retried — the PDF will not become parseable.
90
+ """
91
+
92
+ def __init__(self, pdf_name: str, reason: str) -> None:
93
+ super().__init__(f"cannot parse {pdf_name}: {reason}")
94
+ self.pdf_name = pdf_name
95
+ self.reason = reason
96
+
97
+
98
+ def _timeout_for(pdf_bytes: int) -> float:
99
+ size_mb = pdf_bytes / (1024 * 1024)
100
+ return min(_TIMEOUT_CEILING_S, _TIMEOUT_BASE_S + _TIMEOUT_PER_MB_S * size_mb)
101
+
102
+
103
+ def _is_unparseable_body(body: str) -> bool:
104
+ lowered = body.lower()
105
+ return any(marker.lower() in lowered for marker in _UNPARSEABLE_MARKERS)
106
+
107
+
108
+ def _diagnose_unparseable(pdf_path: Path) -> str:
109
+ """Explain WHY a PDF is unparseable, or say plainly that we cannot tell.
110
+
111
+ Only one probe: whether there is a text layer at all. `pdftotext` is a poppler tool GROBID itself
112
+ depends on, but it is not a declared dependency here, so its absence is reported rather than
113
+ silently skipped.
114
+
115
+ The sciwrite-lint original also flags "known-problematic producers" (CorelDRAW, Preview, Quartz).
116
+ That is deliberately not copied: a producer name correlates with, but does not establish, an
117
+ unparseable structure, and reporting a guess as a diagnosis is exactly the incomplete-guess
118
+ behavior this project forbids.
119
+ """
120
+ if not shutil.which("pdftotext"):
121
+ return (
122
+ "GROBID does not recognize this PDF's structure; the specific reason could not be "
123
+ "determined (install poppler-utils' pdftotext for a text-layer diagnosis)"
124
+ )
125
+ try:
126
+ text = subprocess.run(
127
+ ["pdftotext", "-l", "3", str(pdf_path), "-"],
128
+ capture_output=True,
129
+ text=True,
130
+ timeout=15,
131
+ check=False,
132
+ ).stdout
133
+ except (subprocess.TimeoutExpired, OSError) as exc:
134
+ logger.debug("pdftotext diagnosis failed for {}: {}: {}", pdf_path.name, type(exc).__name__, exc)
135
+ return (
136
+ "GROBID does not recognize this PDF's structure; the text-layer probe itself failed "
137
+ f"({type(exc).__name__})"
138
+ )
139
+ if len(text.strip()) < 100:
140
+ return "image-only PDF (no text layer) — GROBID cannot OCR; run OCR over it first"
141
+ return "GROBID does not recognize this PDF's structure, though it does contain extractable text"
142
+
143
+
144
+ class GrobidClient:
145
+ """A thin client over one GROBID instance.
146
+
147
+ Deliberately an injectable object with no module-level state, unlike the sciwrite-lint original:
148
+ that client is shared by a long-lived batch process, where a cached health check pays for itself.
149
+ An audit run parses exactly one PDF, so a TTL cache buys nothing and module globals would leak
150
+ between tests and rebind across event loops.
151
+ """
152
+
153
+ def __init__(self, base_url: str, *, client: httpx.AsyncClient | None = None) -> None:
154
+ self.base_url = base_url.rstrip("/")
155
+ self._client = client or httpx.AsyncClient(headers={"User-Agent": DEFAULT_USER_AGENT})
156
+ self._owns_client = client is None
157
+
158
+ async def aclose(self) -> None:
159
+ if self._owns_client and not self._client.is_closed:
160
+ await self._client.aclose()
161
+
162
+ async def check_alive(self) -> None:
163
+ """Raise `GrobidUnavailableError` unless the service answers. Called before the upload.
164
+
165
+ Checked up front so an unreachable service fails in seconds with an actionable message,
166
+ rather than after a multi-minute upload timeout.
167
+ """
168
+ url = f"{self.base_url}{ALIVE_ENDPOINT}"
169
+ try:
170
+ resp = await self._client.get(url, timeout=_ALIVE_TIMEOUT)
171
+ except httpx.HTTPError as exc:
172
+ raise GrobidUnavailableError(
173
+ f"GROBID is not reachable at {self.base_url} ({type(exc).__name__}: {exc}).\n"
174
+ f"PDF input needs a running GROBID; start one with:\n {GROBID_START_HINT}\n"
175
+ "Or point --grobid / GROBID_URL at an existing instance."
176
+ ) from exc
177
+ if resp.status_code != 200:
178
+ raise GrobidUnavailableError(
179
+ f"GROBID at {self.base_url} answered {ALIVE_ENDPOINT} with HTTP "
180
+ f"{resp.status_code}, so it is not ready to accept documents.\n"
181
+ f"If it is still loading models, wait ~30 s and retry; otherwise restart it:\n"
182
+ f" {GROBID_START_HINT}"
183
+ )
184
+
185
+ async def fulltext_tei(self, pdf_path: str | Path) -> str:
186
+ """Upload a PDF and return its TEI XML.
187
+
188
+ Raises `GrobidUnavailableError`, `PdfUnparseableError`, or `GrobidRequestError` — every
189
+ failure mode is named, and none of them returns an empty string.
190
+ """
191
+ path = Path(pdf_path)
192
+ await self.check_alive()
193
+
194
+ pdf_bytes = path.read_bytes()
195
+ if not pdf_bytes:
196
+ raise PdfUnparseableError(path.name, "the file is empty (0 bytes)")
197
+
198
+ url = f"{self.base_url}{FULLTEXT_ENDPOINT}"
199
+ # A 500 that names a per-document parse failure is NOT transient: retrying it three times
200
+ # only adds latency, and calling it an outage would blame the service for a bad input.
201
+ def _transient(resp: httpx.Response) -> bool:
202
+ return is_transient_status(resp) and not _is_unparseable_body(resp.text)
203
+
204
+ try:
205
+ resp = await post_multipart(
206
+ self._client,
207
+ url,
208
+ files={"input": (path.name, pdf_bytes, "application/pdf")},
209
+ data=dict(FULLTEXT_PARAMS),
210
+ timeout=_timeout_for(len(pdf_bytes)),
211
+ transient=_transient,
212
+ )
213
+ except TransientHTTPError as exc:
214
+ raise GrobidRequestError(
215
+ f"GROBID at {self.base_url} kept failing on {path.name} after 3 attempts ({exc}).\n"
216
+ "Check the service logs: podman logs grobid"
217
+ ) from exc
218
+
219
+ if resp.status_code == 200:
220
+ if not resp.text.strip():
221
+ raise GrobidRequestError(
222
+ f"GROBID returned an empty body (HTTP 200) for {path.name} — no TEI to parse."
223
+ )
224
+ return resp.text
225
+
226
+ if _is_unparseable_body(resp.text):
227
+ reason = _diagnose_unparseable(path)
228
+ logger.info("GROBID cannot parse {}: {}", path.name, reason)
229
+ raise PdfUnparseableError(path.name, reason)
230
+
231
+ raise GrobidRequestError(
232
+ f"GROBID at {self.base_url} returned HTTP {resp.status_code} for {path.name}: "
233
+ f"{resp.text[:200]!r}\nCheck the service logs: podman logs grobid"
234
+ )