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.
Files changed (76) hide show
  1. sciwrite_lint/__init__.py +3 -0
  2. sciwrite_lint/__main__.py +527 -0
  3. sciwrite_lint/_network.py +195 -0
  4. sciwrite_lint/api.py +1484 -0
  5. sciwrite_lint/checks/__init__.py +1 -0
  6. sciwrite_lint/checks/_section_utils.py +111 -0
  7. sciwrite_lint/checks/cite_purpose.py +122 -0
  8. sciwrite_lint/checks/claim_support.py +96 -0
  9. sciwrite_lint/checks/cross_section_consistency.py +185 -0
  10. sciwrite_lint/checks/dangling_cite.py +93 -0
  11. sciwrite_lint/checks/dangling_ref.py +116 -0
  12. sciwrite_lint/checks/full_paper_consistency.py +604 -0
  13. sciwrite_lint/checks/ref_internal_checks.py +919 -0
  14. sciwrite_lint/checks/reference_accuracy.py +277 -0
  15. sciwrite_lint/checks/reference_exists.py +119 -0
  16. sciwrite_lint/checks/reference_unreliable.py +244 -0
  17. sciwrite_lint/checks/registry.py +136 -0
  18. sciwrite_lint/checks/retracted_cite.py +96 -0
  19. sciwrite_lint/checks/structure_promises.py +115 -0
  20. sciwrite_lint/checks/unreferenced_figure.py +70 -0
  21. sciwrite_lint/claims.py +330 -0
  22. sciwrite_lint/claude_backend.py +94 -0
  23. sciwrite_lint/claude_cli.py +405 -0
  24. sciwrite_lint/cli/__init__.py +1 -0
  25. sciwrite_lint/cli/check.py +480 -0
  26. sciwrite_lint/cli/config.py +229 -0
  27. sciwrite_lint/cli/fetch.py +250 -0
  28. sciwrite_lint/cli/misc.py +1202 -0
  29. sciwrite_lint/cli/rank.py +470 -0
  30. sciwrite_lint/cli/verify.py +437 -0
  31. sciwrite_lint/config.py +646 -0
  32. sciwrite_lint/cross_paper.py +174 -0
  33. sciwrite_lint/eval_claims.py +1196 -0
  34. sciwrite_lint/fulltext.py +851 -0
  35. sciwrite_lint/llm_utils.py +386 -0
  36. sciwrite_lint/local_pdfs.py +122 -0
  37. sciwrite_lint/manuscript_store.py +674 -0
  38. sciwrite_lint/models.py +139 -0
  39. sciwrite_lint/pdf/__init__.py +1 -0
  40. sciwrite_lint/pdf/grobid.py +785 -0
  41. sciwrite_lint/pdf/pdf_download.py +258 -0
  42. sciwrite_lint/pipeline.py +2694 -0
  43. sciwrite_lint/prompt_safety.py +30 -0
  44. sciwrite_lint/rate_limiter.py +227 -0
  45. sciwrite_lint/references/__init__.py +1 -0
  46. sciwrite_lint/references/citations.py +715 -0
  47. sciwrite_lint/references/crossref.py +282 -0
  48. sciwrite_lint/references/embedding_store.py +380 -0
  49. sciwrite_lint/references/matching.py +273 -0
  50. sciwrite_lint/references/metadata.py +273 -0
  51. sciwrite_lint/references/reference_store.py +823 -0
  52. sciwrite_lint/references/retraction_watch.py +178 -0
  53. sciwrite_lint/references/workspace_db.py +1390 -0
  54. sciwrite_lint/report.py +163 -0
  55. sciwrite_lint/schemas.py +260 -0
  56. sciwrite_lint/scoring/__init__.py +1 -0
  57. sciwrite_lint/scoring/chain.py +716 -0
  58. sciwrite_lint/scoring/contribution.py +322 -0
  59. sciwrite_lint/scoring/scilint_score.py +611 -0
  60. sciwrite_lint/tex_parser.py +248 -0
  61. sciwrite_lint/usage.py +594 -0
  62. sciwrite_lint/vision/__init__.py +1 -0
  63. sciwrite_lint/vision/cache.py +140 -0
  64. sciwrite_lint/vision/describe.py +311 -0
  65. sciwrite_lint/vision/image_extraction.py +491 -0
  66. sciwrite_lint/vision/pipeline.py +207 -0
  67. sciwrite_lint/vllm/__init__.py +1 -0
  68. sciwrite_lint/vllm/metrics.py +157 -0
  69. sciwrite_lint/vllm/vllm_server.py +445 -0
  70. sciwrite_lint/web.py +369 -0
  71. sciwrite_lint-0.2.0.dist-info/METADATA +268 -0
  72. sciwrite_lint-0.2.0.dist-info/RECORD +76 -0
  73. sciwrite_lint-0.2.0.dist-info/WHEEL +5 -0
  74. sciwrite_lint-0.2.0.dist-info/entry_points.txt +2 -0
  75. sciwrite_lint-0.2.0.dist-info/licenses/LICENSE +21 -0
  76. 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