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,480 @@
|
|
|
1
|
+
"""CLI handlers for 'check' and 'checks' commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import asyncio
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from loguru import logger
|
|
11
|
+
|
|
12
|
+
from sciwrite_lint.config import LintConfig
|
|
13
|
+
from sciwrite_lint.models import Finding
|
|
14
|
+
from sciwrite_lint.report import format_findings
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _resolve_helpers(
|
|
18
|
+
args: argparse.Namespace, config: LintConfig
|
|
19
|
+
) -> list[tuple[str, Path]]:
|
|
20
|
+
"""Import and call _resolve_input_files from __main__."""
|
|
21
|
+
from sciwrite_lint.__main__ import _resolve_input_files
|
|
22
|
+
|
|
23
|
+
return _resolve_input_files(args, config)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
async def run_llm_checks_batched(
|
|
27
|
+
checks: list[tuple],
|
|
28
|
+
tex_path: Path,
|
|
29
|
+
config: LintConfig,
|
|
30
|
+
) -> list[Finding]:
|
|
31
|
+
"""Run all LLM checks batched by thinking mode.
|
|
32
|
+
|
|
33
|
+
All LLM checks must implement the build_queries/process_results protocol:
|
|
34
|
+
- build_queries(tex_path, config) -> list of (system, user, schema, name) tuples
|
|
35
|
+
- process_results(results) -> list[Finding]
|
|
36
|
+
|
|
37
|
+
Each check can set a ``thinking`` attribute ("off", "low", "medium", "high")
|
|
38
|
+
to control chain-of-thought reasoning. Queries are grouped by thinking mode
|
|
39
|
+
so each group runs as a single batch call.
|
|
40
|
+
"""
|
|
41
|
+
from sciwrite_lint.llm_utils import llm_query_batch
|
|
42
|
+
|
|
43
|
+
# Phase 1: collect queries, grouped by (thinking, max_tokens)
|
|
44
|
+
# max_tokens comes from build_queries._max_tokens (set by full-paper
|
|
45
|
+
# checks based on paper size); None means use model default.
|
|
46
|
+
BatchKey = tuple[str, int | None]
|
|
47
|
+
queries_by_batch: dict[BatchKey, list[tuple[str, str, dict, str]]] = {}
|
|
48
|
+
check_slices: list[tuple[Any, Any, BatchKey, int, int]] = []
|
|
49
|
+
build_failures: list[Finding] = []
|
|
50
|
+
|
|
51
|
+
for meta, fn in checks:
|
|
52
|
+
build = getattr(fn, "build_queries", None)
|
|
53
|
+
if build is None:
|
|
54
|
+
raise RuntimeError(f"LLM check {meta.id} missing build_queries")
|
|
55
|
+
try:
|
|
56
|
+
queries = build(tex_path, config)
|
|
57
|
+
thinking = getattr(fn, "thinking", "off")
|
|
58
|
+
mt: int | None = getattr(build, "_max_tokens", None)
|
|
59
|
+
batch_key: BatchKey = (thinking, mt)
|
|
60
|
+
if batch_key not in queries_by_batch:
|
|
61
|
+
queries_by_batch[batch_key] = []
|
|
62
|
+
start = len(queries_by_batch[batch_key])
|
|
63
|
+
queries_by_batch[batch_key].extend(queries)
|
|
64
|
+
check_slices.append((meta, fn, batch_key, start, len(queries)))
|
|
65
|
+
except Exception as e:
|
|
66
|
+
logger.error(f"Check {meta.id} build_queries failed: {e}")
|
|
67
|
+
build_failures.append(
|
|
68
|
+
Finding(
|
|
69
|
+
level="info",
|
|
70
|
+
rule_id=meta.id,
|
|
71
|
+
message=f"Check {meta.id} could not run (internal error)",
|
|
72
|
+
context=f"{type(e).__name__}: {e!s}"[:200],
|
|
73
|
+
)
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
# Phase 2: one batch call per (thinking, max_tokens) group
|
|
77
|
+
results_by_batch: dict[BatchKey, list[dict | None]] = {}
|
|
78
|
+
for (mode, mt), batch_queries in queries_by_batch.items():
|
|
79
|
+
if batch_queries:
|
|
80
|
+
try:
|
|
81
|
+
results_by_batch[(mode, mt)] = await llm_query_batch(
|
|
82
|
+
batch_queries,
|
|
83
|
+
config=config,
|
|
84
|
+
thinking=mode,
|
|
85
|
+
max_tokens=mt,
|
|
86
|
+
)
|
|
87
|
+
except Exception as e:
|
|
88
|
+
logger.error(f"LLM batch query failed (thinking={mode}): {e}")
|
|
89
|
+
results_by_batch[(mode, mt)] = [None] * len(batch_queries)
|
|
90
|
+
|
|
91
|
+
# Phase 3: distribute results to each check
|
|
92
|
+
findings: list[Finding] = []
|
|
93
|
+
for meta, fn, batch_key, start, count in check_slices:
|
|
94
|
+
try:
|
|
95
|
+
if count > 0:
|
|
96
|
+
process = getattr(fn, "process_results")
|
|
97
|
+
mode_results = results_by_batch.get(batch_key, [])
|
|
98
|
+
check_results = mode_results[start : start + count]
|
|
99
|
+
check_findings = process(check_results)
|
|
100
|
+
else:
|
|
101
|
+
check_findings = []
|
|
102
|
+
|
|
103
|
+
for f in check_findings:
|
|
104
|
+
override = config.effective_severity(meta.id, meta.severity)
|
|
105
|
+
if override != f.level:
|
|
106
|
+
f.level = override # type: ignore[assignment]
|
|
107
|
+
findings.extend(check_findings)
|
|
108
|
+
except Exception as e:
|
|
109
|
+
logger.error(f"Check {meta.id} failed: {e}")
|
|
110
|
+
findings.append(
|
|
111
|
+
Finding(
|
|
112
|
+
level="info",
|
|
113
|
+
rule_id=meta.id,
|
|
114
|
+
message=f"Check {meta.id} could not run (internal error)",
|
|
115
|
+
context=f"{type(e).__name__}: {e!s}"[:200],
|
|
116
|
+
)
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
return build_failures + findings
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def run_check(args: argparse.Namespace) -> int:
|
|
123
|
+
"""Run the full check pipeline: rules + verify + fetch + parse + claims.
|
|
124
|
+
|
|
125
|
+
Single-paper case dispatches to ``run_full_check`` (sequential pipeline).
|
|
126
|
+
Multi-paper case (2+ papers) dispatches to ``run_papers_staged`` for the
|
|
127
|
+
batch-staged pipeline — GPU stages run once with all papers, vLLM and
|
|
128
|
+
network stages run concurrently up to ``--concurrency``.
|
|
129
|
+
"""
|
|
130
|
+
from sciwrite_lint.pipeline import preflight, run_full_check
|
|
131
|
+
|
|
132
|
+
from sciwrite_lint.__main__ import _load_config, _resolve_paper
|
|
133
|
+
|
|
134
|
+
config = _load_config(args)
|
|
135
|
+
fmt = args.format or config.output_format
|
|
136
|
+
fresh = getattr(args, "fresh", False)
|
|
137
|
+
concurrency = getattr(args, "concurrency", 2)
|
|
138
|
+
|
|
139
|
+
# Explicit file — no paper config, run text + LLM rules only
|
|
140
|
+
if hasattr(args, "file") and args.file:
|
|
141
|
+
p = Path(args.file)
|
|
142
|
+
if p.suffix.lower() == ".pdf":
|
|
143
|
+
return run_check_pdf(p, config, fmt)
|
|
144
|
+
return run_check_quick(args, config, fmt)
|
|
145
|
+
|
|
146
|
+
# Resolve to paper configs (need bib_path for claims)
|
|
147
|
+
paper_filter = getattr(args, "paper", None)
|
|
148
|
+
paper_configs: list[tuple[str, Path, Any]] = []
|
|
149
|
+
if paper_filter:
|
|
150
|
+
pc = _resolve_paper(config, paper_filter)
|
|
151
|
+
if not pc:
|
|
152
|
+
return 2
|
|
153
|
+
paper_configs.append((pc.name, pc.file_path, pc))
|
|
154
|
+
elif config.papers:
|
|
155
|
+
for pc in config.papers:
|
|
156
|
+
paper_configs.append((pc.name, pc.file_path, pc))
|
|
157
|
+
else:
|
|
158
|
+
logger.error("No papers registered. Use --paper or add [[papers]] to config.")
|
|
159
|
+
return 2
|
|
160
|
+
|
|
161
|
+
# Filter out missing files up front so the run/batch path sees only
|
|
162
|
+
# papers that actually exist.
|
|
163
|
+
runnable: list[tuple[str, Path, Any]] = []
|
|
164
|
+
all_ok = True
|
|
165
|
+
for name, tex_path, pc in paper_configs:
|
|
166
|
+
if not tex_path.exists():
|
|
167
|
+
logger.error(f"Error: {tex_path} not found")
|
|
168
|
+
all_ok = False
|
|
169
|
+
continue
|
|
170
|
+
runnable.append((name, tex_path, pc))
|
|
171
|
+
|
|
172
|
+
if not runnable:
|
|
173
|
+
return 1
|
|
174
|
+
|
|
175
|
+
# Preflight
|
|
176
|
+
errors = asyncio.run(preflight(config))
|
|
177
|
+
if errors:
|
|
178
|
+
logger.error("Prerequisites not met:")
|
|
179
|
+
for e in errors:
|
|
180
|
+
logger.error(f" ✗ {e}")
|
|
181
|
+
return 2
|
|
182
|
+
|
|
183
|
+
# Multi-paper: use batch-staged pipeline (single GPU model load per
|
|
184
|
+
# stage, concurrent vLLM/network across papers).
|
|
185
|
+
if len(runnable) > 1:
|
|
186
|
+
return _run_check_batch(runnable, config, fmt, fresh, concurrency, all_ok)
|
|
187
|
+
|
|
188
|
+
# Single paper: keep the existing sequential path
|
|
189
|
+
name, tex_path, pc = runnable[0]
|
|
190
|
+
print(f"\n{'=' * 60}")
|
|
191
|
+
print(f"Checking {name} ({tex_path.name})")
|
|
192
|
+
print(f"{'=' * 60}")
|
|
193
|
+
|
|
194
|
+
# PDF files: build ManuscriptContext via GROBID, then run full pipeline
|
|
195
|
+
if tex_path.suffix.lower() == ".pdf":
|
|
196
|
+
from sciwrite_lint.pipeline import build_pdf_context
|
|
197
|
+
|
|
198
|
+
asyncio.run(build_pdf_context(tex_path, config))
|
|
199
|
+
|
|
200
|
+
findings = asyncio.run(run_full_check(name, tex_path, pc, config, fresh=fresh))
|
|
201
|
+
|
|
202
|
+
label = f"{name} ({tex_path.name})" if name != tex_path.stem else tex_path.name
|
|
203
|
+
print()
|
|
204
|
+
format_findings(findings, label, fmt=fmt, color=config.color)
|
|
205
|
+
_print_integrity_summary(name, findings, config)
|
|
206
|
+
|
|
207
|
+
if any(f.level == "error" for f in findings):
|
|
208
|
+
all_ok = False
|
|
209
|
+
|
|
210
|
+
return 0 if all_ok else 1
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _run_check_batch(
|
|
214
|
+
runnable: list[tuple[str, Path, Any]],
|
|
215
|
+
config: LintConfig,
|
|
216
|
+
fmt: str,
|
|
217
|
+
fresh: bool,
|
|
218
|
+
concurrency: int,
|
|
219
|
+
all_ok: bool,
|
|
220
|
+
) -> int:
|
|
221
|
+
"""Run multiple papers via the batch-staged pipeline."""
|
|
222
|
+
from sciwrite_lint.pipeline import build_pdf_context, run_papers_staged
|
|
223
|
+
|
|
224
|
+
print(f"\n{'=' * 60}")
|
|
225
|
+
print(
|
|
226
|
+
f"Checking {len(runnable)} papers via batch-staged pipeline "
|
|
227
|
+
f"(concurrency={concurrency})"
|
|
228
|
+
)
|
|
229
|
+
print(f"{'=' * 60}")
|
|
230
|
+
|
|
231
|
+
# PDFs need ManuscriptContext built upfront (GROBID parse) before staged
|
|
232
|
+
# pipeline runs — same pattern as eval_real_world/runner.py.
|
|
233
|
+
async def _build_pdf_contexts() -> None:
|
|
234
|
+
for _name, tex_path, _pc in runnable:
|
|
235
|
+
if tex_path.suffix.lower() == ".pdf":
|
|
236
|
+
await build_pdf_context(tex_path, config)
|
|
237
|
+
|
|
238
|
+
asyncio.run(_build_pdf_contexts())
|
|
239
|
+
|
|
240
|
+
# run_papers_staged expects (name, tex_path, paper_config, lint_config) tuples
|
|
241
|
+
staged_input = [(name, tex_path, pc, config) for name, tex_path, pc in runnable]
|
|
242
|
+
|
|
243
|
+
try:
|
|
244
|
+
results = asyncio.run(
|
|
245
|
+
run_papers_staged(staged_input, fresh=fresh, concurrency=concurrency)
|
|
246
|
+
)
|
|
247
|
+
except Exception as e:
|
|
248
|
+
logger.error(f"Batch-staged pipeline failed: {e}")
|
|
249
|
+
return 1
|
|
250
|
+
|
|
251
|
+
# Format output for each paper, in original order
|
|
252
|
+
result_map = {r.paper_name: r for r in results}
|
|
253
|
+
for name, tex_path, _pc in runnable:
|
|
254
|
+
sr = result_map.get(name)
|
|
255
|
+
print(f"\n{'=' * 60}")
|
|
256
|
+
print(f"{name} ({tex_path.name})")
|
|
257
|
+
print(f"{'=' * 60}")
|
|
258
|
+
if not sr or sr.error:
|
|
259
|
+
err = sr.error if sr else "missing from batch results"
|
|
260
|
+
logger.error(f"[{name}] Pipeline failed: {err}")
|
|
261
|
+
all_ok = False
|
|
262
|
+
continue
|
|
263
|
+
|
|
264
|
+
label = f"{name} ({tex_path.name})" if name != tex_path.stem else tex_path.name
|
|
265
|
+
format_findings(sr.findings, label, fmt=fmt, color=config.color)
|
|
266
|
+
_print_integrity_summary(name, sr.findings, config)
|
|
267
|
+
|
|
268
|
+
if any(f.level == "error" for f in sr.findings):
|
|
269
|
+
all_ok = False
|
|
270
|
+
|
|
271
|
+
return 0 if all_ok else 1
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _print_integrity_summary(
|
|
275
|
+
paper_name: str,
|
|
276
|
+
findings: list[Finding],
|
|
277
|
+
config: LintConfig,
|
|
278
|
+
) -> None:
|
|
279
|
+
"""Compute integrity, save report, print summary."""
|
|
280
|
+
import json
|
|
281
|
+
|
|
282
|
+
from sciwrite_lint.scoring.scilint_score import (
|
|
283
|
+
_aggregate_ref_claims,
|
|
284
|
+
_score_reference,
|
|
285
|
+
compute_integrity,
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
output_dir = config.results_dir
|
|
289
|
+
|
|
290
|
+
# Load claims, ref internal scores, and metadata from workspace.db
|
|
291
|
+
ws = config.paper_workspace(paper_name)
|
|
292
|
+
claims: list[dict] = []
|
|
293
|
+
ref_internal_scores: dict[str, float] | None = None
|
|
294
|
+
metadata_map = None
|
|
295
|
+
|
|
296
|
+
if ws.root.exists():
|
|
297
|
+
from sciwrite_lint.references.metadata import load_all_metadata
|
|
298
|
+
from sciwrite_lint.references.workspace_db import (
|
|
299
|
+
get_db,
|
|
300
|
+
load_all_ref_internal_scores,
|
|
301
|
+
load_claim_results,
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
with get_db(ws.root) as conn:
|
|
305
|
+
claims = load_claim_results(conn)
|
|
306
|
+
scores = load_all_ref_internal_scores(conn)
|
|
307
|
+
if scores:
|
|
308
|
+
ref_internal_scores = scores
|
|
309
|
+
|
|
310
|
+
metadata_map = load_all_metadata(ws.root)
|
|
311
|
+
|
|
312
|
+
findings_dicts = [f.model_dump() for f in findings]
|
|
313
|
+
result = compute_integrity(
|
|
314
|
+
findings_dicts,
|
|
315
|
+
claims,
|
|
316
|
+
metadata_map=metadata_map,
|
|
317
|
+
ref_internal_scores=ref_internal_scores,
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
# Build per-reference detail for report (matches target format)
|
|
321
|
+
by_ref = _aggregate_ref_claims(claims)
|
|
322
|
+
ref_details: list[dict] = []
|
|
323
|
+
for key, ref_claims in sorted(by_ref.items()):
|
|
324
|
+
rs = _score_reference(ref_claims)
|
|
325
|
+
reliability_score = result.reference_reliability.get(key)
|
|
326
|
+
|
|
327
|
+
# Build reliability breakdown
|
|
328
|
+
reliability: dict[str, Any] = {}
|
|
329
|
+
if metadata_map and key in metadata_map:
|
|
330
|
+
meta = metadata_map[key]
|
|
331
|
+
reliability["score"] = (
|
|
332
|
+
round(reliability_score, 4) if reliability_score is not None else None
|
|
333
|
+
)
|
|
334
|
+
reliability["tier"] = meta.access.get("tier", "")
|
|
335
|
+
reliability["retracted"] = bool(meta.canonical.get("retracted"))
|
|
336
|
+
reliability["metadata_mismatches"] = meta.mismatches
|
|
337
|
+
consistency = ref_internal_scores.get(key) if ref_internal_scores else None
|
|
338
|
+
reliability["consistency"] = (
|
|
339
|
+
round(consistency, 4) if consistency is not None else None
|
|
340
|
+
)
|
|
341
|
+
elif reliability_score is not None:
|
|
342
|
+
reliability["score"] = round(reliability_score, 4)
|
|
343
|
+
|
|
344
|
+
# Compute per-ref SciLint Score (reliability only, contribution=1.0)
|
|
345
|
+
ref_scilint = (
|
|
346
|
+
round(reliability_score, 4) if reliability_score is not None else None
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
# Build signals list
|
|
350
|
+
signals: list[str] = []
|
|
351
|
+
if metadata_map and key in metadata_map:
|
|
352
|
+
meta = metadata_map[key]
|
|
353
|
+
if meta.access.get("tier") == "T3":
|
|
354
|
+
signals.append("not found in APIs")
|
|
355
|
+
if meta.canonical.get("retracted"):
|
|
356
|
+
signals.append("RETRACTED")
|
|
357
|
+
signals.extend(meta.mismatches)
|
|
358
|
+
if rs.verdict == "NOT_SUPPORTED":
|
|
359
|
+
signals.append("claim not supported")
|
|
360
|
+
elif rs.verdict == "PARTIALLY_SUPPORTS":
|
|
361
|
+
signals.append("claim partially supported")
|
|
362
|
+
|
|
363
|
+
detail: dict[str, Any] = {
|
|
364
|
+
"key": key,
|
|
365
|
+
"scilint_score": ref_scilint,
|
|
366
|
+
"verdict": rs.verdict,
|
|
367
|
+
"purpose": {
|
|
368
|
+
"role": rs.purpose,
|
|
369
|
+
"weight": round(rs.weight, 2),
|
|
370
|
+
},
|
|
371
|
+
"reliability": reliability if reliability else None,
|
|
372
|
+
"signals": signals,
|
|
373
|
+
}
|
|
374
|
+
ref_details.append(detail)
|
|
375
|
+
|
|
376
|
+
# Save report
|
|
377
|
+
report = {
|
|
378
|
+
"paper": paper_name,
|
|
379
|
+
"scilint_score": round(
|
|
380
|
+
result.internal_consistency * result.referencing_quality, 4
|
|
381
|
+
),
|
|
382
|
+
"internal_consistency": round(result.internal_consistency, 4),
|
|
383
|
+
"referencing_quality": round(result.referencing_quality, 4),
|
|
384
|
+
"contribution": None,
|
|
385
|
+
"total_findings": len(findings_dicts),
|
|
386
|
+
"errors": sum(1 for f in findings_dicts if f.get("level") == "error"),
|
|
387
|
+
"warnings": sum(1 for f in findings_dicts if f.get("level") == "warning"),
|
|
388
|
+
"references": ref_details,
|
|
389
|
+
"findings": findings_dicts,
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
393
|
+
report_path = output_dir / f"check_{paper_name}.json"
|
|
394
|
+
report_path.write_text(
|
|
395
|
+
json.dumps(report, indent=2, ensure_ascii=False) + "\n",
|
|
396
|
+
encoding="utf-8",
|
|
397
|
+
)
|
|
398
|
+
logger.info(f"Check report saved to {report_path}")
|
|
399
|
+
|
|
400
|
+
# Print summary
|
|
401
|
+
scilint = result.internal_consistency * result.referencing_quality
|
|
402
|
+
print(f"\n SciLint Score: {scilint:.2f}")
|
|
403
|
+
print(f" Internal Consistency: {result.internal_consistency:.2f}")
|
|
404
|
+
print(f" Referencing Quality: {result.referencing_quality:.2f}")
|
|
405
|
+
print(" (Run 'sciwrite-lint contributions' to add contribution axes)")
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def run_check_pdf(pdf_path: Path, config: LintConfig, fmt: str) -> int:
|
|
409
|
+
"""Run checks on a PDF file. Requires GROBID."""
|
|
410
|
+
from sciwrite_lint.pipeline import (
|
|
411
|
+
build_pdf_context,
|
|
412
|
+
run_llm_checks_batched as pipeline_llm_batched,
|
|
413
|
+
run_text_checks,
|
|
414
|
+
)
|
|
415
|
+
|
|
416
|
+
if not pdf_path.exists():
|
|
417
|
+
logger.error(f"Error: {pdf_path} not found")
|
|
418
|
+
return 2
|
|
419
|
+
|
|
420
|
+
async def _do_check() -> list[Finding]:
|
|
421
|
+
await build_pdf_context(pdf_path, config)
|
|
422
|
+
findings = run_text_checks(pdf_path, config)
|
|
423
|
+
findings.extend(await pipeline_llm_batched(pdf_path, config))
|
|
424
|
+
return findings
|
|
425
|
+
|
|
426
|
+
findings = asyncio.run(_do_check())
|
|
427
|
+
|
|
428
|
+
label = pdf_path.name
|
|
429
|
+
print()
|
|
430
|
+
format_findings(findings, label, fmt=fmt, color=config.color)
|
|
431
|
+
|
|
432
|
+
return 0 if not any(f.level == "error" for f in findings) else 1
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def run_check_quick(
|
|
436
|
+
args: argparse.Namespace,
|
|
437
|
+
config: LintConfig,
|
|
438
|
+
fmt: str,
|
|
439
|
+
) -> int:
|
|
440
|
+
"""Quick mode: text + LLM rules only, no verify/fetch/parse/claims."""
|
|
441
|
+
from sciwrite_lint.pipeline import (
|
|
442
|
+
run_llm_checks_batched as pipeline_llm_batched,
|
|
443
|
+
run_text_checks,
|
|
444
|
+
)
|
|
445
|
+
|
|
446
|
+
papers = _resolve_helpers(args, config)
|
|
447
|
+
if not papers:
|
|
448
|
+
return 2
|
|
449
|
+
|
|
450
|
+
all_ok = True
|
|
451
|
+
for name, tex_path in papers:
|
|
452
|
+
if not tex_path.exists():
|
|
453
|
+
logger.error(f"Error: {tex_path} not found")
|
|
454
|
+
all_ok = False
|
|
455
|
+
continue
|
|
456
|
+
|
|
457
|
+
findings = run_text_checks(tex_path, config)
|
|
458
|
+
findings.extend(asyncio.run(pipeline_llm_batched(tex_path, config)))
|
|
459
|
+
|
|
460
|
+
label = f"{name} ({tex_path.name})" if name != tex_path.stem else tex_path.name
|
|
461
|
+
format_findings(findings, label, fmt=fmt, color=config.color)
|
|
462
|
+
|
|
463
|
+
if any(f.level == "error" for f in findings):
|
|
464
|
+
all_ok = False
|
|
465
|
+
|
|
466
|
+
return 0 if all_ok else 1
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
def run_checks_list(args: argparse.Namespace) -> int:
|
|
470
|
+
"""List all registered checks."""
|
|
471
|
+
from sciwrite_lint.checks.registry import ensure_checks_loaded, list_checks
|
|
472
|
+
|
|
473
|
+
ensure_checks_loaded()
|
|
474
|
+
all_checks = list_checks()
|
|
475
|
+
|
|
476
|
+
for meta in all_checks:
|
|
477
|
+
print(f" {meta.id:<32} [{meta.category}] {meta.description}")
|
|
478
|
+
|
|
479
|
+
print(f"\n {len(all_checks)} checks registered.")
|
|
480
|
+
return 0
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
"""CLI handlers for config management (polite email, API keys)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import re
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import TYPE_CHECKING
|
|
9
|
+
|
|
10
|
+
from loguru import logger
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from sciwrite_lint.config import LintConfig
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
# Services that accept API keys, stored in ~/.sciwrite-lint/<filename>
|
|
17
|
+
_API_KEY_SERVICES: dict[str, dict[str, str]] = {
|
|
18
|
+
"semantic-scholar": {
|
|
19
|
+
"file": "s2_api_key",
|
|
20
|
+
"description": "Semantic Scholar (1 → 100 req/s)",
|
|
21
|
+
"url": "https://www.semanticscholar.org/product/api#api-key",
|
|
22
|
+
},
|
|
23
|
+
"ncbi": {
|
|
24
|
+
"file": "ncbi_api_key",
|
|
25
|
+
"description": "NCBI / PubMed Central (3 → 10 req/s)",
|
|
26
|
+
"url": "https://www.ncbi.nlm.nih.gov/account/settings/",
|
|
27
|
+
},
|
|
28
|
+
"core": {
|
|
29
|
+
"file": "core_api_key",
|
|
30
|
+
"description": "CORE (institutional repository access)",
|
|
31
|
+
"url": "https://core.ac.uk/services/api",
|
|
32
|
+
},
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
_KEY_DIR = Path.home() / ".sciwrite-lint"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def check_api_config(config: "LintConfig", needs_email: bool = False) -> list[str]:
|
|
39
|
+
"""Validate API configuration. Returns list of errors (hard stops).
|
|
40
|
+
|
|
41
|
+
Also logs warnings for missing optional API keys.
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
config: Resolved LintConfig.
|
|
45
|
+
needs_email: If True, missing polite_email is an error (not just a warning).
|
|
46
|
+
"""
|
|
47
|
+
errors: list[str] = []
|
|
48
|
+
|
|
49
|
+
# polite_email: required for Unpaywall + Retraction Watch
|
|
50
|
+
if not config.polite_email:
|
|
51
|
+
if needs_email:
|
|
52
|
+
errors.append(
|
|
53
|
+
"polite_email not set — Unpaywall and Retraction Watch will not work. "
|
|
54
|
+
"Set with: sciwrite-lint config set-email you@example.com"
|
|
55
|
+
)
|
|
56
|
+
else:
|
|
57
|
+
logger.warning(
|
|
58
|
+
"polite_email not set — Unpaywall and Retraction Watch disabled. "
|
|
59
|
+
"Set with: sciwrite-lint config set-email you@example.com"
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
# Optional API keys: warn about rate limit benefits
|
|
63
|
+
missing_keys: list[str] = []
|
|
64
|
+
for service, info in _API_KEY_SERVICES.items():
|
|
65
|
+
if not _read_key(info["file"]):
|
|
66
|
+
missing_keys.append(f"{service} ({info['description']})")
|
|
67
|
+
|
|
68
|
+
if missing_keys:
|
|
69
|
+
logger.info(
|
|
70
|
+
"Optional API keys not configured (slower rate limits): {}. "
|
|
71
|
+
"See: sciwrite-lint config show",
|
|
72
|
+
", ".join(missing_keys),
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
return errors
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _read_key(filename: str) -> str | None:
|
|
79
|
+
"""Read an API key from ~/.sciwrite-lint/{filename}."""
|
|
80
|
+
path = _KEY_DIR / filename
|
|
81
|
+
if path.exists():
|
|
82
|
+
val = path.read_text().strip()
|
|
83
|
+
return val or None
|
|
84
|
+
return None
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _mask(value: str) -> str:
|
|
88
|
+
"""Mask all but last 4 characters of a secret."""
|
|
89
|
+
if len(value) <= 4:
|
|
90
|
+
return "****"
|
|
91
|
+
return "*" * (len(value) - 4) + value[-4:]
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def run_config(args: argparse.Namespace) -> int:
|
|
95
|
+
"""Dispatch config subcommands."""
|
|
96
|
+
action = getattr(args, "config_action", None)
|
|
97
|
+
if action == "show":
|
|
98
|
+
return _show(args)
|
|
99
|
+
if action == "set-email":
|
|
100
|
+
return _set_email(args)
|
|
101
|
+
if action == "set-key":
|
|
102
|
+
return _set_key(args)
|
|
103
|
+
if action == "remove-key":
|
|
104
|
+
return _remove_key(args)
|
|
105
|
+
# No subcommand — print help (handled in __main__.py)
|
|
106
|
+
return 0
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _show(args: argparse.Namespace) -> int:
|
|
110
|
+
"""Show current polite email and API key status."""
|
|
111
|
+
from sciwrite_lint.__main__ import _load_config
|
|
112
|
+
|
|
113
|
+
config = _load_config(args)
|
|
114
|
+
|
|
115
|
+
print("Polite email and API key configuration\n")
|
|
116
|
+
|
|
117
|
+
# Polite email
|
|
118
|
+
email = config.polite_email
|
|
119
|
+
if email:
|
|
120
|
+
print(f" polite email: {email}")
|
|
121
|
+
print(" → CrossRef polite pool, Unpaywall, Retraction Watch")
|
|
122
|
+
else:
|
|
123
|
+
print(" polite email: (not set)")
|
|
124
|
+
print(" ⚠ CrossRef: slower rate limits without polite pool")
|
|
125
|
+
print(" ⚠ Unpaywall: will not work without an email")
|
|
126
|
+
print(" ⚠ Retraction Watch: will not work without an email")
|
|
127
|
+
print(" Set with: sciwrite-lint config set-email you@example.com")
|
|
128
|
+
print()
|
|
129
|
+
|
|
130
|
+
# API keys
|
|
131
|
+
print(" API keys (stored in ~/.sciwrite-lint/):\n")
|
|
132
|
+
for service, info in _API_KEY_SERVICES.items():
|
|
133
|
+
key = _read_key(info["file"])
|
|
134
|
+
if key:
|
|
135
|
+
print(f" {service:<20} {_mask(key)} ({info['description']})")
|
|
136
|
+
else:
|
|
137
|
+
print(f" {service:<20} (not set) — {info['description']}")
|
|
138
|
+
print(f" Get one at: {info['url']}")
|
|
139
|
+
|
|
140
|
+
print()
|
|
141
|
+
return 0
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _set_email(args: argparse.Namespace) -> int:
|
|
145
|
+
"""Set polite email in .sciwrite-lint.toml."""
|
|
146
|
+
email = args.email.strip()
|
|
147
|
+
if not re.match(r"^[^@\s]+@[^@\s]+\.[^@\s]+$", email):
|
|
148
|
+
logger.error("Invalid email address: {}", email)
|
|
149
|
+
return 1
|
|
150
|
+
|
|
151
|
+
from sciwrite_lint.config import find_config
|
|
152
|
+
|
|
153
|
+
toml_path = find_config()
|
|
154
|
+
if toml_path is None:
|
|
155
|
+
logger.error("No .sciwrite-lint.toml found. Run `sciwrite-lint init` first.")
|
|
156
|
+
return 1
|
|
157
|
+
|
|
158
|
+
content = toml_path.read_text()
|
|
159
|
+
|
|
160
|
+
# Case 1: [api] section exists with polite_email (commented or not)
|
|
161
|
+
if re.search(r"^#?\s*polite_email\s*=", content, re.MULTILINE):
|
|
162
|
+
content = re.sub(
|
|
163
|
+
r"^#?\s*polite_email\s*=.*$",
|
|
164
|
+
f'polite_email = "{email}"',
|
|
165
|
+
content,
|
|
166
|
+
count=1,
|
|
167
|
+
flags=re.MULTILINE,
|
|
168
|
+
)
|
|
169
|
+
# Case 2: [api] section exists but no polite_email line
|
|
170
|
+
elif re.search(r"^\[api\]", content, re.MULTILINE):
|
|
171
|
+
content = re.sub(
|
|
172
|
+
r"^(\[api\].*)$",
|
|
173
|
+
rf'\1\npolite_email = "{email}"',
|
|
174
|
+
content,
|
|
175
|
+
count=1,
|
|
176
|
+
flags=re.MULTILINE,
|
|
177
|
+
)
|
|
178
|
+
# Case 3: no [api] section at all
|
|
179
|
+
else:
|
|
180
|
+
content = content.rstrip() + f'\n\n[api]\npolite_email = "{email}"\n'
|
|
181
|
+
|
|
182
|
+
toml_path.write_text(content)
|
|
183
|
+
print(f"Set polite email to: {email}")
|
|
184
|
+
print(" → CrossRef polite pool (faster rate limits)")
|
|
185
|
+
print(" → Unpaywall (full-text access)")
|
|
186
|
+
print(" → Retraction Watch (retraction database)")
|
|
187
|
+
return 0
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _set_key(args: argparse.Namespace) -> int:
|
|
191
|
+
"""Save an API key to ~/.sciwrite-lint/<service>."""
|
|
192
|
+
service = args.service
|
|
193
|
+
if service not in _API_KEY_SERVICES:
|
|
194
|
+
names = ", ".join(_API_KEY_SERVICES)
|
|
195
|
+
logger.error("Unknown service '{}'. Available: {}", service, names)
|
|
196
|
+
return 1
|
|
197
|
+
|
|
198
|
+
key = args.key.strip()
|
|
199
|
+
if not key:
|
|
200
|
+
logger.error("API key cannot be empty")
|
|
201
|
+
return 1
|
|
202
|
+
|
|
203
|
+
info = _API_KEY_SERVICES[service]
|
|
204
|
+
_KEY_DIR.mkdir(exist_ok=True)
|
|
205
|
+
key_path = _KEY_DIR / info["file"]
|
|
206
|
+
key_path.write_text(key + "\n")
|
|
207
|
+
key_path.chmod(0o600)
|
|
208
|
+
|
|
209
|
+
print(f"Saved {service} API key to {key_path}")
|
|
210
|
+
print(f" → {info['description']}")
|
|
211
|
+
return 0
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _remove_key(args: argparse.Namespace) -> int:
|
|
215
|
+
"""Remove a stored API key."""
|
|
216
|
+
service = args.service
|
|
217
|
+
if service not in _API_KEY_SERVICES:
|
|
218
|
+
names = ", ".join(_API_KEY_SERVICES)
|
|
219
|
+
logger.error("Unknown service '{}'. Available: {}", service, names)
|
|
220
|
+
return 1
|
|
221
|
+
|
|
222
|
+
info = _API_KEY_SERVICES[service]
|
|
223
|
+
key_path = _KEY_DIR / info["file"]
|
|
224
|
+
if key_path.exists():
|
|
225
|
+
key_path.unlink()
|
|
226
|
+
print(f"Removed {service} API key ({key_path})")
|
|
227
|
+
else:
|
|
228
|
+
print(f"No {service} API key found (nothing to remove)")
|
|
229
|
+
return 0
|