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,178 @@
|
|
|
1
|
+
"""Retraction Watch database: download, cache, and lookup.
|
|
2
|
+
|
|
3
|
+
Downloads the Retraction Watch CSV from CrossRef Labs, caches it locally,
|
|
4
|
+
and provides DOI-keyed lookup for retraction status. The database covers
|
|
5
|
+
retractions, expressions of concern, corrections, and reinstatements.
|
|
6
|
+
|
|
7
|
+
Requires polite_email in config (used as mailto= parameter for download).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import csv
|
|
13
|
+
import io
|
|
14
|
+
import time
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import TYPE_CHECKING
|
|
18
|
+
|
|
19
|
+
from loguru import logger
|
|
20
|
+
|
|
21
|
+
if TYPE_CHECKING:
|
|
22
|
+
from sciwrite_lint.config import LintConfig
|
|
23
|
+
|
|
24
|
+
_RW_URL = "https://api.labs.crossref.org/data/retractionwatch"
|
|
25
|
+
_CACHE_DIR = Path.home() / ".sciwrite-lint"
|
|
26
|
+
_CACHE_FILE = _CACHE_DIR / "retraction-watch.csv"
|
|
27
|
+
_MAX_CSV_SIZE = 200 * 1024 * 1024 # 200 MB — expected ~50 MB, cap for safety
|
|
28
|
+
|
|
29
|
+
# Module-level cache: parsed once per process
|
|
30
|
+
_cached_db: dict[str, RWEntry] | None = None
|
|
31
|
+
_cached_at: float = 0.0
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True, slots=True)
|
|
35
|
+
class RWEntry:
|
|
36
|
+
"""A single entry from the Retraction Watch database."""
|
|
37
|
+
|
|
38
|
+
doi: str
|
|
39
|
+
nature: str # "Retraction", "Expression of Concern", "Correction", "Reinstatement"
|
|
40
|
+
reason: str
|
|
41
|
+
date: str # RetractionDate from CSV
|
|
42
|
+
title: str
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _normalize_doi(doi: str) -> str:
|
|
46
|
+
"""Normalize a DOI for consistent lookup."""
|
|
47
|
+
doi = doi.strip().lower()
|
|
48
|
+
for prefix in ("https://doi.org/", "http://doi.org/", "doi:"):
|
|
49
|
+
if doi.startswith(prefix):
|
|
50
|
+
doi = doi[len(prefix) :]
|
|
51
|
+
return doi.strip()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _parse_csv(csv_text: str) -> dict[str, RWEntry]:
|
|
55
|
+
"""Parse Retraction Watch CSV into a DOI-keyed dict.
|
|
56
|
+
|
|
57
|
+
When multiple entries exist for the same DOI (e.g. retraction then
|
|
58
|
+
reinstatement), the latest entry wins — reinstatement clears retraction.
|
|
59
|
+
"""
|
|
60
|
+
db: dict[str, RWEntry] = {}
|
|
61
|
+
reader = csv.DictReader(io.StringIO(csv_text))
|
|
62
|
+
|
|
63
|
+
for row in reader:
|
|
64
|
+
raw_doi = row.get("OriginalPaperDOI", "").strip()
|
|
65
|
+
if not raw_doi or raw_doi.lower() in ("", "unavailable", "n/a"):
|
|
66
|
+
continue
|
|
67
|
+
|
|
68
|
+
doi = _normalize_doi(raw_doi)
|
|
69
|
+
if not doi:
|
|
70
|
+
continue
|
|
71
|
+
|
|
72
|
+
nature = row.get("RetractionNature", "").strip()
|
|
73
|
+
if not nature:
|
|
74
|
+
continue
|
|
75
|
+
|
|
76
|
+
entry = RWEntry(
|
|
77
|
+
doi=doi,
|
|
78
|
+
nature=nature,
|
|
79
|
+
reason=row.get("Reason", "").strip(),
|
|
80
|
+
date=row.get("RetractionDate", "").strip(),
|
|
81
|
+
title=row.get("Title", "").strip(),
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
# If DOI already in db, keep the entry with the latest date.
|
|
85
|
+
# This handles retraction→reinstatement correctly.
|
|
86
|
+
existing = db.get(doi)
|
|
87
|
+
if existing is None or entry.date >= existing.date:
|
|
88
|
+
db[doi] = entry
|
|
89
|
+
|
|
90
|
+
logger.info(f"Retraction Watch database: {len(db)} entries loaded")
|
|
91
|
+
return db
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _cache_age_hours() -> float:
|
|
95
|
+
"""Return age of cached CSV in hours, or inf if not cached."""
|
|
96
|
+
if not _CACHE_FILE.exists():
|
|
97
|
+
return float("inf")
|
|
98
|
+
mtime = _CACHE_FILE.stat().st_mtime
|
|
99
|
+
return (time.time() - mtime) / 3600
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
async def _download_csv(polite_email: str) -> str:
|
|
103
|
+
"""Download Retraction Watch CSV from CrossRef Labs."""
|
|
104
|
+
import httpx
|
|
105
|
+
|
|
106
|
+
from sciwrite_lint._network import stream_with_limit
|
|
107
|
+
from sciwrite_lint.rate_limiter import retry_on_transient
|
|
108
|
+
|
|
109
|
+
url = f"{_RW_URL}?mailto={polite_email}"
|
|
110
|
+
logger.info(f"Downloading Retraction Watch database from {_RW_URL}")
|
|
111
|
+
|
|
112
|
+
async with httpx.AsyncClient(timeout=120.0, follow_redirects=True) as client:
|
|
113
|
+
resp = await retry_on_transient(
|
|
114
|
+
lambda: stream_with_limit(client, url, _MAX_CSV_SIZE),
|
|
115
|
+
label="Retraction Watch CSV",
|
|
116
|
+
)
|
|
117
|
+
resp.raise_for_status()
|
|
118
|
+
|
|
119
|
+
csv_text = resp.text
|
|
120
|
+
logger.info(f"Retraction Watch CSV downloaded: {len(csv_text)} bytes")
|
|
121
|
+
|
|
122
|
+
# Cache to disk
|
|
123
|
+
_CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
|
124
|
+
_CACHE_FILE.write_text(csv_text, encoding="utf-8")
|
|
125
|
+
logger.info(f"Cached Retraction Watch CSV to {_CACHE_FILE}")
|
|
126
|
+
|
|
127
|
+
return csv_text
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
async def ensure_rw_database(config: LintConfig) -> dict[str, RWEntry]:
|
|
131
|
+
"""Load the Retraction Watch database, downloading if stale.
|
|
132
|
+
|
|
133
|
+
Downloads the CSV if the cached file is older than config.rw_cache_hours
|
|
134
|
+
(default 24). Parses into an in-memory dict keyed by normalized DOI.
|
|
135
|
+
Cached in a module-level variable for reuse within the same process.
|
|
136
|
+
|
|
137
|
+
Raises:
|
|
138
|
+
RuntimeError: If polite_email is not configured.
|
|
139
|
+
httpx.HTTPStatusError: If download fails.
|
|
140
|
+
"""
|
|
141
|
+
global _cached_db, _cached_at
|
|
142
|
+
|
|
143
|
+
# Return module-level cache if still valid
|
|
144
|
+
cache_hours = config.rw_cache_hours
|
|
145
|
+
if _cached_db is not None and (time.time() - _cached_at) / 3600 < cache_hours:
|
|
146
|
+
return _cached_db
|
|
147
|
+
|
|
148
|
+
if not config.polite_email:
|
|
149
|
+
logger.warning(
|
|
150
|
+
"Retraction Watch check skipped: polite_email not set in "
|
|
151
|
+
".sciwrite-lint.toml [api] section"
|
|
152
|
+
)
|
|
153
|
+
return {}
|
|
154
|
+
|
|
155
|
+
# Check disk cache age
|
|
156
|
+
if _cache_age_hours() < cache_hours:
|
|
157
|
+
logger.debug("Loading Retraction Watch database from disk cache")
|
|
158
|
+
csv_text = _CACHE_FILE.read_text(encoding="utf-8")
|
|
159
|
+
else:
|
|
160
|
+
csv_text = await _download_csv(config.polite_email)
|
|
161
|
+
|
|
162
|
+
_cached_db = _parse_csv(csv_text)
|
|
163
|
+
_cached_at = time.time()
|
|
164
|
+
return _cached_db
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def lookup_doi(db: dict[str, RWEntry], doi: str) -> RWEntry | None:
|
|
168
|
+
"""Look up a DOI in the Retraction Watch database."""
|
|
169
|
+
if not doi:
|
|
170
|
+
return None
|
|
171
|
+
return db.get(_normalize_doi(doi))
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def clear_cache() -> None:
|
|
175
|
+
"""Clear module-level cache (for testing)."""
|
|
176
|
+
global _cached_db, _cached_at
|
|
177
|
+
_cached_db = None
|
|
178
|
+
_cached_at = 0.0
|