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,140 @@
1
+ """Cache for vision model figure descriptions.
2
+
3
+ Stores descriptions in workspace.db (``vision_cache`` table), keyed by
4
+ image filename stem + SHA-256 content hash. Only re-runs VL inference
5
+ when images change.
6
+
7
+ All DB access goes through ``get_db()`` — the universal pattern.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import hashlib
13
+ from pathlib import Path
14
+
15
+ from loguru import logger
16
+
17
+ from sciwrite_lint.vision.image_extraction import ExtractedImage
18
+
19
+
20
+ def _hash_image_and_caption(path: Path, caption: str = "") -> str:
21
+ """SHA-256 hash of image bytes + caption text (first 32 hex chars).
22
+
23
+ The VL prompt includes the caption, so changing the caption should
24
+ invalidate the cache (the model may focus on different details).
25
+ """
26
+ h = hashlib.sha256(path.read_bytes())
27
+ h.update(caption.encode("utf-8"))
28
+ return h.hexdigest()[:32]
29
+
30
+
31
+ def split_cached_and_new(
32
+ images: list[ExtractedImage],
33
+ references_dir: Path,
34
+ ) -> list[ExtractedImage]:
35
+ """Return images that need VL inference (not in cache or hash changed).
36
+
37
+ Uses ``get_db()`` to check the vision_cache table.
38
+ """
39
+ from sciwrite_lint.references.workspace_db import get_db, load_vision_entry
40
+
41
+ new_images: list[ExtractedImage] = []
42
+ with get_db(references_dir) as conn:
43
+ for img in images:
44
+ key = img.path.stem
45
+ current_hash = _hash_image_and_caption(img.path, img.caption)
46
+ entry = load_vision_entry(conn, key, expected_hash=current_hash)
47
+ if entry is not None:
48
+ continue # Valid cache hit
49
+ new_images.append(img)
50
+
51
+ return new_images
52
+
53
+
54
+ def update_cache(
55
+ images: list[ExtractedImage],
56
+ descriptions: list[str],
57
+ references_dir: Path,
58
+ ) -> None:
59
+ """Save new descriptions to workspace.db."""
60
+ from sciwrite_lint.references.workspace_db import get_db, save_vision_entry
61
+
62
+ with get_db(references_dir) as conn:
63
+ for img, desc in zip(images, descriptions):
64
+ save_vision_entry(
65
+ conn,
66
+ img.path.stem,
67
+ image_hash=_hash_image_and_caption(img.path, img.caption),
68
+ label=img.label,
69
+ caption=img.caption,
70
+ description=desc,
71
+ )
72
+
73
+
74
+ def format_descriptions_from_db(
75
+ images: list[ExtractedImage],
76
+ references_dir: Path,
77
+ ) -> str:
78
+ """Format figure descriptions for the LLM system prompt.
79
+
80
+ VL descriptions come from workspace.db. Captions and labels come from
81
+ the *current* ``images`` list (freshly parsed from .tex), not from the
82
+ DB — so the formatted output always reflects the latest source even if
83
+ VL inference was cached from an earlier caption.
84
+ """
85
+ from sciwrite_lint.references.workspace_db import get_db, load_all_vision_entries
86
+
87
+ with get_db(references_dir) as conn:
88
+ all_entries = load_all_vision_entries(conn)
89
+
90
+ parts: list[str] = []
91
+ for img in images:
92
+ key = img.path.stem
93
+ entry = all_entries.get(key)
94
+ if entry is None:
95
+ continue
96
+
97
+ # Use current caption/label from extraction, not stale DB values
98
+ header = "Figure"
99
+ if img.label:
100
+ header += f" ({img.label})"
101
+ if img.caption:
102
+ header += f' — Caption: "{img.caption}"'
103
+ parts.append(f"{header}\nVisual content: {entry['description']}")
104
+
105
+ return "\n\n".join(parts)
106
+
107
+
108
+ def load_all_descriptions(references_dir: Path) -> str:
109
+ """Load all cached figure descriptions as formatted text.
110
+
111
+ Used by full-paper consistency checks to inject into the system prompt
112
+ without needing the original ExtractedImage list.
113
+ """
114
+ from sciwrite_lint.references.workspace_db import get_db, load_all_vision_entries
115
+
116
+ with get_db(references_dir) as conn:
117
+ all_entries = load_all_vision_entries(conn)
118
+
119
+ if not all_entries:
120
+ return ""
121
+
122
+ parts: list[str] = []
123
+ for key, entry in all_entries.items():
124
+ header = "Figure"
125
+ if entry["label"]:
126
+ header += f" ({entry['label']})"
127
+ if entry["caption"]:
128
+ header += f' — Caption: "{entry["caption"]}"'
129
+ parts.append(f"{header}\nVisual content: {entry['description']}")
130
+
131
+ logger.info("Loaded {} cached figure descriptions", len(parts))
132
+ return "\n\n".join(parts)
133
+
134
+
135
+ def clear_cache(references_dir: Path) -> None:
136
+ """Delete all vision cache entries (for --fresh)."""
137
+ from sciwrite_lint.references.workspace_db import clear_vision_cache, get_db
138
+
139
+ with get_db(references_dir) as conn:
140
+ clear_vision_cache(conn)
@@ -0,0 +1,311 @@
1
+ """Describe figure images using Qwen3-VL-2B-Instruct.
2
+
3
+ Loads the vision-language model, runs batched inference on extracted images,
4
+ and returns structured text descriptions suitable for injection into the
5
+ full-paper consistency check system prompt.
6
+
7
+ On WSL2, CUDA memory overcommit lets the VL model (~4 GB float16) share
8
+ VRAM with vLLM transparently — idle KV-cache pages swap to system RAM.
9
+ No container stop needed; same pattern as the embedding model.
10
+
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from pathlib import Path
16
+ from typing import Any
17
+
18
+ import torch
19
+ from loguru import logger
20
+
21
+ from sciwrite_lint.vision.image_extraction import ExtractedImage
22
+
23
+ _MODEL_NAME = "Qwen/Qwen3-VL-2B-Instruct"
24
+ _MAX_NEW_TOKENS = 512 # Structured description, not free-form
25
+ _MAX_IMAGE_DIM = 1024 # Resize longest side (benchmarked: no accuracy gain above this)
26
+ _BATCH_SIZE = 16 # Benchmarked sweet spot: 2.8s/img, 6.8 GB VRAM
27
+
28
+
29
+ def _resolve_vision_device(device_cfg: str) -> str:
30
+ """Resolve device string: "auto" picks CUDA on WSL2, else CPU.
31
+
32
+ On WSL2, CUDA memory overcommit lets the VL model (~4 GB float16) share
33
+ VRAM with vLLM — idle KV-cache pages swap to system RAM while VL
34
+ inference runs. On native Linux, cudaMalloc is physical with no
35
+ overcommit, so GPU vision is not safe in auto mode (use "cuda" to force).
36
+ """
37
+ from sciwrite_lint.config import is_wsl2
38
+
39
+ if device_cfg == "auto":
40
+ if not torch.cuda.is_available():
41
+ return "cpu"
42
+ if is_wsl2():
43
+ return "cuda"
44
+ return "cpu"
45
+ return device_cfg
46
+
47
+
48
+ _DESCRIBE_ITEMS = """\
49
+ 1. Figure type (bar chart, line plot, scatter plot, table, diagram, photo, etc.)
50
+ 2. All axis labels and units
51
+ 3. All data series, categories, or groups
52
+ 4. Key numeric values you can read (peaks, intersections, notable data points)
53
+ 5. All text labels, legends, and annotations within the figure
54
+ 6. Overall trend or pattern shown
55
+ 7. Readability issues: overlapping labels, obscured text, truncated axes, \
56
+ illegible values. For anything you cannot read clearly, say so explicitly \
57
+ (e.g., "y-axis label partially obscured, cannot determine units")\
58
+ """
59
+
60
+ _DESCRIBE_PROMPT_BASE = f"""\
61
+ You are analyzing a figure from a scientific paper. Describe precisely:
62
+ {_DESCRIBE_ITEMS}
63
+
64
+ Be factual and precise. Report only what is visible in the image. \
65
+ Never guess values you cannot read — say they are unreadable.\
66
+ """
67
+
68
+ _DESCRIBE_PROMPT_WITH_CAPTION = (
69
+ """\
70
+ You are analyzing a figure from a scientific paper.
71
+
72
+ The figure's caption reads: "{caption}"
73
+
74
+ Describe precisely what the figure ACTUALLY shows:
75
+ """
76
+ + _DESCRIBE_ITEMS
77
+ + """
78
+
79
+ Be factual and precise. Report only what is visible in the image. \
80
+ Never guess values you cannot read — say they are unreadable. \
81
+ If the caption misrepresents the content, describe what you see, not what \
82
+ the caption claims.\
83
+ """
84
+ )
85
+
86
+
87
+ def _build_prompt(caption: str) -> str:
88
+ """Build the VL prompt, including the caption if available."""
89
+ if caption:
90
+ return _DESCRIBE_PROMPT_WITH_CAPTION.format(caption=caption)
91
+ return _DESCRIBE_PROMPT_BASE
92
+
93
+
94
+ # ---------------------------------------------------------------------------
95
+ # Model loading
96
+ # ---------------------------------------------------------------------------
97
+
98
+
99
+ def _load_model(device: str) -> tuple[Any, Any]:
100
+ """Load Qwen3-VL-2B model and processor."""
101
+ from transformers import AutoProcessor, Qwen3VLForConditionalGeneration
102
+
103
+ dtype = torch.float32 if device == "cpu" else torch.float16
104
+
105
+ logger.info(
106
+ "Loading vision model: {} (device={}, dtype={})", _MODEL_NAME, device, dtype
107
+ )
108
+ processor = AutoProcessor.from_pretrained(_MODEL_NAME)
109
+ # Left-padding required for correct batched generation with decoder-only models
110
+ processor.tokenizer.padding_side = "left"
111
+ model = Qwen3VLForConditionalGeneration.from_pretrained(
112
+ _MODEL_NAME,
113
+ device_map=device,
114
+ torch_dtype=dtype,
115
+ )
116
+ logger.info("Vision model loaded")
117
+ return model, processor
118
+
119
+
120
+ # ---------------------------------------------------------------------------
121
+ # Image preprocessing
122
+ # ---------------------------------------------------------------------------
123
+
124
+
125
+ def _resize_image(image: Any, max_dim: int = _MAX_IMAGE_DIM) -> Any:
126
+ """Resize image so longest side is at most max_dim. Preserves aspect ratio."""
127
+ from PIL import Image
128
+
129
+ w, h = image.size
130
+ if max(w, h) <= max_dim:
131
+ return image
132
+ ratio = max_dim / max(w, h)
133
+ return image.resize((int(w * ratio), int(h * ratio)), Image.LANCZOS) # type: ignore[attr-defined]
134
+
135
+
136
+ # ---------------------------------------------------------------------------
137
+ # Batched inference
138
+ # ---------------------------------------------------------------------------
139
+
140
+
141
+ def _describe_images_batch(
142
+ image_paths: list[Path],
143
+ prompts: list[str],
144
+ model: Any,
145
+ processor: Any,
146
+ device: str,
147
+ ) -> list[str]:
148
+ """Run VL model on a batch of images, return descriptions.
149
+
150
+ Each image gets its own prompt (may include the figure's caption).
151
+ """
152
+ from PIL import Image
153
+
154
+ images = [_resize_image(Image.open(p).convert("RGB")) for p in image_paths]
155
+
156
+ # Build per-image messages and text inputs
157
+ all_texts: list[str] = []
158
+ for p, prompt in zip(image_paths, prompts):
159
+ messages = [
160
+ {
161
+ "role": "user",
162
+ "content": [
163
+ {"type": "image", "image": str(p)},
164
+ {"type": "text", "text": prompt},
165
+ ],
166
+ },
167
+ ]
168
+ text_input = processor.apply_chat_template(
169
+ messages, tokenize=False, add_generation_prompt=True
170
+ )
171
+ all_texts.append(text_input)
172
+
173
+ inputs = processor(
174
+ text=all_texts,
175
+ images=images,
176
+ padding=True,
177
+ return_tensors="pt",
178
+ )
179
+ inputs = inputs.to(device)
180
+
181
+ with torch.no_grad():
182
+ output_ids = model.generate(**inputs, max_new_tokens=_MAX_NEW_TOKENS)
183
+
184
+ # Decode each sequence, skipping input tokens
185
+ input_len = inputs["input_ids"].shape[1]
186
+ descriptions: list[str] = []
187
+ for i in range(len(image_paths)):
188
+ generated = output_ids[i][input_len:]
189
+ desc = processor.decode(generated, skip_special_tokens=True).strip()
190
+ descriptions.append(desc)
191
+
192
+ return descriptions
193
+
194
+
195
+ # ---------------------------------------------------------------------------
196
+ # Public API
197
+ # ---------------------------------------------------------------------------
198
+
199
+
200
+ def describe_figures(
201
+ extracted_images: list[ExtractedImage],
202
+ references_dir: Path | None = None,
203
+ device: str = "auto",
204
+ batch_size: int = _BATCH_SIZE,
205
+ fresh: bool = False,
206
+ ) -> str:
207
+ """Describe all extracted figures and return formatted text for the LLM.
208
+
209
+ Uses workspace.db ``vision_cache`` table so only new or changed images
210
+ trigger VL inference.
211
+
212
+ Args:
213
+ extracted_images: Images from ``extract_images_from_latex`` or
214
+ ``extract_images_from_pdf``.
215
+ references_dir: Paper workspace root (``references/{paper}/``) for
216
+ DB caching via ``get_db()``. If None, no caching.
217
+ device: ``"auto"`` (CUDA on WSL2, CPU elsewhere), ``"cpu"``, or ``"cuda"``.
218
+ batch_size: Images per batch for VL inference.
219
+ fresh: Ignore cache and re-describe all images.
220
+
221
+ Returns:
222
+ Formatted string ready for injection into the full-paper consistency
223
+ check system prompt (the ``figure_descriptions`` parameter).
224
+ """
225
+ from sciwrite_lint.vision.cache import (
226
+ clear_cache,
227
+ format_descriptions_from_db,
228
+ split_cached_and_new,
229
+ update_cache,
230
+ )
231
+
232
+ if not extracted_images:
233
+ return ""
234
+
235
+ # Clear cache if --fresh
236
+ if fresh and references_dir:
237
+ clear_cache(references_dir)
238
+
239
+ # Determine which images need inference
240
+ if references_dir and not fresh:
241
+ new_images = split_cached_and_new(extracted_images, references_dir)
242
+ else:
243
+ new_images = list(extracted_images)
244
+
245
+ if not new_images:
246
+ logger.info(
247
+ "All {} figure(s) cached, skipping VL inference", len(extracted_images)
248
+ )
249
+ return format_descriptions_from_db(extracted_images, references_dir) # type: ignore[arg-type]
250
+
251
+ logger.info(
252
+ "{}/{} figure(s) need VL inference",
253
+ len(new_images),
254
+ len(extracted_images),
255
+ )
256
+
257
+ # Resolve device (same pattern as embedding model: CUDA on WSL2, CPU elsewhere)
258
+ device = _resolve_vision_device(device)
259
+
260
+ logger.info("Running VL inference on {} (batch_size={})", device, batch_size)
261
+
262
+ model, processor = _load_model(device)
263
+
264
+ try:
265
+ # Process in batches — each image gets a prompt with its caption
266
+ all_descriptions: list[str] = []
267
+ paths = [img.path for img in new_images]
268
+ prompts = [_build_prompt(img.caption) for img in new_images]
269
+ for i in range(0, len(paths), batch_size):
270
+ batch_paths = paths[i : i + batch_size]
271
+ batch_prompts = prompts[i : i + batch_size]
272
+ descs = _describe_images_batch(
273
+ batch_paths, batch_prompts, model, processor, device
274
+ )
275
+ all_descriptions.extend(descs)
276
+ finally:
277
+ # Free GPU memory immediately — gc.collect() breaks circular refs
278
+ # before empty_cache() so CUDA can reclaim all allocations.
279
+ del model
280
+ del processor
281
+ import gc
282
+
283
+ gc.collect()
284
+ if device == "cuda":
285
+ torch.cuda.empty_cache()
286
+
287
+ # Save to workspace.db
288
+ if references_dir:
289
+ update_cache(new_images, all_descriptions, references_dir)
290
+
291
+ # Format all descriptions (cached + newly inferred)
292
+ if references_dir:
293
+ result = format_descriptions_from_db(extracted_images, references_dir)
294
+ else:
295
+ # No DB — format from what we just inferred
296
+ parts: list[str] = []
297
+ for img, desc in zip(new_images, all_descriptions):
298
+ header = "Figure"
299
+ if img.label:
300
+ header += f" ({img.label})"
301
+ if img.caption:
302
+ header += f' — Caption: "{img.caption}"'
303
+ parts.append(f"{header}\nVisual content: {desc}")
304
+ result = "\n\n".join(parts)
305
+
306
+ logger.info(
307
+ "Generated {} figure descriptions (~{} chars)",
308
+ len(extracted_images),
309
+ len(result),
310
+ )
311
+ return result