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,470 @@
1
+ """CLI handlers for 'contributions' command — SciLint Score (integrity × contribution)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import asyncio
7
+ import json
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ from loguru import logger
12
+
13
+ from sciwrite_lint.config import LintConfig, PaperConfig
14
+ from sciwrite_lint.models import Finding
15
+
16
+
17
+ def extract_claims_from_context(
18
+ ctx: Any, # ManuscriptContext
19
+ file_path: Path,
20
+ ) -> list[dict[str, Any]]:
21
+ """Extract claim dicts from a ManuscriptContext (PDF or .tex)."""
22
+ if ctx.source_type == "latex":
23
+ from sciwrite_lint.eval_claims import extract_claim_contexts
24
+
25
+ return [
26
+ {"key": cc.key, "context": cc.context, "line": cc.line}
27
+ for cc in extract_claim_contexts(file_path)
28
+ ]
29
+ # PDF: convert InlineCitation objects to claim dicts
30
+ return [
31
+ {
32
+ "key": ic.key,
33
+ "context": ic.context,
34
+ "line": ic.line or 0,
35
+ "source_section": ic.section,
36
+ }
37
+ for ic in ctx.inline_citations
38
+ if ic.context
39
+ ]
40
+
41
+
42
+ async def score_standalone_async(
43
+ file_path: Path,
44
+ config: LintConfig,
45
+ *,
46
+ contribution: bool = True,
47
+ model: str = "",
48
+ ) -> tuple[Any, list[Finding], dict[str, float] | None, dict[str, str] | None]:
49
+ """Async core: parse, check, extract claims, compute contribution axes.
50
+
51
+ Designed to be called from asyncio.run() (single paper) or from a batch
52
+ loop that scores multiple papers within one event loop.
53
+ """
54
+ from sciwrite_lint.manuscript_store import ManuscriptContext
55
+ from sciwrite_lint.pipeline import (
56
+ build_pdf_context,
57
+ run_llm_checks_batched,
58
+ run_text_checks,
59
+ )
60
+
61
+ suffix = file_path.suffix.lower()
62
+
63
+ # 1. Parse manuscript and run text checks
64
+ if suffix == ".pdf":
65
+ await build_pdf_context(file_path, config)
66
+ ctx = config.manuscript_context
67
+ else:
68
+ ctx = ManuscriptContext.from_latex(file_path, config)
69
+ findings = run_text_checks(file_path, config)
70
+ findings.extend(await run_llm_checks_batched(file_path, config))
71
+
72
+ # 2. Extract claims
73
+ claim_dicts = extract_claims_from_context(ctx, file_path)
74
+
75
+ # 3. Contribution axes
76
+ c_scores: dict[str, float] | None = None
77
+ c_reasoning: dict[str, str] | None = None
78
+ if contribution:
79
+ ns = argparse.Namespace(model=model)
80
+ c_scores, c_reasoning = await compute_contribution_axes_from_ctx(
81
+ ctx, claim_dicts, config, ns
82
+ )
83
+ return ctx, findings, c_scores, c_reasoning
84
+
85
+
86
+ def build_scilint_result(
87
+ file_path: Path,
88
+ findings: list[Finding],
89
+ contribution_scores: dict[str, float] | None,
90
+ contribution_reasoning: dict[str, str] | None,
91
+ output_dir: Path | None = None,
92
+ ) -> Any:
93
+ """Compute SciLint Score from findings + contribution and optionally save."""
94
+ from sciwrite_lint.scoring.scilint_score import (
95
+ SciLintScoreResult,
96
+ compute_scilint_score,
97
+ )
98
+
99
+ paper_name = file_path.stem
100
+ findings_dicts = [f.model_dump() for f in findings]
101
+ result: SciLintScoreResult = compute_scilint_score(
102
+ paper_name,
103
+ claim_results=[],
104
+ findings=findings_dicts,
105
+ contribution_scores=contribution_scores,
106
+ contribution_reasoning=contribution_reasoning,
107
+ )
108
+ if output_dir:
109
+ output_dir.mkdir(parents=True, exist_ok=True)
110
+ out_path = output_dir / f"scilint_{paper_name}.json"
111
+ out_path.write_text(
112
+ json.dumps(result.to_dict(), indent=2, ensure_ascii=False) + "\n",
113
+ encoding="utf-8",
114
+ )
115
+ logger.info(f"SciLint Score saved to {out_path}")
116
+ return result
117
+
118
+
119
+ def score_standalone_file(
120
+ file_path: Path,
121
+ config: LintConfig,
122
+ *,
123
+ contribution: bool = True,
124
+ model: str = "",
125
+ output_dir: Path | None = None,
126
+ ) -> Any:
127
+ """Score a standalone PDF/tex file. Single-paper entry point (uses asyncio.run).
128
+
129
+ For batch scoring (multiple papers), use score_standalone_async directly
130
+ within a single event loop to avoid repeated asyncio.run() calls.
131
+ """
132
+ _ctx, findings, contribution_scores, contribution_reasoning = asyncio.run(
133
+ score_standalone_async(
134
+ file_path, config, contribution=contribution, model=model
135
+ )
136
+ )
137
+
138
+ return build_scilint_result(
139
+ file_path, findings, contribution_scores, contribution_reasoning, output_dir
140
+ )
141
+
142
+
143
+ def run_contributions(args: argparse.Namespace) -> int:
144
+ """Compute SciLint Score from claim verification results."""
145
+ from sciwrite_lint.__main__ import _load_config, _resolve_paper
146
+
147
+ config = _load_config(args)
148
+
149
+ # File path mode — standalone ranking without verify-claims
150
+ if hasattr(args, "file") and args.file:
151
+ return run_contributions_file(Path(args.file), config, args)
152
+
153
+ if not getattr(args, "paper", None):
154
+ logger.error("Either a file path or --paper is required.")
155
+ return 2
156
+
157
+ from sciwrite_lint.scoring.scilint_score import (
158
+ run_contributions as scilint_run_contributions,
159
+ )
160
+
161
+ pc = _resolve_paper(config, args.paper)
162
+ if not pc:
163
+ return 2
164
+
165
+ output_dir = Path(args.output_dir) if args.output_dir else config.results_dir
166
+
167
+ ws = config.paper_workspace(pc.name)
168
+ refs_dir = ws.root
169
+
170
+ # Load claims from workspace.db
171
+ from sciwrite_lint.references.workspace_db import get_db, load_claim_results
172
+
173
+ if not refs_dir.exists():
174
+ logger.error(
175
+ f"No workspace found for paper '{pc.name}'. "
176
+ f"Run 'sciwrite-lint check --paper {pc.name}' first."
177
+ )
178
+ return 1
179
+
180
+ with get_db(refs_dir) as conn:
181
+ claims = load_claim_results(conn)
182
+
183
+ if not claims:
184
+ logger.error(
185
+ f"No claim results for paper '{pc.name}'. "
186
+ f"Run 'sciwrite-lint check --paper {pc.name}' first."
187
+ )
188
+ return 1
189
+
190
+ findings_path = Path(args.findings) if getattr(args, "findings", None) else None
191
+
192
+ # Contribution axes — always computed for score command
193
+ contribution_scores, contribution_reasoning = asyncio.run(
194
+ compute_contribution_axes(pc, claims, config, args)
195
+ )
196
+
197
+ result = scilint_run_contributions(
198
+ pc.name,
199
+ claims,
200
+ findings_path=findings_path,
201
+ references_dir=refs_dir,
202
+ contribution_scores=contribution_scores,
203
+ contribution_reasoning=contribution_reasoning,
204
+ output_dir=output_dir,
205
+ )
206
+
207
+ if getattr(args, "format", "terminal") == "json":
208
+ print(json.dumps(result.to_dict(), indent=2))
209
+ return 0
210
+
211
+ ir = result.integrity_result
212
+ print(f"\n SciLint Score for '{pc.name}':")
213
+ print(f" SciLint Score: {result.scilint_score:.4f}")
214
+ print(f" Internal Consistency: {ir.internal_consistency:.4f}")
215
+ print(f" Referencing Quality: {ir.referencing_quality:.4f}")
216
+ if ir.reference_reliability:
217
+ print(
218
+ f" Reference reliability: {len(ir.reference_reliability)} refs scored"
219
+ )
220
+ print(f" Contribution: {result.contribution.overall:.4f}")
221
+ c = result.contribution
222
+ if c.empirical_content > 0:
223
+ print(f" Empirical: {c.empirical_content:.4f}")
224
+ print(f" Progressive: {c.progressiveness:.4f}")
225
+ print(f" Unification: {c.unification:.4f}")
226
+ print(f" Problem-solv: {c.problem_solving:.4f}")
227
+ print(f" Test severity: {c.test_severity:.4f}")
228
+ print(f" Claims verified: {result.total_claims}")
229
+ print(f" Refs scored: {result.total_refs_scored}")
230
+
231
+ if result.ref_scores:
232
+ print("\n Per-reference breakdown:")
233
+ for rs in sorted(result.ref_scores, key=lambda r: r.weighted_score):
234
+ print(
235
+ f" {rs.key:30s} {rs.verdict:20s} "
236
+ f"w={rs.weight:.1f} score={rs.verification_score:.2f} "
237
+ f"({rs.purpose})"
238
+ )
239
+
240
+ # Summary counts — quick overview of actionable issues
241
+ _print_issue_summary(claims)
242
+
243
+ return 0
244
+
245
+
246
+ def _print_issue_summary(
247
+ claims: list[dict[str, Any]],
248
+ ) -> None:
249
+ """Print a one-line issue summary below the score breakdown."""
250
+ from sciwrite_lint.checks.cite_purpose import PURPOSE_WEIGHTS, UNSPECIFIED_THRESHOLD
251
+
252
+ active = [r for r in claims if not r.get("dismissed")]
253
+
254
+ not_supported = sum(1 for r in active if r.get("verdict") == "NOT_SUPPORTED")
255
+ partial = sum(1 for r in active if r.get("verdict") == "PARTIALLY_SUPPORTS")
256
+ weak_cite = sum(
257
+ 1
258
+ for r in active
259
+ if PURPOSE_WEIGHTS.get(
260
+ r.get("cite_purpose") or r.get("citation_purpose", ""), 1.0
261
+ )
262
+ <= UNSPECIFIED_THRESHOLD
263
+ )
264
+
265
+ parts: list[str] = []
266
+ if not_supported:
267
+ parts.append(f"{not_supported} unsupported")
268
+ if partial:
269
+ parts.append(f"{partial} partial")
270
+ if weak_cite:
271
+ parts.append(f"{weak_cite} weak citation{'s' if weak_cite != 1 else ''}")
272
+
273
+ if parts:
274
+ print(f"\n Issues: {' | '.join(parts)}")
275
+
276
+
277
+ def run_contributions_file(
278
+ file_path: Path, config: LintConfig, args: argparse.Namespace
279
+ ) -> int:
280
+ """Score a standalone file (PDF or .tex) without prior verify-claims."""
281
+ if not file_path.exists():
282
+ logger.error(f"Error: {file_path} not found")
283
+ return 2
284
+
285
+ suffix = file_path.suffix.lower()
286
+ if suffix not in (".pdf", ".tex"):
287
+ logger.error(f"Unsupported file type: {suffix}. Use .pdf or .tex")
288
+ return 2
289
+
290
+ output_dir = Path(args.output_dir) if args.output_dir else file_path.parent
291
+ result = score_standalone_file(
292
+ file_path,
293
+ config,
294
+ contribution=True,
295
+ model=getattr(args, "model", ""),
296
+ output_dir=output_dir,
297
+ )
298
+
299
+ if getattr(args, "format", "terminal") == "json":
300
+ print(json.dumps(result.to_dict(), indent=2))
301
+ return 0
302
+
303
+ ir = result.integrity_result
304
+ print(f"\n SciLint Score for '{file_path.stem}' (standalone file):")
305
+ print(f" SciLint Score: {result.scilint_score:.4f}")
306
+ print(f" Internal Consistency: {ir.internal_consistency:.4f}")
307
+ print(f" Internal Consistency: {ir.internal_consistency:.4f}")
308
+ print(f" Contribution: {result.contribution.overall:.4f}")
309
+ if result.contribution.empirical_content > 0:
310
+ c = result.contribution
311
+ print(f" Empirical: {c.empirical_content:.4f}")
312
+ print(f" Progressive: {c.progressiveness:.4f}")
313
+ print(f" Unification: {c.unification:.4f}")
314
+ print(f" Problem-solv: {c.problem_solving:.4f}")
315
+ print(f" Test severity: {c.test_severity:.4f}")
316
+
317
+ return 0
318
+
319
+
320
+ async def compute_contribution_axes_from_ctx(
321
+ ctx: Any, # ManuscriptContext
322
+ claim_dicts: list[dict[str, Any]],
323
+ config: LintConfig,
324
+ args: argparse.Namespace,
325
+ ) -> tuple[dict[str, float], dict[str, str]]:
326
+ """Contribution axes from a ManuscriptContext (no PaperConfig needed)."""
327
+ from sciwrite_lint.claims import classify_claims_batch
328
+ from sciwrite_lint.scoring.contribution import compute_all_contribution_axes
329
+
330
+ model = getattr(args, "model", "") or ""
331
+
332
+ # Enrich claims with paper context
333
+ abstract = ctx.abstract or ""
334
+ methods_sections = ctx.get_section_by_title(
335
+ "method",
336
+ "approach",
337
+ "experiment",
338
+ "setup",
339
+ "implementation",
340
+ )
341
+ methods_text = "\n\n".join(s.clean_text for s in methods_sections)
342
+ results_sections = ctx.get_section_by_title(
343
+ "result",
344
+ "evaluation",
345
+ "finding",
346
+ "ablation",
347
+ "analysis",
348
+ )
349
+ results_text = "\n\n".join(s.clean_text for s in results_sections)
350
+
351
+ paper_context = f"ABSTRACT: {abstract[:1000]}"
352
+ if methods_text:
353
+ paper_context += f"\n\nMETHODS SUMMARY: {methods_text[:1500]}"
354
+ if results_text:
355
+ paper_context += f"\n\nRESULTS SUMMARY: {results_text[:1500]}"
356
+
357
+ enriched_claims = []
358
+ for c in claim_dicts:
359
+ enriched = dict(c)
360
+ original_ctx = enriched.get("context", "")
361
+ enriched["context"] = f"{original_ctx}\n\n{paper_context}"
362
+ enriched_claims.append(enriched)
363
+
364
+ logger.info("Classifying claims for contribution scoring...")
365
+ classifications = await classify_claims_batch(enriched_claims, config, model)
366
+
367
+ # Laudan sections
368
+ intro_sections = ctx.get_section_by_title(
369
+ "introduction",
370
+ "intro",
371
+ "overview",
372
+ "background",
373
+ )
374
+ limit_sections = ctx.get_section_by_title(
375
+ "limitation",
376
+ "discussion",
377
+ "conclusion",
378
+ "threat",
379
+ "future work",
380
+ "shortcoming",
381
+ )
382
+ intro_text = "\n\n".join(s.clean_text for s in intro_sections)
383
+ limitations_text = "\n\n".join(s.clean_text for s in limit_sections)
384
+
385
+ if len(intro_text) < 200 and abstract:
386
+ intro_text = f"{abstract}\n\n{intro_text}"
387
+
388
+ return await compute_all_contribution_axes(
389
+ claim_dicts, classifications, intro_text, limitations_text, config, model
390
+ )
391
+
392
+
393
+ async def compute_contribution_axes(
394
+ pc: PaperConfig,
395
+ claims: list[dict[str, Any]],
396
+ config: LintConfig,
397
+ args: argparse.Namespace,
398
+ ) -> tuple[dict[str, float], dict[str, str]]:
399
+ """Run claim taxonomy + contribution axes for a paper."""
400
+ from sciwrite_lint.claims import classify_claims_batch
401
+ from sciwrite_lint.manuscript_store import ManuscriptContext
402
+ from sciwrite_lint.scoring.contribution import compute_all_contribution_axes
403
+
404
+ model = getattr(args, "model", "") or ""
405
+
406
+ # Build manuscript context for section selection
407
+ ctx = ManuscriptContext.from_latex(pc.file_path, config)
408
+
409
+ # Enrich claim context with abstract + methods for better taxonomy
410
+ abstract = ctx.abstract or ""
411
+ methods_sections = ctx.get_section_by_title(
412
+ "method",
413
+ "approach",
414
+ "experiment",
415
+ "setup",
416
+ "implementation",
417
+ )
418
+ methods_text = "\n\n".join(s.clean_text for s in methods_sections)
419
+ results_sections = ctx.get_section_by_title(
420
+ "result",
421
+ "evaluation",
422
+ "finding",
423
+ "ablation",
424
+ "analysis",
425
+ )
426
+ results_text = "\n\n".join(s.clean_text for s in results_sections)
427
+
428
+ # Add paper context to each claim for better classification
429
+ paper_context = f"ABSTRACT: {abstract[:1000]}"
430
+ if methods_text:
431
+ paper_context += f"\n\nMETHODS SUMMARY: {methods_text[:1500]}"
432
+ if results_text:
433
+ paper_context += f"\n\nRESULTS SUMMARY: {results_text[:1500]}"
434
+
435
+ enriched_claims = []
436
+ for c in claims:
437
+ enriched = dict(c)
438
+ original_ctx = enriched.get("context", "")
439
+ enriched["context"] = f"{original_ctx}\n\n{paper_context}"
440
+ enriched_claims.append(enriched)
441
+
442
+ # Classify claims with enriched context
443
+ logger.info("Classifying claims for contribution scoring...")
444
+ classifications = await classify_claims_batch(enriched_claims, config, model)
445
+
446
+ # Extract sections for Laudan (intro + limitations)
447
+ intro_sections = ctx.get_section_by_title(
448
+ "introduction",
449
+ "intro",
450
+ "overview",
451
+ "background",
452
+ )
453
+ limit_sections = ctx.get_section_by_title(
454
+ "limitation",
455
+ "discussion",
456
+ "conclusion",
457
+ "threat",
458
+ "future work",
459
+ "shortcoming",
460
+ )
461
+ intro_text = "\n\n".join(s.clean_text for s in intro_sections)
462
+ limitations_text = "\n\n".join(s.clean_text for s in limit_sections)
463
+
464
+ # Prepend abstract to intro if intro is thin
465
+ if len(intro_text) < 200 and abstract:
466
+ intro_text = f"{abstract}\n\n{intro_text}"
467
+
468
+ return await compute_all_contribution_axes(
469
+ claims, classifications, intro_text, limitations_text, config, model
470
+ )