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