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,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
|