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/config.py
ADDED
|
@@ -0,0 +1,646 @@
|
|
|
1
|
+
"""TOML-based configuration for sciwrite-lint.
|
|
2
|
+
|
|
3
|
+
Looks for .sciwrite-lint.toml in the current directory, then parent
|
|
4
|
+
directories (like .eslintrc). Falls back to built-in defaults.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import tomllib
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
_DEFAULT_WE_VERBS = [
|
|
17
|
+
"propose",
|
|
18
|
+
"argue",
|
|
19
|
+
"present",
|
|
20
|
+
"define",
|
|
21
|
+
"describe",
|
|
22
|
+
"introduce",
|
|
23
|
+
"summarize",
|
|
24
|
+
"examine",
|
|
25
|
+
"suggest",
|
|
26
|
+
"discuss",
|
|
27
|
+
"note",
|
|
28
|
+
"show",
|
|
29
|
+
"demonstrate",
|
|
30
|
+
"identify",
|
|
31
|
+
"call",
|
|
32
|
+
"use",
|
|
33
|
+
"further",
|
|
34
|
+
"consider",
|
|
35
|
+
"begin",
|
|
36
|
+
"turn",
|
|
37
|
+
"return",
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class PaperWorkspace(BaseModel):
|
|
42
|
+
"""Per-paper directory structure for references and parsed files.
|
|
43
|
+
|
|
44
|
+
Each paper gets an isolated workspace under references/{paper_name}/.
|
|
45
|
+
The directory is created lazily on first pipeline run via ensure_dirs().
|
|
46
|
+
Citation metadata is stored in workspace.db (parsed/workspace.db).
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
model_config = ConfigDict(frozen=True)
|
|
50
|
+
|
|
51
|
+
root: Path # references/{paper_name}/
|
|
52
|
+
parsed: Path # references/{paper_name}/parsed/
|
|
53
|
+
source: Path # references/{paper_name}/source/
|
|
54
|
+
|
|
55
|
+
def ensure_dirs(self) -> None:
|
|
56
|
+
"""Create the workspace directories if they don't exist."""
|
|
57
|
+
self.root.mkdir(parents=True, exist_ok=True)
|
|
58
|
+
self.parsed.mkdir(exist_ok=True)
|
|
59
|
+
self.source.mkdir(exist_ok=True)
|
|
60
|
+
|
|
61
|
+
def sub_workspace(self, ref_key: str) -> PaperWorkspace:
|
|
62
|
+
"""Workspace for a reference's own references (depth+1)."""
|
|
63
|
+
sub = self.root / ref_key
|
|
64
|
+
return PaperWorkspace(
|
|
65
|
+
root=sub,
|
|
66
|
+
parsed=sub / "parsed",
|
|
67
|
+
source=sub / "source",
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def source_manifest(self) -> Path:
|
|
72
|
+
"""Path to source.json — records what was analyzed."""
|
|
73
|
+
return self.root / "source.json"
|
|
74
|
+
|
|
75
|
+
def check_source(
|
|
76
|
+
self, tex_path: Path, bib_path: Path | None = None
|
|
77
|
+
) -> tuple[bool, str]:
|
|
78
|
+
"""Check if the workspace source matches the current input.
|
|
79
|
+
|
|
80
|
+
Returns (ok, reason). ok=True means workspace is compatible.
|
|
81
|
+
Reasons for incompatibility:
|
|
82
|
+
- Source type changed (tex→pdf or pdf→tex)
|
|
83
|
+
- First run (no source.json yet) — always ok
|
|
84
|
+
"""
|
|
85
|
+
import json
|
|
86
|
+
|
|
87
|
+
if not self.source_manifest.exists():
|
|
88
|
+
return True, "first_run"
|
|
89
|
+
|
|
90
|
+
try:
|
|
91
|
+
manifest = json.loads(self.source_manifest.read_text(encoding="utf-8"))
|
|
92
|
+
except (json.JSONDecodeError, OSError):
|
|
93
|
+
return True, "corrupt_manifest"
|
|
94
|
+
|
|
95
|
+
old_type = manifest.get("source_type", "")
|
|
96
|
+
new_type = "pdf" if tex_path.suffix.lower() == ".pdf" else "tex"
|
|
97
|
+
if old_type and old_type != new_type:
|
|
98
|
+
return False, f"source_type_changed:{old_type}→{new_type}"
|
|
99
|
+
|
|
100
|
+
return True, "ok"
|
|
101
|
+
|
|
102
|
+
def save_source(self, tex_path: Path, bib_path: Path | None = None) -> None:
|
|
103
|
+
"""Copy source files into workspace and write source.json manifest.
|
|
104
|
+
|
|
105
|
+
For LaTeX input, also copies all images referenced via
|
|
106
|
+
\\includegraphics (respecting \\graphicspath), preserving the
|
|
107
|
+
directory structure relative to the .tex file so paths still resolve.
|
|
108
|
+
"""
|
|
109
|
+
import hashlib
|
|
110
|
+
import json
|
|
111
|
+
import shutil
|
|
112
|
+
|
|
113
|
+
source_type = "pdf" if tex_path.suffix.lower() == ".pdf" else "tex"
|
|
114
|
+
|
|
115
|
+
# Copy source file
|
|
116
|
+
shutil.copy2(tex_path, self.source / tex_path.name)
|
|
117
|
+
|
|
118
|
+
# Copy .bib if present
|
|
119
|
+
bib_hash = ""
|
|
120
|
+
if bib_path and bib_path.exists():
|
|
121
|
+
shutil.copy2(bib_path, self.source / bib_path.name)
|
|
122
|
+
bib_hash = hashlib.sha256(bib_path.read_bytes()).hexdigest()[:16]
|
|
123
|
+
|
|
124
|
+
# Copy referenced images (LaTeX only)
|
|
125
|
+
image_count = 0
|
|
126
|
+
if source_type == "tex":
|
|
127
|
+
from sciwrite_lint.vision.image_extraction import collect_image_paths
|
|
128
|
+
|
|
129
|
+
for abs_path, rel_path in collect_image_paths(tex_path):
|
|
130
|
+
dest = self.source / rel_path
|
|
131
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
132
|
+
shutil.copy2(abs_path, dest)
|
|
133
|
+
image_count += 1
|
|
134
|
+
|
|
135
|
+
manifest = {
|
|
136
|
+
"source_type": source_type,
|
|
137
|
+
"source_file": tex_path.name,
|
|
138
|
+
"source_hash": hashlib.sha256(tex_path.read_bytes()).hexdigest()[:16],
|
|
139
|
+
"bib_file": bib_path.name if bib_path else "",
|
|
140
|
+
"bib_hash": bib_hash,
|
|
141
|
+
"image_count": image_count,
|
|
142
|
+
}
|
|
143
|
+
self.source_manifest.write_text(
|
|
144
|
+
json.dumps(manifest, indent=2), encoding="utf-8"
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
class PaperConfig(BaseModel):
|
|
149
|
+
"""Configuration for a single paper in the project."""
|
|
150
|
+
|
|
151
|
+
name: str
|
|
152
|
+
file_path: Path
|
|
153
|
+
bib: Path | None = None # explicit .bib path; None = auto-detect from .tex
|
|
154
|
+
prohibited_terms: list[str] = Field(default_factory=list)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
class LintConfig(BaseModel):
|
|
158
|
+
"""Resolved configuration for a sciwrite-lint run."""
|
|
159
|
+
|
|
160
|
+
# Paths — the 'before' validator resolves None → CWD-relative defaults
|
|
161
|
+
# before field validation, so these are always Path after construction.
|
|
162
|
+
config_path: Path | None = None # where the TOML was found (None if no TOML)
|
|
163
|
+
project_dir: Path = Field(default=None) # type: ignore[assignment]
|
|
164
|
+
references_dir: Path = Field(default=None) # type: ignore[assignment]
|
|
165
|
+
results_dir: Path = Field(default=None) # type: ignore[assignment]
|
|
166
|
+
calibration_dir: Path = Field(default=None) # type: ignore[assignment]
|
|
167
|
+
benchmarks_dir: Path = Field(default=None) # type: ignore[assignment]
|
|
168
|
+
local_pdfs_dir: Path = Field(default=None) # type: ignore[assignment]
|
|
169
|
+
|
|
170
|
+
@model_validator(mode="before")
|
|
171
|
+
@classmethod
|
|
172
|
+
def _resolve_default_paths(cls, data: dict) -> dict: # type: ignore[override]
|
|
173
|
+
"""Resolve default paths relative to project_dir."""
|
|
174
|
+
if not isinstance(data, dict):
|
|
175
|
+
return data
|
|
176
|
+
project_dir = data.get("project_dir") or Path.cwd()
|
|
177
|
+
data["project_dir"] = project_dir
|
|
178
|
+
refs = data.get("references_dir")
|
|
179
|
+
if refs is None:
|
|
180
|
+
refs = (project_dir / "references").resolve()
|
|
181
|
+
data["references_dir"] = refs
|
|
182
|
+
if data.get("results_dir") is None:
|
|
183
|
+
data["results_dir"] = (project_dir / "results").resolve()
|
|
184
|
+
if data.get("calibration_dir") is None:
|
|
185
|
+
data["calibration_dir"] = (refs / "scilint-calibration").resolve()
|
|
186
|
+
if data.get("benchmarks_dir") is None:
|
|
187
|
+
data["benchmarks_dir"] = (project_dir / "benchmarks").resolve()
|
|
188
|
+
if data.get("local_pdfs_dir") is None:
|
|
189
|
+
data["local_pdfs_dir"] = (project_dir / "local_pdfs").resolve()
|
|
190
|
+
return data
|
|
191
|
+
|
|
192
|
+
# Papers
|
|
193
|
+
papers: list[PaperConfig] = Field(default_factory=list)
|
|
194
|
+
|
|
195
|
+
# Style thresholds
|
|
196
|
+
emdash_threshold: float = 3.0
|
|
197
|
+
sentence_length_max: int = 50
|
|
198
|
+
passive_voice_threshold: float = 0.4
|
|
199
|
+
prohibited_terms: list[str] = Field(default_factory=list)
|
|
200
|
+
we_allowed_verbs: list[str] = Field(default_factory=lambda: list(_DEFAULT_WE_VERBS))
|
|
201
|
+
|
|
202
|
+
# Document size limit for LLM checks (~50 pages at ~3 500 chars/page).
|
|
203
|
+
# Documents larger than this skip consistency and contribution checks.
|
|
204
|
+
max_document_chars: int = 175_000
|
|
205
|
+
|
|
206
|
+
# Rules
|
|
207
|
+
disabled_rules: set[str] = Field(default_factory=set)
|
|
208
|
+
severity_overrides: dict[str, str] = Field(default_factory=dict)
|
|
209
|
+
|
|
210
|
+
# API
|
|
211
|
+
polite_email: str = ""
|
|
212
|
+
openalex_interval: float = 0.22
|
|
213
|
+
s2_interval: float = 1.6
|
|
214
|
+
crossref_interval: float = 0.12
|
|
215
|
+
core_interval: float = 1.0
|
|
216
|
+
unpaywall_interval: float = 0.1
|
|
217
|
+
rw_cache_hours: int = 24 # Retraction Watch CSV re-download interval
|
|
218
|
+
|
|
219
|
+
# Output
|
|
220
|
+
output_format: str = "terminal"
|
|
221
|
+
color: bool = True
|
|
222
|
+
|
|
223
|
+
# Logging
|
|
224
|
+
log_level: str = "DEBUG" # file sink level; stderr sink stays at INFO
|
|
225
|
+
|
|
226
|
+
# LLM (required — fail if not available)
|
|
227
|
+
llm_endpoint: str = "http://localhost:5001/v1"
|
|
228
|
+
llm_model: str = "qwen3"
|
|
229
|
+
llm_timeout: float = 300.0 # per-request timeout; batches queue in vLLM
|
|
230
|
+
|
|
231
|
+
# Embeddings (for reference store semantic retrieval)
|
|
232
|
+
embedding_model: str = "Snowflake/snowflake-arctic-embed-m-v2.0"
|
|
233
|
+
embedding_dim: int = 768
|
|
234
|
+
embedding_device: str = "auto" # "auto" (cuda if available), "cpu", "cuda"
|
|
235
|
+
|
|
236
|
+
# Runtime context — set by pipeline before running checks.
|
|
237
|
+
# Registered checks only receive (tex_path, config), so this is
|
|
238
|
+
# how they know which paper's workspace to use.
|
|
239
|
+
current_paper: str = ""
|
|
240
|
+
|
|
241
|
+
# Manuscript context — set by build_pdf_context() for PDF input,
|
|
242
|
+
# or by get_or_create_manuscript_context() for LaTeX.
|
|
243
|
+
# Typed as Any to avoid circular import with manuscript_store.
|
|
244
|
+
manuscript_context: Any = Field(default=None, exclude=True)
|
|
245
|
+
|
|
246
|
+
# Last pipeline run stats — set by run_full_check(), read by eval runner.
|
|
247
|
+
last_run_stats: Any = Field(default=None, exclude=True)
|
|
248
|
+
|
|
249
|
+
@property
|
|
250
|
+
def is_pdf(self) -> bool:
|
|
251
|
+
"""True when the current manuscript is a PDF (GROBID-parsed)."""
|
|
252
|
+
ctx = self.manuscript_context
|
|
253
|
+
return ctx is not None and getattr(ctx, "source_type", None) == "pdf"
|
|
254
|
+
|
|
255
|
+
# Container config
|
|
256
|
+
grobid_version: str = "0.8.2.1-crf"
|
|
257
|
+
grobid_memory: str = "8g"
|
|
258
|
+
vllm_version: str = "v0.18.0"
|
|
259
|
+
vllm_memory: str = "4g"
|
|
260
|
+
|
|
261
|
+
@property
|
|
262
|
+
def grobid_image(self) -> str:
|
|
263
|
+
return f"docker.io/grobid/grobid:{self.grobid_version}"
|
|
264
|
+
|
|
265
|
+
@property
|
|
266
|
+
def vllm_image(self) -> str:
|
|
267
|
+
return f"docker.io/vllm/vllm-openai:{self.vllm_version}"
|
|
268
|
+
|
|
269
|
+
def is_check_enabled(self, check_id: str) -> bool:
|
|
270
|
+
"""Check if a check is enabled."""
|
|
271
|
+
return check_id not in self.disabled_rules
|
|
272
|
+
|
|
273
|
+
def is_rule_enabled(self, check_id: str) -> bool:
|
|
274
|
+
"""Legacy alias for is_check_enabled."""
|
|
275
|
+
return self.is_check_enabled(check_id)
|
|
276
|
+
|
|
277
|
+
def effective_severity(self, check_id: str, default: str) -> str:
|
|
278
|
+
"""Return overridden severity or the check's default."""
|
|
279
|
+
return self.severity_overrides.get(check_id, default)
|
|
280
|
+
|
|
281
|
+
def get_paper(self, name: str) -> PaperConfig | None:
|
|
282
|
+
"""Look up a paper by name."""
|
|
283
|
+
for p in self.papers:
|
|
284
|
+
if p.name == name:
|
|
285
|
+
return p
|
|
286
|
+
return None
|
|
287
|
+
|
|
288
|
+
def paper_workspace(self, paper_name: str) -> PaperWorkspace:
|
|
289
|
+
"""Return the per-paper workspace under references_dir."""
|
|
290
|
+
root = self.references_dir / paper_name
|
|
291
|
+
return PaperWorkspace(
|
|
292
|
+
root=root,
|
|
293
|
+
parsed=root / "parsed",
|
|
294
|
+
source=root / "source",
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
def effective_references_dir(self) -> Path:
|
|
298
|
+
"""Per-paper references dir if current_paper is set, else global."""
|
|
299
|
+
if self.current_paper:
|
|
300
|
+
return self.paper_workspace(self.current_paper).root
|
|
301
|
+
return self.references_dir
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def is_wsl2() -> bool:
|
|
305
|
+
"""Detect WSL2 via /proc/version (contains 'microsoft' or 'WSL2')."""
|
|
306
|
+
try:
|
|
307
|
+
return "microsoft" in Path("/proc/version").read_text().lower()
|
|
308
|
+
except OSError:
|
|
309
|
+
return False
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def find_config(start: Path | None = None) -> Path | None:
|
|
313
|
+
"""Walk up from start (default: cwd) looking for .sciwrite-lint.toml."""
|
|
314
|
+
current = (start or Path.cwd()).resolve()
|
|
315
|
+
while True:
|
|
316
|
+
candidate = current / ".sciwrite-lint.toml"
|
|
317
|
+
if candidate.is_file():
|
|
318
|
+
return candidate
|
|
319
|
+
parent = current.parent
|
|
320
|
+
if parent == current:
|
|
321
|
+
return None
|
|
322
|
+
current = parent
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def load_config(path: Path | None = None) -> LintConfig:
|
|
326
|
+
"""Load configuration from a TOML file, or use defaults.
|
|
327
|
+
|
|
328
|
+
If path is None, searches for .sciwrite-lint.toml via find_config().
|
|
329
|
+
"""
|
|
330
|
+
config = LintConfig()
|
|
331
|
+
|
|
332
|
+
toml_path = path or find_config()
|
|
333
|
+
if toml_path is None:
|
|
334
|
+
return config
|
|
335
|
+
|
|
336
|
+
config.config_path = toml_path
|
|
337
|
+
config.project_dir = toml_path.parent
|
|
338
|
+
with open(toml_path, "rb") as f:
|
|
339
|
+
data = tomllib.load(f)
|
|
340
|
+
|
|
341
|
+
project_dir = toml_path.parent
|
|
342
|
+
|
|
343
|
+
# Papers — [[papers]] array
|
|
344
|
+
for paper_data in data.get("papers", []):
|
|
345
|
+
fp = paper_data.get("file_path", "")
|
|
346
|
+
if not fp:
|
|
347
|
+
continue
|
|
348
|
+
fp_resolved = (project_dir / fp).resolve()
|
|
349
|
+
bib_path = None
|
|
350
|
+
if paper_data.get("bib"):
|
|
351
|
+
bib_path = (project_dir / paper_data["bib"]).resolve()
|
|
352
|
+
config.papers.append(
|
|
353
|
+
PaperConfig(
|
|
354
|
+
name=paper_data.get("name", fp_resolved.stem),
|
|
355
|
+
file_path=fp_resolved,
|
|
356
|
+
bib=bib_path,
|
|
357
|
+
prohibited_terms=paper_data.get("prohibited_terms", []),
|
|
358
|
+
)
|
|
359
|
+
)
|
|
360
|
+
|
|
361
|
+
# Style section
|
|
362
|
+
style = data.get("style", {})
|
|
363
|
+
if "emdash_threshold" in style:
|
|
364
|
+
config.emdash_threshold = float(style["emdash_threshold"])
|
|
365
|
+
if "sentence_length_max" in style:
|
|
366
|
+
config.sentence_length_max = int(style["sentence_length_max"])
|
|
367
|
+
if "passive_voice_threshold" in style:
|
|
368
|
+
config.passive_voice_threshold = float(style["passive_voice_threshold"])
|
|
369
|
+
if "prohibited_terms" in style:
|
|
370
|
+
config.prohibited_terms = list(style["prohibited_terms"])
|
|
371
|
+
if "we_allowed_verbs" in style:
|
|
372
|
+
config.we_allowed_verbs = list(style["we_allowed_verbs"])
|
|
373
|
+
if "max_document_chars" in style:
|
|
374
|
+
config.max_document_chars = int(style["max_document_chars"])
|
|
375
|
+
|
|
376
|
+
# Rules section
|
|
377
|
+
rules = data.get("rules", {})
|
|
378
|
+
if "disable" in rules:
|
|
379
|
+
config.disabled_rules = set(rules["disable"])
|
|
380
|
+
if "severity_overrides" in rules:
|
|
381
|
+
config.severity_overrides = dict(rules["severity_overrides"])
|
|
382
|
+
|
|
383
|
+
# API section
|
|
384
|
+
api = data.get("api", {})
|
|
385
|
+
for key in (
|
|
386
|
+
"polite_email",
|
|
387
|
+
"openalex_interval",
|
|
388
|
+
"s2_interval",
|
|
389
|
+
"crossref_interval",
|
|
390
|
+
"core_interval",
|
|
391
|
+
"unpaywall_interval",
|
|
392
|
+
"rw_cache_hours",
|
|
393
|
+
):
|
|
394
|
+
if key in api:
|
|
395
|
+
setattr(config, key, api[key])
|
|
396
|
+
|
|
397
|
+
# Output section
|
|
398
|
+
output = data.get("output", {})
|
|
399
|
+
if "format" in output:
|
|
400
|
+
config.output_format = output["format"]
|
|
401
|
+
if "color" in output:
|
|
402
|
+
config.color = output["color"]
|
|
403
|
+
|
|
404
|
+
# Logging section
|
|
405
|
+
logging = data.get("logging", {})
|
|
406
|
+
if "level" in logging:
|
|
407
|
+
config.log_level = logging["level"].upper()
|
|
408
|
+
|
|
409
|
+
# LLM section
|
|
410
|
+
llm = data.get("llm", {})
|
|
411
|
+
if "endpoint" in llm:
|
|
412
|
+
config.llm_endpoint = llm["endpoint"]
|
|
413
|
+
if "model" in llm:
|
|
414
|
+
config.llm_model = llm["model"]
|
|
415
|
+
|
|
416
|
+
# Embeddings section
|
|
417
|
+
_EMBEDDING_PRESETS = {
|
|
418
|
+
"snowflake-m-v2": ("Snowflake/snowflake-arctic-embed-m-v2.0", 768),
|
|
419
|
+
"snowflake-l-v2": ("Snowflake/snowflake-arctic-embed-l-v2.0", 1024),
|
|
420
|
+
}
|
|
421
|
+
emb = data.get("embeddings", {})
|
|
422
|
+
if "model" in emb:
|
|
423
|
+
model_val = emb["model"]
|
|
424
|
+
if model_val in _EMBEDDING_PRESETS:
|
|
425
|
+
config.embedding_model, config.embedding_dim = _EMBEDDING_PRESETS[model_val]
|
|
426
|
+
else:
|
|
427
|
+
config.embedding_model = model_val
|
|
428
|
+
if "dimension" in emb:
|
|
429
|
+
config.embedding_dim = int(emb["dimension"])
|
|
430
|
+
if "dimension" in emb and "model" not in emb:
|
|
431
|
+
config.embedding_dim = int(emb["dimension"])
|
|
432
|
+
if "device" in emb:
|
|
433
|
+
config.embedding_device = emb["device"]
|
|
434
|
+
|
|
435
|
+
# Containers section
|
|
436
|
+
containers = data.get("containers", {})
|
|
437
|
+
if "grobid_version" in containers:
|
|
438
|
+
config.grobid_version = containers["grobid_version"]
|
|
439
|
+
if "grobid_memory" in containers:
|
|
440
|
+
config.grobid_memory = containers["grobid_memory"]
|
|
441
|
+
if "vllm_version" in containers:
|
|
442
|
+
config.vllm_version = containers["vllm_version"]
|
|
443
|
+
if "vllm_memory" in containers:
|
|
444
|
+
config.vllm_memory = containers["vllm_memory"]
|
|
445
|
+
|
|
446
|
+
# Paths (relative to config file location)
|
|
447
|
+
if data.get("references_dir"):
|
|
448
|
+
config.references_dir = (project_dir / data["references_dir"]).resolve()
|
|
449
|
+
else:
|
|
450
|
+
config.references_dir = (project_dir / "references").resolve()
|
|
451
|
+
if data.get("results_dir"):
|
|
452
|
+
config.results_dir = (project_dir / data["results_dir"]).resolve()
|
|
453
|
+
else:
|
|
454
|
+
config.results_dir = (project_dir / "results").resolve()
|
|
455
|
+
if data.get("calibration_dir"):
|
|
456
|
+
config.calibration_dir = (project_dir / data["calibration_dir"]).resolve()
|
|
457
|
+
else:
|
|
458
|
+
config.calibration_dir = (
|
|
459
|
+
config.references_dir / "scilint-calibration"
|
|
460
|
+
).resolve()
|
|
461
|
+
if data.get("benchmarks_dir"):
|
|
462
|
+
config.benchmarks_dir = (project_dir / data["benchmarks_dir"]).resolve()
|
|
463
|
+
else:
|
|
464
|
+
config.benchmarks_dir = (project_dir / "benchmarks").resolve()
|
|
465
|
+
if data.get("local_pdfs_dir"):
|
|
466
|
+
config.local_pdfs_dir = (project_dir / data["local_pdfs_dir"]).resolve()
|
|
467
|
+
else:
|
|
468
|
+
config.local_pdfs_dir = (project_dir / "local_pdfs").resolve()
|
|
469
|
+
|
|
470
|
+
return config
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
def generate_default_toml(papers: list[dict[str, str]] | None = None) -> str:
|
|
474
|
+
"""Generate a default .sciwrite-lint.toml for `sciwrite-lint init`.
|
|
475
|
+
|
|
476
|
+
If papers is provided, include [[papers]] entries for each.
|
|
477
|
+
"""
|
|
478
|
+
lines = [
|
|
479
|
+
"# sciwrite-lint configuration",
|
|
480
|
+
"# See: https://github.com/authentic-research-partners/sciwrite-lint",
|
|
481
|
+
"",
|
|
482
|
+
'# local_pdfs_dir = "local_pdfs" # folder for user-provided PDFs (e.g. paywalled content)',
|
|
483
|
+
"",
|
|
484
|
+
]
|
|
485
|
+
|
|
486
|
+
# Papers section
|
|
487
|
+
if papers:
|
|
488
|
+
for p in papers:
|
|
489
|
+
lines.append("[[papers]]")
|
|
490
|
+
lines.append(f'name = "{p["name"]}"')
|
|
491
|
+
lines.append(f'file_path = "{p["file_path"]}"')
|
|
492
|
+
if p.get("bib"):
|
|
493
|
+
lines.append(f'bib = "{p["bib"]}"')
|
|
494
|
+
lines.append("# prohibited_terms = []")
|
|
495
|
+
lines.append("")
|
|
496
|
+
else:
|
|
497
|
+
lines.extend(
|
|
498
|
+
[
|
|
499
|
+
"# Register your papers here. Each [[papers]] entry is one manuscript.",
|
|
500
|
+
"# sciwrite-lint check — checks all papers",
|
|
501
|
+
"# sciwrite-lint check --paper name — checks one paper",
|
|
502
|
+
"",
|
|
503
|
+
"# [[papers]]",
|
|
504
|
+
'# name = "my-paper"',
|
|
505
|
+
'# file_path = "paper.tex" # path to .tex or .pdf file',
|
|
506
|
+
'# bib = "references.bib" # optional, auto-detected from \\bibliography{}',
|
|
507
|
+
'# prohibited_terms = ["CompanyName"] # terms that must not appear in body',
|
|
508
|
+
"",
|
|
509
|
+
]
|
|
510
|
+
)
|
|
511
|
+
|
|
512
|
+
lines.extend(
|
|
513
|
+
[
|
|
514
|
+
"[llm]",
|
|
515
|
+
'endpoint = "http://localhost:5001/v1"',
|
|
516
|
+
'model = "qwen3" # or "gemma3"',
|
|
517
|
+
"",
|
|
518
|
+
"[api]",
|
|
519
|
+
'# polite_email = "" # recommended for CrossRef/Unpaywall polite pool',
|
|
520
|
+
"# Manage email and API keys interactively: sciwrite-lint config show",
|
|
521
|
+
"",
|
|
522
|
+
"[style]",
|
|
523
|
+
"emdash_threshold = 3.0 # max em-dashes per 1000 words",
|
|
524
|
+
"sentence_length_max = 50 # max words per sentence",
|
|
525
|
+
"max_document_chars = 175000 # skip LLM checks for docs larger than this (~50 pages)",
|
|
526
|
+
"",
|
|
527
|
+
"[rules]",
|
|
528
|
+
'# disable = ["style-001"] # rule IDs to disable',
|
|
529
|
+
"",
|
|
530
|
+
"[output]",
|
|
531
|
+
'format = "terminal" # "terminal" or "json"',
|
|
532
|
+
"color = true",
|
|
533
|
+
"",
|
|
534
|
+
"[logging]",
|
|
535
|
+
'level = "DEBUG" # file sink: DEBUG, INFO, WARNING, ERROR',
|
|
536
|
+
"",
|
|
537
|
+
]
|
|
538
|
+
)
|
|
539
|
+
|
|
540
|
+
return "\n".join(lines) + "\n"
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
def init_project(force: bool = False) -> tuple[bool, str]:
|
|
544
|
+
"""Initialize a sciwrite-lint project in the current directory.
|
|
545
|
+
|
|
546
|
+
Creates .sciwrite-lint.toml and references/ directory structure.
|
|
547
|
+
Never overwrites existing files or directories.
|
|
548
|
+
|
|
549
|
+
Returns (success, message).
|
|
550
|
+
"""
|
|
551
|
+
defaults = LintConfig()
|
|
552
|
+
toml_path = Path(".sciwrite-lint.toml")
|
|
553
|
+
refs_dir = defaults.references_dir
|
|
554
|
+
|
|
555
|
+
created: list[str] = []
|
|
556
|
+
skipped: list[str] = []
|
|
557
|
+
|
|
558
|
+
# Config file
|
|
559
|
+
if toml_path.exists():
|
|
560
|
+
if force:
|
|
561
|
+
# Scan for .tex files to populate papers
|
|
562
|
+
papers = _detect_papers()
|
|
563
|
+
toml_path.write_text(generate_default_toml(papers or None))
|
|
564
|
+
created.append(str(toml_path) + " (overwritten)")
|
|
565
|
+
else:
|
|
566
|
+
skipped.append(str(toml_path) + " (already exists)")
|
|
567
|
+
else:
|
|
568
|
+
papers = _detect_papers()
|
|
569
|
+
toml_path.write_text(generate_default_toml(papers or None))
|
|
570
|
+
created.append(str(toml_path))
|
|
571
|
+
|
|
572
|
+
# References root directory (per-paper subdirs created on first pipeline run)
|
|
573
|
+
if refs_dir.exists():
|
|
574
|
+
skipped.append(str(refs_dir) + "/ (already exists)")
|
|
575
|
+
else:
|
|
576
|
+
refs_dir.mkdir()
|
|
577
|
+
created.append(str(refs_dir) + "/")
|
|
578
|
+
|
|
579
|
+
# Local PDFs directory (user-provided paywalled content)
|
|
580
|
+
local_pdfs = defaults.local_pdfs_dir
|
|
581
|
+
if local_pdfs.exists():
|
|
582
|
+
skipped.append(str(local_pdfs) + "/ (already exists)")
|
|
583
|
+
else:
|
|
584
|
+
local_pdfs.mkdir()
|
|
585
|
+
created.append(str(local_pdfs) + "/")
|
|
586
|
+
|
|
587
|
+
# Build message
|
|
588
|
+
parts: list[str] = []
|
|
589
|
+
if created:
|
|
590
|
+
parts.append("Created:\n" + "\n".join(f" {c}" for c in created))
|
|
591
|
+
if skipped:
|
|
592
|
+
parts.append(
|
|
593
|
+
"Skipped (not overwritten):\n" + "\n".join(f" {s}" for s in skipped)
|
|
594
|
+
)
|
|
595
|
+
|
|
596
|
+
if not created and skipped:
|
|
597
|
+
parts.append("\nProject already initialized. Use --force to overwrite config.")
|
|
598
|
+
|
|
599
|
+
if created:
|
|
600
|
+
parts.append("\nNext steps:")
|
|
601
|
+
parts.append(
|
|
602
|
+
" 1. Edit .sciwrite-lint.toml — register your papers in [[papers]]"
|
|
603
|
+
)
|
|
604
|
+
parts.append(
|
|
605
|
+
" 2. Set your email (required for full-text download + retraction checks):"
|
|
606
|
+
)
|
|
607
|
+
parts.append(" sciwrite-lint config set-email you@example.com")
|
|
608
|
+
parts.append(
|
|
609
|
+
" 3. Drop paywalled PDFs into local_pdfs/ (filename ~ reference title)"
|
|
610
|
+
)
|
|
611
|
+
parts.append(" 4. Start containers: sciwrite-lint containers start")
|
|
612
|
+
parts.append(" 5. Run: sciwrite-lint check")
|
|
613
|
+
parts.append("\nOptional — faster API rate limits: sciwrite-lint config show")
|
|
614
|
+
|
|
615
|
+
return bool(created), "\n".join(parts)
|
|
616
|
+
|
|
617
|
+
|
|
618
|
+
def _detect_papers() -> list[dict[str, str]]:
|
|
619
|
+
"""Scan current directory for .tex files and build paper entries."""
|
|
620
|
+
papers = []
|
|
621
|
+
for tex in sorted(Path(".").glob("**/*.tex")):
|
|
622
|
+
# Skip common non-paper files
|
|
623
|
+
if any(part.startswith(".") for part in tex.parts):
|
|
624
|
+
continue
|
|
625
|
+
if "references" in tex.parts or "archive" in tex.parts:
|
|
626
|
+
continue
|
|
627
|
+
|
|
628
|
+
name = tex.stem
|
|
629
|
+
entry: dict[str, str] = {"name": name, "file_path": str(tex)}
|
|
630
|
+
|
|
631
|
+
# Check for matching .bib file
|
|
632
|
+
tex_text = tex.read_text(encoding="utf-8", errors="ignore")
|
|
633
|
+
import re
|
|
634
|
+
|
|
635
|
+
bib_match = re.search(r"\\bibliography\{([^}]+)\}", tex_text)
|
|
636
|
+
if bib_match:
|
|
637
|
+
bib_name = bib_match.group(1)
|
|
638
|
+
if not bib_name.endswith(".bib"):
|
|
639
|
+
bib_name += ".bib"
|
|
640
|
+
bib_path = tex.parent / bib_name
|
|
641
|
+
if bib_path.exists():
|
|
642
|
+
entry["bib"] = str(bib_path)
|
|
643
|
+
|
|
644
|
+
papers.append(entry)
|
|
645
|
+
|
|
646
|
+
return papers
|