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