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,282 @@
|
|
|
1
|
+
"""CrossRef API client — async httpx.
|
|
2
|
+
|
|
3
|
+
CrossRef is the gold standard for DOI metadata. Provides authoritative
|
|
4
|
+
title, authors, year, venue, and retraction status.
|
|
5
|
+
|
|
6
|
+
Uses httpx.AsyncClient (shared with all other API clients), eliminating
|
|
7
|
+
the per-call ~100-200 MB overhead that habanero/requests caused via
|
|
8
|
+
separate SSL contexts and connection pools per thread.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import re
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
import httpx
|
|
17
|
+
from loguru import logger
|
|
18
|
+
|
|
19
|
+
from sciwrite_lint._network import clean_and_validate_doi
|
|
20
|
+
from sciwrite_lint.models import Citation
|
|
21
|
+
|
|
22
|
+
_CROSSREF_BASE = "https://api.crossref.org/works"
|
|
23
|
+
|
|
24
|
+
# Fields needed by _parse_crossref — avoids downloading full records
|
|
25
|
+
# (reference lists alone can be 5-20 MB per paper).
|
|
26
|
+
_CROSSREF_SELECT = (
|
|
27
|
+
"DOI,title,author,published-print,published-online,issued,"
|
|
28
|
+
"container-title,is-referenced-by-count,type,abstract,update-to"
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _crossref_year(item: dict[str, Any]) -> int | None:
|
|
33
|
+
"""Extract publication year from a raw CrossRef work item."""
|
|
34
|
+
for date_field in ("published-print", "published-online", "issued"):
|
|
35
|
+
parts = item.get(date_field, {}).get("date-parts", [[]])
|
|
36
|
+
if parts and parts[0] and parts[0][0]:
|
|
37
|
+
return parts[0][0]
|
|
38
|
+
return None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _normalize_crossref_item(item: dict[str, Any]) -> dict[str, Any]:
|
|
42
|
+
"""Convert raw CrossRef item to the normalized format _best_match expects."""
|
|
43
|
+
titles = item.get("title", [])
|
|
44
|
+
authors = []
|
|
45
|
+
for a in item.get("author", []):
|
|
46
|
+
given = a.get("given", "")
|
|
47
|
+
family = a.get("family", "")
|
|
48
|
+
if family:
|
|
49
|
+
authors.append(f"{given} {family}".strip() if given else family)
|
|
50
|
+
containers = item.get("container-title", [])
|
|
51
|
+
return {
|
|
52
|
+
"title": titles[0] if titles else "",
|
|
53
|
+
"authors": authors,
|
|
54
|
+
"year": _crossref_year(item),
|
|
55
|
+
"venue": containers[0] if containers else "",
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _best_title_match(
|
|
60
|
+
query_title: str,
|
|
61
|
+
items: list[dict[str, Any]],
|
|
62
|
+
query_authors: list[str] | tuple[str, ...] | None = None,
|
|
63
|
+
query_year: int | None = None,
|
|
64
|
+
query_venue: str = "",
|
|
65
|
+
) -> dict[str, Any] | None:
|
|
66
|
+
"""Pick the CrossRef result whose title best matches the query.
|
|
67
|
+
|
|
68
|
+
Delegates scoring to ``_best_match`` (single implementation of
|
|
69
|
+
title + author + year + venue scoring). Returns the raw CrossRef item.
|
|
70
|
+
"""
|
|
71
|
+
from sciwrite_lint.api import _best_match
|
|
72
|
+
|
|
73
|
+
# Build normalized candidates, keeping a map back to the raw items
|
|
74
|
+
normalized = []
|
|
75
|
+
raw_by_idx: dict[int, dict[str, Any]] = {}
|
|
76
|
+
for i, item in enumerate(items):
|
|
77
|
+
norm = _normalize_crossref_item(item)
|
|
78
|
+
norm["_idx"] = i
|
|
79
|
+
normalized.append(norm)
|
|
80
|
+
raw_by_idx[i] = item
|
|
81
|
+
|
|
82
|
+
best = _best_match(
|
|
83
|
+
query_title,
|
|
84
|
+
normalized,
|
|
85
|
+
query_authors=query_authors,
|
|
86
|
+
query_year=query_year,
|
|
87
|
+
query_venue=query_venue,
|
|
88
|
+
)
|
|
89
|
+
if best is None:
|
|
90
|
+
return None
|
|
91
|
+
return raw_by_idx[best["_idx"]]
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
async def crossref_lookup(
|
|
95
|
+
citation: Citation,
|
|
96
|
+
polite_email: str = "",
|
|
97
|
+
client: httpx.AsyncClient | None = None,
|
|
98
|
+
) -> dict[str, Any] | None:
|
|
99
|
+
"""Look up a citation via CrossRef. Returns normalized dict or None.
|
|
100
|
+
|
|
101
|
+
Async — uses httpx. Caller should pass a shared client for connection
|
|
102
|
+
reuse. Rate limiting is the caller's responsibility.
|
|
103
|
+
"""
|
|
104
|
+
_client = client or httpx.AsyncClient(timeout=15.0, follow_redirects=True)
|
|
105
|
+
|
|
106
|
+
headers = (
|
|
107
|
+
{"User-Agent": f"sciwrite-lint/1.0 (mailto:{polite_email})"}
|
|
108
|
+
if polite_email
|
|
109
|
+
else {}
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
try:
|
|
113
|
+
return await _do_crossref_lookup(citation, _client, headers, polite_email)
|
|
114
|
+
except Exception as e:
|
|
115
|
+
logger.debug("CrossRef lookup failed for {}: {}", citation.key, e)
|
|
116
|
+
return None
|
|
117
|
+
finally:
|
|
118
|
+
if client is None:
|
|
119
|
+
await _client.aclose()
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
async def _do_crossref_lookup(
|
|
123
|
+
citation: Citation,
|
|
124
|
+
client: httpx.AsyncClient,
|
|
125
|
+
headers: dict[str, str],
|
|
126
|
+
polite_email: str = "",
|
|
127
|
+
) -> dict[str, Any] | None:
|
|
128
|
+
"""Core lookup logic with retries handled by retry_on_transient."""
|
|
129
|
+
from sciwrite_lint.rate_limiter import retry_on_transient
|
|
130
|
+
|
|
131
|
+
# mailto query param ensures polite pool (higher rate limits)
|
|
132
|
+
base_params: dict[str, str] = {}
|
|
133
|
+
if polite_email:
|
|
134
|
+
base_params["mailto"] = polite_email
|
|
135
|
+
|
|
136
|
+
# DOI lookup first (most reliable)
|
|
137
|
+
# Note: /works/{doi} does not support select= (returns 400)
|
|
138
|
+
clean_doi = clean_and_validate_doi(citation.doi) if citation.doi else None
|
|
139
|
+
if clean_doi:
|
|
140
|
+
resp = await retry_on_transient(
|
|
141
|
+
lambda: client.get(
|
|
142
|
+
f"{_CROSSREF_BASE}/{clean_doi}",
|
|
143
|
+
params=base_params,
|
|
144
|
+
headers=headers,
|
|
145
|
+
),
|
|
146
|
+
label=f"CrossRef DOI {citation.key}",
|
|
147
|
+
)
|
|
148
|
+
if resp.status_code == 200:
|
|
149
|
+
msg = resp.json().get("message")
|
|
150
|
+
if msg:
|
|
151
|
+
return _parse_crossref(msg)
|
|
152
|
+
|
|
153
|
+
# Title search (DOI not available or failed)
|
|
154
|
+
if citation.title:
|
|
155
|
+
from sciwrite_lint.api import _first_surname
|
|
156
|
+
|
|
157
|
+
surname = _first_surname(citation.authors)
|
|
158
|
+
query = f"{surname} {citation.title}" if surname else citation.title
|
|
159
|
+
resp = await retry_on_transient(
|
|
160
|
+
lambda query=query: client.get(
|
|
161
|
+
_CROSSREF_BASE,
|
|
162
|
+
params={
|
|
163
|
+
**base_params,
|
|
164
|
+
"query": query,
|
|
165
|
+
"rows": 10,
|
|
166
|
+
"select": _CROSSREF_SELECT,
|
|
167
|
+
},
|
|
168
|
+
headers=headers,
|
|
169
|
+
),
|
|
170
|
+
label=f"CrossRef title {citation.key}",
|
|
171
|
+
)
|
|
172
|
+
if resp.status_code == 200:
|
|
173
|
+
items = resp.json().get("message", {}).get("items", [])
|
|
174
|
+
if items:
|
|
175
|
+
best = _best_title_match(
|
|
176
|
+
citation.title,
|
|
177
|
+
items,
|
|
178
|
+
query_authors=citation.authors,
|
|
179
|
+
query_year=int(citation.year) if citation.year.isdigit() else None,
|
|
180
|
+
query_venue=citation.venue,
|
|
181
|
+
)
|
|
182
|
+
if best is not None:
|
|
183
|
+
return _parse_crossref(best)
|
|
184
|
+
|
|
185
|
+
return None
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
async def check_retraction(
|
|
189
|
+
doi: str,
|
|
190
|
+
polite_email: str = "",
|
|
191
|
+
client: httpx.AsyncClient | None = None,
|
|
192
|
+
) -> bool:
|
|
193
|
+
"""Check if a DOI points to a retracted paper via CrossRef metadata."""
|
|
194
|
+
if not doi:
|
|
195
|
+
return False
|
|
196
|
+
|
|
197
|
+
_client = client or httpx.AsyncClient(timeout=15.0, follow_redirects=True)
|
|
198
|
+
|
|
199
|
+
headers = (
|
|
200
|
+
{"User-Agent": f"sciwrite-lint/1.0 (mailto:{polite_email})"}
|
|
201
|
+
if polite_email
|
|
202
|
+
else {}
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
try:
|
|
206
|
+
# /works/{doi} does not support select= (returns 400)
|
|
207
|
+
resp = await _client.get(
|
|
208
|
+
f"{_CROSSREF_BASE}/{doi}",
|
|
209
|
+
headers=headers,
|
|
210
|
+
)
|
|
211
|
+
if resp.status_code != 200:
|
|
212
|
+
return False
|
|
213
|
+
msg = resp.json().get("message", {})
|
|
214
|
+
updates = msg.get("update-to", [])
|
|
215
|
+
for update in updates:
|
|
216
|
+
if update.get("type") == "retraction":
|
|
217
|
+
return True
|
|
218
|
+
label = update.get("label", "").lower()
|
|
219
|
+
if "retract" in label:
|
|
220
|
+
return True
|
|
221
|
+
return False
|
|
222
|
+
except Exception as e:
|
|
223
|
+
logger.debug("Retraction check failed for DOI {}: {}", doi, e)
|
|
224
|
+
return False
|
|
225
|
+
finally:
|
|
226
|
+
if client is None:
|
|
227
|
+
await _client.aclose()
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _parse_crossref(work: dict) -> dict[str, Any]:
|
|
231
|
+
"""Parse CrossRef work into normalized dict."""
|
|
232
|
+
# Authors
|
|
233
|
+
authors = []
|
|
234
|
+
for a in work.get("author", []):
|
|
235
|
+
given = a.get("given", "")
|
|
236
|
+
family = a.get("family", "")
|
|
237
|
+
if family:
|
|
238
|
+
name = f"{given} {family}".strip() if given else family
|
|
239
|
+
authors.append(name)
|
|
240
|
+
|
|
241
|
+
# Title
|
|
242
|
+
titles = work.get("title", [])
|
|
243
|
+
title = titles[0] if titles else ""
|
|
244
|
+
|
|
245
|
+
# Year — prefer print date, then online
|
|
246
|
+
year = None
|
|
247
|
+
for date_field in ("published-print", "published-online", "issued"):
|
|
248
|
+
parts = work.get(date_field, {}).get("date-parts", [[]])
|
|
249
|
+
if parts and parts[0] and parts[0][0]:
|
|
250
|
+
year = parts[0][0]
|
|
251
|
+
break
|
|
252
|
+
|
|
253
|
+
# Venue
|
|
254
|
+
containers = work.get("container-title", [])
|
|
255
|
+
venue = containers[0] if containers else ""
|
|
256
|
+
|
|
257
|
+
# DOI
|
|
258
|
+
doi = work.get("DOI", "")
|
|
259
|
+
|
|
260
|
+
# Abstract
|
|
261
|
+
abstract = work.get("abstract", "")
|
|
262
|
+
if abstract:
|
|
263
|
+
abstract = re.sub(r"<[^>]+>", "", abstract).strip()
|
|
264
|
+
|
|
265
|
+
# Retraction
|
|
266
|
+
retracted = False
|
|
267
|
+
for update in work.get("update-to", []):
|
|
268
|
+
if "retract" in (update.get("type", "") + update.get("label", "")).lower():
|
|
269
|
+
retracted = True
|
|
270
|
+
break
|
|
271
|
+
|
|
272
|
+
return {
|
|
273
|
+
"source": "crossref",
|
|
274
|
+
"title": title,
|
|
275
|
+
"authors": authors,
|
|
276
|
+
"year": year,
|
|
277
|
+
"venue": venue,
|
|
278
|
+
"doi": doi,
|
|
279
|
+
"citation_count": work.get("is-referenced-by-count", 0),
|
|
280
|
+
"abstract": abstract or None,
|
|
281
|
+
"retracted": retracted,
|
|
282
|
+
}
|
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
"""SQLite-vec embedding storage for claim verification.
|
|
2
|
+
|
|
3
|
+
Stores chunk text + embeddings in the per-paper workspace DB
|
|
4
|
+
(``references/{paper}/parsed/workspace.db``) using sqlite-vec for KNN
|
|
5
|
+
retrieval.
|
|
6
|
+
|
|
7
|
+
OOM-safe: encodes and inserts in small batches (EMBED_BATCH_SIZE),
|
|
8
|
+
never holding all vectors in RAM at once.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import sqlite3
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import TYPE_CHECKING
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
import numpy as np
|
|
19
|
+
|
|
20
|
+
from loguru import logger
|
|
21
|
+
|
|
22
|
+
from sciwrite_lint.references.workspace_db import (
|
|
23
|
+
db_path as _db_path,
|
|
24
|
+
open_db as _open_db,
|
|
25
|
+
serialize_f32 as _serialize_f32,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
EMBED_BATCH_SIZE = 32 # chunks per encode + insert batch
|
|
29
|
+
|
|
30
|
+
# Adaptive encode batch sizing based on token length.
|
|
31
|
+
# Eager attention memory scales as O(batch * seq_len^2). With 20 GB VRAM
|
|
32
|
+
# on RTX 4000 Ada and vLLM occupying ~18.6 GB (paged out on WSL2):
|
|
33
|
+
# ≤512 tokens, batch=32: ~2.5 GB peak — fits easily
|
|
34
|
+
# ≤2048 tokens, batch=8: ~5.5 GB peak — safe
|
|
35
|
+
# >2048 tokens, batch=4: ~11 GB peak — safe on WSL2 (worst real: 3810 tokens)
|
|
36
|
+
_ENCODE_TIERS: list[tuple[int, int]] = [
|
|
37
|
+
(512, 32), # short chunks: full batch
|
|
38
|
+
(2048, 8), # medium chunks: reduced batch
|
|
39
|
+
]
|
|
40
|
+
_ENCODE_BATCH_LONG = 4 # anything above the last tier threshold
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _encode_adaptive(model: object, texts: list[str]) -> np.ndarray:
|
|
44
|
+
"""Encode texts with batch size adapted to token length.
|
|
45
|
+
|
|
46
|
+
Short texts (≤512 tokens) are encoded in large batches for throughput.
|
|
47
|
+
Long texts get smaller batches to stay within GPU memory limits.
|
|
48
|
+
Results are reassembled in the original order.
|
|
49
|
+
"""
|
|
50
|
+
import numpy as np
|
|
51
|
+
|
|
52
|
+
tokenizer = model.tokenizer # type: ignore[attr-defined]
|
|
53
|
+
# Tokenize to get lengths (fast, CPU-only)
|
|
54
|
+
token_counts = [len(tokenizer(t, truncation=False)["input_ids"]) for t in texts]
|
|
55
|
+
|
|
56
|
+
# Group indices by tier
|
|
57
|
+
groups: dict[int, list[int]] = {}
|
|
58
|
+
for idx, tok_len in enumerate(token_counts):
|
|
59
|
+
batch_size = _ENCODE_BATCH_LONG
|
|
60
|
+
for threshold, bs in _ENCODE_TIERS:
|
|
61
|
+
if tok_len <= threshold:
|
|
62
|
+
batch_size = bs
|
|
63
|
+
break
|
|
64
|
+
groups.setdefault(batch_size, []).append(idx)
|
|
65
|
+
|
|
66
|
+
# Encode each group with its batch size, collect results
|
|
67
|
+
results: dict[int, np.ndarray] = {}
|
|
68
|
+
for batch_size, indices in groups.items():
|
|
69
|
+
group_texts = [texts[i] for i in indices]
|
|
70
|
+
vecs = model.encode( # type: ignore[attr-defined]
|
|
71
|
+
group_texts, normalize_embeddings=True, batch_size=batch_size
|
|
72
|
+
)
|
|
73
|
+
for i, idx in enumerate(indices):
|
|
74
|
+
results[idx] = vecs[i]
|
|
75
|
+
|
|
76
|
+
# Reassemble in original order
|
|
77
|
+
return np.stack([results[i] for i in range(len(texts))])
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _ensure_vec_table(conn: sqlite3.Connection, dim: int) -> None:
|
|
81
|
+
"""Create the vec0 virtual table if it doesn't exist, or recreate on dimension change."""
|
|
82
|
+
# Check if table exists
|
|
83
|
+
row = conn.execute(
|
|
84
|
+
"SELECT name FROM sqlite_master WHERE type='table' AND name='chunk_embeddings'"
|
|
85
|
+
).fetchone()
|
|
86
|
+
|
|
87
|
+
if row:
|
|
88
|
+
# Check dimension matches
|
|
89
|
+
stored_dim = conn.execute(
|
|
90
|
+
"SELECT value FROM embed_meta WHERE key='dimension'"
|
|
91
|
+
).fetchone()
|
|
92
|
+
if stored_dim and int(stored_dim[0]) == dim:
|
|
93
|
+
return
|
|
94
|
+
# Dimension changed — drop and recreate
|
|
95
|
+
logger.info("Embedding dimension changed to {}, rebuilding vec table", dim)
|
|
96
|
+
conn.execute("DROP TABLE chunk_embeddings")
|
|
97
|
+
|
|
98
|
+
conn.execute(
|
|
99
|
+
f"CREATE VIRTUAL TABLE chunk_embeddings USING vec0("
|
|
100
|
+
f"embedding float[{dim}] distance_metric=cosine)"
|
|
101
|
+
)
|
|
102
|
+
conn.execute(
|
|
103
|
+
"INSERT OR REPLACE INTO embed_meta (key, value) VALUES ('dimension', ?)",
|
|
104
|
+
(str(dim),),
|
|
105
|
+
)
|
|
106
|
+
conn.commit()
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def has_embeddings(
|
|
110
|
+
ref_key: str, references_dir: Path, model_name: str | None = None
|
|
111
|
+
) -> bool:
|
|
112
|
+
"""Check if a reference has complete, current embeddings.
|
|
113
|
+
|
|
114
|
+
Returns False if:
|
|
115
|
+
- No embeddings exist for this key
|
|
116
|
+
- Embedding was interrupted (complete=0) — cleans up broken data
|
|
117
|
+
- Embedding model changed (stored model != current model)
|
|
118
|
+
"""
|
|
119
|
+
db_file = _db_path(references_dir)
|
|
120
|
+
if not db_file.exists():
|
|
121
|
+
return False
|
|
122
|
+
try:
|
|
123
|
+
conn = _open_db(references_dir)
|
|
124
|
+
|
|
125
|
+
# Check completion status
|
|
126
|
+
row = conn.execute(
|
|
127
|
+
"SELECT complete FROM ref_status WHERE ref_key = ?", (ref_key,)
|
|
128
|
+
).fetchone()
|
|
129
|
+
if row is None:
|
|
130
|
+
conn.close()
|
|
131
|
+
return False
|
|
132
|
+
if row[0] != 1:
|
|
133
|
+
# Incomplete — clean up broken data
|
|
134
|
+
logger.debug("Cleaning up incomplete embeddings for {}", ref_key)
|
|
135
|
+
_delete_ref_data(conn, ref_key)
|
|
136
|
+
conn.commit()
|
|
137
|
+
conn.close()
|
|
138
|
+
return False
|
|
139
|
+
|
|
140
|
+
# Check model compatibility
|
|
141
|
+
if model_name is not None:
|
|
142
|
+
stored = conn.execute(
|
|
143
|
+
"SELECT value FROM embed_meta WHERE key='model'"
|
|
144
|
+
).fetchone()
|
|
145
|
+
if stored and stored[0] != model_name:
|
|
146
|
+
conn.close()
|
|
147
|
+
return False
|
|
148
|
+
|
|
149
|
+
conn.close()
|
|
150
|
+
return True
|
|
151
|
+
except Exception:
|
|
152
|
+
return False
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _delete_ref_data(conn: sqlite3.Connection, ref_key: str) -> int:
|
|
156
|
+
"""Delete all data for a reference (chunks, vectors, status). Returns count."""
|
|
157
|
+
existing_ids = conn.execute(
|
|
158
|
+
"SELECT id FROM chunks WHERE ref_key = ?", (ref_key,)
|
|
159
|
+
).fetchall()
|
|
160
|
+
for (chunk_id,) in existing_ids:
|
|
161
|
+
conn.execute("DELETE FROM chunk_embeddings WHERE rowid = ?", (chunk_id,))
|
|
162
|
+
conn.execute("DELETE FROM chunks WHERE ref_key = ?", (ref_key,))
|
|
163
|
+
conn.execute("DELETE FROM ref_status WHERE ref_key = ?", (ref_key,))
|
|
164
|
+
return len(existing_ids)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def store_embeddings(
|
|
168
|
+
ref_key: str,
|
|
169
|
+
chunks: list[dict],
|
|
170
|
+
references_dir: Path,
|
|
171
|
+
model_name: str,
|
|
172
|
+
embedding_dim: int,
|
|
173
|
+
) -> int:
|
|
174
|
+
"""Store chunk text + embeddings in batches (OOM-safe).
|
|
175
|
+
|
|
176
|
+
Each chunk dict must have: text, section_title, granularity, start_char.
|
|
177
|
+
Embeddings are computed internally in batches of EMBED_BATCH_SIZE.
|
|
178
|
+
|
|
179
|
+
Returns the number of chunks stored.
|
|
180
|
+
"""
|
|
181
|
+
from sciwrite_lint.references.reference_store import _get_embedding_model
|
|
182
|
+
|
|
183
|
+
conn = _open_db(references_dir)
|
|
184
|
+
_ensure_vec_table(conn, embedding_dim)
|
|
185
|
+
|
|
186
|
+
# Clear existing data for this key (including incomplete runs)
|
|
187
|
+
_delete_ref_data(conn, ref_key)
|
|
188
|
+
|
|
189
|
+
# Mark as in-progress (complete=0) with expected count
|
|
190
|
+
conn.execute(
|
|
191
|
+
"INSERT INTO ref_status (ref_key, expected_chunks, stored_chunks, complete) "
|
|
192
|
+
"VALUES (?, ?, 0, 0)",
|
|
193
|
+
(ref_key, len(chunks)),
|
|
194
|
+
)
|
|
195
|
+
conn.commit()
|
|
196
|
+
|
|
197
|
+
model = _get_embedding_model()
|
|
198
|
+
total_stored = 0
|
|
199
|
+
|
|
200
|
+
# Process in batches — encode + insert, then free vectors
|
|
201
|
+
for batch_start in range(0, len(chunks), EMBED_BATCH_SIZE):
|
|
202
|
+
batch = chunks[batch_start : batch_start + EMBED_BATCH_SIZE]
|
|
203
|
+
texts = [c["text"] for c in batch]
|
|
204
|
+
|
|
205
|
+
# Encode with adaptive batch size: group by token length to avoid
|
|
206
|
+
# OOM on long chunks (eager attention is O(batch * seq_len^2)).
|
|
207
|
+
vectors = _encode_adaptive(model, texts)
|
|
208
|
+
|
|
209
|
+
# Insert chunk metadata + vectors
|
|
210
|
+
for i, (chunk, vec) in enumerate(zip(batch, vectors)):
|
|
211
|
+
cursor = conn.execute(
|
|
212
|
+
"INSERT INTO chunks (ref_key, chunk_index, text, section_title, "
|
|
213
|
+
"granularity, start_char, text_len) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
|
214
|
+
(
|
|
215
|
+
ref_key,
|
|
216
|
+
batch_start + i,
|
|
217
|
+
chunk["text"],
|
|
218
|
+
chunk["section_title"],
|
|
219
|
+
chunk["granularity"],
|
|
220
|
+
chunk["start_char"],
|
|
221
|
+
len(chunk["text"]),
|
|
222
|
+
),
|
|
223
|
+
)
|
|
224
|
+
chunk_id = cursor.lastrowid
|
|
225
|
+
conn.execute(
|
|
226
|
+
"INSERT INTO chunk_embeddings (rowid, embedding) VALUES (?, ?)",
|
|
227
|
+
(chunk_id, _serialize_f32(vec.tolist())),
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
conn.commit()
|
|
231
|
+
total_stored += len(batch)
|
|
232
|
+
|
|
233
|
+
# Update progress
|
|
234
|
+
conn.execute(
|
|
235
|
+
"UPDATE ref_status SET stored_chunks = ? WHERE ref_key = ?",
|
|
236
|
+
(total_stored, ref_key),
|
|
237
|
+
)
|
|
238
|
+
conn.commit()
|
|
239
|
+
|
|
240
|
+
# vectors go out of scope here — GC can reclaim
|
|
241
|
+
|
|
242
|
+
# Mark complete
|
|
243
|
+
conn.execute("UPDATE ref_status SET complete = 1 WHERE ref_key = ?", (ref_key,))
|
|
244
|
+
conn.execute(
|
|
245
|
+
"INSERT OR REPLACE INTO embed_meta (key, value) VALUES ('model', ?)",
|
|
246
|
+
(model_name,),
|
|
247
|
+
)
|
|
248
|
+
conn.commit()
|
|
249
|
+
conn.close()
|
|
250
|
+
|
|
251
|
+
return total_stored
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def retrieve_similar(
|
|
255
|
+
query_text: str,
|
|
256
|
+
ref_key: str,
|
|
257
|
+
references_dir: Path,
|
|
258
|
+
top_k: int = 15,
|
|
259
|
+
) -> list[dict]:
|
|
260
|
+
"""Find chunks most similar to query via sqlite-vec KNN.
|
|
261
|
+
|
|
262
|
+
Returns list of dicts with: text, section_title, granularity, distance, score.
|
|
263
|
+
Distance is cosine distance (0 = identical, 2 = opposite).
|
|
264
|
+
Score is cosine similarity (1 - distance).
|
|
265
|
+
"""
|
|
266
|
+
from sciwrite_lint.references.reference_store import _get_embedding_model
|
|
267
|
+
|
|
268
|
+
db_file = _db_path(references_dir)
|
|
269
|
+
if not db_file.exists():
|
|
270
|
+
return []
|
|
271
|
+
|
|
272
|
+
conn = _open_db(references_dir)
|
|
273
|
+
|
|
274
|
+
# Check model compatibility
|
|
275
|
+
stored_model = conn.execute(
|
|
276
|
+
"SELECT value FROM embed_meta WHERE key='model'"
|
|
277
|
+
).fetchone()
|
|
278
|
+
from sciwrite_lint.references.reference_store import _get_embedding_config
|
|
279
|
+
|
|
280
|
+
current_model, _, _ = _get_embedding_config()
|
|
281
|
+
if stored_model and stored_model[0] != current_model:
|
|
282
|
+
logger.warning(
|
|
283
|
+
"Embedding model changed ({} → {}), re-embedding needed",
|
|
284
|
+
stored_model[0],
|
|
285
|
+
current_model,
|
|
286
|
+
)
|
|
287
|
+
conn.close()
|
|
288
|
+
return []
|
|
289
|
+
|
|
290
|
+
# Encode query
|
|
291
|
+
model = _get_embedding_model()
|
|
292
|
+
query_vec = model.encode(query_text, normalize_embeddings=True)
|
|
293
|
+
query_blob = _serialize_f32(query_vec.tolist())
|
|
294
|
+
|
|
295
|
+
# KNN search: vec0 doesn't support WHERE on joined columns,
|
|
296
|
+
# so fetch top candidates globally and filter by ref_key in Python.
|
|
297
|
+
# Over-fetch to ensure enough results after filtering.
|
|
298
|
+
fetch_k = top_k * 10
|
|
299
|
+
rows = conn.execute(
|
|
300
|
+
"""
|
|
301
|
+
SELECT c.text, c.section_title, c.granularity, c.start_char,
|
|
302
|
+
c.text_len, ce.distance, c.ref_key
|
|
303
|
+
FROM chunk_embeddings ce
|
|
304
|
+
INNER JOIN chunks c ON c.id = ce.rowid
|
|
305
|
+
WHERE ce.embedding MATCH ?
|
|
306
|
+
AND k = ?
|
|
307
|
+
ORDER BY ce.distance
|
|
308
|
+
""",
|
|
309
|
+
(query_blob, fetch_k),
|
|
310
|
+
).fetchall()
|
|
311
|
+
# Filter to target reference
|
|
312
|
+
rows = [r for r in rows if r[6] == ref_key][:top_k]
|
|
313
|
+
|
|
314
|
+
conn.close()
|
|
315
|
+
|
|
316
|
+
return [
|
|
317
|
+
{
|
|
318
|
+
"text": row[0],
|
|
319
|
+
"section_title": row[1],
|
|
320
|
+
"granularity": row[2],
|
|
321
|
+
"start_char": row[3],
|
|
322
|
+
"text_len": row[4],
|
|
323
|
+
"distance": row[5],
|
|
324
|
+
"score": 1.0 - row[5],
|
|
325
|
+
}
|
|
326
|
+
for row in rows
|
|
327
|
+
]
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def delete_embeddings(ref_key: str, references_dir: Path) -> int:
|
|
331
|
+
"""Delete all embeddings for a reference. Returns count deleted."""
|
|
332
|
+
db_file = _db_path(references_dir)
|
|
333
|
+
if not db_file.exists():
|
|
334
|
+
return 0
|
|
335
|
+
|
|
336
|
+
conn = _open_db(references_dir)
|
|
337
|
+
count = _delete_ref_data(conn, ref_key)
|
|
338
|
+
conn.commit()
|
|
339
|
+
conn.close()
|
|
340
|
+
return count
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def clear_all_embeddings(references_dir: Path) -> int:
|
|
344
|
+
"""Delete the entire embeddings DB. Returns number of chunks removed.
|
|
345
|
+
|
|
346
|
+
Used by ``--fresh`` to force re-embedding of all references.
|
|
347
|
+
"""
|
|
348
|
+
db_file = _db_path(references_dir)
|
|
349
|
+
if not db_file.exists():
|
|
350
|
+
return 0
|
|
351
|
+
|
|
352
|
+
conn = _open_db(references_dir)
|
|
353
|
+
count = conn.execute("SELECT COUNT(*) FROM chunks").fetchone()[0]
|
|
354
|
+
conn.execute("DELETE FROM chunks")
|
|
355
|
+
conn.execute("DELETE FROM ref_status")
|
|
356
|
+
conn.execute("DELETE FROM embed_meta")
|
|
357
|
+
# Drop vec table — will be recreated with correct dim on next store
|
|
358
|
+
try:
|
|
359
|
+
conn.execute("DROP TABLE IF EXISTS chunk_embeddings")
|
|
360
|
+
except Exception as e:
|
|
361
|
+
logger.debug(f"chunk_embeddings drop skipped ({type(e).__name__}: {e})")
|
|
362
|
+
conn.commit()
|
|
363
|
+
conn.execute("VACUUM")
|
|
364
|
+
conn.close()
|
|
365
|
+
logger.info("Cleared {} embeddings from {}", count, db_file)
|
|
366
|
+
return count
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def get_stored_model(references_dir: Path) -> str | None:
|
|
370
|
+
"""Return the embedding model name stored in the DB, or None."""
|
|
371
|
+
db_file = _db_path(references_dir)
|
|
372
|
+
if not db_file.exists():
|
|
373
|
+
return None
|
|
374
|
+
try:
|
|
375
|
+
conn = _open_db(references_dir)
|
|
376
|
+
row = conn.execute("SELECT value FROM embed_meta WHERE key='model'").fetchone()
|
|
377
|
+
conn.close()
|
|
378
|
+
return row[0] if row else None
|
|
379
|
+
except Exception:
|
|
380
|
+
return None
|