sciwrite-lint 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. sciwrite_lint/__init__.py +3 -0
  2. sciwrite_lint/__main__.py +527 -0
  3. sciwrite_lint/_network.py +195 -0
  4. sciwrite_lint/api.py +1484 -0
  5. sciwrite_lint/checks/__init__.py +1 -0
  6. sciwrite_lint/checks/_section_utils.py +111 -0
  7. sciwrite_lint/checks/cite_purpose.py +122 -0
  8. sciwrite_lint/checks/claim_support.py +96 -0
  9. sciwrite_lint/checks/cross_section_consistency.py +185 -0
  10. sciwrite_lint/checks/dangling_cite.py +93 -0
  11. sciwrite_lint/checks/dangling_ref.py +116 -0
  12. sciwrite_lint/checks/full_paper_consistency.py +604 -0
  13. sciwrite_lint/checks/ref_internal_checks.py +919 -0
  14. sciwrite_lint/checks/reference_accuracy.py +277 -0
  15. sciwrite_lint/checks/reference_exists.py +119 -0
  16. sciwrite_lint/checks/reference_unreliable.py +244 -0
  17. sciwrite_lint/checks/registry.py +136 -0
  18. sciwrite_lint/checks/retracted_cite.py +96 -0
  19. sciwrite_lint/checks/structure_promises.py +115 -0
  20. sciwrite_lint/checks/unreferenced_figure.py +70 -0
  21. sciwrite_lint/claims.py +330 -0
  22. sciwrite_lint/claude_backend.py +94 -0
  23. sciwrite_lint/claude_cli.py +405 -0
  24. sciwrite_lint/cli/__init__.py +1 -0
  25. sciwrite_lint/cli/check.py +480 -0
  26. sciwrite_lint/cli/config.py +229 -0
  27. sciwrite_lint/cli/fetch.py +250 -0
  28. sciwrite_lint/cli/misc.py +1202 -0
  29. sciwrite_lint/cli/rank.py +470 -0
  30. sciwrite_lint/cli/verify.py +437 -0
  31. sciwrite_lint/config.py +646 -0
  32. sciwrite_lint/cross_paper.py +174 -0
  33. sciwrite_lint/eval_claims.py +1196 -0
  34. sciwrite_lint/fulltext.py +851 -0
  35. sciwrite_lint/llm_utils.py +386 -0
  36. sciwrite_lint/local_pdfs.py +122 -0
  37. sciwrite_lint/manuscript_store.py +674 -0
  38. sciwrite_lint/models.py +139 -0
  39. sciwrite_lint/pdf/__init__.py +1 -0
  40. sciwrite_lint/pdf/grobid.py +785 -0
  41. sciwrite_lint/pdf/pdf_download.py +258 -0
  42. sciwrite_lint/pipeline.py +2694 -0
  43. sciwrite_lint/prompt_safety.py +30 -0
  44. sciwrite_lint/rate_limiter.py +227 -0
  45. sciwrite_lint/references/__init__.py +1 -0
  46. sciwrite_lint/references/citations.py +715 -0
  47. sciwrite_lint/references/crossref.py +282 -0
  48. sciwrite_lint/references/embedding_store.py +380 -0
  49. sciwrite_lint/references/matching.py +273 -0
  50. sciwrite_lint/references/metadata.py +273 -0
  51. sciwrite_lint/references/reference_store.py +823 -0
  52. sciwrite_lint/references/retraction_watch.py +178 -0
  53. sciwrite_lint/references/workspace_db.py +1390 -0
  54. sciwrite_lint/report.py +163 -0
  55. sciwrite_lint/schemas.py +260 -0
  56. sciwrite_lint/scoring/__init__.py +1 -0
  57. sciwrite_lint/scoring/chain.py +716 -0
  58. sciwrite_lint/scoring/contribution.py +322 -0
  59. sciwrite_lint/scoring/scilint_score.py +611 -0
  60. sciwrite_lint/tex_parser.py +248 -0
  61. sciwrite_lint/usage.py +594 -0
  62. sciwrite_lint/vision/__init__.py +1 -0
  63. sciwrite_lint/vision/cache.py +140 -0
  64. sciwrite_lint/vision/describe.py +311 -0
  65. sciwrite_lint/vision/image_extraction.py +491 -0
  66. sciwrite_lint/vision/pipeline.py +207 -0
  67. sciwrite_lint/vllm/__init__.py +1 -0
  68. sciwrite_lint/vllm/metrics.py +157 -0
  69. sciwrite_lint/vllm/vllm_server.py +445 -0
  70. sciwrite_lint/web.py +369 -0
  71. sciwrite_lint-0.2.0.dist-info/METADATA +268 -0
  72. sciwrite_lint-0.2.0.dist-info/RECORD +76 -0
  73. sciwrite_lint-0.2.0.dist-info/WHEEL +5 -0
  74. sciwrite_lint-0.2.0.dist-info/entry_points.txt +2 -0
  75. sciwrite_lint-0.2.0.dist-info/licenses/LICENSE +21 -0
  76. sciwrite_lint-0.2.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,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