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,437 @@
1
+ """CLI handlers for 'verify', 'verify-claims', 'status', and 'ref-health' commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import asyncio
7
+ from pathlib import Path
8
+
9
+ from loguru import logger
10
+
11
+ from sciwrite_lint.models import CheckResult, Finding
12
+
13
+
14
+ _TIER_MARKERS = {
15
+ "T1": ("\u2713", "has full text"),
16
+ "T2": ("~", "confirmed"),
17
+ "T3": ("\u2717", "unverified"),
18
+ }
19
+
20
+
21
+ def run_verify(args: argparse.Namespace) -> int:
22
+ """Verify citations against APIs (CrossRef, OpenAlex, Semantic Scholar, Open Library, Library of Congress)."""
23
+ from sciwrite_lint.api import CitationAPI, verify_citations
24
+ from sciwrite_lint.references.citations import get_last_citation_source
25
+ from sciwrite_lint.pipeline import extract_citations_for_paper
26
+
27
+ from sciwrite_lint.__main__ import _load_config, _resolve_paper
28
+ from sciwrite_lint.cli.config import check_api_config
29
+ from sciwrite_lint.cli.fetch import fetch_for_citations
30
+
31
+ config = _load_config(args)
32
+ check_api_config(config, needs_email=False)
33
+
34
+ pc = _resolve_paper(config, args.paper)
35
+ if not pc:
36
+ return 2
37
+
38
+ if not pc.file_path.exists():
39
+ logger.error(f"Error: {pc.file_path} not found")
40
+ return 1
41
+
42
+ ws = config.paper_workspace(pc.name)
43
+ ws.ensure_dirs()
44
+ refs_dir = ws.root
45
+
46
+ logger.info(f"Verifying {pc.name}...")
47
+ citations = asyncio.run(
48
+ extract_citations_for_paper(pc.file_path, config, bib_path=pc.bib)
49
+ )
50
+ src = get_last_citation_source()
51
+ if src:
52
+ logger.info(f"Citation source: {src}")
53
+
54
+ if args.key:
55
+ citations = [c for c in citations if c.key == args.key]
56
+ if not citations:
57
+ logger.error(f"Key '{args.key}' not found in {pc.name}")
58
+ return 1
59
+
60
+ async def _verify():
61
+ async with CitationAPI(config=config) as api:
62
+ await verify_citations(
63
+ citations,
64
+ api,
65
+ config=config,
66
+ references_dir=refs_dir,
67
+ )
68
+
69
+ asyncio.run(_verify())
70
+
71
+ from sciwrite_lint.pipeline import _classify_verify_issue
72
+
73
+ result = CheckResult(checker="verify", paper=pc.name)
74
+ for c in citations:
75
+ for issue in c.issues:
76
+ level, rule_id = _classify_verify_issue(issue)
77
+ result.findings.append(
78
+ Finding(
79
+ level=level,
80
+ rule_id=rule_id,
81
+ message=f"{c.key}: {issue}",
82
+ file=pc.file_path.name,
83
+ context=f"Source: {c.api_source}" if c.api_source else "",
84
+ )
85
+ )
86
+
87
+ # Reference-unreliable: fast path (metadata only)
88
+ from sciwrite_lint.checks.reference_unreliable import (
89
+ metadata_to_unreliable_findings,
90
+ )
91
+ from sciwrite_lint.references.metadata import load_all_metadata
92
+
93
+ all_meta = load_all_metadata(refs_dir)
94
+ if all_meta:
95
+ result.findings.extend(metadata_to_unreliable_findings(all_meta, pc.file_path))
96
+
97
+ from sciwrite_lint.report import format_findings
98
+
99
+ label = f"{pc.name} ({pc.file_path.name})"
100
+ format_findings(result.findings, label, fmt=args.format, color=config.color)
101
+
102
+ if args.fetch:
103
+ from sciwrite_lint.cli.config import check_api_config
104
+
105
+ api_errors = check_api_config(config, needs_email=True)
106
+ if api_errors:
107
+ for e in api_errors:
108
+ logger.error(f" ✗ {e}")
109
+ return 2
110
+ fetch_for_citations(
111
+ citations,
112
+ config,
113
+ references_dir=refs_dir,
114
+ )
115
+
116
+ return 1 if result.errors else 0
117
+
118
+
119
+ def run_status(args: argparse.Namespace) -> int:
120
+ """Show citation verification status."""
121
+ from sciwrite_lint.references.citations import get_last_citation_source
122
+ from sciwrite_lint.references.metadata import load_all_metadata
123
+ from sciwrite_lint.pipeline import extract_citations_for_paper
124
+
125
+ from sciwrite_lint.__main__ import _load_config, _resolve_paper
126
+
127
+ config = _load_config(args)
128
+ pc = _resolve_paper(config, args.paper)
129
+ if not pc:
130
+ return 2
131
+
132
+ ws = config.paper_workspace(pc.name)
133
+ all_meta = load_all_metadata(ws.root)
134
+
135
+ if not all_meta:
136
+ print("No metadata found. Run 'sciwrite-lint verify' first.")
137
+ return 0
138
+
139
+ if not pc.file_path.exists():
140
+ logger.error(f"{pc.file_path} not found")
141
+ return 1
142
+
143
+ citations = asyncio.run(
144
+ extract_citations_for_paper(pc.file_path, config, bib_path=pc.bib)
145
+ )
146
+ src = get_last_citation_source()
147
+
148
+ print(f"\n{'=' * 70}")
149
+ print(f" {pc.name} \u2014 {len(citations)} citations")
150
+ if src:
151
+ print(f" Source: {src}")
152
+ print(f"{'=' * 70}")
153
+ print(
154
+ f" {'Key':<25} {'Tier':<5} {'Type':<5} {'API Match':<15} {'Local File':<30} {'Verified'}"
155
+ )
156
+ print(f" {'-' * 25} {'-' * 5} {'-' * 5} {'-' * 15} {'-' * 30} {'-' * 10}")
157
+
158
+ tier_counts = {"T1": 0, "T2": 0, "T3": 0, "NC": 0}
159
+ academic_count = 0
160
+ web_count = 0
161
+
162
+ for c in sorted(citations, key=lambda x: x.key):
163
+ meta = all_meta.get(c.key)
164
+ if meta:
165
+ tier = meta.access.get("tier", "NC")
166
+ api_match: str = meta.api_match or "?"
167
+ local = meta.access.get("local_file") or "\u2014"
168
+ verified = meta.verified_date or "\u2014"
169
+ is_web = api_match in ("web_verified", "web_dead")
170
+ else:
171
+ tier = "NC"
172
+ api_match = "not checked"
173
+ local = c.local_path.split("/")[-1] if c.local_path else "\u2014"
174
+ verified = "\u2014"
175
+ is_web = False
176
+
177
+ if is_web:
178
+ web_count += 1
179
+ else:
180
+ academic_count += 1
181
+
182
+ if args.tier and tier != args.tier:
183
+ continue
184
+
185
+ tier_counts[tier if tier in tier_counts else "NC"] += 1
186
+
187
+ marker, _ = _TIER_MARKERS.get(tier, ("\u00b7", ""))
188
+ rtype = "web" if is_web else "acad"
189
+ print(
190
+ f" {c.key:<25} {marker}{tier:<4} {rtype:<5} {api_match:<15} {local:<30} {verified}"
191
+ )
192
+
193
+ print()
194
+ total = sum(tier_counts.values())
195
+ t1, t2, t3, nc = (tier_counts.get(k, 0) for k in ("T1", "T2", "T3", "NC"))
196
+ print(
197
+ f" Summary: {t1} T1 (has full text), "
198
+ f"{t2} T2 (confirmed), "
199
+ f"{t3} T3 (unverified), "
200
+ f"{nc} not checked | {total} total"
201
+ )
202
+ print(f" Types: {academic_count} academic, {web_count} web resources")
203
+ print()
204
+ print(" T1 = verified + full text (can check claims)")
205
+ print(" T2 = verified, no full text (confirmed real)")
206
+ print(" T3 = not found in APIs or dead URL")
207
+
208
+ # Show acquisition reasons for T2 refs
209
+ t2_reasons: list[tuple[str, str]] = []
210
+ for c in sorted(citations, key=lambda x: x.key):
211
+ meta = all_meta.get(c.key)
212
+ if not meta:
213
+ continue
214
+ tier = meta.access.get("tier", "NC")
215
+ if tier == "T2":
216
+ reason = meta.access.get("acquisition_reason", "")
217
+ if reason:
218
+ t2_reasons.append((c.key, reason))
219
+ if t2_reasons:
220
+ print()
221
+ print(" T2 acquisition details:")
222
+ for key, reason in t2_reasons:
223
+ print(f" {key}: {reason}")
224
+
225
+ return 0
226
+
227
+
228
+ def run_ref_health(args: argparse.Namespace) -> int:
229
+ """Fast deterministic reference health check (no API calls).
230
+
231
+ Parses the .tex and .bib, reports:
232
+ - cite/bib mismatches (cited but missing from bib, in bib but never cited)
233
+ - ID coverage (which refs have DOI, ISBN, arXiv, PMID, etc.)
234
+ - entry type breakdown
235
+ """
236
+ from sciwrite_lint.references.citations import (
237
+ extract_bibitems,
238
+ find_orphans,
239
+ is_web_resource,
240
+ )
241
+
242
+ # Resolve input: positional file or --paper
243
+ tex_file = getattr(args, "file", None)
244
+ paper_name = getattr(args, "paper", None)
245
+ if not tex_file and not paper_name:
246
+ logger.error("Provide a .tex file or --paper NAME")
247
+ return 2
248
+
249
+ if tex_file:
250
+ tex_path = Path(tex_file).resolve()
251
+ bib_path = None
252
+ name = tex_path.stem
253
+ else:
254
+ from sciwrite_lint.__main__ import _load_config, _resolve_paper
255
+
256
+ config = _load_config(args)
257
+ assert paper_name is not None # guarded above
258
+ pc = _resolve_paper(config, paper_name)
259
+ if not pc:
260
+ return 2
261
+ tex_path = pc.file_path
262
+ bib_path = pc.bib
263
+ name = pc.name
264
+
265
+ if not tex_path.exists():
266
+ logger.error(f"{tex_path} not found")
267
+ return 1
268
+
269
+ if tex_path.suffix != ".tex":
270
+ logger.error(f"ref-health requires a .tex file, got {tex_path.suffix}")
271
+ return 1
272
+
273
+ citations = extract_bibitems(tex_path, bib_path=bib_path)
274
+
275
+ if not citations:
276
+ print(f"\n {name}: no references found in {tex_path.name}")
277
+ return 0
278
+
279
+ cited_no_bib, bib_no_cite = find_orphans(citations, tex_path)
280
+
281
+ # --- ID coverage ---
282
+ id_fields = ["doi", "arxiv_id", "pmid", "pmc_id", "isbn", "lccn", "url"]
283
+ has_any_id: list[str] = []
284
+ no_id: list[str] = []
285
+ id_counts: dict[str, int] = {f: 0 for f in id_fields}
286
+
287
+ for c in citations:
288
+ ids_present = [f for f in id_fields if getattr(c, f, "")]
289
+ if ids_present:
290
+ has_any_id.append(c.key)
291
+ for f in ids_present:
292
+ id_counts[f] += 1
293
+ else:
294
+ no_id.append(c.key)
295
+
296
+ # --- Entry type breakdown ---
297
+ type_counts: dict[str, int] = {}
298
+ web_count = 0
299
+ for c in citations:
300
+ et = c.entry_type or "unknown"
301
+ type_counts[et] = type_counts.get(et, 0) + 1
302
+ if is_web_resource(c):
303
+ web_count += 1
304
+
305
+ # --- Local PDFs ---
306
+ local_matched: dict[str, Path] = {}
307
+ local_unmatched: list[Path] = []
308
+ try:
309
+ if tex_file:
310
+ from sciwrite_lint.config import load_config as _load_cfg
311
+
312
+ _cfg = _load_cfg()
313
+ else:
314
+ _cfg = config # type: ignore[possibly-undefined] # noqa: F841
315
+ local_pdfs_dir = _cfg.local_pdfs_dir
316
+ if local_pdfs_dir.is_dir() and any(local_pdfs_dir.glob("*.pdf")):
317
+ from sciwrite_lint.local_pdfs import match_local_pdfs
318
+
319
+ titles = {c.key: c.title for c in citations if c.title}
320
+ local_matched, local_unmatched = match_local_pdfs(local_pdfs_dir, titles)
321
+ except Exception as e:
322
+ logger.debug(
323
+ f"ref-health: local PDF matching skipped ({type(e).__name__}: {e})"
324
+ )
325
+
326
+ # --- Output ---
327
+ total = len(citations)
328
+ print(f"\n{'=' * 60}")
329
+ print(f" {name} — reference health ({total} references)")
330
+ print(f"{'=' * 60}")
331
+
332
+ # Cite/bib mismatches
333
+ if cited_no_bib or bib_no_cite:
334
+ print("\n MISMATCHES")
335
+ if cited_no_bib:
336
+ print(f" Cited but missing from bib ({len(cited_no_bib)}):")
337
+ for k in sorted(cited_no_bib):
338
+ print(f" - {k}")
339
+ if bib_no_cite:
340
+ print(f" In bib but never cited ({len(bib_no_cite)}):")
341
+ for k in sorted(bib_no_cite):
342
+ print(f" - {k}")
343
+ else:
344
+ print("\n Cite/bib: all keys match")
345
+
346
+ # ID coverage
347
+ print("\n ID COVERAGE")
348
+ print(f" With any ID: {len(has_any_id)}/{total}")
349
+ for f in id_fields:
350
+ if id_counts[f]:
351
+ print(f" {f:<10} {id_counts[f]}")
352
+ print(f" No ID: {len(no_id)}/{total}")
353
+ if no_id:
354
+ for k in sorted(no_id):
355
+ print(f" - {k}")
356
+
357
+ # Entry types
358
+ print("\n ENTRY TYPES")
359
+ for et, count in sorted(type_counts.items(), key=lambda x: -x[1]):
360
+ print(f" {et:<15} {count}")
361
+ if web_count:
362
+ print(f" ({web_count} web resources)")
363
+
364
+ # Local PDFs
365
+ if local_matched or local_unmatched:
366
+ print("\n LOCAL PDFs")
367
+ if local_matched:
368
+ print(f" Matched: {len(local_matched)}")
369
+ for key, pdf_path in sorted(local_matched.items()):
370
+ print(f" {pdf_path.name} → {key}")
371
+ if local_unmatched:
372
+ print(f" Unmatched ({len(local_unmatched)}):")
373
+ for pdf_path in sorted(local_unmatched):
374
+ print(f" - {pdf_path.name} (no matching reference title)")
375
+
376
+ print()
377
+ return 0
378
+
379
+
380
+ def run_verify_claims(args: argparse.Namespace) -> int:
381
+ """Verify claims against cited sources using local LLM or Claude Opus."""
382
+ from sciwrite_lint.__main__ import _load_config, _resolve_paper
383
+
384
+ config = _load_config(args)
385
+ pc = _resolve_paper(config, args.paper)
386
+ if not pc:
387
+ return 2
388
+
389
+ ws = config.paper_workspace(pc.name)
390
+ ws.ensure_dirs()
391
+ refs_dir = ws.root
392
+ from sciwrite_lint.eval_claims import run_claim_verification
393
+
394
+ results = asyncio.run(
395
+ run_claim_verification(
396
+ pc.name,
397
+ pc.file_path,
398
+ refs_dir,
399
+ config=config,
400
+ bib_path=pc.bib,
401
+ backend=args.backend,
402
+ model=args.model,
403
+ key_filter=args.key,
404
+ limit=args.limit,
405
+ rerun=args.fresh,
406
+ )
407
+ )
408
+
409
+ if results is None:
410
+ return 1
411
+
412
+ # Convert depth-1 results to findings
413
+ from sciwrite_lint.pipeline import _claims_to_findings
414
+ from sciwrite_lint.report import format_findings
415
+
416
+ findings = _claims_to_findings(results, pc.file_path)
417
+
418
+ # Reference-unreliable: deep path (claims + metadata)
419
+ from sciwrite_lint.checks.reference_unreliable import (
420
+ claims_to_unreliable_findings,
421
+ )
422
+ from sciwrite_lint.references.metadata import load_all_metadata
423
+
424
+ all_meta = load_all_metadata(refs_dir)
425
+ findings.extend(
426
+ claims_to_unreliable_findings(
427
+ results,
428
+ pc.file_path,
429
+ metadata_map=all_meta or None,
430
+ )
431
+ )
432
+
433
+ # Print all findings in one pass
434
+ label = f"{pc.name} ({pc.file_path.name})"
435
+ format_findings(findings, label, fmt=args.format, color=config.color)
436
+
437
+ return 1 if any(f.level == "error" for f in findings) else 0