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
sciwrite_lint/web.py
ADDED
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
"""Web resource verification: URL liveness check + content download as markdown.
|
|
2
|
+
|
|
3
|
+
For citations that are blog posts, GitHub repos, or other web resources rather
|
|
4
|
+
than academic papers. These skip academic APIs and instead verify the URL is
|
|
5
|
+
alive and download content for LLM claim-checking.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
from pydantic import BaseModel
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from urllib.parse import urlparse, urlunparse
|
|
14
|
+
|
|
15
|
+
import httpx
|
|
16
|
+
|
|
17
|
+
from sciwrite_lint._network import is_hostname_safe
|
|
18
|
+
|
|
19
|
+
# Defaults (overridable via LintConfig)
|
|
20
|
+
_DEFAULT_TIMEOUT = 15.0
|
|
21
|
+
_DEFAULT_USER_AGENT = "sciwrite-lint/0.1 (citation-verification)"
|
|
22
|
+
_MAX_HTML_BYTES = 10 * 1024 * 1024 # 10 MB — reject pages larger than this
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _www_variant(url: str) -> str | None:
|
|
26
|
+
"""Return the www-toggled variant of a URL, or None if not applicable."""
|
|
27
|
+
parsed = urlparse(url)
|
|
28
|
+
host = parsed.hostname or ""
|
|
29
|
+
if host.startswith("www."):
|
|
30
|
+
alt_host = host[4:]
|
|
31
|
+
else:
|
|
32
|
+
alt_host = f"www.{host}"
|
|
33
|
+
# Rebuild netloc preserving port if present
|
|
34
|
+
if parsed.port:
|
|
35
|
+
alt_netloc = f"{alt_host}:{parsed.port}"
|
|
36
|
+
else:
|
|
37
|
+
alt_netloc = alt_host
|
|
38
|
+
return urlunparse(parsed._replace(netloc=alt_netloc))
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class WebResult(BaseModel):
|
|
42
|
+
"""Result of a web resource verification attempt."""
|
|
43
|
+
|
|
44
|
+
url_alive: bool
|
|
45
|
+
status_code: int = 0
|
|
46
|
+
content_type: str = ""
|
|
47
|
+
local_path: str | None = None # path relative to references/
|
|
48
|
+
title: str | None = None # extracted page title
|
|
49
|
+
error: str | None = None
|
|
50
|
+
resolved_url: str | None = (
|
|
51
|
+
None # URL that actually responded (may differ from input after www alternation)
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
async def check_url(
|
|
56
|
+
url: str,
|
|
57
|
+
client: httpx.AsyncClient | None = None,
|
|
58
|
+
timeout: float = _DEFAULT_TIMEOUT,
|
|
59
|
+
user_agent: str = _DEFAULT_USER_AGENT,
|
|
60
|
+
) -> WebResult:
|
|
61
|
+
"""Check if a URL is alive via HEAD request, retry with GET on 405.
|
|
62
|
+
|
|
63
|
+
Alternates between www and non-www variants (adds www. if missing,
|
|
64
|
+
removes it if present) to handle sites where only one variant resolves.
|
|
65
|
+
"""
|
|
66
|
+
own_client = client is None
|
|
67
|
+
if own_client:
|
|
68
|
+
client = httpx.AsyncClient(
|
|
69
|
+
timeout=timeout,
|
|
70
|
+
follow_redirects=True,
|
|
71
|
+
headers={"User-Agent": user_agent},
|
|
72
|
+
)
|
|
73
|
+
assert client is not None
|
|
74
|
+
try:
|
|
75
|
+
result = await _check_url_once(url, client, user_agent)
|
|
76
|
+
|
|
77
|
+
# Retry once on transient connection errors
|
|
78
|
+
if not result.url_alive and result.error and result.status_code == 0:
|
|
79
|
+
import asyncio as _asyncio
|
|
80
|
+
|
|
81
|
+
await _asyncio.sleep(1)
|
|
82
|
+
result = await _check_url_once(url, client, user_agent)
|
|
83
|
+
|
|
84
|
+
if result.url_alive:
|
|
85
|
+
result.resolved_url = url
|
|
86
|
+
else:
|
|
87
|
+
alt = _www_variant(url)
|
|
88
|
+
if alt:
|
|
89
|
+
alt_result = await _check_url_once(alt, client, user_agent)
|
|
90
|
+
if alt_result.url_alive:
|
|
91
|
+
alt_result.resolved_url = alt
|
|
92
|
+
return alt_result
|
|
93
|
+
return result
|
|
94
|
+
finally:
|
|
95
|
+
if own_client:
|
|
96
|
+
await client.aclose()
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
async def _check_url_once(
|
|
100
|
+
url: str,
|
|
101
|
+
client: httpx.AsyncClient,
|
|
102
|
+
user_agent: str,
|
|
103
|
+
) -> WebResult:
|
|
104
|
+
"""Single-attempt URL liveness check (HEAD, retry with GET on 405)."""
|
|
105
|
+
try:
|
|
106
|
+
resp = await client.head(url, headers={"User-Agent": user_agent})
|
|
107
|
+
if resp.status_code == 405:
|
|
108
|
+
resp = await client.get(url, headers={"User-Agent": user_agent})
|
|
109
|
+
except httpx.HTTPError as e:
|
|
110
|
+
return WebResult(url_alive=False, error=str(e))
|
|
111
|
+
|
|
112
|
+
alive = 200 <= resp.status_code < 400
|
|
113
|
+
content_type = resp.headers.get("content-type", "")
|
|
114
|
+
return WebResult(
|
|
115
|
+
url_alive=alive,
|
|
116
|
+
status_code=resp.status_code,
|
|
117
|
+
content_type=content_type,
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
async def fetch_web_content(
|
|
122
|
+
url: str,
|
|
123
|
+
key: str,
|
|
124
|
+
references_dir: Path,
|
|
125
|
+
client: httpx.AsyncClient | None = None,
|
|
126
|
+
timeout: float = _DEFAULT_TIMEOUT,
|
|
127
|
+
user_agent: str = _DEFAULT_USER_AGENT,
|
|
128
|
+
) -> WebResult:
|
|
129
|
+
"""Fetch a web page, extract content as markdown, save to references/.
|
|
130
|
+
|
|
131
|
+
Returns WebResult with local_path set if content was saved successfully.
|
|
132
|
+
Alternates between www and non-www variants if the original URL fails.
|
|
133
|
+
"""
|
|
134
|
+
own_client = client is None
|
|
135
|
+
if own_client:
|
|
136
|
+
client = httpx.AsyncClient(
|
|
137
|
+
timeout=timeout,
|
|
138
|
+
follow_redirects=True,
|
|
139
|
+
headers={"User-Agent": user_agent},
|
|
140
|
+
)
|
|
141
|
+
assert client is not None
|
|
142
|
+
try:
|
|
143
|
+
resp, fetch_error = await _fetch_once(url, client, user_agent)
|
|
144
|
+
|
|
145
|
+
# Retry once on transient errors (decompression, connection reset)
|
|
146
|
+
if fetch_error is not None and resp is None:
|
|
147
|
+
import asyncio as _asyncio
|
|
148
|
+
|
|
149
|
+
await _asyncio.sleep(1)
|
|
150
|
+
resp, fetch_error = await _fetch_once(url, client, user_agent)
|
|
151
|
+
|
|
152
|
+
if fetch_error is not None or (resp is not None and resp.status_code >= 400):
|
|
153
|
+
alt = _www_variant(url)
|
|
154
|
+
if alt:
|
|
155
|
+
alt_resp, alt_error = await _fetch_once(alt, client, user_agent)
|
|
156
|
+
if (
|
|
157
|
+
alt_error is None
|
|
158
|
+
and alt_resp is not None
|
|
159
|
+
and alt_resp.status_code < 400
|
|
160
|
+
):
|
|
161
|
+
resp, fetch_error = alt_resp, alt_error
|
|
162
|
+
url = alt # use working URL for saved metadata
|
|
163
|
+
|
|
164
|
+
if fetch_error is not None:
|
|
165
|
+
return WebResult(url_alive=False, error=fetch_error, resolved_url=url)
|
|
166
|
+
|
|
167
|
+
assert resp is not None # mypy: if fetch_error is None, resp is set
|
|
168
|
+
if resp.status_code >= 400:
|
|
169
|
+
return WebResult(
|
|
170
|
+
url_alive=False,
|
|
171
|
+
status_code=resp.status_code,
|
|
172
|
+
error=f"HTTP {resp.status_code}",
|
|
173
|
+
resolved_url=url,
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
content_type = resp.headers.get("content-type", "")
|
|
177
|
+
html = resp.text
|
|
178
|
+
|
|
179
|
+
# Extract page title
|
|
180
|
+
title = _extract_title(html)
|
|
181
|
+
|
|
182
|
+
# Convert to markdown
|
|
183
|
+
markdown = _html_to_markdown(html, url)
|
|
184
|
+
if not markdown or len(markdown.strip()) < 100:
|
|
185
|
+
# Check for JS/meta redirect before giving up
|
|
186
|
+
redirect_url = _extract_redirect_url(html, url)
|
|
187
|
+
if redirect_url and redirect_url != url:
|
|
188
|
+
from loguru import logger
|
|
189
|
+
|
|
190
|
+
logger.info("Following redirect from {} to {}", key, redirect_url)
|
|
191
|
+
redirect_resp, redirect_err = await _fetch_once(
|
|
192
|
+
redirect_url, client, user_agent
|
|
193
|
+
)
|
|
194
|
+
if redirect_err is None and redirect_resp is not None:
|
|
195
|
+
html = redirect_resp.text
|
|
196
|
+
url = redirect_url
|
|
197
|
+
title = _extract_title(html) or title
|
|
198
|
+
markdown = _html_to_markdown(html, url)
|
|
199
|
+
|
|
200
|
+
if not markdown or len(markdown.strip()) < 100:
|
|
201
|
+
return WebResult(
|
|
202
|
+
url_alive=True,
|
|
203
|
+
status_code=resp.status_code,
|
|
204
|
+
content_type=content_type,
|
|
205
|
+
title=title,
|
|
206
|
+
error="Content extraction failed"
|
|
207
|
+
if not markdown
|
|
208
|
+
else f"Extracted content too short ({len(markdown.strip())} chars)",
|
|
209
|
+
resolved_url=url,
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
# Save to references/
|
|
213
|
+
references_dir.mkdir(parents=True, exist_ok=True)
|
|
214
|
+
dest = references_dir / f"{key}_web.md"
|
|
215
|
+
header = f"# {title or key}\n\nSource: {url}\n\n---\n\n"
|
|
216
|
+
dest.write_text(header + markdown, encoding="utf-8")
|
|
217
|
+
local_path = str(dest.relative_to(references_dir))
|
|
218
|
+
|
|
219
|
+
return WebResult(
|
|
220
|
+
url_alive=True,
|
|
221
|
+
status_code=resp.status_code,
|
|
222
|
+
content_type=content_type,
|
|
223
|
+
local_path=local_path,
|
|
224
|
+
title=title,
|
|
225
|
+
resolved_url=url,
|
|
226
|
+
)
|
|
227
|
+
finally:
|
|
228
|
+
if own_client:
|
|
229
|
+
await client.aclose()
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
async def _fetch_once(
|
|
233
|
+
url: str,
|
|
234
|
+
client: httpx.AsyncClient,
|
|
235
|
+
user_agent: str,
|
|
236
|
+
max_bytes: int = _MAX_HTML_BYTES,
|
|
237
|
+
) -> tuple[httpx.Response | None, str | None]:
|
|
238
|
+
"""Single GET attempt with size enforcement.
|
|
239
|
+
|
|
240
|
+
Uses streaming with a gzip-safe size guard: skips Content-Length
|
|
241
|
+
pre-check when Content-Encoding is present (compressed wire size
|
|
242
|
+
!= decompressed body size), and always enforces max_bytes on the
|
|
243
|
+
decompressed data stream.
|
|
244
|
+
"""
|
|
245
|
+
try:
|
|
246
|
+
async with client.stream(
|
|
247
|
+
"GET", url, headers={"User-Agent": user_agent}
|
|
248
|
+
) as resp:
|
|
249
|
+
# Only trust Content-Length when no Content-Encoding —
|
|
250
|
+
# otherwise the header reports compressed wire size,
|
|
251
|
+
# not the decompressed size we'll actually read.
|
|
252
|
+
if not resp.headers.get("content-encoding"):
|
|
253
|
+
cl = resp.headers.get("content-length")
|
|
254
|
+
if cl and int(cl) > max_bytes:
|
|
255
|
+
return None, f"Response too large ({cl} bytes)"
|
|
256
|
+
|
|
257
|
+
# Byte-count guard on decompressed data — always enforced
|
|
258
|
+
chunks: list[bytes] = []
|
|
259
|
+
total = 0
|
|
260
|
+
async for chunk in resp.aiter_bytes():
|
|
261
|
+
total += len(chunk)
|
|
262
|
+
if total > max_bytes:
|
|
263
|
+
return None, f"Response exceeded {max_bytes} bytes during download"
|
|
264
|
+
chunks.append(chunk)
|
|
265
|
+
|
|
266
|
+
body = b"".join(chunks)
|
|
267
|
+
full_resp = httpx.Response(
|
|
268
|
+
status_code=resp.status_code,
|
|
269
|
+
headers=resp.headers,
|
|
270
|
+
content=body,
|
|
271
|
+
request=resp.request,
|
|
272
|
+
)
|
|
273
|
+
return full_resp, None
|
|
274
|
+
except httpx.HTTPError as e:
|
|
275
|
+
return None, str(e)
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _extract_title(html: str) -> str | None:
|
|
279
|
+
"""Extract <title> from HTML."""
|
|
280
|
+
match = re.search(r"<title[^>]*>(.*?)</title>", html, re.IGNORECASE | re.DOTALL)
|
|
281
|
+
if match:
|
|
282
|
+
title = match.group(1).strip()
|
|
283
|
+
title = re.sub(r"\s+", " ", title)
|
|
284
|
+
return title if title else None
|
|
285
|
+
return None
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _extract_redirect_url(html: str, source_url: str) -> str | None:
|
|
289
|
+
"""Extract redirect target from HTML meta-refresh or JS patterns.
|
|
290
|
+
|
|
291
|
+
Detects:
|
|
292
|
+
- <meta http-equiv="refresh" content="0;url=...">
|
|
293
|
+
- window.location = "..."
|
|
294
|
+
- window.location.href = "..."
|
|
295
|
+
- window.location.replace("...")
|
|
296
|
+
- document.location = "..."
|
|
297
|
+
- document.location.href = "..."
|
|
298
|
+
|
|
299
|
+
Returns None if multiple conflicting redirect targets are found.
|
|
300
|
+
"""
|
|
301
|
+
targets: set[str] = set()
|
|
302
|
+
|
|
303
|
+
# Meta refresh
|
|
304
|
+
match = re.search(
|
|
305
|
+
r'<meta[^>]+http-equiv=["\']?refresh["\']?[^>]+content=["\']?\d+;\s*url=([^"\'>]+)',
|
|
306
|
+
html,
|
|
307
|
+
re.IGNORECASE,
|
|
308
|
+
)
|
|
309
|
+
if match:
|
|
310
|
+
targets.add(_resolve_url(match.group(1).strip(), source_url))
|
|
311
|
+
|
|
312
|
+
# JS location patterns
|
|
313
|
+
for pattern in (
|
|
314
|
+
r'(?:window|document)\.location(?:\.href)?\s*=\s*["\']([^"\']+)["\']',
|
|
315
|
+
r'(?:window|document)\.location\.replace\(\s*["\']([^"\']+)["\']\s*\)',
|
|
316
|
+
):
|
|
317
|
+
for m in re.finditer(pattern, html):
|
|
318
|
+
targets.add(_resolve_url(m.group(1).strip(), source_url))
|
|
319
|
+
|
|
320
|
+
# Filter out unsafe redirect targets
|
|
321
|
+
safe_targets = {t for t in targets if _is_safe_redirect(t)}
|
|
322
|
+
if safe_targets != targets:
|
|
323
|
+
from loguru import logger
|
|
324
|
+
|
|
325
|
+
blocked = targets - safe_targets
|
|
326
|
+
logger.warning("Blocked unsafe redirect target(s): {}", blocked)
|
|
327
|
+
|
|
328
|
+
if len(safe_targets) == 1:
|
|
329
|
+
return safe_targets.pop()
|
|
330
|
+
|
|
331
|
+
if len(safe_targets) > 1:
|
|
332
|
+
from loguru import logger
|
|
333
|
+
|
|
334
|
+
logger.warning(
|
|
335
|
+
"Multiple conflicting redirect targets found, ignoring: {}",
|
|
336
|
+
safe_targets,
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
return None
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def _is_safe_redirect(url: str) -> bool:
|
|
343
|
+
"""Check that a redirect URL is safe to follow.
|
|
344
|
+
|
|
345
|
+
Rejects non-HTTPS/HTTP schemes and any hostname that resolves to a
|
|
346
|
+
non-public IP. Delegates IP validation to ``_network.is_hostname_safe``.
|
|
347
|
+
"""
|
|
348
|
+
parsed = urlparse(url)
|
|
349
|
+
if parsed.scheme not in ("http", "https"):
|
|
350
|
+
return False
|
|
351
|
+
host = (parsed.hostname or "").lower()
|
|
352
|
+
if not host:
|
|
353
|
+
return False
|
|
354
|
+
return is_hostname_safe(host)
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def _resolve_url(target: str, source_url: str) -> str:
|
|
358
|
+
"""Resolve a possibly-relative redirect URL against the source URL."""
|
|
359
|
+
from urllib.parse import urljoin
|
|
360
|
+
|
|
361
|
+
return urljoin(source_url, target)
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def _html_to_markdown(html: str, url: str) -> str | None:
|
|
365
|
+
"""Convert HTML to clean markdown using trafilatura."""
|
|
366
|
+
import trafilatura
|
|
367
|
+
|
|
368
|
+
result = trafilatura.extract(html, url=url, include_links=True)
|
|
369
|
+
return result or None
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: sciwrite-lint
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: A linter for scientific manuscripts — reference verification, consistency checking, and structural validation.
|
|
5
|
+
Author: Sergey Samsonau
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/authentic-research-partners/sciwrite-lint
|
|
8
|
+
Project-URL: Repository, https://github.com/authentic-research-partners/sciwrite-lint
|
|
9
|
+
Project-URL: Issues, https://github.com/authentic-research-partners/sciwrite-lint/issues
|
|
10
|
+
Keywords: linter,scientific-writing,citation-verification,latex
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Science/Research
|
|
13
|
+
Classifier: Topic :: Text Processing :: Markup :: LaTeX
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
15
|
+
Requires-Python: <3.14,>=3.13
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
License-File: LICENSE
|
|
18
|
+
Requires-Dist: httpx>=0.27
|
|
19
|
+
Requires-Dist: tenacity>=8.0
|
|
20
|
+
Requires-Dist: trafilatura>=1.8
|
|
21
|
+
Requires-Dist: aiosqlite>=0.20
|
|
22
|
+
Requires-Dist: loguru>=0.7
|
|
23
|
+
Requires-Dist: anyascii>=0.3
|
|
24
|
+
Requires-Dist: rich>=13.0
|
|
25
|
+
Requires-Dist: defusedxml>=0.7
|
|
26
|
+
Requires-Dist: pydantic>=2.0
|
|
27
|
+
Requires-Dist: rapidfuzz>=3.0
|
|
28
|
+
Requires-Dist: bibtexparser>=1.4
|
|
29
|
+
Requires-Dist: pdfplumber>=0.10
|
|
30
|
+
Requires-Dist: openai>=1.0
|
|
31
|
+
Requires-Dist: grobid-tei-xml>=0.1
|
|
32
|
+
Requires-Dist: sentence-transformers>=3.0
|
|
33
|
+
Requires-Dist: numpy>=1.24
|
|
34
|
+
Requires-Dist: sqlite-vec>=0.1.6
|
|
35
|
+
Requires-Dist: torch>=2.0
|
|
36
|
+
Requires-Dist: transformers>=4.45
|
|
37
|
+
Requires-Dist: Pillow>=10.0
|
|
38
|
+
Requires-Dist: pypdf>=4.0
|
|
39
|
+
Requires-Dist: pypdfium2>=4.0
|
|
40
|
+
Requires-Dist: pdf2image>=1.16
|
|
41
|
+
Provides-Extra: dev
|
|
42
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
43
|
+
Requires-Dist: pytest-xdist>=3.0; extra == "dev"
|
|
44
|
+
Dynamic: license-file
|
|
45
|
+
|
|
46
|
+
# sciwrite-lint
|
|
47
|
+
|
|
48
|
+
A linter for scientific manuscripts. Checks that your references exist, your metadata is accurate, your citations support the claims you make, and your cited papers' own bibliographies are real. Works on LaTeX and PDF. Runs entirely on your machine. Produces a **SciLint Score**.
|
|
49
|
+
|
|
50
|
+
The only open-source tool that combines reference verification, claim checking, manuscript consistency, figure analysis, and bibliography verification in one pipeline — powered entirely by a local LLM on your own GPU.
|
|
51
|
+
|
|
52
|
+
## Why
|
|
53
|
+
|
|
54
|
+
AI writing tools produce text that *looks* like good science — fluent prose, correct formatting, plausible-sounding citations. But they don't verify whether the references are real, whether the cited papers actually say what you claim, or whether the numbers in your abstract match your results.
|
|
55
|
+
|
|
56
|
+
**sciwrite-lint does.** It checks your references against academic databases, downloads the cited papers, verifies that they actually say what you claim, and follows one level deeper to check your references' own bibliographies. Fully local — no manuscripts leave your machine.
|
|
57
|
+
|
|
58
|
+
## Features
|
|
59
|
+
|
|
60
|
+
**22 automated checks:**
|
|
61
|
+
|
|
62
|
+
### Reference verification
|
|
63
|
+
- **Do your references exist?** — checked against CrossRef, OpenAlex, Semantic Scholar, Open Library, and Library of Congress
|
|
64
|
+
- **Is the metadata accurate?** — title, authors, year, venue compared against canonical records
|
|
65
|
+
- **Are any retracted?** — every DOI cross-referenced against 60,000+ entries in the Retraction Watch database
|
|
66
|
+
- **Robust matching** — when references lack DOIs, a multi-signal matching engine scores candidates across title, author, year, and venue (handles the metadata errors that LLMs routinely introduce)
|
|
67
|
+
|
|
68
|
+
### Claim verification (local LLM)
|
|
69
|
+
- **Do cited papers support your claims?** — downloads full text from 8 open-access sources (arXiv, Semantic Scholar, OpenAlex, PubMed Central, Europe PMC, Unpaywall, bioRxiv/medRxiv, CORE), parses via GROBID, embeds sections, and verifies each claim against the actual source text
|
|
70
|
+
- **What role does each citation play?** — classifies citation purpose (evidence, contrast, method, attribution, context…) with graduated weights: an unsupported *evidence* citation is serious; an unsupported *context* citation barely matters
|
|
71
|
+
- **Are your references' own bibliographies real?** — batch-checks cited papers' reference lists for existence, metadata accuracy, and retraction. Papers built on fabricated evidence are flagged
|
|
72
|
+
|
|
73
|
+
### Manuscript consistency (local LLM)
|
|
74
|
+
- **Cross-section contradictions** — numbers, claims, and framing that drift between sections
|
|
75
|
+
- **Numbers vs. tables** — text claims that contradict the corresponding table or figure
|
|
76
|
+
- **Arithmetic and percentages** — stated totals that don't match components; percentages that don't sum to 100%
|
|
77
|
+
- **Sample size tracking** — N values that change across sections without explanation
|
|
78
|
+
- **Causal language** — unhedged causal claims in correlational studies
|
|
79
|
+
- **Abstract–body alignment** — abstract makes factual claims the body contradicts
|
|
80
|
+
- **Statistical reporting** — p-values vs. their verbal interpretation
|
|
81
|
+
- **Structure promises** — contributions promised in the introduction but never delivered
|
|
82
|
+
|
|
83
|
+
### Figure checks (vision model + LLM)
|
|
84
|
+
- **Caption vs. content** — does the caption match what the figure actually shows?
|
|
85
|
+
- **Text vs. figure** — does the text describe the figure accurately?
|
|
86
|
+
- **Axis labels** — units, labels, and scales consistent with the text
|
|
87
|
+
- **Figure–table agreement** — same data in a figure and table should agree
|
|
88
|
+
|
|
89
|
+
### Text checks (deterministic, no services needed)
|
|
90
|
+
- **Dangling citations** — `\cite{key}` with no matching bib entry
|
|
91
|
+
- **Dangling cross-references** — `\ref{X}` with no matching `\label{X}`
|
|
92
|
+
- **Unreferenced figures** — figures included but never referenced in the text
|
|
93
|
+
|
|
94
|
+
### Per-reference reliability score
|
|
95
|
+
All signals — metadata, retraction status, claim support, consistency, bibliography health — aggregate into a single reliability score per reference. When multiple independent checks flag the same reference, it is flagged as unreliable with specific reasons.
|
|
96
|
+
|
|
97
|
+
### SciLint Score
|
|
98
|
+
|
|
99
|
+
```
|
|
100
|
+
SciLint Score = Internal Consistency × Referencing Quality × Contribution
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
A single number combining:
|
|
104
|
+
- **Internal Consistency** — fraction of checks passed within the manuscript
|
|
105
|
+
- **Referencing Quality** — are references real, accurate, and do they support your claims? Each reference weighted by its reliability score and citation purpose
|
|
106
|
+
- **Contribution** *(experimental)* — five axes from philosophy of science (Popper, Lakatos, Kitcher, Laudan, Mayo): empirical content, progressiveness, unification, problem-solving effectiveness, test severity. Defaults to 1.0 until `contributions` runs
|
|
107
|
+
|
|
108
|
+
```bash
|
|
109
|
+
sciwrite-lint contributions paper.pdf --format json
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
### Privacy and security
|
|
113
|
+
|
|
114
|
+
- **Your manuscript never leaves your machine.** All parsing, LLM inference, and figure analysis run locally
|
|
115
|
+
- **Only citation metadata is sent externally** — DOIs, titles, author names for API verification. No paper content
|
|
116
|
+
- **Open-weights models pinned to specific versions** — results are reproducible forever, not dependent on a cloud provider's API updates
|
|
117
|
+
- **No API keys required** — all verification uses free public databases. Optional keys increase rate limits
|
|
118
|
+
|
|
119
|
+
### Two audiences
|
|
120
|
+
|
|
121
|
+
- **Humans** — colored terminal output with severity levels, locations, and explanations. Decide in seconds whether each finding is real
|
|
122
|
+
- **AI writing agents** — `--format json` output with structured fields (`level`, `rule_id`, `message`, `context`). Run sciwrite-lint in a write → check → fix → recheck loop. Configurable exit codes for CI integration
|
|
123
|
+
|
|
124
|
+
### Optimizations
|
|
125
|
+
|
|
126
|
+
Three models — a vision model (Qwen3-VL-2B), an embedding model (Arctic Embed), and an 8B reasoning LLM (Qwen3 via vLLM) — share a single consumer GPU. Models run in separate pipeline stages; on WSL2, CUDA memory virtualization pages idle allocations to system RAM, letting all three share physical VRAM. FP8 weights and KV cache (Ada Lovelace+) and per-paper SQLite caching with hash-based invalidation are baseline. On top of that:
|
|
127
|
+
|
|
128
|
+
- **Semantic section filtering** — embedding-based KNN retrieval sends only the ~5 most relevant sections per claim to the LLM, reducing LLM calls ~4x
|
|
129
|
+
- **Prefix-first prompt structure** — shared context placed before variable content in all prompts, maximizing vLLM's automatic prefix caching
|
|
130
|
+
- **Per-call-site thinking budgets** — each LLM call site has empirically tuned (max_tokens, thinking_preset) pairs, measured via grid search to maximize detection quality
|
|
131
|
+
- **Adaptive embedding batches** — token-aware batch sizing gives ~50x speedup over CPU while staying within VRAM limits
|
|
132
|
+
- **Batch-staged multi-paper pipeline** — when checking 2+ papers, GPU models load once per batch (vision/embedding/cited-vision) and vLLM/network stages run concurrently, giving a meaningful speedup over sequential per-paper runs. Tune via `--concurrency` (default 2, validated up to 4 on a single consumer GPU)
|
|
133
|
+
- **Phased API resolution** — citations flow through OpenAlex → Semantic Scholar → CrossRef → Open Library/LoC, each phase only processing what previous phases didn't resolve
|
|
134
|
+
- **8-source full-text cascade** — early exit on first successful download across arXiv, Semantic Scholar, OpenAlex, PubMed Central, Europe PMC, Unpaywall, bioRxiv, CORE
|
|
135
|
+
- **Live monitoring** *(advanced)* — `sciwrite-lint containers monitor` shows service health, VRAM usage, and KV cache utilization in a terminal dashboard
|
|
136
|
+
|
|
137
|
+
Full pipeline on a 50-reference paper: ~30 minutes initial (dominated by downloads and claim verification), minutes on cached reruns. On native Linux, embedding and vision default to CPU (tens of times slower) due to limited VRAM on consumer GPUs and no transparent paging of inactive vLLM container allocations; force GPU via config if VRAM permits.
|
|
138
|
+
|
|
139
|
+
## Install
|
|
140
|
+
|
|
141
|
+
**Assumed setup:** A workstation with an NVIDIA GPU (16+ GB VRAM). Developed and tested on Windows (WSL2). Native Linux is likely to work with GPU memory allocation tuning (see [docs/services.md](docs/services.md#embedding-device)). Not tested on macOS.
|
|
142
|
+
|
|
143
|
+
Requires [uv](https://docs.astral.sh/uv/getting-started/installation/), a container runtime (podman or docker), CUDA drivers, and [NVIDIA Container Toolkit](https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/latest/install-guide.html).
|
|
144
|
+
|
|
145
|
+
```bash
|
|
146
|
+
uv tool install sciwrite-lint --python 3.13
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
uv downloads Python 3.13 automatically (does not affect any Python you may already have) and installs sciwrite-lint as a globally available command.
|
|
150
|
+
|
|
151
|
+
## Setup
|
|
152
|
+
|
|
153
|
+
```bash
|
|
154
|
+
sciwrite-lint init # scaffold .sciwrite-lint.toml + references/ + local_pdfs/
|
|
155
|
+
sciwrite-lint config set-email you@example.com # required for Unpaywall + Retraction Watch
|
|
156
|
+
sciwrite-lint containers start # start GROBID + vLLM (needs GPU for vLLM)
|
|
157
|
+
sciwrite-lint containers monitor # live dashboard: service health, VRAM, KV cache
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+

|
|
161
|
+
|
|
162
|
+
`init` detects `.tex` files and their `.bib` references and generates a `.sciwrite-lint.toml` config. Review to confirm the right files were detected.
|
|
163
|
+
|
|
164
|
+
**Paywalled references:** drop PDFs into `local_pdfs/` with filenames matching the reference title. The tool fuzzy-matches filenames against your `.bib` titles and uses local copies instead of downloading.
|
|
165
|
+
|
|
166
|
+
**Optional API keys** increase rate limits for Semantic Scholar, NCBI, and CORE:
|
|
167
|
+
|
|
168
|
+
```bash
|
|
169
|
+
sciwrite-lint config show # see what's configured
|
|
170
|
+
sciwrite-lint config set-key semantic-scholar YOUR_KEY # dedicated rate limit
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
See [docs/services.md](docs/services.md) for GPU requirements, all external APIs, and API key details.
|
|
174
|
+
|
|
175
|
+
## Usage
|
|
176
|
+
|
|
177
|
+
```bash
|
|
178
|
+
sciwrite-lint check --paper my_paper # full pipeline + SciLint Score
|
|
179
|
+
sciwrite-lint check --paper my_paper --fresh # same, ignoring all caches
|
|
180
|
+
sciwrite-lint check # all papers (batch-staged when 2+, ~4-5x faster than sequential)
|
|
181
|
+
sciwrite-lint check --concurrency 4 # batch parallelism (default 2, validated up to 4)
|
|
182
|
+
sciwrite-lint check paper.tex # text + LLM rules on a .tex file
|
|
183
|
+
sciwrite-lint check paper.pdf # check a PDF (GROBID required)
|
|
184
|
+
sciwrite-lint contributions --paper my_paper # add contribution axes to SciLint Score
|
|
185
|
+
sciwrite-lint contributions paper.pdf # standalone file scoring
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
`check` runs the full pipeline in one command: text checks → figure analysis → LLM consistency → reference verification via APIs → download and parse cited papers → claim verification → consistency checks on cited papers → bibliography verification → aggregate reliability scores → SciLint Score. An initial run on a 50-reference paper takes up to 30 minutes (dominated by downloads and claim verification); subsequent cached runs complete in minutes.
|
|
189
|
+
|
|
190
|
+
Use `--fresh` to start from scratch (backs up the existing workspace before recreating it).
|
|
191
|
+
|
|
192
|
+
### Contribution axes (`sciwrite-lint contributions`)
|
|
193
|
+
|
|
194
|
+
`contributions` computes 5 contribution axes from philosophy of science (Popper, Lakatos, Kitcher, Laudan, Mayo) and updates the SciLint Score. Requires vLLM.
|
|
195
|
+
|
|
196
|
+
```bash
|
|
197
|
+
sciwrite-lint check --paper my_paper # SciLint Score (contribution = 1.0)
|
|
198
|
+
sciwrite-lint contributions --paper my_paper # add 5 contribution axes, update score
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
### Individual stages
|
|
202
|
+
|
|
203
|
+
For debugging or advanced workflows, each pipeline stage is also available as a standalone command:
|
|
204
|
+
|
|
205
|
+
| Command | What it does |
|
|
206
|
+
|---------|-------------|
|
|
207
|
+
| `verify --paper NAME` | API verification only (CrossRef, OpenAlex, Semantic Scholar, Open Library, Library of Congress) |
|
|
208
|
+
| `fetch --paper NAME` | Download full-text PDFs for verified references |
|
|
209
|
+
| `parse --paper NAME` | Parse PDFs via GROBID and compute embeddings |
|
|
210
|
+
| `verify-claims --paper NAME` | LLM reads cited sources, checks claim support |
|
|
211
|
+
| `ref-health --paper NAME` | Fast reference health check: cite/bib mismatches, ID coverage, local PDF matches (no API calls) |
|
|
212
|
+
| `contributions --paper NAME` | Add 5 contribution axes to SciLint Score (requires vLLM) |
|
|
213
|
+
|
|
214
|
+
## Output
|
|
215
|
+
|
|
216
|
+
Each finding has a severity level, a rule ID, and a message explaining the issue:
|
|
217
|
+
|
|
218
|
+
- **error** — a concrete manuscript problem (hallucinated reference, unsupported claim, retracted source)
|
|
219
|
+
- **warning** — needs human judgment (metadata mismatch, weak citation purpose, cross-section inconsistency)
|
|
220
|
+
- **info** — the tool could not complete a check (LLM error, API timeout, internal crash) or informational note
|
|
221
|
+
|
|
222
|
+
Findings also carry a `context` field with the reasoning behind the verdict: the LLM's explanation, which identifiers were searched, or which API provided the canonical data. This lets you distinguish "the tool found a problem" from "the tool couldn't check this."
|
|
223
|
+
|
|
224
|
+
Example terminal output:
|
|
225
|
+
|
|
226
|
+
```
|
|
227
|
+
ERROR reference-exists johnson2024: Not found in any API
|
|
228
|
+
Searched with: title="Deep Learning for Climate", author="Johnson"
|
|
229
|
+
|
|
230
|
+
ERROR claim-support smith2023: Cited paper does not support this claim
|
|
231
|
+
Claim: "transformers outperform RNNs by 15% on BLEU"
|
|
232
|
+
Verdict: paper reports 8% improvement, not 15%
|
|
233
|
+
|
|
234
|
+
WARN reference-accuracy lee2022: Year mismatch (bib: 2022, canonical: 2021)
|
|
235
|
+
Source: OpenAlex (DOI: 10.1234/example)
|
|
236
|
+
|
|
237
|
+
WARN cross-section-consistency
|
|
238
|
+
Abstract claims "three novel contributions" but
|
|
239
|
+
Section 5 delivers two
|
|
240
|
+
|
|
241
|
+
WARN reference-unreliable chen2019: Low reliability (0.35)
|
|
242
|
+
Metadata mismatch, 2 unsupported claims,
|
|
243
|
+
23% hallucinated bibliography entries
|
|
244
|
+
|
|
245
|
+
INFO caption-vs-content Figure 3: could not extract figure from PDF
|
|
246
|
+
|
|
247
|
+
SciLint Score: 0.41
|
|
248
|
+
Internal Consistency: 0.85
|
|
249
|
+
Referencing Quality: 0.48
|
|
250
|
+
(Run 'sciwrite-lint contributions' to add contribution axes)
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
Output formats: terminal (default) or `--format json`.
|
|
254
|
+
|
|
255
|
+
## Documentation
|
|
256
|
+
|
|
257
|
+
- `sciwrite-lint checks` — list all checks
|
|
258
|
+
- `sciwrite-lint <command> --help` — detailed usage for any command
|
|
259
|
+
- [docs/services.md](docs/services.md) — GROBID, vLLM, external APIs, configuration
|
|
260
|
+
|
|
261
|
+
For contributors and advanced users:
|
|
262
|
+
|
|
263
|
+
- [docs/evals.md](docs/evals.md) — detection evaluation framework, adding test cases
|
|
264
|
+
- [docs/calibration.md](docs/calibration.md) — SciLint Score calibration against ground-truth papers
|
|
265
|
+
|
|
266
|
+
## License
|
|
267
|
+
|
|
268
|
+
MIT
|