sciwrite-lint 0.2.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.
- sciwrite_lint/__init__.py +3 -0
- sciwrite_lint/__main__.py +527 -0
- sciwrite_lint/_network.py +195 -0
- sciwrite_lint/api.py +1484 -0
- sciwrite_lint/checks/__init__.py +1 -0
- sciwrite_lint/checks/_section_utils.py +111 -0
- sciwrite_lint/checks/cite_purpose.py +122 -0
- sciwrite_lint/checks/claim_support.py +96 -0
- sciwrite_lint/checks/cross_section_consistency.py +185 -0
- sciwrite_lint/checks/dangling_cite.py +93 -0
- sciwrite_lint/checks/dangling_ref.py +116 -0
- sciwrite_lint/checks/full_paper_consistency.py +604 -0
- sciwrite_lint/checks/ref_internal_checks.py +919 -0
- sciwrite_lint/checks/reference_accuracy.py +277 -0
- sciwrite_lint/checks/reference_exists.py +119 -0
- sciwrite_lint/checks/reference_unreliable.py +244 -0
- sciwrite_lint/checks/registry.py +136 -0
- sciwrite_lint/checks/retracted_cite.py +96 -0
- sciwrite_lint/checks/structure_promises.py +115 -0
- sciwrite_lint/checks/unreferenced_figure.py +70 -0
- sciwrite_lint/claims.py +330 -0
- sciwrite_lint/claude_backend.py +94 -0
- sciwrite_lint/claude_cli.py +405 -0
- sciwrite_lint/cli/__init__.py +1 -0
- sciwrite_lint/cli/check.py +480 -0
- sciwrite_lint/cli/config.py +229 -0
- sciwrite_lint/cli/fetch.py +250 -0
- sciwrite_lint/cli/misc.py +1202 -0
- sciwrite_lint/cli/rank.py +470 -0
- sciwrite_lint/cli/verify.py +437 -0
- sciwrite_lint/config.py +646 -0
- sciwrite_lint/cross_paper.py +174 -0
- sciwrite_lint/eval_claims.py +1196 -0
- sciwrite_lint/fulltext.py +851 -0
- sciwrite_lint/llm_utils.py +386 -0
- sciwrite_lint/local_pdfs.py +122 -0
- sciwrite_lint/manuscript_store.py +674 -0
- sciwrite_lint/models.py +139 -0
- sciwrite_lint/pdf/__init__.py +1 -0
- sciwrite_lint/pdf/grobid.py +785 -0
- sciwrite_lint/pdf/pdf_download.py +258 -0
- sciwrite_lint/pipeline.py +2694 -0
- sciwrite_lint/prompt_safety.py +30 -0
- sciwrite_lint/rate_limiter.py +227 -0
- sciwrite_lint/references/__init__.py +1 -0
- sciwrite_lint/references/citations.py +715 -0
- sciwrite_lint/references/crossref.py +282 -0
- sciwrite_lint/references/embedding_store.py +380 -0
- sciwrite_lint/references/matching.py +273 -0
- sciwrite_lint/references/metadata.py +273 -0
- sciwrite_lint/references/reference_store.py +823 -0
- sciwrite_lint/references/retraction_watch.py +178 -0
- sciwrite_lint/references/workspace_db.py +1390 -0
- sciwrite_lint/report.py +163 -0
- sciwrite_lint/schemas.py +260 -0
- sciwrite_lint/scoring/__init__.py +1 -0
- sciwrite_lint/scoring/chain.py +716 -0
- sciwrite_lint/scoring/contribution.py +322 -0
- sciwrite_lint/scoring/scilint_score.py +611 -0
- sciwrite_lint/tex_parser.py +248 -0
- sciwrite_lint/usage.py +594 -0
- sciwrite_lint/vision/__init__.py +1 -0
- sciwrite_lint/vision/cache.py +140 -0
- sciwrite_lint/vision/describe.py +311 -0
- sciwrite_lint/vision/image_extraction.py +491 -0
- sciwrite_lint/vision/pipeline.py +207 -0
- sciwrite_lint/vllm/__init__.py +1 -0
- sciwrite_lint/vllm/metrics.py +157 -0
- sciwrite_lint/vllm/vllm_server.py +445 -0
- sciwrite_lint/web.py +369 -0
- sciwrite_lint-0.2.0.dist-info/METADATA +268 -0
- sciwrite_lint-0.2.0.dist-info/RECORD +76 -0
- sciwrite_lint-0.2.0.dist-info/WHEEL +5 -0
- sciwrite_lint-0.2.0.dist-info/entry_points.txt +2 -0
- sciwrite_lint-0.2.0.dist-info/licenses/LICENSE +21 -0
- sciwrite_lint-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,715 @@
|
|
|
1
|
+
"""Citation parsing, orphan detection, and local source checking."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from loguru import logger
|
|
9
|
+
|
|
10
|
+
from sciwrite_lint.models import Citation, Finding
|
|
11
|
+
from sciwrite_lint.tex_parser import (
|
|
12
|
+
extract_bibliography,
|
|
13
|
+
find_all_cite_keys,
|
|
14
|
+
find_bare_cite_keys,
|
|
15
|
+
find_unverified_cite_keys,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
# ---------------------------------------------------------------------------
|
|
20
|
+
# Citation extraction (bibitem or .bib)
|
|
21
|
+
# ---------------------------------------------------------------------------
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class CitationSource:
|
|
25
|
+
"""Describes how citations were extracted (for reporting)."""
|
|
26
|
+
|
|
27
|
+
def __init__(
|
|
28
|
+
self, method: str, path: str, count: int, warnings: list[str] | None = None
|
|
29
|
+
):
|
|
30
|
+
self.method = method # "bibtex", "bibitem_simple", "bibitem_natbib"
|
|
31
|
+
self.path = path # file that was parsed
|
|
32
|
+
self.count = count # number of citations found
|
|
33
|
+
self.warnings = warnings or []
|
|
34
|
+
|
|
35
|
+
def __str__(self) -> str:
|
|
36
|
+
return f"{self.method} ({self.count} citations from {self.path})"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
# Module-level last extraction info (set by extract_bibitems)
|
|
40
|
+
_last_source: CitationSource | None = None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def get_last_citation_source() -> CitationSource | None:
|
|
44
|
+
"""Return info about the last extraction (for UI/CLI reporting)."""
|
|
45
|
+
return _last_source
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def extract_bibitems(
|
|
49
|
+
tex_path: Path,
|
|
50
|
+
bib_format: str = "auto",
|
|
51
|
+
bib_path: Path | None = None,
|
|
52
|
+
) -> list[Citation]:
|
|
53
|
+
r"""Extract citations from a paper.
|
|
54
|
+
|
|
55
|
+
Supports two modes:
|
|
56
|
+
- \bibitem inline in .tex (simple or natbib)
|
|
57
|
+
- .bib file referenced via \bibliography{}
|
|
58
|
+
|
|
59
|
+
Args:
|
|
60
|
+
tex_path: Path to .tex file.
|
|
61
|
+
bib_format: "simple", "natbib", or "auto" (detect).
|
|
62
|
+
bib_path: Explicit path to .bib file. If None, auto-detected from
|
|
63
|
+
\bibliography{} command in .tex.
|
|
64
|
+
|
|
65
|
+
Raises:
|
|
66
|
+
ValueError: If both .bib file and \bibitem entries are found.
|
|
67
|
+
"""
|
|
68
|
+
global _last_source
|
|
69
|
+
text = tex_path.read_text(encoding="utf-8")
|
|
70
|
+
|
|
71
|
+
# Detect both sources
|
|
72
|
+
resolved_bib = bib_path or _find_bib_file(tex_path, text)
|
|
73
|
+
has_bib_file = resolved_bib is not None and resolved_bib.exists()
|
|
74
|
+
bib_text = extract_bibliography(text)
|
|
75
|
+
has_bibitems = bool(bib_text and "\\bibitem" in bib_text)
|
|
76
|
+
|
|
77
|
+
# Conflict: both .bib and \bibitem present
|
|
78
|
+
if has_bib_file and has_bibitems:
|
|
79
|
+
assert resolved_bib is not None # narrowing for mypy
|
|
80
|
+
raise ValueError(
|
|
81
|
+
f"Ambiguous bibliography in {tex_path.name}: "
|
|
82
|
+
f"found both .bib file ({resolved_bib.name}) and "
|
|
83
|
+
f"\\bibitem entries. Use one or the other."
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
# No citations found at all
|
|
87
|
+
if not has_bib_file and not has_bibitems:
|
|
88
|
+
_last_source = CitationSource(
|
|
89
|
+
"none", str(tex_path.name), 0, ["No bibliography found"]
|
|
90
|
+
)
|
|
91
|
+
return []
|
|
92
|
+
|
|
93
|
+
# .bib file mode
|
|
94
|
+
if has_bib_file:
|
|
95
|
+
assert resolved_bib is not None # narrowing for mypy
|
|
96
|
+
citations = _extract_from_bib(resolved_bib, tex_path.stem)
|
|
97
|
+
_last_source = CitationSource(
|
|
98
|
+
"bibtex",
|
|
99
|
+
str(resolved_bib.name),
|
|
100
|
+
len(citations),
|
|
101
|
+
)
|
|
102
|
+
return citations
|
|
103
|
+
|
|
104
|
+
# \bibitem mode
|
|
105
|
+
if bib_format == "auto":
|
|
106
|
+
bib_format = "natbib" if "\\newblock" in bib_text else "simple"
|
|
107
|
+
|
|
108
|
+
pattern = re.compile(
|
|
109
|
+
r"\\bibitem(?:\[([^\]]*)\])?\{([^}]+)\}\s*(.*?)(?=\\bibitem|$)",
|
|
110
|
+
re.DOTALL,
|
|
111
|
+
)
|
|
112
|
+
citations = []
|
|
113
|
+
for match in pattern.finditer(bib_text):
|
|
114
|
+
label, key, body = match.group(1), match.group(2), match.group(3).strip()
|
|
115
|
+
if bib_format == "natbib":
|
|
116
|
+
c = _parse_natbib(key, body, label or "")
|
|
117
|
+
else:
|
|
118
|
+
c = _parse_simple(key, body)
|
|
119
|
+
c.source_paper = tex_path.stem
|
|
120
|
+
c.bib_format = bib_format
|
|
121
|
+
citations.append(c)
|
|
122
|
+
|
|
123
|
+
_last_source = CitationSource(
|
|
124
|
+
f"bibitem_{bib_format}",
|
|
125
|
+
str(tex_path.name),
|
|
126
|
+
len(citations),
|
|
127
|
+
)
|
|
128
|
+
return citations
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _find_bib_file(tex_path: Path, text: str) -> Path | None:
|
|
132
|
+
r"""Find .bib file from \bibliography{name} command in .tex."""
|
|
133
|
+
match = re.search(r"\\bibliography\{([^}]+)\}", text)
|
|
134
|
+
if not match:
|
|
135
|
+
return None
|
|
136
|
+
bib_name = match.group(1).strip()
|
|
137
|
+
if not bib_name.endswith(".bib"):
|
|
138
|
+
bib_name += ".bib"
|
|
139
|
+
bib_path = tex_path.parent / bib_name
|
|
140
|
+
return bib_path if bib_path.exists() else None
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _extract_from_bib(bib_path: Path, source_paper: str) -> list[Citation]:
|
|
144
|
+
"""Parse a .bib file into Citation objects using bibtexparser."""
|
|
145
|
+
try:
|
|
146
|
+
import bibtexparser
|
|
147
|
+
except ImportError:
|
|
148
|
+
return []
|
|
149
|
+
|
|
150
|
+
text = bib_path.read_text(encoding="utf-8")
|
|
151
|
+
db = bibtexparser.loads(text)
|
|
152
|
+
|
|
153
|
+
citations = []
|
|
154
|
+
for entry in db.entries:
|
|
155
|
+
key = entry.get("ID", "")
|
|
156
|
+
if not key:
|
|
157
|
+
continue
|
|
158
|
+
|
|
159
|
+
# Authors: bibtexparser gives "First Last and First Last"
|
|
160
|
+
author_str = entry.get("author", "")
|
|
161
|
+
authors = _parse_author_string(author_str) if author_str else []
|
|
162
|
+
|
|
163
|
+
# Title: strip braces used for capitalization preservation
|
|
164
|
+
title = entry.get("title", "")
|
|
165
|
+
title = re.sub(r"[{}]", "", title).strip()
|
|
166
|
+
|
|
167
|
+
# Venue: journal, booktitle, or howpublished
|
|
168
|
+
venue = (
|
|
169
|
+
entry.get("journal", "")
|
|
170
|
+
or entry.get("booktitle", "")
|
|
171
|
+
or entry.get("howpublished", "")
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
# DOI and URL
|
|
175
|
+
doi = entry.get("doi", "")
|
|
176
|
+
url = entry.get("url", "")
|
|
177
|
+
# Extract URL from howpublished if url field is empty
|
|
178
|
+
if not url:
|
|
179
|
+
hp = entry.get("howpublished", "")
|
|
180
|
+
url_match = re.search(r"\\url\{([^}]+)\}", hp)
|
|
181
|
+
if url_match:
|
|
182
|
+
url = url_match.group(1)
|
|
183
|
+
# Extract DOI from howpublished URL if present
|
|
184
|
+
if not doi and url:
|
|
185
|
+
doi_match = re.search(r"(10\.\d{4,9}/[^\s,}]+)", url)
|
|
186
|
+
if doi_match:
|
|
187
|
+
doi = doi_match.group(1).rstrip(".")
|
|
188
|
+
if not doi:
|
|
189
|
+
hp = entry.get("howpublished", "")
|
|
190
|
+
doi_match = re.search(r"(10\.\d{4,9}/[^\s,}]+)", hp)
|
|
191
|
+
if doi_match:
|
|
192
|
+
doi = doi_match.group(1).rstrip(".")
|
|
193
|
+
|
|
194
|
+
# Build raw text for display
|
|
195
|
+
raw_parts = []
|
|
196
|
+
if author_str:
|
|
197
|
+
raw_parts.append(author_str)
|
|
198
|
+
if title:
|
|
199
|
+
raw_parts.append(title)
|
|
200
|
+
if venue:
|
|
201
|
+
raw_parts.append(venue)
|
|
202
|
+
raw_text = ". ".join(raw_parts)
|
|
203
|
+
|
|
204
|
+
# arXiv ID from eprint field — the NNNN.NNNNN format is
|
|
205
|
+
# arXiv-specific, no other preprint server uses it.
|
|
206
|
+
arxiv_id = ""
|
|
207
|
+
eprint = entry.get("eprint", "")
|
|
208
|
+
if eprint:
|
|
209
|
+
arxiv_match = re.match(r"(\d{4}\.\d{4,5})", eprint)
|
|
210
|
+
if arxiv_match:
|
|
211
|
+
arxiv_id = arxiv_match.group(1)
|
|
212
|
+
|
|
213
|
+
# ISBN — standard BibTeX field for books/chapters
|
|
214
|
+
isbn = entry.get("isbn", "")
|
|
215
|
+
|
|
216
|
+
# LCCN — Library of Congress Control Number
|
|
217
|
+
lccn = entry.get("lccn", "")
|
|
218
|
+
|
|
219
|
+
c = Citation(
|
|
220
|
+
key=key,
|
|
221
|
+
raw_text=raw_text,
|
|
222
|
+
authors=authors,
|
|
223
|
+
title=title,
|
|
224
|
+
year=entry.get("year", ""),
|
|
225
|
+
venue=venue,
|
|
226
|
+
doi=doi,
|
|
227
|
+
url=url,
|
|
228
|
+
arxiv_id=arxiv_id,
|
|
229
|
+
isbn=isbn,
|
|
230
|
+
lccn=lccn,
|
|
231
|
+
source_paper=source_paper,
|
|
232
|
+
bib_format="bibtex",
|
|
233
|
+
entry_type=entry.get("ENTRYTYPE", ""),
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
# Sweep non-standard fields for identifiers not yet found
|
|
237
|
+
_sweep_identifiers(c, entry)
|
|
238
|
+
|
|
239
|
+
citations.append(c)
|
|
240
|
+
|
|
241
|
+
return citations
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
# Patterns for identifier extraction from non-standard fields.
|
|
245
|
+
# Strict length constraints to minimize false positives.
|
|
246
|
+
_ARXIV_RE = re.compile(r"arXiv[:\s]+(\d{4}\.\d{4,5})", re.IGNORECASE)
|
|
247
|
+
_DOI_RE = re.compile(r"(10\.\d{4,9}/[^\s,}]+)")
|
|
248
|
+
_PMID_RE = re.compile(r"PMID[:\s]+(\d{6,9})")
|
|
249
|
+
_PMC_RE = re.compile(r"(PMC\d{6,8})\b")
|
|
250
|
+
_ISBN_RE = re.compile(r"(?:ISBN[:\s-]*)?(97[89]\d{10}|\d{9}[\dXx])", re.IGNORECASE)
|
|
251
|
+
_LCCN_RE = re.compile(r"LCCN[:\s]+(\d{8,12})", re.IGNORECASE)
|
|
252
|
+
|
|
253
|
+
# Standard BibTeX fields — skip these in the sweep (already parsed above)
|
|
254
|
+
_STANDARD_ID_FIELDS = {
|
|
255
|
+
"doi",
|
|
256
|
+
"eprint",
|
|
257
|
+
"url",
|
|
258
|
+
"eprinttype",
|
|
259
|
+
"archiveprefix",
|
|
260
|
+
"isbn",
|
|
261
|
+
"lccn",
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _sweep_identifiers(citation: Citation, entry: dict[str, str]) -> None:
|
|
266
|
+
"""Scan non-standard bib fields for structured identifiers.
|
|
267
|
+
|
|
268
|
+
Only fills in identifiers that are still missing after standard-field
|
|
269
|
+
parsing. Logs every regex-extracted identifier so we can trace the source.
|
|
270
|
+
"""
|
|
271
|
+
# Collect text from non-standard fields only
|
|
272
|
+
sweep_parts: list[tuple[str, str]] = []
|
|
273
|
+
for field_name, value in entry.items():
|
|
274
|
+
if field_name.lower() in _STANDARD_ID_FIELDS:
|
|
275
|
+
continue
|
|
276
|
+
if field_name in ("ID", "ENTRYTYPE"):
|
|
277
|
+
continue
|
|
278
|
+
if value and isinstance(value, str):
|
|
279
|
+
sweep_parts.append((field_name, value))
|
|
280
|
+
|
|
281
|
+
if not sweep_parts:
|
|
282
|
+
return
|
|
283
|
+
|
|
284
|
+
for field_name, value in sweep_parts:
|
|
285
|
+
# DOI
|
|
286
|
+
if not citation.doi:
|
|
287
|
+
doi_match = _DOI_RE.search(value)
|
|
288
|
+
if doi_match:
|
|
289
|
+
citation.doi = doi_match.group(1).rstrip(".")
|
|
290
|
+
logger.debug(
|
|
291
|
+
"Extracted DOI from '{}' field of '{}': {}",
|
|
292
|
+
field_name,
|
|
293
|
+
citation.key,
|
|
294
|
+
citation.doi,
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
# arXiv
|
|
298
|
+
if not citation.arxiv_id:
|
|
299
|
+
arxiv_match = _ARXIV_RE.search(value)
|
|
300
|
+
if arxiv_match:
|
|
301
|
+
citation.arxiv_id = arxiv_match.group(1)
|
|
302
|
+
logger.debug(
|
|
303
|
+
"Extracted arXiv ID from '{}' field of '{}': {}",
|
|
304
|
+
field_name,
|
|
305
|
+
citation.key,
|
|
306
|
+
citation.arxiv_id,
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
# PMID
|
|
310
|
+
if not citation.pmid:
|
|
311
|
+
pmid_match = _PMID_RE.search(value)
|
|
312
|
+
if pmid_match:
|
|
313
|
+
citation.pmid = pmid_match.group(1)
|
|
314
|
+
logger.debug(
|
|
315
|
+
"Extracted PMID from '{}' field of '{}': {}",
|
|
316
|
+
field_name,
|
|
317
|
+
citation.key,
|
|
318
|
+
citation.pmid,
|
|
319
|
+
)
|
|
320
|
+
|
|
321
|
+
# PMC
|
|
322
|
+
if not citation.pmc_id:
|
|
323
|
+
pmc_match = _PMC_RE.search(value)
|
|
324
|
+
if pmc_match:
|
|
325
|
+
citation.pmc_id = pmc_match.group(1)
|
|
326
|
+
logger.debug(
|
|
327
|
+
"Extracted PMC ID from '{}' field of '{}': {}",
|
|
328
|
+
field_name,
|
|
329
|
+
citation.key,
|
|
330
|
+
citation.pmc_id,
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
# ISBN
|
|
334
|
+
if not citation.isbn:
|
|
335
|
+
isbn_match = _ISBN_RE.search(value)
|
|
336
|
+
if isbn_match:
|
|
337
|
+
citation.isbn = isbn_match.group(1)
|
|
338
|
+
logger.debug(
|
|
339
|
+
"Extracted ISBN from '{}' field of '{}': {}",
|
|
340
|
+
field_name,
|
|
341
|
+
citation.key,
|
|
342
|
+
citation.isbn,
|
|
343
|
+
)
|
|
344
|
+
|
|
345
|
+
# LCCN
|
|
346
|
+
if not citation.lccn:
|
|
347
|
+
lccn_match = _LCCN_RE.search(value)
|
|
348
|
+
if lccn_match:
|
|
349
|
+
citation.lccn = lccn_match.group(1)
|
|
350
|
+
logger.debug(
|
|
351
|
+
"Extracted LCCN from '{}' field of '{}': {}",
|
|
352
|
+
field_name,
|
|
353
|
+
citation.key,
|
|
354
|
+
citation.lccn,
|
|
355
|
+
)
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def _parse_simple(key: str, body: str) -> Citation:
|
|
359
|
+
r"""Parse a simple-style bibitem (no \newblock)."""
|
|
360
|
+
c = Citation(key=key, raw_text=body)
|
|
361
|
+
_extract_doi_url(c, body)
|
|
362
|
+
_extract_year(c, body)
|
|
363
|
+
|
|
364
|
+
# Split by newlines BEFORE cleaning (to preserve line structure)
|
|
365
|
+
raw_lines = [ln.strip() for ln in body.split("\n") if ln.strip()]
|
|
366
|
+
|
|
367
|
+
if raw_lines:
|
|
368
|
+
c.authors = _parse_author_string(_clean_text(raw_lines[0]))
|
|
369
|
+
|
|
370
|
+
# Title: second line (between author and venue)
|
|
371
|
+
if len(raw_lines) > 1:
|
|
372
|
+
title_text = raw_lines[1]
|
|
373
|
+
# Remove \emph{} blocks (those are venue names)
|
|
374
|
+
title_text = re.sub(r"\\emph\{[^}]+\}", "", title_text)
|
|
375
|
+
title_text = _clean_text(title_text).rstrip(".,")
|
|
376
|
+
if title_text:
|
|
377
|
+
c.title = title_text
|
|
378
|
+
|
|
379
|
+
# Venue: first \emph{} in body
|
|
380
|
+
emph_matches = re.findall(r"\\emph\{([^}]+)\}", body)
|
|
381
|
+
if emph_matches:
|
|
382
|
+
c.venue = emph_matches[0]
|
|
383
|
+
|
|
384
|
+
return c
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def _parse_natbib(key: str, body: str, label: str) -> Citation:
|
|
388
|
+
r"""Parse a natbib-style bibitem with \newblock separators."""
|
|
389
|
+
c = Citation(key=key, raw_text=body)
|
|
390
|
+
_extract_doi_url(c, body)
|
|
391
|
+
_extract_year(c, body)
|
|
392
|
+
|
|
393
|
+
# Extract year from natbib label if present: [Author(Year)]
|
|
394
|
+
label_year = re.search(r"\((\d{4})\)", label)
|
|
395
|
+
if label_year and not c.year:
|
|
396
|
+
c.year = label_year.group(1)
|
|
397
|
+
|
|
398
|
+
# Split by \newblock
|
|
399
|
+
blocks = re.split(r"\\newblock\s*", body)
|
|
400
|
+
|
|
401
|
+
# Block 0: author line (e.g., "Bloom, B.~S. (1984).")
|
|
402
|
+
if blocks:
|
|
403
|
+
author_block = blocks[0].strip()
|
|
404
|
+
# Remove year in parens
|
|
405
|
+
author_clean = re.sub(r"\(\d{4}\)\.", "", author_block).strip()
|
|
406
|
+
c.authors = _parse_author_string(author_clean)
|
|
407
|
+
|
|
408
|
+
# Block 1: typically the title
|
|
409
|
+
if len(blocks) > 1:
|
|
410
|
+
title_block = blocks[1].strip()
|
|
411
|
+
# If title is in \emph{}, it's a book title
|
|
412
|
+
emph_match = re.match(r"\\emph\{(.+?)\}", title_block, re.DOTALL)
|
|
413
|
+
if emph_match:
|
|
414
|
+
c.title = _clean_text(emph_match.group(1)).rstrip(".")
|
|
415
|
+
else:
|
|
416
|
+
c.title = _clean_text(title_block).rstrip(".")
|
|
417
|
+
|
|
418
|
+
# Block 2+: venue
|
|
419
|
+
if len(blocks) > 2:
|
|
420
|
+
venue_block = blocks[2].strip()
|
|
421
|
+
emph_match = re.search(r"\\emph\{([^}]+)\}", venue_block)
|
|
422
|
+
if emph_match:
|
|
423
|
+
c.venue = emph_match.group(1)
|
|
424
|
+
|
|
425
|
+
return c
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def _extract_doi_url(c: Citation, body: str) -> None:
|
|
429
|
+
"""Extract DOI and URL from bibitem body text."""
|
|
430
|
+
url_match = re.search(r"\\url\{([^}]+)\}", body)
|
|
431
|
+
if url_match:
|
|
432
|
+
c.url = url_match.group(1)
|
|
433
|
+
|
|
434
|
+
doi_match = re.search(r"(10\.\d{4,9}/[^\s,}]+)", body)
|
|
435
|
+
if doi_match:
|
|
436
|
+
c.doi = doi_match.group(1).rstrip(".")
|
|
437
|
+
elif c.url:
|
|
438
|
+
doi_in_url = re.search(r"(10\.\d{4,9}/[^\s,}]+)", c.url)
|
|
439
|
+
if doi_in_url:
|
|
440
|
+
c.doi = doi_in_url.group(1).rstrip(".")
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def _extract_year(c: Citation, body: str) -> None:
|
|
444
|
+
"""Extract publication year from bibitem body."""
|
|
445
|
+
year_match = re.search(r"\b(19\d{2}|20\d{2})\b", body)
|
|
446
|
+
if year_match:
|
|
447
|
+
c.year = year_match.group(1)
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
def _parse_author_string(text: str) -> list[str]:
|
|
451
|
+
"""Parse an author string into individual author names."""
|
|
452
|
+
text = _clean_text(text).rstrip(".")
|
|
453
|
+
if not text:
|
|
454
|
+
return []
|
|
455
|
+
parts = re.split(r"\s+and\s+|\s*&\s*|\s*,\s+and\s+", text)
|
|
456
|
+
authors = []
|
|
457
|
+
for part in parts:
|
|
458
|
+
part = part.strip().rstrip(",")
|
|
459
|
+
if part and len(part) > 1:
|
|
460
|
+
authors.append(part)
|
|
461
|
+
return authors
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def _clean_text(text: str) -> str:
|
|
465
|
+
"""Remove LaTeX markup from text."""
|
|
466
|
+
text = re.sub(r"\\emph\{([^}]+)\}", r"\1", text)
|
|
467
|
+
text = re.sub(r"\\textbf\{([^}]+)\}", r"\1", text)
|
|
468
|
+
text = re.sub(r"\\texttt\{([^}]+)\}", r"\1", text)
|
|
469
|
+
text = re.sub(r"\\url\{[^}]*\}", "", text)
|
|
470
|
+
text = re.sub(r"\{([^}]*)\}", r"\1", text)
|
|
471
|
+
text = re.sub(r"~", " ", text)
|
|
472
|
+
text = re.sub(r"\\newblock\s*", "", text)
|
|
473
|
+
text = re.sub(r"\s+", " ", text)
|
|
474
|
+
return text.strip()
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
# ---------------------------------------------------------------------------
|
|
478
|
+
# Web resource detection
|
|
479
|
+
# ---------------------------------------------------------------------------
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
def is_web_resource(citation: Citation) -> bool:
|
|
483
|
+
"""Detect if a citation is a web resource rather than an academic paper."""
|
|
484
|
+
return citation.entry_type == "misc" and bool(citation.url) and not citation.doi
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
# ---------------------------------------------------------------------------
|
|
488
|
+
# Orphan detection
|
|
489
|
+
# ---------------------------------------------------------------------------
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
def find_orphans(
|
|
493
|
+
citations: list[Citation],
|
|
494
|
+
tex_path: Path,
|
|
495
|
+
aux_path: Path | None = None,
|
|
496
|
+
) -> tuple[set[str], set[str]]:
|
|
497
|
+
"""Find cite keys without bibitems and bibitems never cited.
|
|
498
|
+
|
|
499
|
+
Returns (cited_but_no_bib, bib_but_not_cited).
|
|
500
|
+
"""
|
|
501
|
+
text = tex_path.read_text(encoding="utf-8")
|
|
502
|
+
cite_keys = {k for _, k in find_all_cite_keys(text)}
|
|
503
|
+
bib_keys = {c.key for c in citations}
|
|
504
|
+
|
|
505
|
+
if aux_path and aux_path.exists():
|
|
506
|
+
aux_cite, aux_bib = parse_aux_citations(aux_path)
|
|
507
|
+
cite_keys |= aux_cite
|
|
508
|
+
|
|
509
|
+
return cite_keys - bib_keys, bib_keys - cite_keys
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
def parse_aux_citations(aux_path: Path) -> tuple[set[str], set[str]]:
|
|
513
|
+
"""Parse .aux file for citation and bibcite keys."""
|
|
514
|
+
text = aux_path.read_text(encoding="utf-8")
|
|
515
|
+
cite_keys = set()
|
|
516
|
+
bib_keys = set()
|
|
517
|
+
|
|
518
|
+
for match in re.finditer(r"\\citation\{([^}]+)\}", text):
|
|
519
|
+
for key in match.group(1).split(","):
|
|
520
|
+
cite_keys.add(key.strip())
|
|
521
|
+
|
|
522
|
+
for match in re.finditer(r"\\bibcite\{([^}]+)\}", text):
|
|
523
|
+
bib_keys.add(match.group(1).strip())
|
|
524
|
+
|
|
525
|
+
return cite_keys, bib_keys
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
# ---------------------------------------------------------------------------
|
|
529
|
+
# Local source checking
|
|
530
|
+
# ---------------------------------------------------------------------------
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
def _is_valid_local_pdf(path: Path) -> bool:
|
|
534
|
+
"""Check that a local file is actually a PDF (magic header + minimum size)."""
|
|
535
|
+
from sciwrite_lint.fulltext import _is_valid_pdf
|
|
536
|
+
|
|
537
|
+
try:
|
|
538
|
+
data = path.read_bytes()
|
|
539
|
+
except OSError:
|
|
540
|
+
return False
|
|
541
|
+
return _is_valid_pdf(data)
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
def check_local_sources(
|
|
545
|
+
citations: list[Citation],
|
|
546
|
+
refs_dir: Path,
|
|
547
|
+
) -> None:
|
|
548
|
+
"""Check which citations have local PDFs or markdown summaries."""
|
|
549
|
+
if not refs_dir.exists():
|
|
550
|
+
return
|
|
551
|
+
|
|
552
|
+
local_files = {f.name.lower(): f for f in refs_dir.iterdir() if f.is_file()}
|
|
553
|
+
|
|
554
|
+
for c in citations:
|
|
555
|
+
key_lower = c.key.lower()
|
|
556
|
+
found = False
|
|
557
|
+
|
|
558
|
+
for suffix in [".pdf", ".md"]:
|
|
559
|
+
for pattern in [
|
|
560
|
+
f"{key_lower}{suffix}",
|
|
561
|
+
f"{key_lower.replace('2', '_2', 1)}{suffix}",
|
|
562
|
+
]:
|
|
563
|
+
if pattern in local_files:
|
|
564
|
+
fpath = local_files[pattern]
|
|
565
|
+
if suffix == ".pdf" and not _is_valid_local_pdf(fpath):
|
|
566
|
+
logger.warning(
|
|
567
|
+
"{}: local file {} is not a valid PDF, skipping",
|
|
568
|
+
c.key,
|
|
569
|
+
fpath.name,
|
|
570
|
+
)
|
|
571
|
+
continue
|
|
572
|
+
c.local_status = "pdf" if suffix == ".pdf" else "md"
|
|
573
|
+
c.local_path = str(fpath)
|
|
574
|
+
found = True
|
|
575
|
+
break
|
|
576
|
+
if found:
|
|
577
|
+
break
|
|
578
|
+
|
|
579
|
+
if not found:
|
|
580
|
+
author_part = re.match(r"[a-z]+", key_lower)
|
|
581
|
+
year_in_key = re.search(r"\d{4}", key_lower)
|
|
582
|
+
if author_part and year_in_key:
|
|
583
|
+
author = author_part.group(0)
|
|
584
|
+
year = year_in_key.group(0)
|
|
585
|
+
for fname, fpath in local_files.items():
|
|
586
|
+
if (
|
|
587
|
+
fname.startswith(author)
|
|
588
|
+
and year in fname
|
|
589
|
+
and (fname.endswith(".pdf") or fname.endswith(".md"))
|
|
590
|
+
):
|
|
591
|
+
if fname.endswith(".pdf") and not _is_valid_local_pdf(fpath):
|
|
592
|
+
logger.warning(
|
|
593
|
+
"{}: local file {} is not a valid PDF, skipping",
|
|
594
|
+
c.key,
|
|
595
|
+
fname,
|
|
596
|
+
)
|
|
597
|
+
continue
|
|
598
|
+
c.local_status = "pdf" if fname.endswith(".pdf") else "md"
|
|
599
|
+
c.local_path = str(fpath)
|
|
600
|
+
found = True
|
|
601
|
+
break
|
|
602
|
+
|
|
603
|
+
if not found:
|
|
604
|
+
c.local_status = "none"
|
|
605
|
+
|
|
606
|
+
|
|
607
|
+
# ---------------------------------------------------------------------------
|
|
608
|
+
# Shared citation consistency
|
|
609
|
+
# ---------------------------------------------------------------------------
|
|
610
|
+
|
|
611
|
+
|
|
612
|
+
def check_shared_citations(
|
|
613
|
+
all_citations: dict[str, list[Citation]],
|
|
614
|
+
) -> list[Finding]:
|
|
615
|
+
"""Check citations shared across papers for consistency."""
|
|
616
|
+
by_key: dict[str, list[Citation]] = {}
|
|
617
|
+
for paper_name, citations in all_citations.items():
|
|
618
|
+
for c in citations:
|
|
619
|
+
by_key.setdefault(c.key, []).append(c)
|
|
620
|
+
|
|
621
|
+
findings = []
|
|
622
|
+
for key, cites in by_key.items():
|
|
623
|
+
if len(cites) < 2:
|
|
624
|
+
continue
|
|
625
|
+
years = {c.year for c in cites if c.year}
|
|
626
|
+
if len(years) > 1:
|
|
627
|
+
findings.append(
|
|
628
|
+
Finding(
|
|
629
|
+
level="error",
|
|
630
|
+
rule_id="ref-006",
|
|
631
|
+
message=f"Citation '{key}' has different years across papers: {years}",
|
|
632
|
+
file="cross",
|
|
633
|
+
)
|
|
634
|
+
)
|
|
635
|
+
titles = {c.title for c in cites if c.title}
|
|
636
|
+
if len(titles) > 1:
|
|
637
|
+
findings.append(
|
|
638
|
+
Finding(
|
|
639
|
+
level="warning",
|
|
640
|
+
rule_id="ref-006",
|
|
641
|
+
message=f"Citation '{key}' has different titles across papers: {titles}",
|
|
642
|
+
file="cross",
|
|
643
|
+
)
|
|
644
|
+
)
|
|
645
|
+
|
|
646
|
+
return findings
|
|
647
|
+
|
|
648
|
+
|
|
649
|
+
# ---------------------------------------------------------------------------
|
|
650
|
+
# Tier enforcement
|
|
651
|
+
# ---------------------------------------------------------------------------
|
|
652
|
+
|
|
653
|
+
|
|
654
|
+
def check_tiers(
|
|
655
|
+
citations: list[Citation],
|
|
656
|
+
tex_path: Path,
|
|
657
|
+
metadata_dir: Path,
|
|
658
|
+
) -> list[Finding]:
|
|
659
|
+
r"""Check tier enforcement: bare \cite on T2 = warning, T3 cite = error."""
|
|
660
|
+
from sciwrite_lint.references.metadata import load_all_metadata
|
|
661
|
+
|
|
662
|
+
findings: list[Finding] = []
|
|
663
|
+
all_meta = load_all_metadata(metadata_dir)
|
|
664
|
+
if not all_meta:
|
|
665
|
+
return findings
|
|
666
|
+
|
|
667
|
+
text = tex_path.read_text(encoding="utf-8")
|
|
668
|
+
bare_keys = find_bare_cite_keys(text)
|
|
669
|
+
unverified_keys = find_unverified_cite_keys(text)
|
|
670
|
+
|
|
671
|
+
for c in citations:
|
|
672
|
+
meta = all_meta.get(c.key)
|
|
673
|
+
if not meta:
|
|
674
|
+
continue
|
|
675
|
+
|
|
676
|
+
tier = meta.access.get("tier", "")
|
|
677
|
+
|
|
678
|
+
if tier == "T3" and c.key in (bare_keys | unverified_keys):
|
|
679
|
+
reason = "dead URL" if meta.api_match == "web_dead" else "not found in APIs"
|
|
680
|
+
findings.append(
|
|
681
|
+
Finding(
|
|
682
|
+
level="error",
|
|
683
|
+
rule_id="ref-008",
|
|
684
|
+
message=(
|
|
685
|
+
f"'{c.key}' is T3 ({reason}). Cannot cite — remove or replace."
|
|
686
|
+
),
|
|
687
|
+
file=str(tex_path.name),
|
|
688
|
+
)
|
|
689
|
+
)
|
|
690
|
+
elif tier == "T2" and c.key in bare_keys:
|
|
691
|
+
findings.append(
|
|
692
|
+
Finding(
|
|
693
|
+
level="warning",
|
|
694
|
+
rule_id="ref-009",
|
|
695
|
+
message=(
|
|
696
|
+
f"'{c.key}' is T2 (no full text). Use \\citeunverified{{{c.key}}} "
|
|
697
|
+
f"or obtain full text."
|
|
698
|
+
),
|
|
699
|
+
file=str(tex_path.name),
|
|
700
|
+
)
|
|
701
|
+
)
|
|
702
|
+
elif tier == "T1" and c.key in unverified_keys:
|
|
703
|
+
findings.append(
|
|
704
|
+
Finding(
|
|
705
|
+
level="info",
|
|
706
|
+
rule_id="ref-009",
|
|
707
|
+
message=(
|
|
708
|
+
f"'{c.key}' is T1 (full text available). "
|
|
709
|
+
f"Can upgrade \\citeunverified to \\cite."
|
|
710
|
+
),
|
|
711
|
+
file=str(tex_path.name),
|
|
712
|
+
)
|
|
713
|
+
)
|
|
714
|
+
|
|
715
|
+
return findings
|