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,823 @@
|
|
|
1
|
+
"""Persistent reference store: parse once, reuse everywhere.
|
|
2
|
+
|
|
3
|
+
Parses PDFs via GROBID, stores the markdown in
|
|
4
|
+
``references/parsed/{key}.md`` with a metadata sidecar. Subsequent reads
|
|
5
|
+
are instant file reads — no re-parsing.
|
|
6
|
+
|
|
7
|
+
Optionally computes Snowflake Arctic Embed-S embeddings for each chunk
|
|
8
|
+
and stores them in ``references/parsed/embeddings.db`` (sqlite-vec).
|
|
9
|
+
This enables fast semantic search for claim verification (see
|
|
10
|
+
``retrieve_relevant_sections``).
|
|
11
|
+
|
|
12
|
+
Design rationale:
|
|
13
|
+
- GROBID parsing is 5-15 s per PDF — deterministic output, cache it.
|
|
14
|
+
- Embeddings are ~500 ms per reference on CPU — no GPU contention.
|
|
15
|
+
- Hash-based invalidation: re-parse only if PDF content changes.
|
|
16
|
+
- Fallback: if cache missing/stale, parse on demand (requires GROBID).
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import asyncio
|
|
22
|
+
import hashlib
|
|
23
|
+
import re
|
|
24
|
+
from dataclasses import dataclass
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from typing import TYPE_CHECKING
|
|
27
|
+
|
|
28
|
+
from loguru import logger
|
|
29
|
+
|
|
30
|
+
if TYPE_CHECKING:
|
|
31
|
+
from sciwrite_lint.pdf.grobid import GrobidReference, GrobidResult
|
|
32
|
+
|
|
33
|
+
# Re-export Section for convenience (canonical definition in eval_claims)
|
|
34
|
+
from sciwrite_lint.eval_claims import Section
|
|
35
|
+
|
|
36
|
+
# ---------------------------------------------------------------------------
|
|
37
|
+
# Data types
|
|
38
|
+
# ---------------------------------------------------------------------------
|
|
39
|
+
|
|
40
|
+
PARSED_DIR_NAME = "parsed"
|
|
41
|
+
|
|
42
|
+
# Entry types that are book-like (used for logging, not routing).
|
|
43
|
+
_BOOK_ENTRY_TYPES = {"book", "inbook", "incollection", "manual", "proceedings"}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _pdf_page_count(pdf_path: Path) -> int | None:
|
|
47
|
+
"""Return the number of pages in a PDF, or None if unreadable."""
|
|
48
|
+
try:
|
|
49
|
+
import pdfplumber
|
|
50
|
+
|
|
51
|
+
with pdfplumber.open(pdf_path) as pdf:
|
|
52
|
+
return len(pdf.pages)
|
|
53
|
+
except Exception:
|
|
54
|
+
return None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
_FORMAL_MIN_REFERENCES = 10
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass
|
|
61
|
+
class ParseMeta:
|
|
62
|
+
"""Metadata sidecar for a cached parse."""
|
|
63
|
+
|
|
64
|
+
pdf_hash: str
|
|
65
|
+
parse_date: str
|
|
66
|
+
parser: str # "grobid"
|
|
67
|
+
sections_count: int
|
|
68
|
+
char_count: int
|
|
69
|
+
is_formal: bool = False
|
|
70
|
+
has_embeddings: bool = False
|
|
71
|
+
embedding_model: str = ""
|
|
72
|
+
chunks_count: int = 0
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
# ---------------------------------------------------------------------------
|
|
76
|
+
# Hashing
|
|
77
|
+
# ---------------------------------------------------------------------------
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _file_hash(path: Path) -> str:
|
|
81
|
+
"""SHA-256 of file contents (fast enough for PDFs up to ~50 MB)."""
|
|
82
|
+
h = hashlib.sha256()
|
|
83
|
+
with open(path, "rb") as f:
|
|
84
|
+
for chunk in iter(lambda: f.read(1 << 16), b""):
|
|
85
|
+
h.update(chunk)
|
|
86
|
+
return h.hexdigest()
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
# ---------------------------------------------------------------------------
|
|
90
|
+
# Parsed cache paths
|
|
91
|
+
# ---------------------------------------------------------------------------
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _parsed_dir(references_dir: Path) -> Path:
|
|
95
|
+
return references_dir / PARSED_DIR_NAME
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _parsed_md_path(references_dir: Path, key: str) -> Path:
|
|
99
|
+
return _parsed_dir(references_dir) / f"{key}.md"
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _parsed_meta_path(references_dir: Path, key: str) -> Path:
|
|
103
|
+
return _parsed_dir(references_dir) / f"{key}.meta.json"
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _embeddings_path(references_dir: Path, key: str) -> Path:
|
|
107
|
+
return _parsed_dir(references_dir) / f"{key}.embeddings.npz"
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
# ---------------------------------------------------------------------------
|
|
111
|
+
# Core: parse and store
|
|
112
|
+
# ---------------------------------------------------------------------------
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
async def parse_and_store(
|
|
116
|
+
key: str,
|
|
117
|
+
pdf_path: Path,
|
|
118
|
+
references_dir: Path,
|
|
119
|
+
force: bool = False,
|
|
120
|
+
entry_type: str = "",
|
|
121
|
+
) -> str | None:
|
|
122
|
+
"""Parse a PDF via GROBID and store the result in references/parsed/.
|
|
123
|
+
|
|
124
|
+
Returns the parsed markdown text, or None on failure.
|
|
125
|
+
Skips parsing if a valid cache exists (unless *force*).
|
|
126
|
+
|
|
127
|
+
All PDFs (articles and books) are parsed via GROBID, which produces
|
|
128
|
+
usable section structure even for 55-page books.
|
|
129
|
+
"""
|
|
130
|
+
from datetime import datetime
|
|
131
|
+
|
|
132
|
+
if not pdf_path.exists() or pdf_path.suffix != ".pdf":
|
|
133
|
+
return None
|
|
134
|
+
|
|
135
|
+
parsed_dir = _parsed_dir(references_dir)
|
|
136
|
+
md_path = _parsed_md_path(references_dir, key)
|
|
137
|
+
|
|
138
|
+
# Check cache validity via DB
|
|
139
|
+
from sciwrite_lint.references.workspace_db import (
|
|
140
|
+
get_db,
|
|
141
|
+
load_parse_cache,
|
|
142
|
+
save_parse_cache,
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
with get_db(references_dir) as conn:
|
|
146
|
+
if not force and md_path.exists():
|
|
147
|
+
cached = load_parse_cache(conn, key)
|
|
148
|
+
if cached and cached["pdf_hash"] == _file_hash(pdf_path):
|
|
149
|
+
return md_path.read_text(encoding="utf-8")
|
|
150
|
+
|
|
151
|
+
grobid_result = await _try_grobid(pdf_path)
|
|
152
|
+
text = _grobid_result_to_markdown(grobid_result)
|
|
153
|
+
formal = _is_formal_document(grobid_result)
|
|
154
|
+
|
|
155
|
+
# Store
|
|
156
|
+
parsed_dir.mkdir(parents=True, exist_ok=True)
|
|
157
|
+
|
|
158
|
+
md_path.write_text(text, encoding="utf-8")
|
|
159
|
+
|
|
160
|
+
from sciwrite_lint.eval_claims import split_sections
|
|
161
|
+
|
|
162
|
+
sections = split_sections(text)
|
|
163
|
+
|
|
164
|
+
save_parse_cache(
|
|
165
|
+
conn,
|
|
166
|
+
key,
|
|
167
|
+
pdf_hash=_file_hash(pdf_path),
|
|
168
|
+
parse_date=datetime.now().isoformat(timespec="seconds"),
|
|
169
|
+
parser="grobid",
|
|
170
|
+
sections_count=len(sections),
|
|
171
|
+
char_count=len(text),
|
|
172
|
+
is_formal=formal,
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
if not formal:
|
|
176
|
+
logger.info(
|
|
177
|
+
"{}: non-formal document (title={}, authors={}, refs={})",
|
|
178
|
+
key,
|
|
179
|
+
bool(grobid_result.title),
|
|
180
|
+
len(grobid_result.authors),
|
|
181
|
+
len(grobid_result.references),
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
# Persist structured bibliography entries in workspace.db for chain
|
|
185
|
+
# metadata verification. Only for formal documents — non-formal PDFs
|
|
186
|
+
# (webpages, news) have unreliable GROBID bibliographies.
|
|
187
|
+
# Note: the parsed markdown + embeddings are stored regardless and
|
|
188
|
+
# remain available for claim verification at depth 0→1.
|
|
189
|
+
if formal and grobid_result.references:
|
|
190
|
+
_register_grobid_bibliography(key, grobid_result.references, references_dir)
|
|
191
|
+
|
|
192
|
+
return text
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _register_grobid_bibliography(
|
|
196
|
+
parent_key: str,
|
|
197
|
+
references: list[GrobidReference],
|
|
198
|
+
references_dir: Path,
|
|
199
|
+
) -> None:
|
|
200
|
+
"""Register GROBID-parsed bibliography entries in workspace.db.
|
|
201
|
+
|
|
202
|
+
Each entry is stored at depth=1 with parent_key pointing to the
|
|
203
|
+
reference whose bibliography was parsed.
|
|
204
|
+
"""
|
|
205
|
+
from sciwrite_lint.references.workspace_db import get_db, register_reference
|
|
206
|
+
|
|
207
|
+
with get_db(references_dir) as conn:
|
|
208
|
+
for ref in references:
|
|
209
|
+
register_reference(
|
|
210
|
+
conn,
|
|
211
|
+
ref_key=f"bib_{ref.index}",
|
|
212
|
+
workspace_path=".",
|
|
213
|
+
depth=1,
|
|
214
|
+
parent_key=parent_key,
|
|
215
|
+
doi=ref.doi or None,
|
|
216
|
+
arxiv_id=ref.arxiv_id or None,
|
|
217
|
+
pmid=ref.pmid or None,
|
|
218
|
+
isbn=ref.isbn or None,
|
|
219
|
+
lccn=ref.lccn or None,
|
|
220
|
+
title=ref.title or None,
|
|
221
|
+
authors=ref.authors if ref.authors else None,
|
|
222
|
+
year=ref.year or None,
|
|
223
|
+
venue=ref.venue or None,
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
async def _try_grobid(pdf_path: Path) -> "GrobidResult":
|
|
228
|
+
"""Parse PDF via GROBID. Raises RuntimeError if GROBID is unavailable."""
|
|
229
|
+
from sciwrite_lint.pdf.grobid import is_grobid_running, process_pdf
|
|
230
|
+
|
|
231
|
+
if not await is_grobid_running():
|
|
232
|
+
raise RuntimeError(
|
|
233
|
+
"GROBID is required for PDF parsing.\n"
|
|
234
|
+
" Start with: sciwrite-lint containers start"
|
|
235
|
+
)
|
|
236
|
+
result = await process_pdf(pdf_path)
|
|
237
|
+
text = _grobid_result_to_markdown(result)
|
|
238
|
+
if not text or len(text) <= 100:
|
|
239
|
+
raise RuntimeError(
|
|
240
|
+
f"GROBID returned empty/too-short output for {pdf_path.name}"
|
|
241
|
+
)
|
|
242
|
+
return result
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _grobid_result_to_markdown(result: "GrobidResult") -> str:
|
|
246
|
+
"""Convert a GrobidResult to markdown with ## headings."""
|
|
247
|
+
lines: list[str] = []
|
|
248
|
+
if result.abstract:
|
|
249
|
+
lines.append("## Abstract")
|
|
250
|
+
lines.append("")
|
|
251
|
+
lines.append(result.abstract)
|
|
252
|
+
lines.append("")
|
|
253
|
+
for sec in result.sections:
|
|
254
|
+
prefix = "#" * (sec.level + 2)
|
|
255
|
+
lines.append(f"{prefix} {sec.title}")
|
|
256
|
+
lines.append("")
|
|
257
|
+
lines.append(sec.text)
|
|
258
|
+
lines.append("")
|
|
259
|
+
return "\n".join(lines)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _is_formal_document(result: "GrobidResult") -> bool:
|
|
263
|
+
"""Classify a GROBID parse result as formal academic document.
|
|
264
|
+
|
|
265
|
+
Formal = has title + at least 1 author + at least 10 references.
|
|
266
|
+
Non-formal (news articles, guides, short essays) lack structured
|
|
267
|
+
metadata and are used as text only — no reference extraction or
|
|
268
|
+
depth-2 chain verification.
|
|
269
|
+
"""
|
|
270
|
+
return (
|
|
271
|
+
bool(result.title)
|
|
272
|
+
and len(result.authors) >= 1
|
|
273
|
+
and len(result.references) >= _FORMAL_MIN_REFERENCES
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def is_formal_cached(key: str, references_dir: Path) -> bool:
|
|
278
|
+
"""Check if a parsed reference is a formal academic document.
|
|
279
|
+
|
|
280
|
+
Reads the ``is_formal`` flag from the parse_cache table in workspace.db.
|
|
281
|
+
Returns False if no record exists.
|
|
282
|
+
|
|
283
|
+
Convenience wrapper — use ``is_formal_cached_db(conn, key)`` directly
|
|
284
|
+
when you already have a connection open.
|
|
285
|
+
"""
|
|
286
|
+
from sciwrite_lint.references.workspace_db import get_db, is_formal_cached_db
|
|
287
|
+
|
|
288
|
+
with get_db(references_dir) as conn:
|
|
289
|
+
return is_formal_cached_db(conn, key)
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
# ---------------------------------------------------------------------------
|
|
293
|
+
# Read from cache (with lazy parse on miss)
|
|
294
|
+
# ---------------------------------------------------------------------------
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
async def read_cached_reference(
|
|
298
|
+
key: str,
|
|
299
|
+
ref_path: Path,
|
|
300
|
+
references_dir: Path,
|
|
301
|
+
) -> str | None:
|
|
302
|
+
"""Read a reference, using the persistent cache when available.
|
|
303
|
+
|
|
304
|
+
For PDFs: check parsed cache first, parse on demand if missing.
|
|
305
|
+
For .md files: read directly (no caching needed).
|
|
306
|
+
"""
|
|
307
|
+
if ref_path.suffix == ".md":
|
|
308
|
+
return ref_path.read_text(encoding="utf-8") if ref_path.exists() else None
|
|
309
|
+
|
|
310
|
+
if ref_path.suffix == ".pdf":
|
|
311
|
+
if not ref_path.exists():
|
|
312
|
+
return None
|
|
313
|
+
|
|
314
|
+
# Try cache first via DB
|
|
315
|
+
md_path = _parsed_md_path(references_dir, key)
|
|
316
|
+
if md_path.exists():
|
|
317
|
+
from sciwrite_lint.references.workspace_db import get_db, load_parse_cache
|
|
318
|
+
|
|
319
|
+
with get_db(references_dir) as conn:
|
|
320
|
+
cached = load_parse_cache(conn, key)
|
|
321
|
+
if cached and cached["pdf_hash"] == _file_hash(ref_path):
|
|
322
|
+
return md_path.read_text(encoding="utf-8")
|
|
323
|
+
|
|
324
|
+
# Cache miss — parse on demand
|
|
325
|
+
return await parse_and_store(key, ref_path, references_dir)
|
|
326
|
+
|
|
327
|
+
return None
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
# ---------------------------------------------------------------------------
|
|
331
|
+
# Batch parse: all T1 references
|
|
332
|
+
# ---------------------------------------------------------------------------
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
async def parse_all_missing(
|
|
336
|
+
references_dir: Path,
|
|
337
|
+
force: bool = False,
|
|
338
|
+
sem: "asyncio.Semaphore | None" = None,
|
|
339
|
+
) -> dict[str, str]:
|
|
340
|
+
"""Parse all PDFs with local files that don't have a cached parse.
|
|
341
|
+
|
|
342
|
+
Returns {key: status} where status is "cached", "parsed", or "failed".
|
|
343
|
+
Sends up to 4 concurrent requests to GROBID (multi-threaded Java server).
|
|
344
|
+
|
|
345
|
+
Includes all tiers with local PDFs (T0, T1, T2) — claim verification
|
|
346
|
+
runs against any ref with a local file, so all need parsed markdown
|
|
347
|
+
and embeddings.
|
|
348
|
+
|
|
349
|
+
Args:
|
|
350
|
+
sem: Semaphore for concurrency control. If None, creates a local one.
|
|
351
|
+
Pass the pipeline's shared semaphore to enable memory-based throttling.
|
|
352
|
+
"""
|
|
353
|
+
from sciwrite_lint.references.workspace_db import (
|
|
354
|
+
get_db,
|
|
355
|
+
load_all_parse_cache,
|
|
356
|
+
query_refs_with_local_pdfs,
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
with get_db(references_dir) as conn:
|
|
360
|
+
pdf_refs = query_refs_with_local_pdfs(conn)
|
|
361
|
+
all_parse_cache = load_all_parse_cache(conn) if not force else {}
|
|
362
|
+
|
|
363
|
+
results: dict[str, str] = {}
|
|
364
|
+
to_parse: list[tuple[str, Path, str]] = []
|
|
365
|
+
|
|
366
|
+
for key, (local_file, entry_type) in pdf_refs.items():
|
|
367
|
+
pdf_path = references_dir / local_file
|
|
368
|
+
if not pdf_path.exists():
|
|
369
|
+
results[key] = "missing_pdf"
|
|
370
|
+
continue
|
|
371
|
+
|
|
372
|
+
md_path = _parsed_md_path(references_dir, key)
|
|
373
|
+
|
|
374
|
+
# Already cached and valid?
|
|
375
|
+
if not force and md_path.exists():
|
|
376
|
+
cached = all_parse_cache.get(key)
|
|
377
|
+
if cached and cached["pdf_hash"] == _file_hash(pdf_path):
|
|
378
|
+
results[key] = "cached"
|
|
379
|
+
continue
|
|
380
|
+
|
|
381
|
+
to_parse.append((key, pdf_path, entry_type))
|
|
382
|
+
|
|
383
|
+
if to_parse:
|
|
384
|
+
from sciwrite_lint.pdf.grobid import MAX_PARSE_CONCURRENCY
|
|
385
|
+
|
|
386
|
+
parse_sem = sem or asyncio.Semaphore(MAX_PARSE_CONCURRENCY)
|
|
387
|
+
|
|
388
|
+
async def _parse_one(key: str, pdf_path: Path, entry_type: str) -> None:
|
|
389
|
+
async with parse_sem:
|
|
390
|
+
try:
|
|
391
|
+
text = await parse_and_store(
|
|
392
|
+
key,
|
|
393
|
+
pdf_path,
|
|
394
|
+
references_dir,
|
|
395
|
+
force=force,
|
|
396
|
+
entry_type=entry_type,
|
|
397
|
+
)
|
|
398
|
+
except RuntimeError as exc:
|
|
399
|
+
logger.error("Failed to parse {}: {}", key, exc)
|
|
400
|
+
results[key] = "error"
|
|
401
|
+
return
|
|
402
|
+
results[key] = "parsed" if text else "failed"
|
|
403
|
+
|
|
404
|
+
await asyncio.gather(*[_parse_one(k, p, et) for k, p, et in to_parse])
|
|
405
|
+
|
|
406
|
+
return results
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
# ---------------------------------------------------------------------------
|
|
410
|
+
# Embeddings: chunk + embed + store
|
|
411
|
+
# ---------------------------------------------------------------------------
|
|
412
|
+
|
|
413
|
+
# Embedding defaults (overridden by [embeddings] in .sciwrite-lint.toml)
|
|
414
|
+
EMBEDDING_MODEL = "Snowflake/snowflake-arctic-embed-m-v2.0"
|
|
415
|
+
EMBEDDING_DIM = 768
|
|
416
|
+
|
|
417
|
+
# Maximum chars per embedding chunk. Arctic Embed M v2.0 has an 8192-token
|
|
418
|
+
# context window (~32k chars). This cap prevents storing absurdly large
|
|
419
|
+
# chunks from badly parsed PDFs. Set to 30k to stay within the model's
|
|
420
|
+
# window with margin for non-English text.
|
|
421
|
+
MAX_CHUNK_CHARS = 30_000
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def _get_embedding_config() -> tuple[str, int, str]:
|
|
425
|
+
"""Return (model_name, dimension, device) from config, or defaults."""
|
|
426
|
+
try:
|
|
427
|
+
from sciwrite_lint.config import load_config
|
|
428
|
+
|
|
429
|
+
cfg = load_config()
|
|
430
|
+
return cfg.embedding_model, cfg.embedding_dim, cfg.embedding_device
|
|
431
|
+
except Exception as e:
|
|
432
|
+
logger.debug("Could not load embedding config, using defaults: {}", e)
|
|
433
|
+
return EMBEDDING_MODEL, EMBEDDING_DIM, "auto"
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
@dataclass
|
|
437
|
+
class Chunk:
|
|
438
|
+
"""A text chunk with its position in the original document."""
|
|
439
|
+
|
|
440
|
+
text: str
|
|
441
|
+
start_char: int
|
|
442
|
+
section_title: str
|
|
443
|
+
granularity: str # "paragraph" or "sentence"
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
_SENTENCE_RE = re.compile(r"(?<=[.!?])\s+(?=[A-Z])")
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def _chunk_text(
|
|
450
|
+
text: str,
|
|
451
|
+
section_title: str = "",
|
|
452
|
+
paragraph_only: bool = False,
|
|
453
|
+
paragraph_window: int = 3,
|
|
454
|
+
sentence_window: int = 3,
|
|
455
|
+
) -> list[Chunk]:
|
|
456
|
+
"""Split text into overlapping chunks at paragraph and sentence level.
|
|
457
|
+
|
|
458
|
+
Two granularities, both using sliding windows:
|
|
459
|
+
- **Paragraph**: 3-paragraph window, stride 1. Each chunk is 1-3 real
|
|
460
|
+
paragraphs as the author wrote them. Captures thematic ideas.
|
|
461
|
+
- **Sentence**: 3-sentence window, stride 1, within each paragraph.
|
|
462
|
+
Captures precise facts (numbers, definitions).
|
|
463
|
+
|
|
464
|
+
Args:
|
|
465
|
+
paragraph_only: skip sentence-level splitting entirely.
|
|
466
|
+
paragraph_window: paragraphs per paragraph-level chunk (default 3).
|
|
467
|
+
sentence_window: sentences per sentence-level chunk (default 3).
|
|
468
|
+
"""
|
|
469
|
+
chunks: list[Chunk] = []
|
|
470
|
+
paragraphs = [p.strip() for p in text.split("\n\n") if p.strip()]
|
|
471
|
+
|
|
472
|
+
# Paragraph-level chunks: sliding window of real paragraphs
|
|
473
|
+
for i in range(len(paragraphs)):
|
|
474
|
+
window = paragraphs[i : i + paragraph_window]
|
|
475
|
+
window_text = "\n\n".join(window)
|
|
476
|
+
if len(window_text) > MAX_CHUNK_CHARS:
|
|
477
|
+
window_text = window_text[:MAX_CHUNK_CHARS]
|
|
478
|
+
|
|
479
|
+
# Find position of first paragraph in window
|
|
480
|
+
start = text.find(paragraphs[i])
|
|
481
|
+
if start == -1:
|
|
482
|
+
start = 0
|
|
483
|
+
|
|
484
|
+
chunks.append(
|
|
485
|
+
Chunk(
|
|
486
|
+
text=window_text,
|
|
487
|
+
start_char=start,
|
|
488
|
+
section_title=section_title,
|
|
489
|
+
granularity="paragraph",
|
|
490
|
+
)
|
|
491
|
+
)
|
|
492
|
+
if i + paragraph_window >= len(paragraphs):
|
|
493
|
+
break
|
|
494
|
+
|
|
495
|
+
if paragraph_only:
|
|
496
|
+
return chunks
|
|
497
|
+
|
|
498
|
+
# Sentence-level chunks: sliding window within each paragraph
|
|
499
|
+
for para in paragraphs:
|
|
500
|
+
para_start = text.find(para)
|
|
501
|
+
if para_start == -1:
|
|
502
|
+
para_start = 0
|
|
503
|
+
sentences = _SENTENCE_RE.split(para)
|
|
504
|
+
sentences = [s.strip() for s in sentences if s.strip() and len(s.strip()) > 30]
|
|
505
|
+
|
|
506
|
+
if not sentences:
|
|
507
|
+
continue
|
|
508
|
+
|
|
509
|
+
for i in range(len(sentences)):
|
|
510
|
+
window = sentences[i : i + sentence_window]
|
|
511
|
+
window_text = " ".join(window)
|
|
512
|
+
if len(window_text) > MAX_CHUNK_CHARS:
|
|
513
|
+
window_text = window_text[:MAX_CHUNK_CHARS]
|
|
514
|
+
chunks.append(
|
|
515
|
+
Chunk(
|
|
516
|
+
text=window_text,
|
|
517
|
+
start_char=para_start,
|
|
518
|
+
section_title=section_title,
|
|
519
|
+
granularity="sentence",
|
|
520
|
+
)
|
|
521
|
+
)
|
|
522
|
+
if i + sentence_window >= len(sentences):
|
|
523
|
+
break
|
|
524
|
+
|
|
525
|
+
return chunks
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
_embedding_model = None
|
|
529
|
+
_embedding_model_name: str = ""
|
|
530
|
+
|
|
531
|
+
|
|
532
|
+
def _resolve_embedding_device(device_cfg: str) -> str:
|
|
533
|
+
"""Resolve device string: "auto" picks CUDA on WSL2, else CPU.
|
|
534
|
+
|
|
535
|
+
On WSL2, CUDA memory overcommit lets the embedding model (~1.2 GB)
|
|
536
|
+
share VRAM with vLLM transparently — idle KV-cache pages swap to
|
|
537
|
+
system RAM while embedding runs. On native Linux, cudaMalloc is
|
|
538
|
+
physical with no overcommit, so GPU embedding is not supported
|
|
539
|
+
in auto mode (use device="cuda" to force it).
|
|
540
|
+
"""
|
|
541
|
+
from sciwrite_lint.config import is_wsl2
|
|
542
|
+
|
|
543
|
+
if device_cfg == "auto":
|
|
544
|
+
try:
|
|
545
|
+
import torch
|
|
546
|
+
|
|
547
|
+
if not torch.cuda.is_available():
|
|
548
|
+
return "cpu"
|
|
549
|
+
if is_wsl2():
|
|
550
|
+
return "cuda"
|
|
551
|
+
# Native Linux: no VRAM overcommit, default to CPU.
|
|
552
|
+
# Users can force GPU via [embeddings] device = "cuda".
|
|
553
|
+
return "cpu"
|
|
554
|
+
except ImportError:
|
|
555
|
+
return "cpu"
|
|
556
|
+
return device_cfg
|
|
557
|
+
|
|
558
|
+
|
|
559
|
+
def _get_embedding_model():
|
|
560
|
+
"""Lazy singleton for embedding model.
|
|
561
|
+
|
|
562
|
+
Device is selected via config: "auto" (default) uses CUDA when available.
|
|
563
|
+
See _resolve_embedding_device() for GPU memory sharing details.
|
|
564
|
+
|
|
565
|
+
Reads model name from config. If the config model differs from the
|
|
566
|
+
loaded singleton, reloads (handles config changes between runs).
|
|
567
|
+
"""
|
|
568
|
+
global _embedding_model, _embedding_model_name
|
|
569
|
+
|
|
570
|
+
model_name, _, device_cfg = _get_embedding_config()
|
|
571
|
+
|
|
572
|
+
if _embedding_model is not None and _embedding_model_name == model_name:
|
|
573
|
+
return _embedding_model
|
|
574
|
+
|
|
575
|
+
device = _resolve_embedding_device(device_cfg)
|
|
576
|
+
|
|
577
|
+
from sentence_transformers import SentenceTransformer
|
|
578
|
+
|
|
579
|
+
# Arctic Embed's custom attention code requires xformers for "sdpa" mode.
|
|
580
|
+
# Force "eager" attention to avoid that dependency on GPU. Also disable
|
|
581
|
+
# memory_efficient_attention (xformers assertion guard).
|
|
582
|
+
_embedding_model = SentenceTransformer(
|
|
583
|
+
model_name,
|
|
584
|
+
device=device,
|
|
585
|
+
trust_remote_code=True,
|
|
586
|
+
config_kwargs={
|
|
587
|
+
"use_memory_efficient_attention": False,
|
|
588
|
+
"attn_implementation": "eager",
|
|
589
|
+
},
|
|
590
|
+
)
|
|
591
|
+
_embedding_model_name = model_name
|
|
592
|
+
logger.info("Embedding model loaded on {} ({})", device, model_name)
|
|
593
|
+
return _embedding_model
|
|
594
|
+
|
|
595
|
+
|
|
596
|
+
def release_embedding_model() -> None:
|
|
597
|
+
"""Free the embedding model and release GPU memory.
|
|
598
|
+
|
|
599
|
+
Must be called after embedding completes and before vLLM inference
|
|
600
|
+
resumes, to return VRAM to the vLLM process. Without this, the
|
|
601
|
+
embedding model stays in GPU memory until garbage collected, which
|
|
602
|
+
may overlap with vLLM's claim verification stage.
|
|
603
|
+
"""
|
|
604
|
+
global _embedding_model, _embedding_model_name
|
|
605
|
+
if _embedding_model is None:
|
|
606
|
+
return
|
|
607
|
+
|
|
608
|
+
device = str(_embedding_model.device)
|
|
609
|
+
del _embedding_model
|
|
610
|
+
_embedding_model = None
|
|
611
|
+
_embedding_model_name = ""
|
|
612
|
+
|
|
613
|
+
if "cuda" in device:
|
|
614
|
+
try:
|
|
615
|
+
import torch
|
|
616
|
+
|
|
617
|
+
torch.cuda.empty_cache()
|
|
618
|
+
logger.debug("GPU memory released after embedding")
|
|
619
|
+
except ImportError:
|
|
620
|
+
pass
|
|
621
|
+
|
|
622
|
+
|
|
623
|
+
def compute_and_store_embeddings(
|
|
624
|
+
key: str,
|
|
625
|
+
text: str,
|
|
626
|
+
references_dir: Path,
|
|
627
|
+
) -> int:
|
|
628
|
+
"""Chunk text, compute embeddings, store in sqlite-vec DB.
|
|
629
|
+
|
|
630
|
+
OOM-safe: encodes and inserts in batches of 32, never holding all
|
|
631
|
+
vectors in RAM at once.
|
|
632
|
+
|
|
633
|
+
Uses 3-sentence sliding window for sentence-level chunks (proven
|
|
634
|
+
by eval to achieve 100% Recall@5 on both books and articles with
|
|
635
|
+
fewer chunks than single-sentence splitting).
|
|
636
|
+
|
|
637
|
+
Returns chunk count.
|
|
638
|
+
"""
|
|
639
|
+
from sciwrite_lint.eval_claims import split_sections
|
|
640
|
+
from sciwrite_lint.references.embedding_store import store_embeddings
|
|
641
|
+
|
|
642
|
+
sections = split_sections(text)
|
|
643
|
+
all_chunks: list[Chunk] = []
|
|
644
|
+
for sec in sections:
|
|
645
|
+
all_chunks.extend(_chunk_text(sec.text, section_title=sec.title))
|
|
646
|
+
|
|
647
|
+
if not all_chunks:
|
|
648
|
+
return 0
|
|
649
|
+
|
|
650
|
+
model_name, dim, _ = _get_embedding_config()
|
|
651
|
+
|
|
652
|
+
chunk_dicts = [
|
|
653
|
+
{
|
|
654
|
+
"text": c.text,
|
|
655
|
+
"section_title": c.section_title,
|
|
656
|
+
"granularity": c.granularity,
|
|
657
|
+
"start_char": c.start_char,
|
|
658
|
+
}
|
|
659
|
+
for c in all_chunks
|
|
660
|
+
]
|
|
661
|
+
|
|
662
|
+
count = store_embeddings(key, chunk_dicts, references_dir, model_name, dim)
|
|
663
|
+
|
|
664
|
+
# Update parse cache in DB
|
|
665
|
+
from sciwrite_lint.references.workspace_db import (
|
|
666
|
+
get_db,
|
|
667
|
+
update_parse_cache_embeddings,
|
|
668
|
+
)
|
|
669
|
+
|
|
670
|
+
with get_db(references_dir) as conn:
|
|
671
|
+
update_parse_cache_embeddings(
|
|
672
|
+
conn,
|
|
673
|
+
key,
|
|
674
|
+
has_embeddings=True,
|
|
675
|
+
embedding_model=model_name,
|
|
676
|
+
chunks_count=count,
|
|
677
|
+
)
|
|
678
|
+
|
|
679
|
+
return count
|
|
680
|
+
|
|
681
|
+
|
|
682
|
+
# ---------------------------------------------------------------------------
|
|
683
|
+
# Semantic retrieval: find relevant sections for a claim
|
|
684
|
+
# ---------------------------------------------------------------------------
|
|
685
|
+
|
|
686
|
+
|
|
687
|
+
def retrieve_relevant_sections(
|
|
688
|
+
claim_text: str,
|
|
689
|
+
key: str,
|
|
690
|
+
references_dir: Path,
|
|
691
|
+
all_sections: list[Section],
|
|
692
|
+
top_k: int = 5,
|
|
693
|
+
min_score_ratio: float = 0.95,
|
|
694
|
+
) -> list[Section] | None:
|
|
695
|
+
"""Find sections most relevant to a claim using sqlite-vec KNN.
|
|
696
|
+
|
|
697
|
+
Returns a filtered list of sections, or None if embeddings unavailable
|
|
698
|
+
(caller should use full scan instead).
|
|
699
|
+
|
|
700
|
+
Strategy:
|
|
701
|
+
1. KNN query against sqlite-vec for this reference's chunks.
|
|
702
|
+
2. Apply relative scoring cutoff (within min_score_ratio of best).
|
|
703
|
+
3. Map chunk hits back to sections (expand ±1 neighbor for context).
|
|
704
|
+
4. Return deduplicated sections ordered by original position.
|
|
705
|
+
|
|
706
|
+
Returns None if embeddings are not available (caller uses full scan).
|
|
707
|
+
"""
|
|
708
|
+
from sciwrite_lint.references.embedding_store import (
|
|
709
|
+
has_embeddings,
|
|
710
|
+
retrieve_similar,
|
|
711
|
+
)
|
|
712
|
+
|
|
713
|
+
model_name, _, _ = _get_embedding_config()
|
|
714
|
+
|
|
715
|
+
# has_embeddings checks: exists + complete + model match.
|
|
716
|
+
# If False (missing, incomplete, or model changed), try to (re-)embed.
|
|
717
|
+
if not has_embeddings(key, references_dir, model_name=model_name):
|
|
718
|
+
md_path = _parsed_md_path(references_dir, key)
|
|
719
|
+
if not md_path.exists():
|
|
720
|
+
return None
|
|
721
|
+
try:
|
|
722
|
+
text = md_path.read_text(encoding="utf-8")
|
|
723
|
+
compute_and_store_embeddings(key, text, references_dir)
|
|
724
|
+
except Exception as e:
|
|
725
|
+
logger.debug("Embedding failed for {}: {}", key, e)
|
|
726
|
+
return None
|
|
727
|
+
|
|
728
|
+
# KNN search via sqlite-vec
|
|
729
|
+
hits = retrieve_similar(claim_text, key, references_dir, top_k=top_k * 3)
|
|
730
|
+
if not hits:
|
|
731
|
+
logger.debug(
|
|
732
|
+
"retrieve_similar returned no hits for {} (model mismatch or no chunks?)",
|
|
733
|
+
key,
|
|
734
|
+
)
|
|
735
|
+
return None
|
|
736
|
+
|
|
737
|
+
# Apply relative scoring cutoff
|
|
738
|
+
best_score = hits[0]["score"]
|
|
739
|
+
cutoff = best_score * min_score_ratio
|
|
740
|
+
hits = [h for h in hits if h["score"] >= cutoff]
|
|
741
|
+
|
|
742
|
+
# Map chunk hits to sections (deduplicate, include ±1 neighbors)
|
|
743
|
+
seen_titles: set[str] = set()
|
|
744
|
+
matched_sections: list[Section] = []
|
|
745
|
+
|
|
746
|
+
for hit in hits:
|
|
747
|
+
title = hit["section_title"]
|
|
748
|
+
if title in seen_titles:
|
|
749
|
+
continue
|
|
750
|
+
seen_titles.add(title)
|
|
751
|
+
|
|
752
|
+
for i, sec in enumerate(all_sections):
|
|
753
|
+
if sec.title == title:
|
|
754
|
+
if i > 0 and all_sections[i - 1].title not in seen_titles:
|
|
755
|
+
matched_sections.append(all_sections[i - 1])
|
|
756
|
+
seen_titles.add(all_sections[i - 1].title)
|
|
757
|
+
matched_sections.append(sec)
|
|
758
|
+
if (
|
|
759
|
+
i + 1 < len(all_sections)
|
|
760
|
+
and all_sections[i + 1].title not in seen_titles
|
|
761
|
+
):
|
|
762
|
+
matched_sections.append(all_sections[i + 1])
|
|
763
|
+
seen_titles.add(all_sections[i + 1].title)
|
|
764
|
+
break
|
|
765
|
+
|
|
766
|
+
if len(matched_sections) >= top_k:
|
|
767
|
+
break
|
|
768
|
+
|
|
769
|
+
matched_sections.sort(key=lambda s: s.index)
|
|
770
|
+
|
|
771
|
+
if not matched_sections:
|
|
772
|
+
# KNN returned hits but none mapped to sections in all_sections.
|
|
773
|
+
# This indicates a section title mismatch between embedding store
|
|
774
|
+
# and the current parsed sections — treat as unavailable.
|
|
775
|
+
logger.debug(
|
|
776
|
+
"Embedding hits for {} didn't map to any sections (title mismatch?)",
|
|
777
|
+
key,
|
|
778
|
+
)
|
|
779
|
+
return None
|
|
780
|
+
|
|
781
|
+
if len(matched_sections) >= len(all_sections) * 0.7:
|
|
782
|
+
# Filtering wasn't selective — claim is broadly relevant to the
|
|
783
|
+
# document. Return all sections so the caller uses a full scan.
|
|
784
|
+
logger.debug(
|
|
785
|
+
"Embedding filter for {} matched {}/{} sections, using full scan",
|
|
786
|
+
key,
|
|
787
|
+
len(matched_sections),
|
|
788
|
+
len(all_sections),
|
|
789
|
+
)
|
|
790
|
+
return all_sections
|
|
791
|
+
|
|
792
|
+
return matched_sections
|
|
793
|
+
|
|
794
|
+
|
|
795
|
+
# ---------------------------------------------------------------------------
|
|
796
|
+
# Convenience: parse + embed in one call
|
|
797
|
+
# ---------------------------------------------------------------------------
|
|
798
|
+
|
|
799
|
+
|
|
800
|
+
async def parse_and_embed(
|
|
801
|
+
key: str,
|
|
802
|
+
pdf_path: Path,
|
|
803
|
+
references_dir: Path,
|
|
804
|
+
force: bool = False,
|
|
805
|
+
embed: bool = True,
|
|
806
|
+
) -> tuple[str | None, int]:
|
|
807
|
+
"""Parse a PDF and optionally compute embeddings.
|
|
808
|
+
|
|
809
|
+
Returns (markdown_text, chunk_count). chunk_count is 0 if embed=False
|
|
810
|
+
or if sentence-transformers is not installed.
|
|
811
|
+
"""
|
|
812
|
+
text = await parse_and_store(key, pdf_path, references_dir, force=force)
|
|
813
|
+
if not text:
|
|
814
|
+
return None, 0
|
|
815
|
+
|
|
816
|
+
chunk_count = 0
|
|
817
|
+
if embed:
|
|
818
|
+
try:
|
|
819
|
+
chunk_count = compute_and_store_embeddings(key, text, references_dir)
|
|
820
|
+
except ImportError:
|
|
821
|
+
pass # sentence-transformers not installed — skip embeddings
|
|
822
|
+
|
|
823
|
+
return text, chunk_count
|