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,2694 @@
|
|
|
1
|
+
"""Unified check pipeline: text rules + API verification + claims in one pass.
|
|
2
|
+
|
|
3
|
+
Orchestrates all stages of manuscript verification:
|
|
4
|
+
1. Text + LLM rules (concurrent with stage 2)
|
|
5
|
+
2. Reference verification via batch APIs
|
|
6
|
+
3. Full-text acquisition
|
|
7
|
+
4. GROBID parsing + embeddings
|
|
8
|
+
5. Claim verification (sem-001)
|
|
9
|
+
6. Merged report
|
|
10
|
+
|
|
11
|
+
Supports both LaTeX (.tex) and PDF input. For PDF, the manuscript is parsed
|
|
12
|
+
via GROBID and a ManuscriptContext is built from the result.
|
|
13
|
+
|
|
14
|
+
Requires: vLLM running, GROBID running, network access.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import asyncio
|
|
20
|
+
import time
|
|
21
|
+
from collections.abc import Iterator
|
|
22
|
+
from contextlib import contextmanager
|
|
23
|
+
from dataclasses import dataclass
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from typing import Any, Literal
|
|
26
|
+
|
|
27
|
+
import httpx
|
|
28
|
+
from loguru import logger
|
|
29
|
+
|
|
30
|
+
from sciwrite_lint.config import LintConfig, PaperConfig, PaperWorkspace
|
|
31
|
+
from sciwrite_lint.models import Citation, Finding
|
|
32
|
+
from sciwrite_lint.references.workspace_db import (
|
|
33
|
+
get_db,
|
|
34
|
+
init_pipeline_stages,
|
|
35
|
+
update_pipeline_stage,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _track(refs_dir: Path, stage: str, status: str, detail: str = "") -> None:
|
|
40
|
+
"""Write a stage status update to workspace.db."""
|
|
41
|
+
try:
|
|
42
|
+
with get_db(refs_dir) as conn:
|
|
43
|
+
update_pipeline_stage(conn, stage, status, detail)
|
|
44
|
+
except Exception as e:
|
|
45
|
+
logger.debug(f"pipeline stage tracking failed ({type(e).__name__}: {e})")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class _StageStatus:
|
|
49
|
+
"""Mutable detail holder yielded by :func:`_stage_tracking`.
|
|
50
|
+
|
|
51
|
+
Assign ``status.detail`` inside the ``with`` block to set the detail
|
|
52
|
+
string recorded when the stage is marked ``done``.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
__slots__ = ("detail",)
|
|
56
|
+
|
|
57
|
+
def __init__(self) -> None:
|
|
58
|
+
self.detail: str = ""
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@contextmanager
|
|
62
|
+
def _stage_tracking(
|
|
63
|
+
refs_dirs: Path | list[Path],
|
|
64
|
+
stages: str | list[str],
|
|
65
|
+
) -> Iterator[_StageStatus]:
|
|
66
|
+
"""Mark one or more pipeline stages ``running`` → ``done`` / ``failed``.
|
|
67
|
+
|
|
68
|
+
On enter, every (refs_dir, stage) pair is marked ``running``. On clean
|
|
69
|
+
exit, every pair is marked ``done`` with the detail set on the yielded
|
|
70
|
+
``_StageStatus``. On exception, every pair is marked ``failed`` with a
|
|
71
|
+
truncated error message and the exception is re-raised so the caller
|
|
72
|
+
can still set ``ctx.error`` or propagate.
|
|
73
|
+
|
|
74
|
+
Use for simple stages where ``running`` and ``done`` are tracked in the
|
|
75
|
+
same scope. For stages where ``done`` is deferred to a later step (e.g.
|
|
76
|
+
multi-paper parse + batch embed), track manually with :func:`_track`.
|
|
77
|
+
"""
|
|
78
|
+
dirs = [refs_dirs] if isinstance(refs_dirs, Path) else list(refs_dirs)
|
|
79
|
+
stage_list = [stages] if isinstance(stages, str) else list(stages)
|
|
80
|
+
|
|
81
|
+
for d in dirs:
|
|
82
|
+
for s in stage_list:
|
|
83
|
+
_track(d, s, "running")
|
|
84
|
+
|
|
85
|
+
status = _StageStatus()
|
|
86
|
+
try:
|
|
87
|
+
yield status
|
|
88
|
+
except Exception as e:
|
|
89
|
+
msg = str(e)[:200]
|
|
90
|
+
for d in dirs:
|
|
91
|
+
for s in stage_list:
|
|
92
|
+
_track(d, s, "failed", msg)
|
|
93
|
+
raise
|
|
94
|
+
else:
|
|
95
|
+
for d in dirs:
|
|
96
|
+
for s in stage_list:
|
|
97
|
+
_track(d, s, "done", status.detail)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@contextmanager
|
|
101
|
+
def _stage_failure_guard(
|
|
102
|
+
refs_dirs: Path | list[Path],
|
|
103
|
+
stages: str | list[str],
|
|
104
|
+
) -> Iterator[None]:
|
|
105
|
+
"""Mark stages ``failed`` if the block raises, but never marks running/done.
|
|
106
|
+
|
|
107
|
+
Use for stages where ``running`` and ``done`` transitions are managed
|
|
108
|
+
per-item outside this scope (e.g. multi-paper parse + batch embed, or
|
|
109
|
+
batch cited-vision where different ctxs have different done details).
|
|
110
|
+
This guard only guarantees that an uncaught batch-level failure does
|
|
111
|
+
not leave stages stuck in ``running`` in the monitor DB.
|
|
112
|
+
"""
|
|
113
|
+
dirs = [refs_dirs] if isinstance(refs_dirs, Path) else list(refs_dirs)
|
|
114
|
+
stage_list = [stages] if isinstance(stages, str) else list(stages)
|
|
115
|
+
try:
|
|
116
|
+
yield
|
|
117
|
+
except Exception as e:
|
|
118
|
+
msg = str(e)[:200]
|
|
119
|
+
for d in dirs:
|
|
120
|
+
for s in stage_list:
|
|
121
|
+
_track(d, s, "failed", msg)
|
|
122
|
+
raise
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
# ---------------------------------------------------------------------------
|
|
126
|
+
# Preflight: verify all required services
|
|
127
|
+
# ---------------------------------------------------------------------------
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
async def preflight(config: LintConfig) -> list[str]:
|
|
131
|
+
"""Check vLLM, GROBID, network, and API configuration. Return list of errors."""
|
|
132
|
+
from sciwrite_lint.cli.config import check_api_config
|
|
133
|
+
|
|
134
|
+
errors: list[str] = check_api_config(config, needs_email=True)
|
|
135
|
+
|
|
136
|
+
from sciwrite_lint.pdf.grobid import is_grobid_running
|
|
137
|
+
|
|
138
|
+
if not await is_grobid_running():
|
|
139
|
+
errors.append("GROBID not running. Start with: sciwrite-lint containers start")
|
|
140
|
+
|
|
141
|
+
try:
|
|
142
|
+
from sciwrite_lint.llm_utils import get_model_config
|
|
143
|
+
|
|
144
|
+
model_cfg = get_model_config(config)
|
|
145
|
+
served_name = model_cfg["model"]
|
|
146
|
+
|
|
147
|
+
async with httpx.AsyncClient() as client:
|
|
148
|
+
resp = await client.get(f"{config.llm_endpoint}/models", timeout=5.0)
|
|
149
|
+
if resp.status_code != 200:
|
|
150
|
+
errors.append(f"vLLM not responding at {config.llm_endpoint}")
|
|
151
|
+
else:
|
|
152
|
+
model_ids = [m["id"] for m in resp.json().get("data", [])]
|
|
153
|
+
if served_name not in model_ids:
|
|
154
|
+
errors.append(
|
|
155
|
+
f"vLLM model mismatch: config wants '{served_name}' "
|
|
156
|
+
f"but server has {model_ids}. "
|
|
157
|
+
f"Either change [llm] model in .sciwrite-lint.toml "
|
|
158
|
+
f"or restart vLLM with the right model."
|
|
159
|
+
)
|
|
160
|
+
except httpx.HTTPError as e:
|
|
161
|
+
errors.append(
|
|
162
|
+
f"vLLM not responding at {config.llm_endpoint}: {e}. "
|
|
163
|
+
"Start with: sciwrite-lint containers start"
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
try:
|
|
167
|
+
async with httpx.AsyncClient() as client:
|
|
168
|
+
resp = await client.get(
|
|
169
|
+
"https://api.openalex.org/works?per_page=1", timeout=10.0
|
|
170
|
+
)
|
|
171
|
+
if resp.status_code != 200:
|
|
172
|
+
errors.append("Network: OpenAlex API unreachable")
|
|
173
|
+
except httpx.HTTPError:
|
|
174
|
+
errors.append("Network: cannot reach api.openalex.org")
|
|
175
|
+
|
|
176
|
+
return errors
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
# ---------------------------------------------------------------------------
|
|
180
|
+
# ---------------------------------------------------------------------------
|
|
181
|
+
# Subprocess helpers for CUDA isolation
|
|
182
|
+
# ---------------------------------------------------------------------------
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _run_embeddings_subprocess(
|
|
186
|
+
keys: list[str],
|
|
187
|
+
references_dir: Path,
|
|
188
|
+
config: LintConfig,
|
|
189
|
+
) -> str:
|
|
190
|
+
"""Run embedding computation in a subprocess for CUDA isolation.
|
|
191
|
+
|
|
192
|
+
The embedding model brings batch data to VRAM; subprocess isolation
|
|
193
|
+
ensures all CUDA allocations are released when embedding finishes.
|
|
194
|
+
|
|
195
|
+
Returns:
|
|
196
|
+
Empty string on success. On failure (non-zero exit, timeout, or
|
|
197
|
+
crash), returns a human-readable diagnostic (subprocess stderr
|
|
198
|
+
tail, timeout notice, or exception message) for inclusion in the
|
|
199
|
+
RuntimeError raised by ``_verify_embeddings_or_raise``.
|
|
200
|
+
"""
|
|
201
|
+
import subprocess
|
|
202
|
+
import sys
|
|
203
|
+
|
|
204
|
+
# Pass keys as comma-separated arg, references_dir, and config path
|
|
205
|
+
keys_str = ",".join(keys)
|
|
206
|
+
cmd = [
|
|
207
|
+
sys.executable,
|
|
208
|
+
"-c",
|
|
209
|
+
"import sys; "
|
|
210
|
+
"from sciwrite_lint.pipeline import _embed_keys; "
|
|
211
|
+
f"_embed_keys({keys_str!r}, {str(references_dir)!r})",
|
|
212
|
+
]
|
|
213
|
+
try:
|
|
214
|
+
result = subprocess.run(
|
|
215
|
+
cmd,
|
|
216
|
+
capture_output=True,
|
|
217
|
+
text=True,
|
|
218
|
+
timeout=300,
|
|
219
|
+
cwd=str(config.project_dir) if config.project_dir else None,
|
|
220
|
+
)
|
|
221
|
+
if result.returncode != 0:
|
|
222
|
+
stderr = result.stderr.strip()[-1500:] if result.stderr else ""
|
|
223
|
+
logger.warning("Embedding subprocess failed: {}", stderr)
|
|
224
|
+
return f"non-zero exit {result.returncode}: {stderr}"
|
|
225
|
+
except subprocess.TimeoutExpired:
|
|
226
|
+
logger.warning("Embedding subprocess timed out (300s)")
|
|
227
|
+
return "subprocess timed out after 300s"
|
|
228
|
+
except Exception as e:
|
|
229
|
+
logger.warning("Embedding subprocess error: {}", e)
|
|
230
|
+
return f"{type(e).__name__}: {e}"
|
|
231
|
+
return ""
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _embed_keys(keys_csv: str, references_dir_str: str) -> None:
|
|
235
|
+
"""Subprocess entry point: compute embeddings for given keys."""
|
|
236
|
+
from sciwrite_lint.references.reference_store import (
|
|
237
|
+
compute_and_store_embeddings,
|
|
238
|
+
release_embedding_model,
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
references_dir = Path(references_dir_str)
|
|
242
|
+
keys = keys_csv.split(",")
|
|
243
|
+
|
|
244
|
+
for key in keys:
|
|
245
|
+
# Check for parsed markdown
|
|
246
|
+
md_path = references_dir / "parsed" / f"{key}.md"
|
|
247
|
+
# Also check for web summaries
|
|
248
|
+
web_path = references_dir / f"{key}_web.md"
|
|
249
|
+
text_path = (
|
|
250
|
+
md_path if md_path.exists() else (web_path if web_path.exists() else None)
|
|
251
|
+
)
|
|
252
|
+
if text_path is None:
|
|
253
|
+
continue
|
|
254
|
+
try:
|
|
255
|
+
text = text_path.read_text(encoding="utf-8")
|
|
256
|
+
compute_and_store_embeddings(key, text, references_dir)
|
|
257
|
+
except ImportError:
|
|
258
|
+
break # sentence-transformers not installed
|
|
259
|
+
except Exception as e:
|
|
260
|
+
logger.debug(f"embedding skipped for {key} ({type(e).__name__}: {e})")
|
|
261
|
+
continue
|
|
262
|
+
|
|
263
|
+
release_embedding_model()
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _batch_embed_entry(manifest_path_str: str) -> None:
|
|
267
|
+
"""Subprocess entry point: embed keys for multiple papers in one process.
|
|
268
|
+
|
|
269
|
+
Loads the embedding model once and iterates over papers. Each paper's
|
|
270
|
+
keys are embedded and stored in its own workspace. Called by
|
|
271
|
+
``_batch_embed()`` via ``subprocess.run``.
|
|
272
|
+
|
|
273
|
+
Manifest JSON: list of {"keys": [...], "references_dir": "..."}.
|
|
274
|
+
"""
|
|
275
|
+
import json
|
|
276
|
+
|
|
277
|
+
from sciwrite_lint.references.reference_store import (
|
|
278
|
+
compute_and_store_embeddings,
|
|
279
|
+
release_embedding_model,
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
manifest = json.loads(Path(manifest_path_str).read_text(encoding="utf-8"))
|
|
283
|
+
|
|
284
|
+
for entry in manifest:
|
|
285
|
+
references_dir = Path(entry["references_dir"])
|
|
286
|
+
keys = entry["keys"]
|
|
287
|
+
for key in keys:
|
|
288
|
+
md_path = references_dir / "parsed" / f"{key}.md"
|
|
289
|
+
web_path = references_dir / f"{key}_web.md"
|
|
290
|
+
text_path = (
|
|
291
|
+
md_path
|
|
292
|
+
if md_path.exists()
|
|
293
|
+
else (web_path if web_path.exists() else None)
|
|
294
|
+
)
|
|
295
|
+
if text_path is None:
|
|
296
|
+
continue
|
|
297
|
+
try:
|
|
298
|
+
text = text_path.read_text(encoding="utf-8")
|
|
299
|
+
compute_and_store_embeddings(key, text, references_dir)
|
|
300
|
+
except ImportError:
|
|
301
|
+
release_embedding_model()
|
|
302
|
+
return
|
|
303
|
+
except Exception as e:
|
|
304
|
+
logger.debug(f"embedding skipped for {key} ({type(e).__name__}: {e})")
|
|
305
|
+
continue
|
|
306
|
+
|
|
307
|
+
release_embedding_model()
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _batch_cited_vision_entry(manifest_path_str: str) -> None:
|
|
311
|
+
"""Subprocess entry point: run VL inference on cited papers for multiple papers.
|
|
312
|
+
|
|
313
|
+
Loads the VL model once and iterates over papers. Each paper's cited
|
|
314
|
+
PDFs are processed and results cached in workspace.db. Called by
|
|
315
|
+
``_batch_cited_vision()`` via ``subprocess.run``.
|
|
316
|
+
|
|
317
|
+
Manifest JSON: list of {"references_dir": "...", "fresh": bool}.
|
|
318
|
+
"""
|
|
319
|
+
import json
|
|
320
|
+
|
|
321
|
+
manifest = json.loads(Path(manifest_path_str).read_text(encoding="utf-8"))
|
|
322
|
+
|
|
323
|
+
for entry in manifest:
|
|
324
|
+
references_dir = Path(entry["references_dir"])
|
|
325
|
+
fresh = entry.get("fresh", False)
|
|
326
|
+
|
|
327
|
+
parsed_dir = references_dir / "parsed"
|
|
328
|
+
if not parsed_dir.exists():
|
|
329
|
+
continue
|
|
330
|
+
|
|
331
|
+
keys = [f.stem for f in sorted(parsed_dir.glob("*.md"))]
|
|
332
|
+
if not keys:
|
|
333
|
+
continue
|
|
334
|
+
|
|
335
|
+
from sciwrite_lint.vision.describe import describe_figures
|
|
336
|
+
from sciwrite_lint.vision.image_extraction import (
|
|
337
|
+
ExtractedImage,
|
|
338
|
+
extract_images_from_pdf,
|
|
339
|
+
)
|
|
340
|
+
|
|
341
|
+
all_images: list[ExtractedImage] = []
|
|
342
|
+
output_dir = references_dir / "parsed" / "ref_figures"
|
|
343
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
344
|
+
|
|
345
|
+
for key in keys:
|
|
346
|
+
candidates = sorted(references_dir.glob(f"{key}*.pdf"))
|
|
347
|
+
if not candidates:
|
|
348
|
+
continue
|
|
349
|
+
images = extract_images_from_pdf(candidates[0], output_dir / key)
|
|
350
|
+
all_images.extend(images)
|
|
351
|
+
|
|
352
|
+
if all_images:
|
|
353
|
+
describe_figures(all_images, references_dir=references_dir, fresh=fresh)
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
# ---------------------------------------------------------------------------
|
|
357
|
+
# Batch launchers — spawn ONE subprocess for all papers in a stage
|
|
358
|
+
# ---------------------------------------------------------------------------
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def _batch_vision(
|
|
362
|
+
papers: list[dict[str, Any]],
|
|
363
|
+
timeout: int = 600,
|
|
364
|
+
) -> None:
|
|
365
|
+
"""Run vision for all papers in one subprocess (single model load).
|
|
366
|
+
|
|
367
|
+
Args:
|
|
368
|
+
papers: List of dicts with keys: paper_name, tex_path, config_path, fresh.
|
|
369
|
+
timeout: Subprocess timeout in seconds.
|
|
370
|
+
"""
|
|
371
|
+
import json
|
|
372
|
+
import subprocess
|
|
373
|
+
import sys
|
|
374
|
+
import tempfile
|
|
375
|
+
|
|
376
|
+
if not papers:
|
|
377
|
+
return
|
|
378
|
+
|
|
379
|
+
manifest = tempfile.NamedTemporaryFile(
|
|
380
|
+
mode="w", suffix=".json", delete=False, encoding="utf-8"
|
|
381
|
+
)
|
|
382
|
+
try:
|
|
383
|
+
json.dump(papers, manifest)
|
|
384
|
+
manifest.close()
|
|
385
|
+
cmd = [
|
|
386
|
+
sys.executable,
|
|
387
|
+
"-m",
|
|
388
|
+
"sciwrite_lint.vision.pipeline",
|
|
389
|
+
"--batch",
|
|
390
|
+
manifest.name,
|
|
391
|
+
]
|
|
392
|
+
result = subprocess.run(
|
|
393
|
+
cmd,
|
|
394
|
+
capture_output=True,
|
|
395
|
+
text=True,
|
|
396
|
+
timeout=timeout,
|
|
397
|
+
)
|
|
398
|
+
if result.returncode != 0:
|
|
399
|
+
stderr = result.stderr.strip()[-1500:] if result.stderr else ""
|
|
400
|
+
logger.error(
|
|
401
|
+
"Batch vision subprocess failed (exit {}): {}\n"
|
|
402
|
+
"Manuscript figures will be missing from full-paper LLM "
|
|
403
|
+
"checks for all papers in this batch — checks will run with "
|
|
404
|
+
"reduced visual context.",
|
|
405
|
+
result.returncode,
|
|
406
|
+
stderr,
|
|
407
|
+
)
|
|
408
|
+
except subprocess.TimeoutExpired:
|
|
409
|
+
logger.error(
|
|
410
|
+
"Batch vision subprocess timed out ({}s) — manuscript figures "
|
|
411
|
+
"missing for batch",
|
|
412
|
+
timeout,
|
|
413
|
+
)
|
|
414
|
+
except Exception as e:
|
|
415
|
+
logger.error("Batch vision subprocess error: {}: {}", type(e).__name__, e)
|
|
416
|
+
finally:
|
|
417
|
+
Path(manifest.name).unlink(missing_ok=True)
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def _batch_embed(
|
|
421
|
+
papers: list[dict[str, Any]],
|
|
422
|
+
timeout: int = 600,
|
|
423
|
+
) -> str:
|
|
424
|
+
"""Run embedding for all papers in one subprocess (single model load).
|
|
425
|
+
|
|
426
|
+
Args:
|
|
427
|
+
papers: List of dicts with keys: keys (list[str]), references_dir (str).
|
|
428
|
+
timeout: Subprocess timeout in seconds.
|
|
429
|
+
|
|
430
|
+
Returns:
|
|
431
|
+
Empty string on success. On failure, returns a human-readable
|
|
432
|
+
diagnostic (subprocess stderr tail, timeout notice, or exception
|
|
433
|
+
message) for inclusion in any per-paper error surfaced by the
|
|
434
|
+
post-embed has_embeddings() re-check in ``run_papers_staged``.
|
|
435
|
+
"""
|
|
436
|
+
import json
|
|
437
|
+
import subprocess
|
|
438
|
+
import sys
|
|
439
|
+
import tempfile
|
|
440
|
+
|
|
441
|
+
if not papers:
|
|
442
|
+
return ""
|
|
443
|
+
|
|
444
|
+
# Filter out papers with no keys to embed
|
|
445
|
+
papers = [p for p in papers if p.get("keys")]
|
|
446
|
+
if not papers:
|
|
447
|
+
return ""
|
|
448
|
+
|
|
449
|
+
manifest = tempfile.NamedTemporaryFile(
|
|
450
|
+
mode="w", suffix=".json", delete=False, encoding="utf-8"
|
|
451
|
+
)
|
|
452
|
+
try:
|
|
453
|
+
json.dump(papers, manifest)
|
|
454
|
+
manifest.close()
|
|
455
|
+
cmd = [
|
|
456
|
+
sys.executable,
|
|
457
|
+
"-c",
|
|
458
|
+
"from sciwrite_lint.pipeline import _batch_embed_entry; "
|
|
459
|
+
f"_batch_embed_entry({manifest.name!r})",
|
|
460
|
+
]
|
|
461
|
+
try:
|
|
462
|
+
result = subprocess.run(
|
|
463
|
+
cmd,
|
|
464
|
+
capture_output=True,
|
|
465
|
+
text=True,
|
|
466
|
+
timeout=timeout,
|
|
467
|
+
)
|
|
468
|
+
except subprocess.TimeoutExpired:
|
|
469
|
+
logger.warning("Batch embedding subprocess timed out ({}s)", timeout)
|
|
470
|
+
return f"subprocess timed out after {timeout}s"
|
|
471
|
+
except Exception as e:
|
|
472
|
+
logger.warning("Batch embedding subprocess error: {}", e)
|
|
473
|
+
return f"{type(e).__name__}: {e}"
|
|
474
|
+
if result.returncode != 0:
|
|
475
|
+
stderr = result.stderr.strip()[-1500:] if result.stderr else ""
|
|
476
|
+
logger.warning("Batch embedding subprocess failed: {}", stderr)
|
|
477
|
+
return f"non-zero exit {result.returncode}: {stderr}"
|
|
478
|
+
return ""
|
|
479
|
+
finally:
|
|
480
|
+
Path(manifest.name).unlink(missing_ok=True)
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
def _batch_cited_vision(
|
|
484
|
+
papers: list[dict[str, Any]],
|
|
485
|
+
timeout: int = 600,
|
|
486
|
+
) -> None:
|
|
487
|
+
"""Run cited-paper vision for all papers in one subprocess (single model load).
|
|
488
|
+
|
|
489
|
+
Args:
|
|
490
|
+
papers: List of dicts with keys: references_dir (str), fresh (bool).
|
|
491
|
+
timeout: Subprocess timeout in seconds.
|
|
492
|
+
"""
|
|
493
|
+
import json
|
|
494
|
+
import subprocess
|
|
495
|
+
import sys
|
|
496
|
+
import tempfile
|
|
497
|
+
|
|
498
|
+
if not papers:
|
|
499
|
+
return
|
|
500
|
+
|
|
501
|
+
manifest = tempfile.NamedTemporaryFile(
|
|
502
|
+
mode="w", suffix=".json", delete=False, encoding="utf-8"
|
|
503
|
+
)
|
|
504
|
+
try:
|
|
505
|
+
json.dump(papers, manifest)
|
|
506
|
+
manifest.close()
|
|
507
|
+
cmd = [
|
|
508
|
+
sys.executable,
|
|
509
|
+
"-c",
|
|
510
|
+
"from sciwrite_lint.pipeline import _batch_cited_vision_entry; "
|
|
511
|
+
f"_batch_cited_vision_entry({manifest.name!r})",
|
|
512
|
+
]
|
|
513
|
+
result = subprocess.run(
|
|
514
|
+
cmd,
|
|
515
|
+
capture_output=True,
|
|
516
|
+
text=True,
|
|
517
|
+
timeout=timeout,
|
|
518
|
+
)
|
|
519
|
+
if result.returncode != 0:
|
|
520
|
+
stderr = result.stderr.strip()[-1500:] if result.stderr else ""
|
|
521
|
+
logger.error(
|
|
522
|
+
"Batch cited vision subprocess failed (exit {}): {}\n"
|
|
523
|
+
"Cited paper figures will be missing for all papers in this "
|
|
524
|
+
"batch — ref-internal checks will run with reduced context.",
|
|
525
|
+
result.returncode,
|
|
526
|
+
stderr,
|
|
527
|
+
)
|
|
528
|
+
except subprocess.TimeoutExpired:
|
|
529
|
+
logger.error(
|
|
530
|
+
"Batch cited vision subprocess timed out ({}s) — cited figures "
|
|
531
|
+
"missing for batch",
|
|
532
|
+
timeout,
|
|
533
|
+
)
|
|
534
|
+
except Exception as e:
|
|
535
|
+
logger.error("Batch cited vision subprocess error: {}: {}", type(e).__name__, e)
|
|
536
|
+
finally:
|
|
537
|
+
Path(manifest.name).unlink(missing_ok=True)
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
# ---------------------------------------------------------------------------
|
|
541
|
+
# Stage 0.5: Vision — figure descriptions for full-paper consistency checks
|
|
542
|
+
# ---------------------------------------------------------------------------
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
def _stage_vision(
|
|
546
|
+
tex_path: Path,
|
|
547
|
+
config: LintConfig,
|
|
548
|
+
paper_name: str,
|
|
549
|
+
fresh: bool = False,
|
|
550
|
+
) -> None:
|
|
551
|
+
"""Extract and describe manuscript figures via Qwen3-VL-2B.
|
|
552
|
+
|
|
553
|
+
Populates the vision cache so that full-paper consistency checks
|
|
554
|
+
(Stage 2) can include figure descriptions in the shared prefix.
|
|
555
|
+
|
|
556
|
+
Runs in a subprocess so the CUDA context (~500 MB) is fully released
|
|
557
|
+
when the VL model finishes. Without subprocess isolation, the CUDA
|
|
558
|
+
runtime context persists in the parent process even after del model +
|
|
559
|
+
gc.collect() + empty_cache(), stealing VRAM from vLLM's KV cache and
|
|
560
|
+
causing timeouts on LLM queries.
|
|
561
|
+
|
|
562
|
+
Results are written to workspace.db (vision_cache table) by the
|
|
563
|
+
subprocess; the parent reads them from DB — no return value transfer.
|
|
564
|
+
"""
|
|
565
|
+
import subprocess
|
|
566
|
+
import sys
|
|
567
|
+
|
|
568
|
+
# Build a command that runs the vision pipeline in isolation.
|
|
569
|
+
# Uses the same Python interpreter and config.
|
|
570
|
+
cmd = [
|
|
571
|
+
sys.executable,
|
|
572
|
+
"-m",
|
|
573
|
+
"sciwrite_lint.vision.pipeline",
|
|
574
|
+
str(tex_path),
|
|
575
|
+
"--paper",
|
|
576
|
+
paper_name,
|
|
577
|
+
]
|
|
578
|
+
if fresh:
|
|
579
|
+
cmd.append("--fresh")
|
|
580
|
+
if config.config_path:
|
|
581
|
+
cmd.extend(["--config", str(config.config_path)])
|
|
582
|
+
|
|
583
|
+
try:
|
|
584
|
+
result = subprocess.run(
|
|
585
|
+
cmd,
|
|
586
|
+
capture_output=True,
|
|
587
|
+
text=True,
|
|
588
|
+
timeout=300,
|
|
589
|
+
cwd=str(config.project_dir) if config.project_dir else None,
|
|
590
|
+
)
|
|
591
|
+
if result.returncode == 0:
|
|
592
|
+
logger.info("Vision: figure descriptions ready for full-paper checks")
|
|
593
|
+
else:
|
|
594
|
+
stderr = result.stderr.strip()[-1500:] if result.stderr else ""
|
|
595
|
+
logger.error(
|
|
596
|
+
"Vision subprocess exited with code {}: {}\n"
|
|
597
|
+
"Manuscript figures missing — full-paper checks will run "
|
|
598
|
+
"with reduced visual context.",
|
|
599
|
+
result.returncode,
|
|
600
|
+
stderr,
|
|
601
|
+
)
|
|
602
|
+
except subprocess.TimeoutExpired:
|
|
603
|
+
logger.error(
|
|
604
|
+
"Vision subprocess timed out (300s) — manuscript figures missing, "
|
|
605
|
+
"checks continue with reduced visual context"
|
|
606
|
+
)
|
|
607
|
+
except Exception as e:
|
|
608
|
+
# Vision is best-effort — don't block the pipeline
|
|
609
|
+
logger.error(
|
|
610
|
+
"Vision pipeline failed ({}: {}) — checks continue without figures",
|
|
611
|
+
type(e).__name__,
|
|
612
|
+
e,
|
|
613
|
+
)
|
|
614
|
+
|
|
615
|
+
|
|
616
|
+
# ---------------------------------------------------------------------------
|
|
617
|
+
# Stage 1: Manuscript + local-LLM checks (reuses existing logic)
|
|
618
|
+
# ---------------------------------------------------------------------------
|
|
619
|
+
|
|
620
|
+
|
|
621
|
+
def run_text_checks(tex_path: Path, config: LintConfig) -> list[Finding]:
|
|
622
|
+
"""Run all manuscript-engine checks (CPU-bound, no I/O). Returns findings."""
|
|
623
|
+
from sciwrite_lint.checks.registry import ensure_checks_loaded, get_checks
|
|
624
|
+
|
|
625
|
+
ensure_checks_loaded()
|
|
626
|
+
findings: list[Finding] = []
|
|
627
|
+
|
|
628
|
+
for meta, fn in get_checks(config=config):
|
|
629
|
+
if meta.category in ("reference-db", "local-llm"):
|
|
630
|
+
continue
|
|
631
|
+
try:
|
|
632
|
+
check_findings = fn(tex_path, config)
|
|
633
|
+
for f in check_findings:
|
|
634
|
+
override = config.effective_severity(meta.id, meta.severity)
|
|
635
|
+
if override != f.level:
|
|
636
|
+
f.level = override # type: ignore[assignment]
|
|
637
|
+
findings.extend(check_findings)
|
|
638
|
+
except Exception as e:
|
|
639
|
+
logger.warning(f"Check {meta.id} skipped: {e}")
|
|
640
|
+
findings.append(
|
|
641
|
+
Finding(
|
|
642
|
+
level="info",
|
|
643
|
+
rule_id=meta.id,
|
|
644
|
+
message=f"Check {meta.id} could not run (internal error)",
|
|
645
|
+
context=f"{type(e).__name__}: {e!s}"[:200],
|
|
646
|
+
)
|
|
647
|
+
)
|
|
648
|
+
|
|
649
|
+
return findings
|
|
650
|
+
|
|
651
|
+
|
|
652
|
+
async def run_llm_checks_batched(tex_path: Path, config: LintConfig) -> list[Finding]:
|
|
653
|
+
"""Run all local-llm-engine checks via batched vLLM queries. Returns findings."""
|
|
654
|
+
from sciwrite_lint.cli.check import (
|
|
655
|
+
run_llm_checks_batched as _run_llm_checks_batched,
|
|
656
|
+
)
|
|
657
|
+
from sciwrite_lint.checks.registry import ensure_checks_loaded, get_checks
|
|
658
|
+
|
|
659
|
+
ensure_checks_loaded()
|
|
660
|
+
llm_checks = [
|
|
661
|
+
(meta, fn)
|
|
662
|
+
for meta, fn in get_checks(config=config)
|
|
663
|
+
if meta.category == "local-llm"
|
|
664
|
+
]
|
|
665
|
+
if not llm_checks:
|
|
666
|
+
return []
|
|
667
|
+
return await _run_llm_checks_batched(llm_checks, tex_path, config)
|
|
668
|
+
|
|
669
|
+
|
|
670
|
+
# ---------------------------------------------------------------------------
|
|
671
|
+
# Stage 2: Batch API verification
|
|
672
|
+
# ---------------------------------------------------------------------------
|
|
673
|
+
|
|
674
|
+
|
|
675
|
+
def _register_ref_in_workspace(
|
|
676
|
+
meta: Any,
|
|
677
|
+
references_dir: Path,
|
|
678
|
+
) -> None:
|
|
679
|
+
"""Register a verified reference in the workspace DB for cross-depth dedup."""
|
|
680
|
+
from sciwrite_lint.references.workspace_db import get_db, register_reference
|
|
681
|
+
|
|
682
|
+
canonical = meta.canonical or {}
|
|
683
|
+
bibitem = meta.bibitem or {}
|
|
684
|
+
authors = canonical.get("authors") or bibitem.get("authors") or None
|
|
685
|
+
|
|
686
|
+
try:
|
|
687
|
+
with get_db(references_dir) as conn:
|
|
688
|
+
register_reference(
|
|
689
|
+
conn,
|
|
690
|
+
ref_key=meta.key,
|
|
691
|
+
workspace_path=".",
|
|
692
|
+
depth=0,
|
|
693
|
+
parent_key="",
|
|
694
|
+
doi=canonical.get("doi") or bibitem.get("doi"),
|
|
695
|
+
arxiv_id=canonical.get("arxiv_id") or bibitem.get("arxiv_id"),
|
|
696
|
+
pmid=canonical.get("pmid") or bibitem.get("pmid"),
|
|
697
|
+
pmcid=canonical.get("pmcid") or bibitem.get("pmc_id"),
|
|
698
|
+
isbn=canonical.get("isbn") or bibitem.get("isbn"),
|
|
699
|
+
lccn=canonical.get("lccn") or bibitem.get("lccn"),
|
|
700
|
+
title=canonical.get("title") or bibitem.get("title"),
|
|
701
|
+
authors=authors if isinstance(authors, list) else None,
|
|
702
|
+
)
|
|
703
|
+
except Exception:
|
|
704
|
+
logger.debug("Failed to register {} in workspace DB", meta.key)
|
|
705
|
+
|
|
706
|
+
|
|
707
|
+
async def _stage_verify(
|
|
708
|
+
citations: list[Citation],
|
|
709
|
+
config: LintConfig,
|
|
710
|
+
references_dir: Path,
|
|
711
|
+
*,
|
|
712
|
+
fresh: bool = False,
|
|
713
|
+
) -> list[Finding]:
|
|
714
|
+
"""Verify citations against external sources. Returns ref-* findings.
|
|
715
|
+
|
|
716
|
+
Verification proceeds through five phases, each narrowing the set of
|
|
717
|
+
unresolved references before the next phase runs:
|
|
718
|
+
|
|
719
|
+
Phase A — OpenAlex batch (single request, resolves DOI/arXiv/PMID)
|
|
720
|
+
Phase B — Semantic Scholar (batch, resolves DOI/arXiv/PMID/PMC)
|
|
721
|
+
Phase C — CrossRef parallel (per-citation, resolves DOI + title search)
|
|
722
|
+
Phase C2 — Open Library + LoC (per-citation, resolves ISBN → OL, LCCN → LoC;
|
|
723
|
+
OL and LoC run in parallel per citation)
|
|
724
|
+
Phase D — URL verification (per-citation, HEAD/GET check for refs with URLs;
|
|
725
|
+
last resort before marking not_found)
|
|
726
|
+
|
|
727
|
+
Web resources (@misc with URL, no DOI) bypass all phases and go directly
|
|
728
|
+
to URL verification (concurrent, semaphore-limited).
|
|
729
|
+
|
|
730
|
+
Each phase only processes citations still unresolved after prior phases.
|
|
731
|
+
Results are validated by ``_id_result_matches`` (composite title/author/year
|
|
732
|
+
score ≥ 0.40) to reject API results that don't match the bib entry.
|
|
733
|
+
"""
|
|
734
|
+
from sciwrite_lint.api import (
|
|
735
|
+
_id_result_matches,
|
|
736
|
+
batch_openalex,
|
|
737
|
+
batch_s2,
|
|
738
|
+
cross_validate_ids,
|
|
739
|
+
parallel_crossref,
|
|
740
|
+
)
|
|
741
|
+
from sciwrite_lint.references.citations import is_web_resource
|
|
742
|
+
from sciwrite_lint.references.matching import compare_citation_detailed
|
|
743
|
+
from sciwrite_lint.references.metadata import (
|
|
744
|
+
build_metadata_from_citation,
|
|
745
|
+
merge_source_paper,
|
|
746
|
+
save_metadata,
|
|
747
|
+
)
|
|
748
|
+
|
|
749
|
+
findings: list[Finding] = []
|
|
750
|
+
|
|
751
|
+
# Partition: already verified vs needs verification
|
|
752
|
+
# Single DB query to get all already-verified metadata
|
|
753
|
+
unverified: list[Citation] = []
|
|
754
|
+
web_citations: list[Citation] = []
|
|
755
|
+
if not fresh:
|
|
756
|
+
from sciwrite_lint.references.workspace_db import (
|
|
757
|
+
get_db,
|
|
758
|
+
query_verified_metadata,
|
|
759
|
+
)
|
|
760
|
+
|
|
761
|
+
with get_db(references_dir) as conn:
|
|
762
|
+
cached_meta = query_verified_metadata(conn)
|
|
763
|
+
else:
|
|
764
|
+
cached_meta = {}
|
|
765
|
+
|
|
766
|
+
for c in citations:
|
|
767
|
+
if not fresh:
|
|
768
|
+
existing = cached_meta.get(c.key)
|
|
769
|
+
if existing:
|
|
770
|
+
# Already verified — collect findings from stored issues
|
|
771
|
+
for issue in existing.issues:
|
|
772
|
+
level, rule_id = _classify_verify_issue(issue)
|
|
773
|
+
findings.append(
|
|
774
|
+
Finding(
|
|
775
|
+
level=level,
|
|
776
|
+
rule_id=rule_id,
|
|
777
|
+
message=f"{c.key}: {issue}",
|
|
778
|
+
file="",
|
|
779
|
+
context=f"Source: {existing.api_source}"
|
|
780
|
+
if existing.api_source
|
|
781
|
+
else "",
|
|
782
|
+
)
|
|
783
|
+
)
|
|
784
|
+
c.api_match = existing.api_match
|
|
785
|
+
c.tier = existing.access.get("tier", "")
|
|
786
|
+
continue
|
|
787
|
+
if is_web_resource(c):
|
|
788
|
+
web_citations.append(c)
|
|
789
|
+
else:
|
|
790
|
+
unverified.append(c)
|
|
791
|
+
|
|
792
|
+
if not unverified and not web_citations:
|
|
793
|
+
return findings
|
|
794
|
+
|
|
795
|
+
total = len(unverified) + len(web_citations)
|
|
796
|
+
logger.info(
|
|
797
|
+
f"Verifying {total} citations ({len(unverified)} academic, {len(web_citations)} web)"
|
|
798
|
+
)
|
|
799
|
+
|
|
800
|
+
# ------------------------------------------------------------------
|
|
801
|
+
# Phases A–C: Batch academic API lookups (DOI, arXiv, PMID, title)
|
|
802
|
+
# ------------------------------------------------------------------
|
|
803
|
+
resolved: dict[str, dict[str, Any]] = {}
|
|
804
|
+
|
|
805
|
+
if unverified:
|
|
806
|
+
# Phase A: OpenAlex batch — single request, covers DOI + arXiv DOI
|
|
807
|
+
oa_results = await batch_openalex(unverified, config)
|
|
808
|
+
resolved.update(oa_results)
|
|
809
|
+
oa_found = len(oa_results)
|
|
810
|
+
|
|
811
|
+
# Phase B: Semantic Scholar batch — DOI, arXiv ID, PMID, PMC
|
|
812
|
+
still_need = [c for c in unverified if c.key not in resolved]
|
|
813
|
+
if still_need:
|
|
814
|
+
s2_results = await batch_s2(still_need, config)
|
|
815
|
+
resolved.update(s2_results)
|
|
816
|
+
|
|
817
|
+
# Phase C: CrossRef parallel — DOI + title/author search
|
|
818
|
+
cr_need = [c for c in unverified if c.key not in resolved]
|
|
819
|
+
if cr_need:
|
|
820
|
+
cr_results = await parallel_crossref(cr_need, config)
|
|
821
|
+
for key, result in cr_results.items():
|
|
822
|
+
if key not in resolved:
|
|
823
|
+
resolved[key] = result
|
|
824
|
+
|
|
825
|
+
# ------------------------------------------------------------------
|
|
826
|
+
# Phase C2: Open Library (ISBN) + Library of Congress (LCCN)
|
|
827
|
+
#
|
|
828
|
+
# Books and government reports often have ISBN/LCCN but no DOI, so
|
|
829
|
+
# academic APIs miss them. Per-citation lookups run concurrently;
|
|
830
|
+
# OL and LoC run in parallel per citation when both IDs are present.
|
|
831
|
+
# ------------------------------------------------------------------
|
|
832
|
+
from sciwrite_lint._network import is_valid_isbn, is_valid_lccn
|
|
833
|
+
|
|
834
|
+
ol_need = [
|
|
835
|
+
c
|
|
836
|
+
for c in unverified
|
|
837
|
+
if c.key not in resolved
|
|
838
|
+
and (
|
|
839
|
+
(c.isbn and is_valid_isbn(c.isbn)) or (c.lccn and is_valid_lccn(c.lccn))
|
|
840
|
+
)
|
|
841
|
+
]
|
|
842
|
+
if ol_need:
|
|
843
|
+
from sciwrite_lint.api import CitationAPI
|
|
844
|
+
|
|
845
|
+
async with CitationAPI(config=config) as cat_api:
|
|
846
|
+
ol_sem = asyncio.Semaphore(5)
|
|
847
|
+
|
|
848
|
+
async def _try_ol_loc(c: Citation) -> None:
|
|
849
|
+
async with ol_sem:
|
|
850
|
+
has_isbn = c.isbn and is_valid_isbn(c.isbn)
|
|
851
|
+
has_lccn = c.lccn and is_valid_lccn(c.lccn)
|
|
852
|
+
tasks: list[asyncio.Task] = []
|
|
853
|
+
if has_isbn:
|
|
854
|
+
tasks.append(
|
|
855
|
+
asyncio.create_task(cat_api._openlibrary_lookup(c))
|
|
856
|
+
)
|
|
857
|
+
if has_lccn:
|
|
858
|
+
tasks.append(asyncio.create_task(cat_api._loc_lookup(c)))
|
|
859
|
+
results = await asyncio.gather(*tasks)
|
|
860
|
+
for result in results:
|
|
861
|
+
if result and not result.get("error"):
|
|
862
|
+
if _id_result_matches(c, result):
|
|
863
|
+
resolved[c.key] = result
|
|
864
|
+
return
|
|
865
|
+
|
|
866
|
+
await asyncio.gather(*[_try_ol_loc(c) for c in ol_need])
|
|
867
|
+
ol_found = sum(1 for c in ol_need if c.key in resolved)
|
|
868
|
+
|
|
869
|
+
if ol_found:
|
|
870
|
+
logger.info(f"Open Library / LoC: {ol_found} found")
|
|
871
|
+
|
|
872
|
+
logger.info(
|
|
873
|
+
f"API results: {oa_found} OpenAlex, "
|
|
874
|
+
f"{len(resolved) - oa_found} S2/CrossRef/OL/LoC, "
|
|
875
|
+
f"{len(unverified) - len(resolved)} not found"
|
|
876
|
+
)
|
|
877
|
+
|
|
878
|
+
# ------------------------------------------------------------------
|
|
879
|
+
# Phase D: URL verification (last resort)
|
|
880
|
+
#
|
|
881
|
+
# References not found in any academic/book API but carrying a URL
|
|
882
|
+
# (techreports, journalism, books hosted online, conference pages).
|
|
883
|
+
# Confirms the URL is alive (→ web_verified) or dead (→ web_dead)
|
|
884
|
+
# before falling through to not_found.
|
|
885
|
+
# ------------------------------------------------------------------
|
|
886
|
+
url_need = [c for c in unverified if c.key not in resolved and c.url]
|
|
887
|
+
if url_need:
|
|
888
|
+
from sciwrite_lint.api import _verify_web_resource
|
|
889
|
+
|
|
890
|
+
url_sem = asyncio.Semaphore(5)
|
|
891
|
+
|
|
892
|
+
async def _try_url(c: Citation) -> None:
|
|
893
|
+
async with url_sem:
|
|
894
|
+
# Each task gets its own httpx client — sharing a client
|
|
895
|
+
# across concurrent streaming requests causes gzip
|
|
896
|
+
# decompression errors (corrupt shared decoder state).
|
|
897
|
+
async with httpx.AsyncClient(
|
|
898
|
+
timeout=15.0, follow_redirects=True
|
|
899
|
+
) as client:
|
|
900
|
+
await _verify_web_resource(c, client, references_dir)
|
|
901
|
+
|
|
902
|
+
await asyncio.gather(*[_try_url(c) for c in url_need])
|
|
903
|
+
|
|
904
|
+
url_resolved_keys = {
|
|
905
|
+
c.key for c in url_need if c.api_match in ("web_verified", "web_dead")
|
|
906
|
+
}
|
|
907
|
+
logger.info(
|
|
908
|
+
f"URL verification: {len(url_resolved_keys)}/{len(url_need)} resolved"
|
|
909
|
+
)
|
|
910
|
+
|
|
911
|
+
# Apply results and run metadata comparison
|
|
912
|
+
for c in unverified:
|
|
913
|
+
result = resolved.get(c.key) # type: ignore[assignment]
|
|
914
|
+
if result and not result.get("error"):
|
|
915
|
+
c.api_data = result
|
|
916
|
+
c.api_source = result.get("source", "")
|
|
917
|
+
c.issues.extend(compare_citation_detailed(c, result))
|
|
918
|
+
elif c.api_match not in ("web_verified", "web_dead"):
|
|
919
|
+
# Not found in any API and no URL (or URL not yet checked)
|
|
920
|
+
c.api_match = "not_found"
|
|
921
|
+
c.issues.append(
|
|
922
|
+
"Not found in CrossRef, OpenAlex, Semantic Scholar, Open Library, or Library of Congress"
|
|
923
|
+
)
|
|
924
|
+
|
|
925
|
+
# ------------------------------------------------------------------
|
|
926
|
+
# Phase E: Cross-validate identifiers
|
|
927
|
+
#
|
|
928
|
+
# For verified citations with multiple IDs, look up each ID
|
|
929
|
+
# independently via OpenAlex and verify they all resolve to the
|
|
930
|
+
# same paper. Catches LLM-mixed bib entries where DOI → paper A
|
|
931
|
+
# but arXiv ID → paper B.
|
|
932
|
+
# ------------------------------------------------------------------
|
|
933
|
+
cross_need = [c for c in unverified if c.api_data and not c.api_data.get("error")]
|
|
934
|
+
if cross_need:
|
|
935
|
+
cross_sem = asyncio.Semaphore(5)
|
|
936
|
+
|
|
937
|
+
async with httpx.AsyncClient(
|
|
938
|
+
timeout=15.0, follow_redirects=True
|
|
939
|
+
) as cross_client:
|
|
940
|
+
|
|
941
|
+
async def _cross_validate(c: Citation) -> None:
|
|
942
|
+
async with cross_sem:
|
|
943
|
+
cross_issues = await cross_validate_ids(
|
|
944
|
+
c, c.api_data, config, client=cross_client
|
|
945
|
+
)
|
|
946
|
+
c.issues.extend(cross_issues)
|
|
947
|
+
|
|
948
|
+
await asyncio.gather(*[_cross_validate(c) for c in cross_need])
|
|
949
|
+
|
|
950
|
+
# Set api_match based on all issues (including cross-validation).
|
|
951
|
+
# Only for citations resolved via academic APIs — don't overwrite
|
|
952
|
+
# web_verified / web_dead status set by URL verification.
|
|
953
|
+
for c in unverified:
|
|
954
|
+
result = resolved.get(c.key) # type: ignore[assignment]
|
|
955
|
+
if result and not result.get("error"):
|
|
956
|
+
c.api_match = (
|
|
957
|
+
"mismatch"
|
|
958
|
+
if any("mismatch" in i.lower() for i in c.issues)
|
|
959
|
+
else "verified"
|
|
960
|
+
)
|
|
961
|
+
|
|
962
|
+
# Persist and generate findings (single DB connection for all writes)
|
|
963
|
+
from sciwrite_lint.references.workspace_db import (
|
|
964
|
+
get_db,
|
|
965
|
+
load_citation_metadata,
|
|
966
|
+
save_citation_metadata,
|
|
967
|
+
)
|
|
968
|
+
|
|
969
|
+
with get_db(references_dir) as conn:
|
|
970
|
+
for c in unverified:
|
|
971
|
+
result = resolved.get(c.key) # type: ignore[assignment]
|
|
972
|
+
|
|
973
|
+
existing = load_citation_metadata(conn, c.key)
|
|
974
|
+
meta = build_metadata_from_citation(
|
|
975
|
+
c, result, references_dir=references_dir
|
|
976
|
+
)
|
|
977
|
+
if existing:
|
|
978
|
+
merge_source_paper(meta, c.source_paper)
|
|
979
|
+
for sp in existing.bibitem.get("source_papers", []):
|
|
980
|
+
merge_source_paper(meta, sp)
|
|
981
|
+
if existing.manual_override:
|
|
982
|
+
meta.manual_override = existing.manual_override
|
|
983
|
+
save_citation_metadata(conn, meta)
|
|
984
|
+
_register_ref_in_workspace(meta, references_dir)
|
|
985
|
+
c.tier = meta.access.get("tier", "")
|
|
986
|
+
|
|
987
|
+
for issue in c.issues:
|
|
988
|
+
level, rule_id = _classify_verify_issue(issue)
|
|
989
|
+
findings.append(
|
|
990
|
+
Finding(
|
|
991
|
+
level=level,
|
|
992
|
+
rule_id=rule_id,
|
|
993
|
+
message=f"{c.key}: {issue}",
|
|
994
|
+
file="",
|
|
995
|
+
context=f"Source: {c.api_source}" if c.api_source else "",
|
|
996
|
+
)
|
|
997
|
+
)
|
|
998
|
+
|
|
999
|
+
# Web resources (concurrent — independent HTTP checks)
|
|
1000
|
+
if web_citations:
|
|
1001
|
+
from sciwrite_lint.api import _verify_web_resource
|
|
1002
|
+
|
|
1003
|
+
sem = asyncio.Semaphore(5)
|
|
1004
|
+
|
|
1005
|
+
async def _verify_one_web(c: Citation) -> None:
|
|
1006
|
+
async with sem:
|
|
1007
|
+
async with httpx.AsyncClient(
|
|
1008
|
+
timeout=15.0, follow_redirects=True
|
|
1009
|
+
) as client:
|
|
1010
|
+
result = await _verify_web_resource(c, client, references_dir)
|
|
1011
|
+
meta = build_metadata_from_citation(
|
|
1012
|
+
c, result, references_dir=references_dir
|
|
1013
|
+
)
|
|
1014
|
+
save_metadata(meta, references_dir)
|
|
1015
|
+
c.tier = meta.access.get("tier", "")
|
|
1016
|
+
|
|
1017
|
+
await asyncio.gather(*[_verify_one_web(c) for c in web_citations])
|
|
1018
|
+
|
|
1019
|
+
for c in web_citations:
|
|
1020
|
+
for issue in c.issues:
|
|
1021
|
+
level, rule_id = _classify_verify_issue(issue)
|
|
1022
|
+
findings.append(
|
|
1023
|
+
Finding(
|
|
1024
|
+
level=level,
|
|
1025
|
+
rule_id=rule_id,
|
|
1026
|
+
message=f"{c.key}: {issue}",
|
|
1027
|
+
file="",
|
|
1028
|
+
context=f"Source: {c.api_source}" if c.api_source else "",
|
|
1029
|
+
)
|
|
1030
|
+
)
|
|
1031
|
+
|
|
1032
|
+
# Confirm venue mismatches via vLLM (suppresses false positives like
|
|
1033
|
+
# "NeurIPS" vs "Advances in Neural Information Processing Systems")
|
|
1034
|
+
findings = await _confirm_venue_findings(findings, config)
|
|
1035
|
+
|
|
1036
|
+
# Retraction Watch enrichment: check all metadata against RW database
|
|
1037
|
+
from sciwrite_lint.references.retraction_watch import ensure_rw_database
|
|
1038
|
+
|
|
1039
|
+
rw_db = await ensure_rw_database(config)
|
|
1040
|
+
if rw_db:
|
|
1041
|
+
from sciwrite_lint.references.metadata import enrich_retraction_status
|
|
1042
|
+
from sciwrite_lint.references.workspace_db import (
|
|
1043
|
+
get_db,
|
|
1044
|
+
load_all_citation_metadata,
|
|
1045
|
+
save_citation_metadata,
|
|
1046
|
+
)
|
|
1047
|
+
|
|
1048
|
+
with get_db(references_dir) as conn:
|
|
1049
|
+
all_meta = load_all_citation_metadata(conn)
|
|
1050
|
+
for _key, meta in all_meta.items():
|
|
1051
|
+
if enrich_retraction_status(meta, rw_db):
|
|
1052
|
+
save_citation_metadata(conn, meta)
|
|
1053
|
+
|
|
1054
|
+
return findings
|
|
1055
|
+
|
|
1056
|
+
|
|
1057
|
+
async def _confirm_venue_findings(
|
|
1058
|
+
findings: list[Finding],
|
|
1059
|
+
config: LintConfig,
|
|
1060
|
+
) -> list[Finding]:
|
|
1061
|
+
"""Filter venue mismatch findings through vLLM confirmation.
|
|
1062
|
+
|
|
1063
|
+
If vLLM is available and says the venues match, the finding is dropped.
|
|
1064
|
+
If vLLM is unavailable, all findings pass through unchanged.
|
|
1065
|
+
"""
|
|
1066
|
+
from sciwrite_lint.references.matching import venue_match_llm
|
|
1067
|
+
|
|
1068
|
+
venue_findings = []
|
|
1069
|
+
other_findings = []
|
|
1070
|
+
for f in findings:
|
|
1071
|
+
if "Venue mismatch" in f.message:
|
|
1072
|
+
venue_findings.append(f)
|
|
1073
|
+
else:
|
|
1074
|
+
other_findings.append(f)
|
|
1075
|
+
|
|
1076
|
+
if not venue_findings:
|
|
1077
|
+
return findings
|
|
1078
|
+
|
|
1079
|
+
# Extract venue pairs from finding messages
|
|
1080
|
+
import re
|
|
1081
|
+
|
|
1082
|
+
sem = asyncio.Semaphore(50)
|
|
1083
|
+
|
|
1084
|
+
async def _confirm_one(f: Finding) -> Finding | None:
|
|
1085
|
+
m = re.search(r"tex='([^']*)', (?:canonical|API)='([^']*)'", f.message)
|
|
1086
|
+
if not m:
|
|
1087
|
+
return f # can't parse, keep
|
|
1088
|
+
async with sem:
|
|
1089
|
+
try:
|
|
1090
|
+
same = await venue_match_llm(m.group(1), m.group(2), config=config)
|
|
1091
|
+
except Exception as e:
|
|
1092
|
+
logger.debug("Venue match LLM failed: {}", e)
|
|
1093
|
+
return f # vLLM error, keep the finding
|
|
1094
|
+
if same is True:
|
|
1095
|
+
return None # vLLM confirmed same venue — suppress
|
|
1096
|
+
return f
|
|
1097
|
+
|
|
1098
|
+
results = await asyncio.gather(*[_confirm_one(f) for f in venue_findings])
|
|
1099
|
+
confirmed = [r for r in results if r is not None]
|
|
1100
|
+
|
|
1101
|
+
suppressed = len(venue_findings) - len(confirmed)
|
|
1102
|
+
if suppressed:
|
|
1103
|
+
logger.info(f"Venue: {suppressed} false positive(s) suppressed by vLLM")
|
|
1104
|
+
|
|
1105
|
+
return other_findings + confirmed
|
|
1106
|
+
|
|
1107
|
+
|
|
1108
|
+
# ---------------------------------------------------------------------------
|
|
1109
|
+
# Reference accuracy (post-verify, no API calls)
|
|
1110
|
+
# ---------------------------------------------------------------------------
|
|
1111
|
+
|
|
1112
|
+
|
|
1113
|
+
def _stage_reference_accuracy(
|
|
1114
|
+
config: LintConfig,
|
|
1115
|
+
references_dir: Path,
|
|
1116
|
+
) -> list[Finding]:
|
|
1117
|
+
"""Run reference-accuracy check on stored metadata. No API calls."""
|
|
1118
|
+
from sciwrite_lint.checks.reference_accuracy import (
|
|
1119
|
+
check_reference_accuracy_from_metadata,
|
|
1120
|
+
)
|
|
1121
|
+
from sciwrite_lint.references.metadata import load_all_metadata
|
|
1122
|
+
|
|
1123
|
+
if not config.is_check_enabled("reference-accuracy"):
|
|
1124
|
+
return []
|
|
1125
|
+
if not references_dir.exists():
|
|
1126
|
+
return []
|
|
1127
|
+
|
|
1128
|
+
all_metadata = load_all_metadata(references_dir)
|
|
1129
|
+
if not all_metadata:
|
|
1130
|
+
return []
|
|
1131
|
+
|
|
1132
|
+
findings = check_reference_accuracy_from_metadata(all_metadata)
|
|
1133
|
+
|
|
1134
|
+
# Apply severity override
|
|
1135
|
+
override = config.effective_severity("reference-accuracy", "warning")
|
|
1136
|
+
for f in findings:
|
|
1137
|
+
if f.level == "warning" and override != "warning":
|
|
1138
|
+
f.level = override # type: ignore[assignment]
|
|
1139
|
+
|
|
1140
|
+
if findings:
|
|
1141
|
+
logger.info(f"Reference accuracy: {len(findings)} issues")
|
|
1142
|
+
return findings
|
|
1143
|
+
|
|
1144
|
+
|
|
1145
|
+
# ---------------------------------------------------------------------------
|
|
1146
|
+
# Stage 3: Full-text acquisition
|
|
1147
|
+
# ---------------------------------------------------------------------------
|
|
1148
|
+
|
|
1149
|
+
|
|
1150
|
+
async def _stage_fetch(
|
|
1151
|
+
citations: list[Citation],
|
|
1152
|
+
config: LintConfig,
|
|
1153
|
+
references_dir: Path,
|
|
1154
|
+
embed_inline: bool = True,
|
|
1155
|
+
) -> int:
|
|
1156
|
+
"""Download PDFs for citations missing local files. Returns count fetched.
|
|
1157
|
+
|
|
1158
|
+
Args:
|
|
1159
|
+
embed_inline: If True (default), eagerly embed parsed PDFs in-process.
|
|
1160
|
+
Set to False in batch-staged mode to avoid loading the embedding
|
|
1161
|
+
model's CUDA context in the parent process — the batch embedding
|
|
1162
|
+
subprocess handles all embeddings instead.
|
|
1163
|
+
"""
|
|
1164
|
+
from sciwrite_lint.fulltext import acquire_fulltext
|
|
1165
|
+
from sciwrite_lint.references.metadata import (
|
|
1166
|
+
compute_tier,
|
|
1167
|
+
load_metadata,
|
|
1168
|
+
save_metadata,
|
|
1169
|
+
)
|
|
1170
|
+
from sciwrite_lint.references.reference_store import parse_and_embed
|
|
1171
|
+
from sciwrite_lint.usage import tracked
|
|
1172
|
+
|
|
1173
|
+
need_fetch = []
|
|
1174
|
+
|
|
1175
|
+
for c in citations:
|
|
1176
|
+
meta = load_metadata(c.key, references_dir)
|
|
1177
|
+
if not meta:
|
|
1178
|
+
continue
|
|
1179
|
+
tier = meta.access.get("tier", "")
|
|
1180
|
+
local = meta.access.get("local_file", "")
|
|
1181
|
+
if tier == "T1" and local and (references_dir / local).exists():
|
|
1182
|
+
continue
|
|
1183
|
+
if tier == "T3" or meta.api_match in ("not_found", "web_verified", "web_dead"):
|
|
1184
|
+
continue
|
|
1185
|
+
need_fetch.append((c, meta))
|
|
1186
|
+
|
|
1187
|
+
if not need_fetch:
|
|
1188
|
+
return 0
|
|
1189
|
+
|
|
1190
|
+
# Check local_pdfs_dir for user-provided PDFs before downloading
|
|
1191
|
+
from sciwrite_lint.local_pdfs import copy_local_pdf, match_local_pdfs
|
|
1192
|
+
|
|
1193
|
+
local_pdfs_dir = config.local_pdfs_dir
|
|
1194
|
+
if local_pdfs_dir.is_dir() and any(local_pdfs_dir.glob("*.pdf")):
|
|
1195
|
+
titles = {
|
|
1196
|
+
c.key: (c.title or meta.canonical.get("title", ""))
|
|
1197
|
+
for c, meta in need_fetch
|
|
1198
|
+
}
|
|
1199
|
+
matched, _ = match_local_pdfs(local_pdfs_dir, titles)
|
|
1200
|
+
|
|
1201
|
+
still_need = []
|
|
1202
|
+
for c, meta in need_fetch:
|
|
1203
|
+
if c.key in matched:
|
|
1204
|
+
local_path = copy_local_pdf(matched[c.key], c.key, references_dir)
|
|
1205
|
+
meta.access["local_file"] = local_path
|
|
1206
|
+
meta.access["tier"] = compute_tier(meta)
|
|
1207
|
+
save_metadata(meta, references_dir)
|
|
1208
|
+
logger.info(f"{c.key}: using local PDF [{meta.access['tier']}]")
|
|
1209
|
+
else:
|
|
1210
|
+
still_need.append((c, meta))
|
|
1211
|
+
need_fetch = still_need
|
|
1212
|
+
|
|
1213
|
+
if not need_fetch:
|
|
1214
|
+
return 0
|
|
1215
|
+
|
|
1216
|
+
logger.info(f"Fetching full text for {len(need_fetch)} citations")
|
|
1217
|
+
sem = asyncio.Semaphore(5) # max 5 concurrent downloads
|
|
1218
|
+
fetched_keys: list[str] = []
|
|
1219
|
+
|
|
1220
|
+
async def _fetch_one(c: Citation, meta) -> None:
|
|
1221
|
+
doi = meta.canonical.get("doi") or c.doi
|
|
1222
|
+
arxiv_id = meta.canonical.get("arxiv_id")
|
|
1223
|
+
oa_url = meta.access.get("oa_url")
|
|
1224
|
+
s2_pdf_url = meta.canonical.get("s2_pdf_url")
|
|
1225
|
+
pmcid = meta.canonical.get("pmcid")
|
|
1226
|
+
expected_title = c.title or meta.canonical.get("title", "")
|
|
1227
|
+
expected_authors = meta.canonical.get("authors") or c.authors
|
|
1228
|
+
|
|
1229
|
+
async with sem:
|
|
1230
|
+
async with tracked("fetch"):
|
|
1231
|
+
result = await acquire_fulltext(
|
|
1232
|
+
c.key,
|
|
1233
|
+
references_dir,
|
|
1234
|
+
config=config,
|
|
1235
|
+
doi=doi,
|
|
1236
|
+
arxiv_id=arxiv_id,
|
|
1237
|
+
oa_url=oa_url,
|
|
1238
|
+
s2_pdf_url=s2_pdf_url,
|
|
1239
|
+
pmcid=pmcid,
|
|
1240
|
+
expected_title=expected_title,
|
|
1241
|
+
expected_authors=expected_authors,
|
|
1242
|
+
progress=False,
|
|
1243
|
+
)
|
|
1244
|
+
|
|
1245
|
+
if result.found and result.local_path:
|
|
1246
|
+
meta.access["local_file"] = result.local_path
|
|
1247
|
+
meta.access["tier"] = compute_tier(meta)
|
|
1248
|
+
save_metadata(meta, references_dir)
|
|
1249
|
+
fetched_keys.append(c.key)
|
|
1250
|
+
logger.info(f"{c.key}: downloaded [{meta.access['tier']}]")
|
|
1251
|
+
|
|
1252
|
+
# Eager parse (also concurrent — GROBID handles it).
|
|
1253
|
+
# In batch mode (embed_inline=False), skip in-process embedding
|
|
1254
|
+
# to avoid loading the CUDA context in the parent process.
|
|
1255
|
+
if result.local_path.endswith(".pdf"):
|
|
1256
|
+
try:
|
|
1257
|
+
text, chunks = await parse_and_embed(
|
|
1258
|
+
c.key,
|
|
1259
|
+
references_dir / result.local_path,
|
|
1260
|
+
references_dir,
|
|
1261
|
+
embed=embed_inline,
|
|
1262
|
+
)
|
|
1263
|
+
if text:
|
|
1264
|
+
suffix = f", {chunks} chunks" if chunks else ""
|
|
1265
|
+
logger.debug(f"{c.key}: parsed {len(text)} chars{suffix}")
|
|
1266
|
+
|
|
1267
|
+
# Store formal classification in citation metadata
|
|
1268
|
+
from sciwrite_lint.references.reference_store import (
|
|
1269
|
+
is_formal_cached,
|
|
1270
|
+
)
|
|
1271
|
+
|
|
1272
|
+
formal = is_formal_cached(c.key, references_dir)
|
|
1273
|
+
meta.access["is_formal"] = formal
|
|
1274
|
+
save_metadata(meta, references_dir)
|
|
1275
|
+
if not formal:
|
|
1276
|
+
logger.info(
|
|
1277
|
+
f"{c.key}: non-formal document — "
|
|
1278
|
+
f"text available, no reference extraction"
|
|
1279
|
+
)
|
|
1280
|
+
except Exception as e:
|
|
1281
|
+
logger.warning(f"{c.key}: parse failed: {e}")
|
|
1282
|
+
elif not result.found:
|
|
1283
|
+
if result.reason:
|
|
1284
|
+
meta.access["acquisition_reason"] = result.reason
|
|
1285
|
+
save_metadata(meta, references_dir)
|
|
1286
|
+
logger.debug(
|
|
1287
|
+
f"{c.key}: PDF not acquired (stays T2)"
|
|
1288
|
+
+ (f" — {result.reason}" if result.reason else "")
|
|
1289
|
+
)
|
|
1290
|
+
|
|
1291
|
+
if result.abstract and not meta.canonical.get("abstract"):
|
|
1292
|
+
meta.canonical["abstract"] = result.abstract
|
|
1293
|
+
meta.access["tier"] = compute_tier(meta)
|
|
1294
|
+
save_metadata(meta, references_dir)
|
|
1295
|
+
|
|
1296
|
+
await asyncio.gather(*[_fetch_one(c, meta) for c, meta in need_fetch])
|
|
1297
|
+
return len(fetched_keys)
|
|
1298
|
+
|
|
1299
|
+
|
|
1300
|
+
# ---------------------------------------------------------------------------
|
|
1301
|
+
# Stage 4: GROBID parse + embeddings
|
|
1302
|
+
# ---------------------------------------------------------------------------
|
|
1303
|
+
|
|
1304
|
+
|
|
1305
|
+
async def _stage_parse(
|
|
1306
|
+
config: LintConfig,
|
|
1307
|
+
references_dir: Path,
|
|
1308
|
+
parse_sem: asyncio.Semaphore | None = None,
|
|
1309
|
+
skip_embeddings: bool = False,
|
|
1310
|
+
) -> tuple[int, int]:
|
|
1311
|
+
"""Parse unparsed PDFs via GROBID + build embeddings. Returns (parsed_count, cached_count).
|
|
1312
|
+
|
|
1313
|
+
Args:
|
|
1314
|
+
skip_embeddings: If True, skip the embedding subprocess. Used by
|
|
1315
|
+
``run_papers_staged()`` which runs embedding in a single batch
|
|
1316
|
+
subprocess across all papers (see ``_batch_embed``).
|
|
1317
|
+
"""
|
|
1318
|
+
from sciwrite_lint.references.reference_store import parse_all_missing
|
|
1319
|
+
|
|
1320
|
+
results = await parse_all_missing(references_dir, sem=parse_sem)
|
|
1321
|
+
|
|
1322
|
+
cached = sum(1 for v in results.values() if v == "cached")
|
|
1323
|
+
parsed = sum(1 for v in results.values() if v == "parsed")
|
|
1324
|
+
failed = sum(1 for v in results.values() if v == "failed")
|
|
1325
|
+
|
|
1326
|
+
if not skip_embeddings:
|
|
1327
|
+
_run_embeddings_for_paper(results, references_dir, config)
|
|
1328
|
+
|
|
1329
|
+
parts = []
|
|
1330
|
+
if parsed:
|
|
1331
|
+
parts.append(f"{parsed} new")
|
|
1332
|
+
if cached:
|
|
1333
|
+
parts.append(f"{cached} cached")
|
|
1334
|
+
if failed:
|
|
1335
|
+
parts.append(f"{failed} failed")
|
|
1336
|
+
if parts:
|
|
1337
|
+
logger.info("Parse: {}", ", ".join(parts))
|
|
1338
|
+
|
|
1339
|
+
return parsed, cached
|
|
1340
|
+
|
|
1341
|
+
|
|
1342
|
+
def _run_embeddings_for_paper(
|
|
1343
|
+
parse_results: dict[str, str],
|
|
1344
|
+
references_dir: Path,
|
|
1345
|
+
config: LintConfig,
|
|
1346
|
+
) -> None:
|
|
1347
|
+
"""Run embedding subprocesses for one paper's parsed + web keys.
|
|
1348
|
+
|
|
1349
|
+
Raises ``RuntimeError`` if the embedding subprocess fails to actually
|
|
1350
|
+
produce embeddings for any requested key. ``_run_embeddings_subprocess``
|
|
1351
|
+
swallows all subprocess failures (non-zero exit, timeout, exception)
|
|
1352
|
+
with just a warning log, so we verify by re-checking has_embeddings()
|
|
1353
|
+
against the DB afterwards — the authoritative source of truth. Failing
|
|
1354
|
+
loudly here avoids a cryptic "no embeddings found" crash 20+ minutes
|
|
1355
|
+
later during claim verification.
|
|
1356
|
+
"""
|
|
1357
|
+
from sciwrite_lint.references.embedding_store import has_embeddings
|
|
1358
|
+
from sciwrite_lint.references.reference_store import _get_embedding_config
|
|
1359
|
+
|
|
1360
|
+
model_name, _, _ = _get_embedding_config()
|
|
1361
|
+
keys_to_embed = []
|
|
1362
|
+
for key, status in parse_results.items():
|
|
1363
|
+
if status == "parsed":
|
|
1364
|
+
keys_to_embed.append(key)
|
|
1365
|
+
elif status == "cached" and not has_embeddings(
|
|
1366
|
+
key, references_dir, model_name=model_name
|
|
1367
|
+
):
|
|
1368
|
+
keys_to_embed.append(key)
|
|
1369
|
+
|
|
1370
|
+
# Run embedding in a subprocess to isolate the CUDA context.
|
|
1371
|
+
# The embedding model (~1.2 GB on WSL2/CUDA) brings batch data to
|
|
1372
|
+
# VRAM; subprocess isolation ensures all CUDA allocations are released
|
|
1373
|
+
# when embedding finishes, freeing VRAM for vLLM claim verification.
|
|
1374
|
+
if keys_to_embed:
|
|
1375
|
+
diag = _run_embeddings_subprocess(keys_to_embed, references_dir, config)
|
|
1376
|
+
_verify_embeddings_or_raise(
|
|
1377
|
+
keys_to_embed,
|
|
1378
|
+
references_dir,
|
|
1379
|
+
model_name,
|
|
1380
|
+
kind="parsed",
|
|
1381
|
+
subprocess_diag=diag,
|
|
1382
|
+
)
|
|
1383
|
+
|
|
1384
|
+
# Embed web summaries ({key}_web.md) that lack embeddings.
|
|
1385
|
+
from sciwrite_lint.references.metadata import load_all_metadata
|
|
1386
|
+
|
|
1387
|
+
all_meta = load_all_metadata(references_dir)
|
|
1388
|
+
web_keys = []
|
|
1389
|
+
for key, meta in all_meta.items():
|
|
1390
|
+
local_file = meta.access.get("local_file") or ""
|
|
1391
|
+
if not local_file.endswith("_web.md"):
|
|
1392
|
+
continue
|
|
1393
|
+
if has_embeddings(key, references_dir, model_name=model_name):
|
|
1394
|
+
continue
|
|
1395
|
+
web_path = references_dir / local_file
|
|
1396
|
+
if web_path.exists():
|
|
1397
|
+
web_keys.append(key)
|
|
1398
|
+
|
|
1399
|
+
if web_keys:
|
|
1400
|
+
diag = _run_embeddings_subprocess(web_keys, references_dir, config)
|
|
1401
|
+
_verify_embeddings_or_raise(
|
|
1402
|
+
web_keys,
|
|
1403
|
+
references_dir,
|
|
1404
|
+
model_name,
|
|
1405
|
+
kind="web summary",
|
|
1406
|
+
subprocess_diag=diag,
|
|
1407
|
+
)
|
|
1408
|
+
|
|
1409
|
+
|
|
1410
|
+
def _verify_embeddings_or_raise(
|
|
1411
|
+
keys: list[str],
|
|
1412
|
+
references_dir: Path,
|
|
1413
|
+
model_name: str,
|
|
1414
|
+
kind: str,
|
|
1415
|
+
subprocess_diag: str = "",
|
|
1416
|
+
) -> None:
|
|
1417
|
+
"""Verify that all requested keys have embeddings in the DB.
|
|
1418
|
+
|
|
1419
|
+
Called after ``_run_embeddings_subprocess``. Raises ``RuntimeError``
|
|
1420
|
+
with a clear message if any keys are missing, including the subprocess
|
|
1421
|
+
stderr tail so the user sees the actual failure (CUDA OOM, subprocess
|
|
1422
|
+
timeout, model load failure, etc.) inline with the error.
|
|
1423
|
+
"""
|
|
1424
|
+
from sciwrite_lint.references.embedding_store import has_embeddings
|
|
1425
|
+
|
|
1426
|
+
missing = [
|
|
1427
|
+
k for k in keys if not has_embeddings(k, references_dir, model_name=model_name)
|
|
1428
|
+
]
|
|
1429
|
+
if missing:
|
|
1430
|
+
sample = ", ".join(missing[:3])
|
|
1431
|
+
msg = (
|
|
1432
|
+
f"Embedding subprocess failed to produce {kind} embeddings: "
|
|
1433
|
+
f"{len(missing)}/{len(keys)} keys missing (first missing: "
|
|
1434
|
+
f"{sample})."
|
|
1435
|
+
)
|
|
1436
|
+
if subprocess_diag:
|
|
1437
|
+
msg += f"\n\nSubprocess diagnostic:\n{subprocess_diag}"
|
|
1438
|
+
else:
|
|
1439
|
+
msg += (
|
|
1440
|
+
"\n\nSubprocess exited cleanly but embeddings are absent — "
|
|
1441
|
+
"likely a silent internal skip (check for per-key exceptions "
|
|
1442
|
+
"in the subprocess log above)."
|
|
1443
|
+
)
|
|
1444
|
+
msg += (
|
|
1445
|
+
"\n\nCommon causes: CUDA OOM, subprocess timeout (300s default), "
|
|
1446
|
+
"model load failure, or sqlite-vec DB lock."
|
|
1447
|
+
)
|
|
1448
|
+
raise RuntimeError(msg)
|
|
1449
|
+
|
|
1450
|
+
|
|
1451
|
+
# ---------------------------------------------------------------------------
|
|
1452
|
+
# Stage 4.6: Bibliography verification (existence + metadata + retraction)
|
|
1453
|
+
# ---------------------------------------------------------------------------
|
|
1454
|
+
|
|
1455
|
+
|
|
1456
|
+
def _collect_parse_hashes(references_dir: Path) -> dict[str, str]:
|
|
1457
|
+
"""Collect MD5 hashes of parsed markdown files for cache invalidation."""
|
|
1458
|
+
import hashlib
|
|
1459
|
+
|
|
1460
|
+
parsed_dir = references_dir / "parsed"
|
|
1461
|
+
if not parsed_dir.exists():
|
|
1462
|
+
return {}
|
|
1463
|
+
hashes: dict[str, str] = {}
|
|
1464
|
+
for md_path in parsed_dir.glob("*.md"):
|
|
1465
|
+
content = md_path.read_bytes()
|
|
1466
|
+
hashes[md_path.stem] = hashlib.md5(content, usedforsecurity=False).hexdigest()
|
|
1467
|
+
return hashes
|
|
1468
|
+
|
|
1469
|
+
|
|
1470
|
+
async def _stage_bib_verify(
|
|
1471
|
+
config: LintConfig,
|
|
1472
|
+
references_dir: Path,
|
|
1473
|
+
fresh: bool = False,
|
|
1474
|
+
) -> list[Any]:
|
|
1475
|
+
"""Verify bibliography entries of parsed formal references via APIs.
|
|
1476
|
+
|
|
1477
|
+
Runs after parse (Stage 4) and concurrently with claim verification
|
|
1478
|
+
(Stage 5). Uses structured GROBID data stored in workspace.db at
|
|
1479
|
+
parse time. No GPU needed — API calls only.
|
|
1480
|
+
|
|
1481
|
+
Results are cached in workspace.db (bib_checks table), invalidated
|
|
1482
|
+
when a reference's parsed markdown changes (hash mismatch).
|
|
1483
|
+
"""
|
|
1484
|
+
from sciwrite_lint.references.workspace_db import (
|
|
1485
|
+
get_db,
|
|
1486
|
+
load_bib_checks as load_bib_checks_db,
|
|
1487
|
+
save_bib_checks,
|
|
1488
|
+
)
|
|
1489
|
+
from sciwrite_lint.scoring.chain import RefBibCheck
|
|
1490
|
+
|
|
1491
|
+
with get_db(references_dir) as conn:
|
|
1492
|
+
# Compute parse hashes for cache invalidation
|
|
1493
|
+
parse_hashes = _collect_parse_hashes(references_dir)
|
|
1494
|
+
|
|
1495
|
+
if not fresh:
|
|
1496
|
+
cached = load_bib_checks_db(conn, parse_hashes=parse_hashes)
|
|
1497
|
+
if cached:
|
|
1498
|
+
logger.info("Bibliography verification: {} cached results", len(cached))
|
|
1499
|
+
return [RefBibCheck(**c) for c in cached]
|
|
1500
|
+
|
|
1501
|
+
from sciwrite_lint.scoring.chain import run_bib_verification
|
|
1502
|
+
|
|
1503
|
+
results = await run_bib_verification(references_dir, config)
|
|
1504
|
+
|
|
1505
|
+
# Cache in workspace.db with parse hashes
|
|
1506
|
+
if results:
|
|
1507
|
+
save_bib_checks(
|
|
1508
|
+
conn,
|
|
1509
|
+
[r.model_dump() for r in results],
|
|
1510
|
+
parse_hashes=parse_hashes,
|
|
1511
|
+
)
|
|
1512
|
+
|
|
1513
|
+
return results
|
|
1514
|
+
|
|
1515
|
+
|
|
1516
|
+
# ---------------------------------------------------------------------------
|
|
1517
|
+
# Stage 5: Claim verification → sem-001 findings
|
|
1518
|
+
# ---------------------------------------------------------------------------
|
|
1519
|
+
|
|
1520
|
+
|
|
1521
|
+
async def _stage_claims(
|
|
1522
|
+
paper_name: str,
|
|
1523
|
+
tex_path: Path,
|
|
1524
|
+
config: LintConfig,
|
|
1525
|
+
references_dir: Path,
|
|
1526
|
+
bib_path: Path | None = None,
|
|
1527
|
+
rerun: bool = False,
|
|
1528
|
+
) -> tuple[list[Finding], list[dict]]:
|
|
1529
|
+
"""Run claim verification. Returns (findings, raw_results)."""
|
|
1530
|
+
from sciwrite_lint.eval_claims import run_claim_verification
|
|
1531
|
+
|
|
1532
|
+
results = await run_claim_verification(
|
|
1533
|
+
paper_name,
|
|
1534
|
+
tex_path,
|
|
1535
|
+
references_dir,
|
|
1536
|
+
config=config,
|
|
1537
|
+
bib_path=bib_path,
|
|
1538
|
+
backend="vllm",
|
|
1539
|
+
model=config.llm_model or "",
|
|
1540
|
+
rerun=rerun,
|
|
1541
|
+
)
|
|
1542
|
+
|
|
1543
|
+
return _claims_to_findings(results, tex_path), results
|
|
1544
|
+
|
|
1545
|
+
|
|
1546
|
+
def _claims_to_findings(results: list[dict], tex_path: Path) -> list[Finding]:
|
|
1547
|
+
"""Convert claim verification results to findings. Delegates to check modules."""
|
|
1548
|
+
from sciwrite_lint.checks.claim_support import claims_to_findings
|
|
1549
|
+
from sciwrite_lint.checks.cite_purpose import cite_purposes_to_findings
|
|
1550
|
+
|
|
1551
|
+
findings = claims_to_findings(results, tex_path)
|
|
1552
|
+
findings.extend(cite_purposes_to_findings(results, tex_path))
|
|
1553
|
+
return findings
|
|
1554
|
+
|
|
1555
|
+
|
|
1556
|
+
# ---------------------------------------------------------------------------
|
|
1557
|
+
# Stage 4.5: Reference internal consistency checks
|
|
1558
|
+
# ---------------------------------------------------------------------------
|
|
1559
|
+
|
|
1560
|
+
|
|
1561
|
+
def _stage_cited_vision(
|
|
1562
|
+
references_dir: Path,
|
|
1563
|
+
fresh: bool = False,
|
|
1564
|
+
) -> dict[str, str]:
|
|
1565
|
+
"""Stage 4.2: Describe figures from cited paper PDFs.
|
|
1566
|
+
|
|
1567
|
+
Runs VL model in a subprocess (same isolation as _stage_vision) after
|
|
1568
|
+
the embedding model is unloaded (Stage 4) and before vLLM ref_internal
|
|
1569
|
+
queries (Stage 4.5).
|
|
1570
|
+
|
|
1571
|
+
The subprocess runs VL inference and caches results in workspace.db.
|
|
1572
|
+
The parent process reads cached descriptions from DB afterwards.
|
|
1573
|
+
|
|
1574
|
+
Returns {ref_key: figure_descriptions_str} for injection into
|
|
1575
|
+
ref_internal consistency queries.
|
|
1576
|
+
"""
|
|
1577
|
+
import subprocess
|
|
1578
|
+
import sys
|
|
1579
|
+
|
|
1580
|
+
from sciwrite_lint.vision.cache import format_descriptions_from_db
|
|
1581
|
+
from sciwrite_lint.vision.image_extraction import (
|
|
1582
|
+
ExtractedImage,
|
|
1583
|
+
extract_images_from_pdf,
|
|
1584
|
+
)
|
|
1585
|
+
|
|
1586
|
+
parsed_dir = references_dir / "parsed"
|
|
1587
|
+
if not parsed_dir.exists():
|
|
1588
|
+
return {}
|
|
1589
|
+
|
|
1590
|
+
keys = [f.stem for f in sorted(parsed_dir.glob("*.md"))]
|
|
1591
|
+
if not keys:
|
|
1592
|
+
return {}
|
|
1593
|
+
|
|
1594
|
+
# Collect images and their ref_key ranges (lightweight, no GPU)
|
|
1595
|
+
all_images: list[ExtractedImage] = []
|
|
1596
|
+
ref_image_ranges: dict[str, tuple[int, int]] = {}
|
|
1597
|
+
|
|
1598
|
+
output_dir = references_dir / "parsed" / "ref_figures"
|
|
1599
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
1600
|
+
|
|
1601
|
+
for key in keys:
|
|
1602
|
+
candidates = sorted(references_dir.glob(f"{key}*.pdf"))
|
|
1603
|
+
if not candidates:
|
|
1604
|
+
continue
|
|
1605
|
+
start = len(all_images)
|
|
1606
|
+
images = extract_images_from_pdf(candidates[0], output_dir / key)
|
|
1607
|
+
all_images.extend(images)
|
|
1608
|
+
if images:
|
|
1609
|
+
ref_image_ranges[key] = (start, len(all_images))
|
|
1610
|
+
|
|
1611
|
+
if not all_images:
|
|
1612
|
+
return {}
|
|
1613
|
+
|
|
1614
|
+
# Dynamic timeout: ~5s per image (conservative, GPU batch=16), 60s for model load
|
|
1615
|
+
timeout = max(120, 60 + len(all_images) * 5)
|
|
1616
|
+
|
|
1617
|
+
# Run VL inference in subprocess to isolate CUDA context
|
|
1618
|
+
cmd = [
|
|
1619
|
+
sys.executable,
|
|
1620
|
+
"-c",
|
|
1621
|
+
"from sciwrite_lint.checks.ref_internal_checks import _describe_cited_figures_vl; "
|
|
1622
|
+
f"_describe_cited_figures_vl({str(references_dir)!r}, {fresh!r})",
|
|
1623
|
+
]
|
|
1624
|
+
try:
|
|
1625
|
+
result = subprocess.run(
|
|
1626
|
+
cmd,
|
|
1627
|
+
capture_output=True,
|
|
1628
|
+
text=True,
|
|
1629
|
+
timeout=timeout,
|
|
1630
|
+
)
|
|
1631
|
+
if result.returncode != 0:
|
|
1632
|
+
stderr = result.stderr.strip()[-1500:] if result.stderr else ""
|
|
1633
|
+
logger.error(
|
|
1634
|
+
"Cited vision subprocess failed (exit {}): {}\n"
|
|
1635
|
+
"Cited figures missing — ref-internal checks will run with "
|
|
1636
|
+
"reduced visual context.",
|
|
1637
|
+
result.returncode,
|
|
1638
|
+
stderr,
|
|
1639
|
+
)
|
|
1640
|
+
except subprocess.TimeoutExpired:
|
|
1641
|
+
logger.error(
|
|
1642
|
+
"Cited vision subprocess timed out ({}s, {} images) — figures missing",
|
|
1643
|
+
timeout,
|
|
1644
|
+
len(all_images),
|
|
1645
|
+
)
|
|
1646
|
+
except Exception as e:
|
|
1647
|
+
logger.error("Cited vision subprocess error: {}: {}", type(e).__name__, e)
|
|
1648
|
+
|
|
1649
|
+
# Read cached descriptions from DB (written by subprocess)
|
|
1650
|
+
descriptions: dict[str, str] = {}
|
|
1651
|
+
for key, (start, end) in ref_image_ranges.items():
|
|
1652
|
+
ref_images = all_images[start:end]
|
|
1653
|
+
desc = format_descriptions_from_db(ref_images, references_dir)
|
|
1654
|
+
if desc:
|
|
1655
|
+
descriptions[key] = desc
|
|
1656
|
+
|
|
1657
|
+
if descriptions:
|
|
1658
|
+
logger.info(
|
|
1659
|
+
"Cited paper figures: {} papers, {} images described",
|
|
1660
|
+
len(descriptions),
|
|
1661
|
+
len(all_images),
|
|
1662
|
+
)
|
|
1663
|
+
|
|
1664
|
+
return descriptions
|
|
1665
|
+
|
|
1666
|
+
|
|
1667
|
+
async def _stage_ref_internal(
|
|
1668
|
+
references_dir: Path,
|
|
1669
|
+
config: LintConfig,
|
|
1670
|
+
*,
|
|
1671
|
+
fresh: bool = False,
|
|
1672
|
+
ref_figure_descs: dict[str, str] | None = None,
|
|
1673
|
+
) -> dict[str, Any]:
|
|
1674
|
+
"""Run consistency checks on cited papers (automatic in default pipeline)."""
|
|
1675
|
+
from sciwrite_lint.checks.ref_internal_checks import run_ref_internal_checks
|
|
1676
|
+
|
|
1677
|
+
return await run_ref_internal_checks(
|
|
1678
|
+
references_dir,
|
|
1679
|
+
config,
|
|
1680
|
+
fresh=fresh,
|
|
1681
|
+
ref_figure_descs=ref_figure_descs,
|
|
1682
|
+
)
|
|
1683
|
+
|
|
1684
|
+
|
|
1685
|
+
# ---------------------------------------------------------------------------
|
|
1686
|
+
# Stage 6: Reference-unreliable (aggregate reliability signals)
|
|
1687
|
+
# ---------------------------------------------------------------------------
|
|
1688
|
+
|
|
1689
|
+
|
|
1690
|
+
def _stage_unreliable(
|
|
1691
|
+
tex_path: Path,
|
|
1692
|
+
references_dir: Path,
|
|
1693
|
+
claim_results: list[dict],
|
|
1694
|
+
bib_checks: list[Any] | None = None,
|
|
1695
|
+
) -> list[Finding]:
|
|
1696
|
+
"""Aggregate reliability signals into reference-unreliable findings.
|
|
1697
|
+
|
|
1698
|
+
Uses claim results (deep path) when available, otherwise metadata only.
|
|
1699
|
+
Bibliography checks are passed from the pipeline (Stage 4.6).
|
|
1700
|
+
"""
|
|
1701
|
+
from sciwrite_lint.checks.reference_unreliable import (
|
|
1702
|
+
claims_to_unreliable_findings,
|
|
1703
|
+
metadata_to_unreliable_findings,
|
|
1704
|
+
)
|
|
1705
|
+
from sciwrite_lint.references.metadata import load_all_metadata
|
|
1706
|
+
|
|
1707
|
+
all_meta = load_all_metadata(references_dir)
|
|
1708
|
+
if not all_meta:
|
|
1709
|
+
return []
|
|
1710
|
+
|
|
1711
|
+
if claim_results:
|
|
1712
|
+
return claims_to_unreliable_findings(
|
|
1713
|
+
claim_results,
|
|
1714
|
+
tex_path,
|
|
1715
|
+
metadata_map=all_meta,
|
|
1716
|
+
bib_checks=bib_checks,
|
|
1717
|
+
)
|
|
1718
|
+
return metadata_to_unreliable_findings(
|
|
1719
|
+
all_meta,
|
|
1720
|
+
tex_path,
|
|
1721
|
+
bib_checks=bib_checks,
|
|
1722
|
+
)
|
|
1723
|
+
|
|
1724
|
+
|
|
1725
|
+
# ---------------------------------------------------------------------------
|
|
1726
|
+
# Issue classifier (moved from __main__.py, shared)
|
|
1727
|
+
# ---------------------------------------------------------------------------
|
|
1728
|
+
|
|
1729
|
+
|
|
1730
|
+
def _format_usage_summary(run: Any) -> str:
|
|
1731
|
+
"""One-line summary of external tool calls for terminal output."""
|
|
1732
|
+
parts: list[str] = []
|
|
1733
|
+
|
|
1734
|
+
# APIs
|
|
1735
|
+
api_parts: list[str] = []
|
|
1736
|
+
for name, svc in [
|
|
1737
|
+
("OpenAlex", run.openalex),
|
|
1738
|
+
("S2", run.semantic_scholar),
|
|
1739
|
+
("CrossRef", run.crossref),
|
|
1740
|
+
]:
|
|
1741
|
+
if svc.calls:
|
|
1742
|
+
api_parts.append(f"{svc.calls} {name}")
|
|
1743
|
+
if api_parts:
|
|
1744
|
+
parts.append(f"APIs: {', '.join(api_parts)}")
|
|
1745
|
+
|
|
1746
|
+
# GROBID
|
|
1747
|
+
if run.grobid.calls:
|
|
1748
|
+
parts.append(f"GROBID: {run.grobid.calls} parsed")
|
|
1749
|
+
|
|
1750
|
+
# Fetch
|
|
1751
|
+
if run.fetch.calls:
|
|
1752
|
+
parts.append(f"Fetch: {run.fetch.calls} downloads")
|
|
1753
|
+
|
|
1754
|
+
# vLLM
|
|
1755
|
+
if run.vllm.calls:
|
|
1756
|
+
tokens = run.vllm.extra.get("prompt_tokens", 0) + run.vllm.extra.get(
|
|
1757
|
+
"completion_tokens", 0
|
|
1758
|
+
)
|
|
1759
|
+
token_str = f", {tokens:,} tokens" if tokens else ""
|
|
1760
|
+
err_str = f", {run.vllm.errors} errors" if run.vllm.errors else ""
|
|
1761
|
+
parts.append(f"vLLM: {run.vllm.calls} calls{token_str}{err_str}")
|
|
1762
|
+
|
|
1763
|
+
return " | ".join(parts) if parts else "No external calls (all cached)"
|
|
1764
|
+
|
|
1765
|
+
|
|
1766
|
+
def _classify_verify_issue(
|
|
1767
|
+
issue: str,
|
|
1768
|
+
) -> tuple[Literal["error", "warning", "info"], str]:
|
|
1769
|
+
"""Classify a verify issue string into (level, check_id).
|
|
1770
|
+
|
|
1771
|
+
Accuracy-related mismatches (title, author, year, venue) route to
|
|
1772
|
+
reference-accuracy. Existence issues (not found, dead URL, retracted)
|
|
1773
|
+
stay under reference-exists.
|
|
1774
|
+
"""
|
|
1775
|
+
issue_lower = issue.lower()
|
|
1776
|
+
# Existence issues → reference-exists
|
|
1777
|
+
if "dead url" in issue_lower:
|
|
1778
|
+
return "error", "reference-exists"
|
|
1779
|
+
if "web content saved" in issue_lower:
|
|
1780
|
+
return "info", "reference-exists"
|
|
1781
|
+
if "content extraction failed" in issue_lower:
|
|
1782
|
+
return "warning", "reference-exists"
|
|
1783
|
+
if "not found" in issue_lower:
|
|
1784
|
+
return "error", "reference-exists"
|
|
1785
|
+
if "retracted" in issue_lower:
|
|
1786
|
+
return "error", "retracted-cite"
|
|
1787
|
+
# Accuracy issues → reference-accuracy
|
|
1788
|
+
if "mismatch" in issue_lower:
|
|
1789
|
+
return "warning", "reference-accuracy"
|
|
1790
|
+
# Unverified URL identifier — warning but no mismatch penalty
|
|
1791
|
+
if "unverified url identifier" in issue_lower:
|
|
1792
|
+
return "info", "reference-accuracy"
|
|
1793
|
+
# Informational (not problems)
|
|
1794
|
+
if "open access pdf available" in issue_lower:
|
|
1795
|
+
return "info", "reference-exists"
|
|
1796
|
+
if "doi available but missing" in issue_lower:
|
|
1797
|
+
return "info", "reference-exists"
|
|
1798
|
+
return "warning", "reference-exists"
|
|
1799
|
+
|
|
1800
|
+
|
|
1801
|
+
# ---------------------------------------------------------------------------
|
|
1802
|
+
# PDF support: ManuscriptContext setup and Citation extraction
|
|
1803
|
+
# ---------------------------------------------------------------------------
|
|
1804
|
+
|
|
1805
|
+
|
|
1806
|
+
async def build_pdf_context(
|
|
1807
|
+
pdf_path: Path,
|
|
1808
|
+
config: LintConfig,
|
|
1809
|
+
) -> None:
|
|
1810
|
+
"""Parse a PDF via GROBID and set up ManuscriptContext for all checks.
|
|
1811
|
+
|
|
1812
|
+
Builds ManuscriptContext from GROBID output, caches it, and attaches
|
|
1813
|
+
it to config so checks can detect PDF mode.
|
|
1814
|
+
"""
|
|
1815
|
+
from sciwrite_lint.pdf.grobid import process_pdf
|
|
1816
|
+
from sciwrite_lint.manuscript_store import (
|
|
1817
|
+
ManuscriptContext,
|
|
1818
|
+
set_manuscript_context,
|
|
1819
|
+
)
|
|
1820
|
+
|
|
1821
|
+
grobid_result = await process_pdf(pdf_path)
|
|
1822
|
+
ctx = ManuscriptContext.from_grobid(pdf_path, grobid_result)
|
|
1823
|
+
set_manuscript_context(pdf_path, ctx)
|
|
1824
|
+
config.manuscript_context = ctx
|
|
1825
|
+
|
|
1826
|
+
|
|
1827
|
+
def citations_from_pdf_context(config: LintConfig) -> list[Citation]:
|
|
1828
|
+
"""Extract Citation objects from a PDF ManuscriptContext.
|
|
1829
|
+
|
|
1830
|
+
Converts ParsedReference entries to Citation objects suitable for the
|
|
1831
|
+
API verification pipeline.
|
|
1832
|
+
"""
|
|
1833
|
+
from sciwrite_lint.manuscript_store import ManuscriptContext
|
|
1834
|
+
|
|
1835
|
+
ctx: ManuscriptContext = config.manuscript_context
|
|
1836
|
+
citations: list[Citation] = []
|
|
1837
|
+
for ref in ctx.parsed_references:
|
|
1838
|
+
citations.append(
|
|
1839
|
+
Citation(
|
|
1840
|
+
key=ref.key,
|
|
1841
|
+
raw_text=ref.raw,
|
|
1842
|
+
authors=list(ref.authors),
|
|
1843
|
+
title=ref.title,
|
|
1844
|
+
year=ref.year,
|
|
1845
|
+
venue=ref.venue,
|
|
1846
|
+
doi=ref.doi,
|
|
1847
|
+
url=ref.url,
|
|
1848
|
+
isbn=ref.isbn,
|
|
1849
|
+
lccn=ref.lccn,
|
|
1850
|
+
source_paper=ctx.source_path.stem,
|
|
1851
|
+
bib_format="grobid",
|
|
1852
|
+
entry_type="",
|
|
1853
|
+
)
|
|
1854
|
+
)
|
|
1855
|
+
return citations
|
|
1856
|
+
|
|
1857
|
+
|
|
1858
|
+
async def extract_citations_for_paper(
|
|
1859
|
+
tex_path: Path,
|
|
1860
|
+
config: LintConfig,
|
|
1861
|
+
bib_path: Path | None = None,
|
|
1862
|
+
) -> list[Citation]:
|
|
1863
|
+
"""Extract citations from a paper, handling both .tex and .pdf inputs.
|
|
1864
|
+
|
|
1865
|
+
For .tex files, uses extract_bibitems + check_local_sources.
|
|
1866
|
+
For .pdf files, runs GROBID via build_pdf_context + citations_from_pdf_context.
|
|
1867
|
+
"""
|
|
1868
|
+
from sciwrite_lint.references.citations import check_local_sources, extract_bibitems
|
|
1869
|
+
|
|
1870
|
+
if tex_path.suffix.lower() == ".pdf":
|
|
1871
|
+
await build_pdf_context(tex_path, config)
|
|
1872
|
+
return citations_from_pdf_context(config)
|
|
1873
|
+
|
|
1874
|
+
citations = extract_bibitems(tex_path, "auto", bib_path=bib_path)
|
|
1875
|
+
check_local_sources(citations, config.effective_references_dir())
|
|
1876
|
+
return citations
|
|
1877
|
+
|
|
1878
|
+
|
|
1879
|
+
# ---------------------------------------------------------------------------
|
|
1880
|
+
# Top-level orchestrator
|
|
1881
|
+
# ---------------------------------------------------------------------------
|
|
1882
|
+
|
|
1883
|
+
|
|
1884
|
+
def _backup_workspace(ws: PaperWorkspace) -> Path | None:
|
|
1885
|
+
"""Back up a paper workspace as a zip archive before --fresh wipe.
|
|
1886
|
+
|
|
1887
|
+
Creates references/{paper}_backup_YYYYMMDD_HHMMSS.zip from the workspace,
|
|
1888
|
+
then removes the original directory. Zip-first ensures the backup exists
|
|
1889
|
+
even if removal is interrupted.
|
|
1890
|
+
|
|
1891
|
+
Returns the zip path, or None if nothing to back up.
|
|
1892
|
+
"""
|
|
1893
|
+
if not ws.root.exists():
|
|
1894
|
+
return None
|
|
1895
|
+
import shutil
|
|
1896
|
+
from datetime import datetime
|
|
1897
|
+
|
|
1898
|
+
stamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
1899
|
+
archive_base = ws.root.parent / f"{ws.root.name}_backup_{stamp}"
|
|
1900
|
+
zip_path = Path(
|
|
1901
|
+
shutil.make_archive(str(archive_base), "zip", ws.root.parent, ws.root.name)
|
|
1902
|
+
)
|
|
1903
|
+
shutil.rmtree(ws.root)
|
|
1904
|
+
logger.info(f"Backed up {ws.root.name} → {zip_path.name}")
|
|
1905
|
+
return zip_path
|
|
1906
|
+
|
|
1907
|
+
|
|
1908
|
+
async def run_full_check(
|
|
1909
|
+
paper_name: str,
|
|
1910
|
+
tex_path: Path,
|
|
1911
|
+
pc: PaperConfig,
|
|
1912
|
+
config: LintConfig,
|
|
1913
|
+
fresh: bool = False,
|
|
1914
|
+
) -> list[Finding]:
|
|
1915
|
+
"""Run the complete check pipeline for one paper.
|
|
1916
|
+
|
|
1917
|
+
Stages 1 (text+LLM) and 2 (verify) run concurrently via asyncio.
|
|
1918
|
+
Stages 3-5 run sequentially after.
|
|
1919
|
+
|
|
1920
|
+
**Not concurrency-safe.** GPU subprocess stages (vision, embedding,
|
|
1921
|
+
cited vision) assume exclusive VRAM access. Do not call this function
|
|
1922
|
+
concurrently for multiple papers — use ``run_papers_staged()`` instead,
|
|
1923
|
+
which coordinates GPU stages across papers.
|
|
1924
|
+
|
|
1925
|
+
Args:
|
|
1926
|
+
fresh: Start from scratch — back up existing workspace, then
|
|
1927
|
+
re-verify, re-fetch, re-parse, and re-run claims.
|
|
1928
|
+
|
|
1929
|
+
Supports both LaTeX (.tex) and PDF input. For PDF, call
|
|
1930
|
+
build_pdf_context() before this function.
|
|
1931
|
+
"""
|
|
1932
|
+
from sciwrite_lint.usage import end_run, save_run_async, start_run
|
|
1933
|
+
|
|
1934
|
+
# Per-paper workspace: references/{paper_name}/
|
|
1935
|
+
ws = config.paper_workspace(paper_name)
|
|
1936
|
+
|
|
1937
|
+
# Check source compatibility (tex→pdf switch requires --fresh)
|
|
1938
|
+
if not fresh:
|
|
1939
|
+
ok, reason = ws.check_source(tex_path, bib_path=pc.bib)
|
|
1940
|
+
if not ok:
|
|
1941
|
+
raise RuntimeError(
|
|
1942
|
+
f"Source type changed ({reason}). Run with --fresh to start over."
|
|
1943
|
+
)
|
|
1944
|
+
|
|
1945
|
+
if fresh:
|
|
1946
|
+
_backup_workspace(ws)
|
|
1947
|
+
ws.ensure_dirs()
|
|
1948
|
+
ws.save_source(tex_path, bib_path=pc.bib)
|
|
1949
|
+
refs_dir = ws.root
|
|
1950
|
+
# Set paper context for registered checks that need per-paper paths
|
|
1951
|
+
config.current_paper = paper_name
|
|
1952
|
+
|
|
1953
|
+
# Extract citations — source depends on input type
|
|
1954
|
+
if config.is_pdf:
|
|
1955
|
+
citations = citations_from_pdf_context(config)
|
|
1956
|
+
else:
|
|
1957
|
+
from sciwrite_lint.references.citations import (
|
|
1958
|
+
check_local_sources,
|
|
1959
|
+
extract_bibitems,
|
|
1960
|
+
)
|
|
1961
|
+
|
|
1962
|
+
citations = extract_bibitems(tex_path, "auto", bib_path=pc.bib)
|
|
1963
|
+
check_local_sources(citations, refs_dir)
|
|
1964
|
+
|
|
1965
|
+
logger.info(f"{len(citations)} citations extracted")
|
|
1966
|
+
|
|
1967
|
+
# Start usage tracking
|
|
1968
|
+
run = start_run(
|
|
1969
|
+
paper=paper_name,
|
|
1970
|
+
model=config.llm_model or "",
|
|
1971
|
+
workspace_root=str(refs_dir),
|
|
1972
|
+
)
|
|
1973
|
+
run.citations = len(citations)
|
|
1974
|
+
|
|
1975
|
+
# Initialize pipeline stage tracking
|
|
1976
|
+
with get_db(refs_dir) as conn:
|
|
1977
|
+
init_pipeline_stages(conn)
|
|
1978
|
+
|
|
1979
|
+
t0 = time.monotonic()
|
|
1980
|
+
all_findings: list[Finding] = []
|
|
1981
|
+
|
|
1982
|
+
try:
|
|
1983
|
+
# Stage 0.5: Vision — describe figures for full-paper consistency checks.
|
|
1984
|
+
# Runs before LLM checks so descriptions are in the cache when
|
|
1985
|
+
# _build_system_prompt loads them. On WSL2, the VL model (~4 GB)
|
|
1986
|
+
# shares VRAM with vLLM via memory overcommit — no container restart.
|
|
1987
|
+
with _stage_tracking(refs_dir, "vision"):
|
|
1988
|
+
_stage_vision(tex_path, config, paper_name, fresh=fresh)
|
|
1989
|
+
|
|
1990
|
+
# Stage 1 + 2: concurrent (text checks in thread, LLM + verify async)
|
|
1991
|
+
with _stage_tracking(refs_dir, ["text_checks", "llm_checks", "verify"]):
|
|
1992
|
+
loop = asyncio.get_event_loop()
|
|
1993
|
+
text_task = loop.run_in_executor(None, run_text_checks, tex_path, config)
|
|
1994
|
+
llm_task = run_llm_checks_batched(tex_path, config)
|
|
1995
|
+
verify_task = _stage_verify(citations, config, refs_dir, fresh=fresh)
|
|
1996
|
+
text_findings, llm_findings, verify_findings = await asyncio.gather(
|
|
1997
|
+
text_task,
|
|
1998
|
+
llm_task,
|
|
1999
|
+
verify_task,
|
|
2000
|
+
)
|
|
2001
|
+
check_findings = text_findings + llm_findings
|
|
2002
|
+
|
|
2003
|
+
t_checks = time.monotonic() - t0
|
|
2004
|
+
run.stage_rules_s = t_checks
|
|
2005
|
+
run.stage_verify_s = t_checks # concurrent, same wall time
|
|
2006
|
+
logger.info(f"Checks + verify: {t_checks:.1f}s")
|
|
2007
|
+
|
|
2008
|
+
# Note: reference-accuracy findings are produced by _stage_verify
|
|
2009
|
+
# via _classify_verify_issue routing mismatch issues. The registered
|
|
2010
|
+
# reference-accuracy check exists for standalone use (sciwrite-lint check).
|
|
2011
|
+
|
|
2012
|
+
# Stage 3: fetch + Stage 4: parse (monitor GROBID memory)
|
|
2013
|
+
from sciwrite_lint.pdf.grobid import (
|
|
2014
|
+
MAX_PARSE_CONCURRENCY,
|
|
2015
|
+
monitor_container_memory,
|
|
2016
|
+
)
|
|
2017
|
+
|
|
2018
|
+
parse_sem = asyncio.Semaphore(MAX_PARSE_CONCURRENCY)
|
|
2019
|
+
mem_monitor = asyncio.create_task(monitor_container_memory(parse_sem))
|
|
2020
|
+
try:
|
|
2021
|
+
t1 = time.monotonic()
|
|
2022
|
+
with _stage_tracking(refs_dir, "fetch") as st:
|
|
2023
|
+
fetched = await _stage_fetch(citations, config, refs_dir)
|
|
2024
|
+
st.detail = f"{fetched} downloaded"
|
|
2025
|
+
run.stage_fetch_s = time.monotonic() - t1
|
|
2026
|
+
|
|
2027
|
+
t2 = time.monotonic()
|
|
2028
|
+
with _stage_tracking(refs_dir, "parse") as st:
|
|
2029
|
+
parsed, cached = await _stage_parse(
|
|
2030
|
+
config, refs_dir, parse_sem=parse_sem
|
|
2031
|
+
)
|
|
2032
|
+
st.detail = f"{parsed} new, {cached} cached"
|
|
2033
|
+
run.stage_parse_s = time.monotonic() - t2
|
|
2034
|
+
finally:
|
|
2035
|
+
mem_monitor.cancel()
|
|
2036
|
+
|
|
2037
|
+
if fetched or parsed:
|
|
2038
|
+
logger.info(f"Fetch + parse: {run.stage_fetch_s + run.stage_parse_s:.1f}s")
|
|
2039
|
+
|
|
2040
|
+
# Stage 4.2: Vision on cited papers (VL model on GPU, sequential).
|
|
2041
|
+
# Never pass fresh — cited paper descriptions use hash-based
|
|
2042
|
+
# invalidation. Passing fresh=True would clear the entire
|
|
2043
|
+
# vision_cache table, destroying source paper descriptions
|
|
2044
|
+
# written by _stage_vision (Stage 0.5).
|
|
2045
|
+
with _stage_tracking(refs_dir, "cited_vision"):
|
|
2046
|
+
ref_figure_descs = _stage_cited_vision(refs_dir, fresh=False)
|
|
2047
|
+
|
|
2048
|
+
# Stage 4.5: ref internal checks (vLLM, thinking=low/medium)
|
|
2049
|
+
t3 = time.monotonic()
|
|
2050
|
+
with _stage_tracking(refs_dir, "ref_internal"):
|
|
2051
|
+
ref_internal_results = await _stage_ref_internal(
|
|
2052
|
+
refs_dir, config, fresh=fresh, ref_figure_descs=ref_figure_descs
|
|
2053
|
+
)
|
|
2054
|
+
|
|
2055
|
+
# Stage 4.6 + 5: bib verify (network) + claims (vLLM) — concurrent
|
|
2056
|
+
with _stage_tracking(refs_dir, ["bib_verify", "claims"]):
|
|
2057
|
+
bib_checks, (claim_findings, claim_results) = await asyncio.gather(
|
|
2058
|
+
_stage_bib_verify(config, refs_dir, fresh=fresh),
|
|
2059
|
+
_stage_claims(
|
|
2060
|
+
paper_name,
|
|
2061
|
+
tex_path,
|
|
2062
|
+
config,
|
|
2063
|
+
refs_dir,
|
|
2064
|
+
bib_path=pc.bib,
|
|
2065
|
+
rerun=fresh,
|
|
2066
|
+
),
|
|
2067
|
+
)
|
|
2068
|
+
run.stage_claims_s = time.monotonic() - t3
|
|
2069
|
+
if claim_findings or ref_internal_results:
|
|
2070
|
+
logger.info(f"Claims + ref checks: {run.stage_claims_s:.1f}s")
|
|
2071
|
+
|
|
2072
|
+
# Stage 6: reference-unreliable (aggregates signals from verify + claims)
|
|
2073
|
+
with _stage_tracking(refs_dir, "unreliable"):
|
|
2074
|
+
unreliable_findings = _stage_unreliable(
|
|
2075
|
+
tex_path,
|
|
2076
|
+
refs_dir,
|
|
2077
|
+
claim_results,
|
|
2078
|
+
bib_checks=bib_checks,
|
|
2079
|
+
)
|
|
2080
|
+
|
|
2081
|
+
# Merge all findings
|
|
2082
|
+
all_findings = (
|
|
2083
|
+
list(check_findings)
|
|
2084
|
+
+ verify_findings
|
|
2085
|
+
+ claim_findings
|
|
2086
|
+
+ unreliable_findings
|
|
2087
|
+
)
|
|
2088
|
+
|
|
2089
|
+
finally:
|
|
2090
|
+
# Always save usage — even on crash, partial data is useful
|
|
2091
|
+
run.total_elapsed_s = time.monotonic() - t0
|
|
2092
|
+
logger.info(f"Total: {run.total_elapsed_s:.1f}s")
|
|
2093
|
+
logger.info(f"{_format_usage_summary(run)}")
|
|
2094
|
+
stats = end_run()
|
|
2095
|
+
if stats:
|
|
2096
|
+
await save_run_async(stats)
|
|
2097
|
+
config.last_run_stats = stats
|
|
2098
|
+
|
|
2099
|
+
return all_findings
|
|
2100
|
+
|
|
2101
|
+
|
|
2102
|
+
# ---------------------------------------------------------------------------
|
|
2103
|
+
# Batch-staged orchestrator for multi-paper runs (evals)
|
|
2104
|
+
# ---------------------------------------------------------------------------
|
|
2105
|
+
|
|
2106
|
+
|
|
2107
|
+
@dataclass
|
|
2108
|
+
class StagedPaperResult:
|
|
2109
|
+
"""Result from ``run_papers_staged`` for one paper.
|
|
2110
|
+
|
|
2111
|
+
Contains all pipeline outputs needed for scoring: findings from all
|
|
2112
|
+
stages (text, LLM, verify, claims, unreliable), raw claim verification
|
|
2113
|
+
results, and bibliography checks. Used by the real-world eval and
|
|
2114
|
+
calibration eval to compute SciLint Scores.
|
|
2115
|
+
"""
|
|
2116
|
+
|
|
2117
|
+
paper_name: str
|
|
2118
|
+
findings: list[Finding]
|
|
2119
|
+
claim_results: list[dict]
|
|
2120
|
+
bib_checks: list[Any]
|
|
2121
|
+
error: str | None = None
|
|
2122
|
+
|
|
2123
|
+
|
|
2124
|
+
@dataclass
|
|
2125
|
+
class _PaperCtx:
|
|
2126
|
+
"""Per-paper mutable state carried across stages in ``run_papers_staged``.
|
|
2127
|
+
|
|
2128
|
+
Each paper gets one context object at setup. Stages mutate it
|
|
2129
|
+
(appending findings, storing claim results, etc.) as they complete.
|
|
2130
|
+
"""
|
|
2131
|
+
|
|
2132
|
+
name: str
|
|
2133
|
+
tex_path: Path
|
|
2134
|
+
pc: PaperConfig
|
|
2135
|
+
config: LintConfig
|
|
2136
|
+
refs_dir: Path
|
|
2137
|
+
citations: list[Citation]
|
|
2138
|
+
check_findings: list[Finding]
|
|
2139
|
+
verify_findings: list[Finding]
|
|
2140
|
+
claim_findings: list[Finding]
|
|
2141
|
+
claim_results: list[dict]
|
|
2142
|
+
bib_checks: list[Any]
|
|
2143
|
+
ref_figure_descs: dict[str, str]
|
|
2144
|
+
run: Any # RunStats from usage.py
|
|
2145
|
+
error: str | None = None
|
|
2146
|
+
|
|
2147
|
+
|
|
2148
|
+
async def run_papers_staged(
|
|
2149
|
+
papers: list[tuple[str, Path, PaperConfig, LintConfig]],
|
|
2150
|
+
fresh: bool = False,
|
|
2151
|
+
concurrency: int = 0,
|
|
2152
|
+
) -> list[StagedPaperResult]:
|
|
2153
|
+
"""Run multiple papers through the pipeline with batch-by-stage orchestration.
|
|
2154
|
+
|
|
2155
|
+
GPU stages (vision, embedding, cited vision) run in a single subprocess
|
|
2156
|
+
per batch — the model loads once and processes all papers sequentially.
|
|
2157
|
+
Non-GPU stages (vLLM checks, verify, fetch, GROBID, ref_internal, claims)
|
|
2158
|
+
run up to ``concurrency`` papers concurrently (0 = unlimited).
|
|
2159
|
+
|
|
2160
|
+
Args:
|
|
2161
|
+
papers: List of (paper_name, tex_path, PaperConfig, LintConfig) tuples.
|
|
2162
|
+
fresh: Start from scratch for all papers.
|
|
2163
|
+
concurrency: Max papers in non-GPU concurrent stages. 0 = no limit.
|
|
2164
|
+
|
|
2165
|
+
Returns:
|
|
2166
|
+
List of StagedPaperResult, one per paper.
|
|
2167
|
+
"""
|
|
2168
|
+
from sciwrite_lint.usage import end_run, save_run_async, set_current, start_run
|
|
2169
|
+
|
|
2170
|
+
if not papers:
|
|
2171
|
+
return []
|
|
2172
|
+
|
|
2173
|
+
if concurrency < 0:
|
|
2174
|
+
raise ValueError(f"concurrency must be >= 0, got {concurrency}")
|
|
2175
|
+
|
|
2176
|
+
if concurrency > 0 and concurrency >= len(papers):
|
|
2177
|
+
logger.info(
|
|
2178
|
+
"concurrency={} >= {} papers — all papers will run concurrently",
|
|
2179
|
+
concurrency,
|
|
2180
|
+
len(papers),
|
|
2181
|
+
)
|
|
2182
|
+
|
|
2183
|
+
# Soft warning for high concurrency. On the single-GPU + single-vLLM-
|
|
2184
|
+
# server setup we ship with, validated baseline is up to 4 concurrent
|
|
2185
|
+
# papers. Above 4, the live monitor (which polls each workspace.db
|
|
2186
|
+
# plus the shared usage.db) saturates and can fail to surface stage
|
|
2187
|
+
# progress, vLLM queue depth grows, API rate limiters bite, and GROBID
|
|
2188
|
+
# memory pressure can stall parses. Larger numbers may still work on
|
|
2189
|
+
# bigger hardware but are not part of the validated baseline.
|
|
2190
|
+
_CONCURRENCY_TESTED_MAX = 4
|
|
2191
|
+
if concurrency > _CONCURRENCY_TESTED_MAX:
|
|
2192
|
+
logger.warning(
|
|
2193
|
+
"concurrency={} exceeds tested maximum ({}). Above this, the "
|
|
2194
|
+
"live monitor may fail to surface progress, vLLM queue depth "
|
|
2195
|
+
"grows, and API rate limits / GROBID memory may degrade "
|
|
2196
|
+
"throughput. The setting is allowed but not part of the "
|
|
2197
|
+
"validated baseline.",
|
|
2198
|
+
concurrency,
|
|
2199
|
+
_CONCURRENCY_TESTED_MAX,
|
|
2200
|
+
)
|
|
2201
|
+
|
|
2202
|
+
# ------------------------------------------------------------------
|
|
2203
|
+
# Setup: workspace, citations, usage tracking for each paper
|
|
2204
|
+
# ------------------------------------------------------------------
|
|
2205
|
+
ctxs: list[_PaperCtx] = []
|
|
2206
|
+
for paper_name, tex_path, pc, config in papers:
|
|
2207
|
+
ws = config.paper_workspace(paper_name)
|
|
2208
|
+
if not fresh:
|
|
2209
|
+
ok, reason = ws.check_source(tex_path, bib_path=pc.bib)
|
|
2210
|
+
if not ok:
|
|
2211
|
+
raise RuntimeError(
|
|
2212
|
+
f"[{paper_name}] Source type changed ({reason}). "
|
|
2213
|
+
"Run with --fresh to start over."
|
|
2214
|
+
)
|
|
2215
|
+
if fresh:
|
|
2216
|
+
_backup_workspace(ws)
|
|
2217
|
+
ws.ensure_dirs()
|
|
2218
|
+
ws.save_source(tex_path, bib_path=pc.bib)
|
|
2219
|
+
refs_dir = ws.root
|
|
2220
|
+
config.current_paper = paper_name
|
|
2221
|
+
|
|
2222
|
+
# Extract citations
|
|
2223
|
+
if config.is_pdf:
|
|
2224
|
+
citations = citations_from_pdf_context(config)
|
|
2225
|
+
else:
|
|
2226
|
+
from sciwrite_lint.references.citations import (
|
|
2227
|
+
check_local_sources,
|
|
2228
|
+
extract_bibitems,
|
|
2229
|
+
)
|
|
2230
|
+
|
|
2231
|
+
citations = extract_bibitems(tex_path, "auto", bib_path=pc.bib)
|
|
2232
|
+
check_local_sources(citations, refs_dir)
|
|
2233
|
+
|
|
2234
|
+
logger.info("[{}] {} citations extracted", paper_name, len(citations))
|
|
2235
|
+
|
|
2236
|
+
run = start_run(
|
|
2237
|
+
paper=paper_name,
|
|
2238
|
+
model=config.llm_model or "",
|
|
2239
|
+
workspace_root=str(refs_dir),
|
|
2240
|
+
)
|
|
2241
|
+
run.citations = len(citations)
|
|
2242
|
+
|
|
2243
|
+
with get_db(refs_dir) as conn:
|
|
2244
|
+
init_pipeline_stages(conn)
|
|
2245
|
+
|
|
2246
|
+
ctxs.append(
|
|
2247
|
+
_PaperCtx(
|
|
2248
|
+
name=paper_name,
|
|
2249
|
+
tex_path=tex_path,
|
|
2250
|
+
pc=pc,
|
|
2251
|
+
config=config,
|
|
2252
|
+
refs_dir=refs_dir,
|
|
2253
|
+
citations=citations,
|
|
2254
|
+
check_findings=[],
|
|
2255
|
+
verify_findings=[],
|
|
2256
|
+
claim_findings=[],
|
|
2257
|
+
claim_results=[],
|
|
2258
|
+
bib_checks=[],
|
|
2259
|
+
ref_figure_descs={},
|
|
2260
|
+
run=run,
|
|
2261
|
+
)
|
|
2262
|
+
)
|
|
2263
|
+
|
|
2264
|
+
t0 = time.monotonic()
|
|
2265
|
+
|
|
2266
|
+
# Semaphore for non-GPU concurrent stages (0 = unlimited)
|
|
2267
|
+
paper_sem: asyncio.Semaphore | None = None
|
|
2268
|
+
if concurrency > 0:
|
|
2269
|
+
paper_sem = asyncio.Semaphore(concurrency)
|
|
2270
|
+
|
|
2271
|
+
def _active() -> list[_PaperCtx]:
|
|
2272
|
+
"""Papers that haven't failed yet."""
|
|
2273
|
+
return [ctx for ctx in ctxs if ctx.error is None]
|
|
2274
|
+
|
|
2275
|
+
try:
|
|
2276
|
+
# ------------------------------------------------------------------
|
|
2277
|
+
# Stage 0.5: BATCH VISION — one subprocess, all papers
|
|
2278
|
+
# ------------------------------------------------------------------
|
|
2279
|
+
logger.info("=== Stage 0.5: Batch vision ({} papers) ===", len(ctxs))
|
|
2280
|
+
vision_manifest = [
|
|
2281
|
+
{
|
|
2282
|
+
"paper_name": ctx.name,
|
|
2283
|
+
"tex_path": str(ctx.tex_path),
|
|
2284
|
+
"config_path": str(ctx.config.config_path)
|
|
2285
|
+
if ctx.config.config_path
|
|
2286
|
+
else None,
|
|
2287
|
+
"fresh": fresh,
|
|
2288
|
+
}
|
|
2289
|
+
for ctx in ctxs
|
|
2290
|
+
]
|
|
2291
|
+
with _stage_tracking([ctx.refs_dir for ctx in ctxs], "vision"):
|
|
2292
|
+
_batch_vision(vision_manifest)
|
|
2293
|
+
|
|
2294
|
+
# ------------------------------------------------------------------
|
|
2295
|
+
# Stages 1+2: TEXT + LLM + VERIFY — all papers concurrent
|
|
2296
|
+
# ------------------------------------------------------------------
|
|
2297
|
+
logger.info("=== Stages 1+2: Checks + verify ({} papers) ===", len(ctxs))
|
|
2298
|
+
|
|
2299
|
+
async def _checks_verify(ctx: _PaperCtx) -> None:
|
|
2300
|
+
set_current(ctx.run)
|
|
2301
|
+
if paper_sem:
|
|
2302
|
+
await paper_sem.acquire()
|
|
2303
|
+
try:
|
|
2304
|
+
with _stage_tracking(
|
|
2305
|
+
ctx.refs_dir, ["text_checks", "llm_checks", "verify"]
|
|
2306
|
+
):
|
|
2307
|
+
loop = asyncio.get_event_loop()
|
|
2308
|
+
text_task = loop.run_in_executor(
|
|
2309
|
+
None, run_text_checks, ctx.tex_path, ctx.config
|
|
2310
|
+
)
|
|
2311
|
+
llm_task = run_llm_checks_batched(ctx.tex_path, ctx.config)
|
|
2312
|
+
verify_task = _stage_verify(
|
|
2313
|
+
ctx.citations, ctx.config, ctx.refs_dir, fresh=fresh
|
|
2314
|
+
)
|
|
2315
|
+
text_findings, llm_findings, verify_findings = await asyncio.gather(
|
|
2316
|
+
text_task,
|
|
2317
|
+
llm_task,
|
|
2318
|
+
verify_task,
|
|
2319
|
+
)
|
|
2320
|
+
ctx.check_findings = list(text_findings) + list(llm_findings)
|
|
2321
|
+
ctx.verify_findings = list(verify_findings)
|
|
2322
|
+
except Exception as e:
|
|
2323
|
+
logger.error("[{}] Checks+verify failed: {}", ctx.name, e)
|
|
2324
|
+
ctx.error = str(e)
|
|
2325
|
+
finally:
|
|
2326
|
+
if paper_sem:
|
|
2327
|
+
paper_sem.release()
|
|
2328
|
+
|
|
2329
|
+
await asyncio.gather(*[_checks_verify(ctx) for ctx in _active()])
|
|
2330
|
+
t_checks = time.monotonic() - t0
|
|
2331
|
+
logger.info("Checks + verify: {:.1f}s", t_checks)
|
|
2332
|
+
|
|
2333
|
+
# ------------------------------------------------------------------
|
|
2334
|
+
# Stage 3: FETCH — all papers concurrent
|
|
2335
|
+
# ------------------------------------------------------------------
|
|
2336
|
+
logger.info("=== Stage 3: Fetch ({} papers) ===", len(ctxs))
|
|
2337
|
+
|
|
2338
|
+
async def _fetch_paper(ctx: _PaperCtx) -> None:
|
|
2339
|
+
set_current(ctx.run)
|
|
2340
|
+
if paper_sem:
|
|
2341
|
+
await paper_sem.acquire()
|
|
2342
|
+
try:
|
|
2343
|
+
with _stage_tracking(ctx.refs_dir, "fetch") as st:
|
|
2344
|
+
fetched = await _stage_fetch(
|
|
2345
|
+
ctx.citations, ctx.config, ctx.refs_dir, embed_inline=False
|
|
2346
|
+
)
|
|
2347
|
+
st.detail = f"{fetched} downloaded"
|
|
2348
|
+
except Exception as e:
|
|
2349
|
+
logger.error("[{}] Fetch failed: {}", ctx.name, e)
|
|
2350
|
+
ctx.error = str(e)
|
|
2351
|
+
finally:
|
|
2352
|
+
if paper_sem:
|
|
2353
|
+
paper_sem.release()
|
|
2354
|
+
|
|
2355
|
+
await asyncio.gather(*[_fetch_paper(ctx) for ctx in _active()])
|
|
2356
|
+
|
|
2357
|
+
# ------------------------------------------------------------------
|
|
2358
|
+
# Stage 4a: GROBID PARSE — concurrent with shared memory monitor
|
|
2359
|
+
# ------------------------------------------------------------------
|
|
2360
|
+
logger.info("=== Stage 4a: GROBID parse ({} papers) ===", len(ctxs))
|
|
2361
|
+
from sciwrite_lint.pdf.grobid import (
|
|
2362
|
+
MAX_PARSE_CONCURRENCY,
|
|
2363
|
+
monitor_container_memory,
|
|
2364
|
+
)
|
|
2365
|
+
|
|
2366
|
+
parse_sem = asyncio.Semaphore(MAX_PARSE_CONCURRENCY)
|
|
2367
|
+
mem_monitor = asyncio.create_task(monitor_container_memory(parse_sem))
|
|
2368
|
+
|
|
2369
|
+
# Store parse results for each paper (needed to collect embedding keys)
|
|
2370
|
+
parse_results_map: dict[str, dict[str, str]] = {}
|
|
2371
|
+
|
|
2372
|
+
async def _parse_paper(ctx: _PaperCtx) -> None:
|
|
2373
|
+
set_current(ctx.run)
|
|
2374
|
+
if paper_sem:
|
|
2375
|
+
await paper_sem.acquire()
|
|
2376
|
+
try:
|
|
2377
|
+
_track(ctx.refs_dir, "parse", "running")
|
|
2378
|
+
from sciwrite_lint.references.reference_store import parse_all_missing
|
|
2379
|
+
|
|
2380
|
+
pr = await parse_all_missing(ctx.refs_dir, sem=parse_sem)
|
|
2381
|
+
parse_results_map[ctx.name] = pr
|
|
2382
|
+
# Keep stage "running" — batch embedding runs after all papers
|
|
2383
|
+
# finish parsing, and its timing must be attributed to this stage.
|
|
2384
|
+
# Stage is marked "done" after `_batch_embed` completes below.
|
|
2385
|
+
except Exception as e:
|
|
2386
|
+
logger.error("[{}] Parse failed: {}", ctx.name, e)
|
|
2387
|
+
ctx.error = str(e)
|
|
2388
|
+
_track(ctx.refs_dir, "parse", "failed", str(e))
|
|
2389
|
+
finally:
|
|
2390
|
+
if paper_sem:
|
|
2391
|
+
paper_sem.release()
|
|
2392
|
+
|
|
2393
|
+
try:
|
|
2394
|
+
await asyncio.gather(*[_parse_paper(ctx) for ctx in _active()])
|
|
2395
|
+
finally:
|
|
2396
|
+
mem_monitor.cancel()
|
|
2397
|
+
|
|
2398
|
+
# ------------------------------------------------------------------
|
|
2399
|
+
# Stage 4b: BATCH EMBEDDING — one subprocess, all papers
|
|
2400
|
+
# ------------------------------------------------------------------
|
|
2401
|
+
logger.info("=== Stage 4b: Batch embedding ({} papers) ===", len(ctxs))
|
|
2402
|
+
from sciwrite_lint.references.embedding_store import has_embeddings
|
|
2403
|
+
from sciwrite_lint.references.metadata import load_all_metadata
|
|
2404
|
+
from sciwrite_lint.references.reference_store import _get_embedding_config
|
|
2405
|
+
|
|
2406
|
+
model_name, _, _ = _get_embedding_config()
|
|
2407
|
+
|
|
2408
|
+
embed_manifest: list[dict[str, Any]] = []
|
|
2409
|
+
for ctx in _active():
|
|
2410
|
+
keys_to_embed: list[str] = []
|
|
2411
|
+
|
|
2412
|
+
# Scan ALL parsed markdown files — catches refs parsed during
|
|
2413
|
+
# fetch (eager parse_and_embed) as well as _stage_parse.
|
|
2414
|
+
parsed_dir = ctx.refs_dir / "parsed"
|
|
2415
|
+
if parsed_dir.exists():
|
|
2416
|
+
for md_file in sorted(parsed_dir.glob("*.md")):
|
|
2417
|
+
key = md_file.stem
|
|
2418
|
+
if not has_embeddings(key, ctx.refs_dir, model_name=model_name):
|
|
2419
|
+
keys_to_embed.append(key)
|
|
2420
|
+
|
|
2421
|
+
# Web summary keys
|
|
2422
|
+
all_meta = load_all_metadata(ctx.refs_dir)
|
|
2423
|
+
for key, meta in all_meta.items():
|
|
2424
|
+
local_file = meta.access.get("local_file") or ""
|
|
2425
|
+
if not local_file.endswith("_web.md"):
|
|
2426
|
+
continue
|
|
2427
|
+
if has_embeddings(key, ctx.refs_dir, model_name=model_name):
|
|
2428
|
+
continue
|
|
2429
|
+
web_path = ctx.refs_dir / local_file
|
|
2430
|
+
if web_path.exists():
|
|
2431
|
+
keys_to_embed.append(key)
|
|
2432
|
+
|
|
2433
|
+
if keys_to_embed:
|
|
2434
|
+
embed_manifest.append(
|
|
2435
|
+
{
|
|
2436
|
+
"keys": keys_to_embed,
|
|
2437
|
+
"references_dir": str(ctx.refs_dir),
|
|
2438
|
+
}
|
|
2439
|
+
)
|
|
2440
|
+
|
|
2441
|
+
with _stage_failure_guard([ctx.refs_dir for ctx in _active()], "parse"):
|
|
2442
|
+
embed_diag = _batch_embed(embed_manifest)
|
|
2443
|
+
|
|
2444
|
+
# Mark parse stage done per paper, now that batch embedding has run.
|
|
2445
|
+
# _batch_embed swallows subprocess failures (crash, timeout, partial)
|
|
2446
|
+
# and returns a diagnostic string — we verify actual success by
|
|
2447
|
+
# re-checking has_embeddings() against the DB (authoritative source of
|
|
2448
|
+
# truth) and propagate the subprocess stderr into ctx.error so the
|
|
2449
|
+
# user sees the real failure reason (CUDA OOM, etc.) inline rather
|
|
2450
|
+
# than a cryptic "no embeddings" crash during claim verification.
|
|
2451
|
+
keys_requested: dict[str, list[str]] = {
|
|
2452
|
+
entry["references_dir"]: entry["keys"] for entry in embed_manifest
|
|
2453
|
+
}
|
|
2454
|
+
for ctx in _active():
|
|
2455
|
+
pr = parse_results_map.get(ctx.name, {})
|
|
2456
|
+
n_parsed = sum(1 for v in pr.values() if v == "parsed")
|
|
2457
|
+
n_cached = sum(1 for v in pr.values() if v == "cached")
|
|
2458
|
+
requested = keys_requested.get(str(ctx.refs_dir), [])
|
|
2459
|
+
n_embedded = sum(
|
|
2460
|
+
1
|
|
2461
|
+
for key in requested
|
|
2462
|
+
if has_embeddings(key, ctx.refs_dir, model_name=model_name)
|
|
2463
|
+
)
|
|
2464
|
+
if n_embedded < len(requested):
|
|
2465
|
+
missing = len(requested) - n_embedded
|
|
2466
|
+
_track(
|
|
2467
|
+
ctx.refs_dir,
|
|
2468
|
+
"parse",
|
|
2469
|
+
"failed",
|
|
2470
|
+
f"{n_parsed} new, {n_cached} cached, "
|
|
2471
|
+
f"only {n_embedded}/{len(requested)} embedded",
|
|
2472
|
+
)
|
|
2473
|
+
if embed_diag:
|
|
2474
|
+
ctx.error = (
|
|
2475
|
+
f"batch embed: {missing}/{len(requested)} keys "
|
|
2476
|
+
f"missing | {embed_diag}"
|
|
2477
|
+
)
|
|
2478
|
+
else:
|
|
2479
|
+
ctx.error = (
|
|
2480
|
+
f"batch embed: {missing}/{len(requested)} keys "
|
|
2481
|
+
"missing (subprocess exited cleanly — likely silent "
|
|
2482
|
+
"per-key skip, check subprocess logs)"
|
|
2483
|
+
)
|
|
2484
|
+
logger.error(
|
|
2485
|
+
"[{}] Batch embed incomplete: {} of {} keys missing | {}",
|
|
2486
|
+
ctx.name,
|
|
2487
|
+
missing,
|
|
2488
|
+
len(requested),
|
|
2489
|
+
embed_diag or "subprocess exited cleanly",
|
|
2490
|
+
)
|
|
2491
|
+
else:
|
|
2492
|
+
_track(
|
|
2493
|
+
ctx.refs_dir,
|
|
2494
|
+
"parse",
|
|
2495
|
+
"done",
|
|
2496
|
+
f"{n_parsed} new, {n_cached} cached, {n_embedded} embedded",
|
|
2497
|
+
)
|
|
2498
|
+
|
|
2499
|
+
# ------------------------------------------------------------------
|
|
2500
|
+
# Stage 4.2: BATCH CITED VISION — one subprocess, all papers
|
|
2501
|
+
# ------------------------------------------------------------------
|
|
2502
|
+
logger.info("=== Stage 4.2: Batch cited vision ({} papers) ===", len(ctxs))
|
|
2503
|
+
active_ctxs = _active()
|
|
2504
|
+
for ctx in active_ctxs:
|
|
2505
|
+
_track(ctx.refs_dir, "cited_vision", "running")
|
|
2506
|
+
|
|
2507
|
+
cited_vis_manifest = [
|
|
2508
|
+
{"references_dir": str(ctx.refs_dir), "fresh": False} for ctx in active_ctxs
|
|
2509
|
+
]
|
|
2510
|
+
# Count total images across all papers for dynamic timeout.
|
|
2511
|
+
# Image extraction is lightweight (no GPU) — just PDF page scanning.
|
|
2512
|
+
from sciwrite_lint.vision.image_extraction import extract_images_from_pdf
|
|
2513
|
+
|
|
2514
|
+
total_images = 0
|
|
2515
|
+
for ctx in active_ctxs:
|
|
2516
|
+
parsed_dir = ctx.refs_dir / "parsed"
|
|
2517
|
+
if not parsed_dir.exists():
|
|
2518
|
+
continue
|
|
2519
|
+
for md_file in sorted(parsed_dir.glob("*.md")):
|
|
2520
|
+
key = md_file.stem
|
|
2521
|
+
candidates = sorted(ctx.refs_dir.glob(f"{key}*.pdf"))
|
|
2522
|
+
if candidates:
|
|
2523
|
+
total_images += len(
|
|
2524
|
+
extract_images_from_pdf(
|
|
2525
|
+
candidates[0],
|
|
2526
|
+
ctx.refs_dir / "parsed" / "ref_figures" / key,
|
|
2527
|
+
)
|
|
2528
|
+
)
|
|
2529
|
+
|
|
2530
|
+
# ~5s per image (conservative, GPU batch=16), 60s for model load
|
|
2531
|
+
cited_vis_timeout = max(120, 60 + total_images * 5)
|
|
2532
|
+
logger.info(
|
|
2533
|
+
"Cited vision: {} images across {} papers, timeout={}s",
|
|
2534
|
+
total_images,
|
|
2535
|
+
len(active_ctxs),
|
|
2536
|
+
cited_vis_timeout,
|
|
2537
|
+
)
|
|
2538
|
+
with _stage_failure_guard(
|
|
2539
|
+
[ctx.refs_dir for ctx in active_ctxs], "cited_vision"
|
|
2540
|
+
):
|
|
2541
|
+
_batch_cited_vision(cited_vis_manifest, timeout=cited_vis_timeout)
|
|
2542
|
+
|
|
2543
|
+
# Read cited vision results from DB for each paper
|
|
2544
|
+
from sciwrite_lint.vision.cache import format_descriptions_from_db
|
|
2545
|
+
from sciwrite_lint.vision.image_extraction import extract_images_from_pdf
|
|
2546
|
+
|
|
2547
|
+
for ctx in active_ctxs:
|
|
2548
|
+
parsed_dir = ctx.refs_dir / "parsed"
|
|
2549
|
+
if not parsed_dir.exists():
|
|
2550
|
+
_track(ctx.refs_dir, "cited_vision", "done")
|
|
2551
|
+
continue
|
|
2552
|
+
keys = [f.stem for f in sorted(parsed_dir.glob("*.md"))]
|
|
2553
|
+
descriptions: dict[str, str] = {}
|
|
2554
|
+
for key in keys:
|
|
2555
|
+
candidates = sorted(ctx.refs_dir.glob(f"{key}*.pdf"))
|
|
2556
|
+
if not candidates:
|
|
2557
|
+
continue
|
|
2558
|
+
output_dir = ctx.refs_dir / "parsed" / "ref_figures" / key
|
|
2559
|
+
images = extract_images_from_pdf(candidates[0], output_dir)
|
|
2560
|
+
if images:
|
|
2561
|
+
desc = format_descriptions_from_db(images, ctx.refs_dir)
|
|
2562
|
+
if desc:
|
|
2563
|
+
descriptions[key] = desc
|
|
2564
|
+
ctx.ref_figure_descs = descriptions
|
|
2565
|
+
_track(ctx.refs_dir, "cited_vision", "done")
|
|
2566
|
+
|
|
2567
|
+
# ------------------------------------------------------------------
|
|
2568
|
+
# Stage 4.5: REF INTERNAL — all papers concurrent (vLLM)
|
|
2569
|
+
# ------------------------------------------------------------------
|
|
2570
|
+
logger.info("=== Stage 4.5: Ref internal ({} papers) ===", len(ctxs))
|
|
2571
|
+
|
|
2572
|
+
async def _ref_internal_paper(ctx: _PaperCtx) -> None:
|
|
2573
|
+
set_current(ctx.run)
|
|
2574
|
+
if paper_sem:
|
|
2575
|
+
await paper_sem.acquire()
|
|
2576
|
+
try:
|
|
2577
|
+
with _stage_tracking(ctx.refs_dir, "ref_internal"):
|
|
2578
|
+
await _stage_ref_internal(
|
|
2579
|
+
ctx.refs_dir,
|
|
2580
|
+
ctx.config,
|
|
2581
|
+
fresh=fresh,
|
|
2582
|
+
ref_figure_descs=ctx.ref_figure_descs,
|
|
2583
|
+
)
|
|
2584
|
+
except Exception as e:
|
|
2585
|
+
logger.error("[{}] Ref internal failed: {}", ctx.name, e)
|
|
2586
|
+
ctx.error = str(e)
|
|
2587
|
+
finally:
|
|
2588
|
+
if paper_sem:
|
|
2589
|
+
paper_sem.release()
|
|
2590
|
+
|
|
2591
|
+
await asyncio.gather(*[_ref_internal_paper(ctx) for ctx in _active()])
|
|
2592
|
+
|
|
2593
|
+
# ------------------------------------------------------------------
|
|
2594
|
+
# Stage 5: CLAIMS + BIB VERIFY — all papers concurrent (vLLM + network)
|
|
2595
|
+
# ------------------------------------------------------------------
|
|
2596
|
+
logger.info("=== Stage 5: Claims + bib verify ({} papers) ===", len(ctxs))
|
|
2597
|
+
|
|
2598
|
+
async def _claims_bib_paper(ctx: _PaperCtx) -> None:
|
|
2599
|
+
set_current(ctx.run)
|
|
2600
|
+
if paper_sem:
|
|
2601
|
+
await paper_sem.acquire()
|
|
2602
|
+
try:
|
|
2603
|
+
with _stage_tracking(ctx.refs_dir, ["bib_verify", "claims"]):
|
|
2604
|
+
bib, (c_findings, c_results) = await asyncio.gather(
|
|
2605
|
+
_stage_bib_verify(ctx.config, ctx.refs_dir, fresh=fresh),
|
|
2606
|
+
_stage_claims(
|
|
2607
|
+
ctx.name,
|
|
2608
|
+
ctx.tex_path,
|
|
2609
|
+
ctx.config,
|
|
2610
|
+
ctx.refs_dir,
|
|
2611
|
+
bib_path=ctx.pc.bib,
|
|
2612
|
+
rerun=fresh,
|
|
2613
|
+
),
|
|
2614
|
+
)
|
|
2615
|
+
ctx.bib_checks = bib
|
|
2616
|
+
ctx.claim_findings = list(c_findings)
|
|
2617
|
+
ctx.claim_results = list(c_results)
|
|
2618
|
+
except Exception as e:
|
|
2619
|
+
logger.error("[{}] Claims+bib failed: {}", ctx.name, e)
|
|
2620
|
+
ctx.error = str(e)
|
|
2621
|
+
finally:
|
|
2622
|
+
if paper_sem:
|
|
2623
|
+
paper_sem.release()
|
|
2624
|
+
|
|
2625
|
+
await asyncio.gather(*[_claims_bib_paper(ctx) for ctx in _active()])
|
|
2626
|
+
|
|
2627
|
+
# ------------------------------------------------------------------
|
|
2628
|
+
# Stage 6: UNRELIABLE — all papers
|
|
2629
|
+
# ------------------------------------------------------------------
|
|
2630
|
+
active_for_unreliable = _active()
|
|
2631
|
+
logger.info(
|
|
2632
|
+
"=== Stage 6: Unreliable ({} papers) ===", len(active_for_unreliable)
|
|
2633
|
+
)
|
|
2634
|
+
for ctx in active_for_unreliable:
|
|
2635
|
+
try:
|
|
2636
|
+
with _stage_tracking(ctx.refs_dir, "unreliable"):
|
|
2637
|
+
unreliable = _stage_unreliable(
|
|
2638
|
+
ctx.tex_path,
|
|
2639
|
+
ctx.refs_dir,
|
|
2640
|
+
ctx.claim_results,
|
|
2641
|
+
bib_checks=ctx.bib_checks,
|
|
2642
|
+
)
|
|
2643
|
+
except Exception as e:
|
|
2644
|
+
logger.error("[{}] Unreliable failed: {}", ctx.name, e)
|
|
2645
|
+
ctx.error = str(e)
|
|
2646
|
+
continue
|
|
2647
|
+
|
|
2648
|
+
# Merge all findings for this paper
|
|
2649
|
+
ctx.check_findings.extend(ctx.verify_findings)
|
|
2650
|
+
ctx.check_findings.extend(ctx.claim_findings)
|
|
2651
|
+
ctx.check_findings.extend(unreliable)
|
|
2652
|
+
|
|
2653
|
+
finally:
|
|
2654
|
+
# Save usage tracking for all papers.
|
|
2655
|
+
# Note: each ctx.run was created by start_run() which wrote a
|
|
2656
|
+
# preliminary row to usage.db. We save stats directly from ctx.run
|
|
2657
|
+
# (not via end_run()) because the global _current only holds the
|
|
2658
|
+
# last paper's RunStats — the batch creates multiple runs.
|
|
2659
|
+
total_elapsed = time.monotonic() - t0
|
|
2660
|
+
for ctx in ctxs:
|
|
2661
|
+
ctx.run.total_elapsed_s = total_elapsed
|
|
2662
|
+
await save_run_async(ctx.run)
|
|
2663
|
+
ctx.config.last_run_stats = ctx.run
|
|
2664
|
+
# Clear the global so end_run() doesn't return stale data
|
|
2665
|
+
end_run()
|
|
2666
|
+
|
|
2667
|
+
# Build results
|
|
2668
|
+
results: list[StagedPaperResult] = []
|
|
2669
|
+
for ctx in ctxs:
|
|
2670
|
+
results.append(
|
|
2671
|
+
StagedPaperResult(
|
|
2672
|
+
paper_name=ctx.name,
|
|
2673
|
+
findings=ctx.check_findings,
|
|
2674
|
+
claim_results=ctx.claim_results,
|
|
2675
|
+
bib_checks=ctx.bib_checks,
|
|
2676
|
+
error=ctx.error,
|
|
2677
|
+
)
|
|
2678
|
+
)
|
|
2679
|
+
|
|
2680
|
+
failed = [ctx for ctx in ctxs if ctx.error]
|
|
2681
|
+
if failed:
|
|
2682
|
+
logger.warning(
|
|
2683
|
+
"Batch pipeline: {}/{} papers failed: {}",
|
|
2684
|
+
len(failed),
|
|
2685
|
+
len(ctxs),
|
|
2686
|
+
", ".join(f"{ctx.name} ({ctx.error})" for ctx in failed),
|
|
2687
|
+
)
|
|
2688
|
+
logger.info(
|
|
2689
|
+
"Batch pipeline complete: {}/{} papers succeeded in {:.1f}s",
|
|
2690
|
+
len(ctxs) - len(failed),
|
|
2691
|
+
len(ctxs),
|
|
2692
|
+
time.monotonic() - t0,
|
|
2693
|
+
)
|
|
2694
|
+
return results
|