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,1390 @@
1
+ """Per-paper workspace SQLite database.
2
+
3
+ Single DB per paper workspace (``references/{paper}/parsed/workspace.db``)
4
+ that holds all per-paper persistent data: embeddings, chunk metadata,
5
+ reference registry, and future tables.
6
+
7
+ Migrates automatically from the legacy ``embeddings.db`` name on first open.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import sqlite3
14
+ import struct
15
+ from collections.abc import Iterator
16
+ from contextlib import contextmanager
17
+ from pathlib import Path
18
+ from typing import TYPE_CHECKING, Any
19
+
20
+ from loguru import logger
21
+
22
+ if TYPE_CHECKING:
23
+ from sciwrite_lint.models import CitationMetadata
24
+
25
+ DB_NAME = "workspace.db"
26
+
27
+
28
+ def db_path(references_dir: Path) -> Path:
29
+ """Return the workspace DB file path for a paper's references dir."""
30
+ return references_dir / "parsed" / DB_NAME
31
+
32
+
33
+ # ---------------------------------------------------------------------------
34
+ # Embedding tables (managed by embedding_store.py)
35
+ # ---------------------------------------------------------------------------
36
+
37
+ _EMBEDDING_SCHEMA = """\
38
+ CREATE TABLE IF NOT EXISTS chunks (
39
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
40
+ ref_key TEXT NOT NULL,
41
+ chunk_index INTEGER NOT NULL,
42
+ text TEXT NOT NULL,
43
+ section_title TEXT NOT NULL DEFAULT '',
44
+ granularity TEXT NOT NULL DEFAULT 'paragraph',
45
+ start_char INTEGER NOT NULL DEFAULT 0,
46
+ text_len INTEGER NOT NULL DEFAULT 0
47
+ );
48
+ CREATE INDEX IF NOT EXISTS idx_chunks_key ON chunks(ref_key);
49
+
50
+ CREATE TABLE IF NOT EXISTS embed_meta (
51
+ key TEXT PRIMARY KEY,
52
+ value TEXT NOT NULL
53
+ );
54
+
55
+ CREATE TABLE IF NOT EXISTS ref_status (
56
+ ref_key TEXT PRIMARY KEY,
57
+ expected_chunks INTEGER NOT NULL,
58
+ stored_chunks INTEGER NOT NULL DEFAULT 0,
59
+ complete INTEGER NOT NULL DEFAULT 0
60
+ );
61
+ """
62
+
63
+
64
+ # ---------------------------------------------------------------------------
65
+ # Reference registry (cross-depth dedup)
66
+ # ---------------------------------------------------------------------------
67
+
68
+ _REGISTRY_SCHEMA = """\
69
+ CREATE TABLE IF NOT EXISTS ref_registry (
70
+ ref_key TEXT NOT NULL,
71
+ doi TEXT,
72
+ arxiv_id TEXT,
73
+ pmid TEXT,
74
+ pmcid TEXT,
75
+ isbn TEXT,
76
+ lccn TEXT,
77
+ title TEXT,
78
+ authors_json TEXT,
79
+ year TEXT,
80
+ venue TEXT,
81
+ depth INTEGER NOT NULL DEFAULT 0,
82
+ parent_key TEXT,
83
+ workspace_path TEXT NOT NULL,
84
+ PRIMARY KEY (ref_key, depth, parent_key)
85
+ );
86
+ CREATE INDEX IF NOT EXISTS idx_reg_doi ON ref_registry(doi) WHERE doi IS NOT NULL;
87
+ CREATE INDEX IF NOT EXISTS idx_reg_arxiv ON ref_registry(arxiv_id) WHERE arxiv_id IS NOT NULL;
88
+ CREATE INDEX IF NOT EXISTS idx_reg_pmid ON ref_registry(pmid) WHERE pmid IS NOT NULL;
89
+ CREATE INDEX IF NOT EXISTS idx_reg_pmcid ON ref_registry(pmcid) WHERE pmcid IS NOT NULL;
90
+ CREATE INDEX IF NOT EXISTS idx_reg_isbn ON ref_registry(isbn) WHERE isbn IS NOT NULL;
91
+ CREATE INDEX IF NOT EXISTS idx_reg_lccn ON ref_registry(lccn) WHERE lccn IS NOT NULL;
92
+ CREATE INDEX IF NOT EXISTS idx_reg_parent ON ref_registry(parent_key, depth)
93
+ WHERE parent_key IS NOT NULL;
94
+ """
95
+
96
+
97
+ # ---------------------------------------------------------------------------
98
+ # Bibliography verification results
99
+ # ---------------------------------------------------------------------------
100
+
101
+ _BIB_CHECKS_SCHEMA = """\
102
+ CREATE TABLE IF NOT EXISTS bib_checks (
103
+ ref_key TEXT PRIMARY KEY,
104
+ parse_hash TEXT NOT NULL DEFAULT '',
105
+ total_entries INTEGER NOT NULL,
106
+ found INTEGER NOT NULL,
107
+ not_found INTEGER NOT NULL,
108
+ retracted INTEGER NOT NULL DEFAULT 0,
109
+ metadata_mismatches INTEGER NOT NULL DEFAULT 0,
110
+ mismatch_details_json TEXT NOT NULL DEFAULT '[]'
111
+ );
112
+ """
113
+
114
+
115
+ # ---------------------------------------------------------------------------
116
+ # Parse cache (migrated from per-key .meta.json files)
117
+ # ---------------------------------------------------------------------------
118
+
119
+ _PARSE_CACHE_SCHEMA = """\
120
+ CREATE TABLE IF NOT EXISTS parse_cache (
121
+ ref_key TEXT PRIMARY KEY,
122
+ pdf_hash TEXT NOT NULL,
123
+ parse_date TEXT NOT NULL DEFAULT '',
124
+ parser TEXT NOT NULL DEFAULT 'grobid',
125
+ sections_count INTEGER NOT NULL DEFAULT 0,
126
+ char_count INTEGER NOT NULL DEFAULT 0,
127
+ is_formal INTEGER NOT NULL DEFAULT 1,
128
+ has_embeddings INTEGER NOT NULL DEFAULT 0,
129
+ embedding_model TEXT NOT NULL DEFAULT '',
130
+ chunks_count INTEGER NOT NULL DEFAULT 0
131
+ );
132
+ """
133
+
134
+
135
+ # ---------------------------------------------------------------------------
136
+ # Ref internal cache (migrated from per-key .internal.json files)
137
+ # ---------------------------------------------------------------------------
138
+
139
+ _REF_INTERNAL_CACHE_SCHEMA = """\
140
+ CREATE TABLE IF NOT EXISTS ref_internal_cache (
141
+ ref_key TEXT PRIMARY KEY,
142
+ md_hash TEXT NOT NULL,
143
+ cache_version TEXT NOT NULL DEFAULT '1',
144
+ internal_score REAL NOT NULL DEFAULT 0.0,
145
+ contribution_score REAL,
146
+ contribution_json TEXT NOT NULL DEFAULT '{}',
147
+ findings_json TEXT NOT NULL DEFAULT '[]',
148
+ sections_found INTEGER NOT NULL DEFAULT 0,
149
+ checks_run_json TEXT NOT NULL DEFAULT '[]'
150
+ );
151
+ """
152
+
153
+
154
+ # ---------------------------------------------------------------------------
155
+ # Claim results (migrated from claims_{paper}.json files)
156
+ # ---------------------------------------------------------------------------
157
+
158
+ _CLAIM_RESULTS_SCHEMA = """\
159
+ CREATE TABLE IF NOT EXISTS claim_results (
160
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
161
+ ref_key TEXT NOT NULL,
162
+ claim_text TEXT NOT NULL DEFAULT '',
163
+ line INTEGER,
164
+ context TEXT NOT NULL DEFAULT '',
165
+ verdict TEXT NOT NULL DEFAULT '',
166
+ confidence REAL NOT NULL DEFAULT 0.0,
167
+ relevant_quote TEXT NOT NULL DEFAULT '',
168
+ explanation TEXT NOT NULL DEFAULT '',
169
+ backend TEXT NOT NULL DEFAULT '',
170
+ model TEXT NOT NULL DEFAULT '',
171
+ ref_type TEXT NOT NULL DEFAULT '',
172
+ cite_purpose TEXT NOT NULL DEFAULT '',
173
+ dismissed INTEGER NOT NULL DEFAULT 0,
174
+ reviewer_comment TEXT NOT NULL DEFAULT '',
175
+ dismissed_date TEXT NOT NULL DEFAULT ''
176
+ );
177
+ CREATE INDEX IF NOT EXISTS idx_claims_key ON claim_results(ref_key);
178
+ CREATE INDEX IF NOT EXISTS idx_claims_verdict ON claim_results(verdict);
179
+ """
180
+
181
+
182
+ # ---------------------------------------------------------------------------
183
+ # Vision cache (figure descriptions from Qwen3-VL-2B)
184
+ # ---------------------------------------------------------------------------
185
+
186
+ _VISION_CACHE_SCHEMA = """\
187
+ CREATE TABLE IF NOT EXISTS vision_cache (
188
+ image_key TEXT PRIMARY KEY,
189
+ image_hash TEXT NOT NULL,
190
+ label TEXT NOT NULL DEFAULT '',
191
+ caption TEXT NOT NULL DEFAULT '',
192
+ description TEXT NOT NULL DEFAULT '',
193
+ cache_version TEXT NOT NULL DEFAULT '1'
194
+ );
195
+ """
196
+
197
+
198
+ # ---------------------------------------------------------------------------
199
+ # Citation metadata (migrated from per-key JSON files)
200
+ # ---------------------------------------------------------------------------
201
+
202
+ _CITATION_METADATA_SCHEMA = """\
203
+ CREATE TABLE IF NOT EXISTS citation_metadata (
204
+ ref_key TEXT PRIMARY KEY,
205
+ verified_date TEXT NOT NULL DEFAULT '',
206
+ api_source TEXT NOT NULL DEFAULT '',
207
+ api_match TEXT NOT NULL DEFAULT '',
208
+ canonical_json TEXT NOT NULL DEFAULT '{}',
209
+ bibitem_json TEXT NOT NULL DEFAULT '{}',
210
+ access_json TEXT NOT NULL DEFAULT '{}',
211
+ mismatches_json TEXT NOT NULL DEFAULT '[]',
212
+ issues_json TEXT NOT NULL DEFAULT '[]',
213
+ manual_override_json TEXT NOT NULL DEFAULT '{}'
214
+ );
215
+ """
216
+
217
+ _PIPELINE_STAGE_SCHEMA = """\
218
+ CREATE TABLE IF NOT EXISTS pipeline_stage (
219
+ stage TEXT PRIMARY KEY,
220
+ status TEXT NOT NULL DEFAULT 'pending',
221
+ start_time REAL,
222
+ end_time REAL,
223
+ detail TEXT NOT NULL DEFAULT ''
224
+ );
225
+ """
226
+
227
+
228
+ # ---------------------------------------------------------------------------
229
+ # Connection
230
+ # ---------------------------------------------------------------------------
231
+
232
+
233
+ def open_db(references_dir: Path) -> sqlite3.Connection:
234
+ """Open (or create) the workspace DB with sqlite-vec loaded.
235
+
236
+ Creates all schema tables if they don't exist.
237
+
238
+ **Do not call directly** — use ``get_db()`` context manager instead,
239
+ which guarantees the connection is closed on exit.
240
+ """
241
+ import sqlite_vec
242
+
243
+ db_file = db_path(references_dir)
244
+ db_file.parent.mkdir(parents=True, exist_ok=True)
245
+
246
+ conn = sqlite3.connect(str(db_file), timeout=10.0)
247
+ conn.enable_load_extension(True)
248
+ sqlite_vec.load(conn)
249
+ conn.enable_load_extension(False)
250
+
251
+ conn.execute("PRAGMA journal_mode=WAL")
252
+ conn.execute("PRAGMA busy_timeout=5000")
253
+ conn.executescript(_EMBEDDING_SCHEMA)
254
+ conn.executescript(_REGISTRY_SCHEMA)
255
+ conn.executescript(_BIB_CHECKS_SCHEMA)
256
+ conn.executescript(_PARSE_CACHE_SCHEMA)
257
+ conn.executescript(_REF_INTERNAL_CACHE_SCHEMA)
258
+ conn.executescript(_CLAIM_RESULTS_SCHEMA)
259
+ conn.executescript(_CITATION_METADATA_SCHEMA)
260
+ conn.executescript(_VISION_CACHE_SCHEMA)
261
+ conn.executescript(_PIPELINE_STAGE_SCHEMA)
262
+
263
+ return conn
264
+
265
+
266
+ @contextmanager
267
+ def get_db(references_dir: Path) -> Iterator[sqlite3.Connection]:
268
+ """Context manager for workspace DB connections.
269
+
270
+ This is the **only** way to obtain a DB connection outside this module.
271
+ Guarantees cleanup on exit (including exceptions and early returns).
272
+
273
+ Usage::
274
+
275
+ with get_db(references_dir) as conn:
276
+ save_parse_cache(conn, ...)
277
+ """
278
+ conn = open_db(references_dir)
279
+ try:
280
+ yield conn
281
+ finally:
282
+ conn.close()
283
+
284
+
285
+ def serialize_f32(vec: list[float]) -> bytes:
286
+ """Serialize a float32 vector to bytes for sqlite-vec."""
287
+ return struct.pack(f"{len(vec)}f", *vec)
288
+
289
+
290
+ # ---------------------------------------------------------------------------
291
+ # Reference registry operations
292
+ # ---------------------------------------------------------------------------
293
+
294
+
295
+ def register_reference(
296
+ conn: sqlite3.Connection,
297
+ *,
298
+ ref_key: str,
299
+ workspace_path: str,
300
+ depth: int = 0,
301
+ parent_key: str = "",
302
+ doi: str | None = None,
303
+ arxiv_id: str | None = None,
304
+ pmid: str | None = None,
305
+ pmcid: str | None = None,
306
+ isbn: str | None = None,
307
+ lccn: str | None = None,
308
+ title: str | None = None,
309
+ authors: list[str] | None = None,
310
+ year: str | None = None,
311
+ venue: str | None = None,
312
+ ) -> None:
313
+ """Register a reference in the workspace DB for cross-depth dedup.
314
+
315
+ Upserts: if (ref_key, depth, parent_key) exists, updates all fields.
316
+ """
317
+ authors_json = json.dumps(authors) if authors else None
318
+ conn.execute(
319
+ """INSERT INTO ref_registry
320
+ (ref_key, doi, arxiv_id, pmid, pmcid, isbn, lccn,
321
+ title, authors_json, year, venue,
322
+ depth, parent_key, workspace_path)
323
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
324
+ ON CONFLICT (ref_key, depth, parent_key) DO UPDATE SET
325
+ doi = excluded.doi,
326
+ arxiv_id = excluded.arxiv_id,
327
+ pmid = excluded.pmid,
328
+ pmcid = excluded.pmcid,
329
+ isbn = excluded.isbn,
330
+ lccn = excluded.lccn,
331
+ title = excluded.title,
332
+ authors_json = excluded.authors_json,
333
+ year = excluded.year,
334
+ venue = excluded.venue,
335
+ workspace_path = excluded.workspace_path
336
+ """,
337
+ (
338
+ ref_key,
339
+ doi or None,
340
+ arxiv_id or None,
341
+ pmid or None,
342
+ pmcid or None,
343
+ isbn or None,
344
+ lccn or None,
345
+ title or None,
346
+ authors_json,
347
+ year or None,
348
+ venue or None,
349
+ depth,
350
+ parent_key,
351
+ workspace_path,
352
+ ),
353
+ )
354
+ conn.commit()
355
+
356
+
357
+ def lookup_reference(
358
+ conn: sqlite3.Connection,
359
+ *,
360
+ doi: str | None = None,
361
+ arxiv_id: str | None = None,
362
+ pmid: str | None = None,
363
+ pmcid: str | None = None,
364
+ isbn: str | None = None,
365
+ lccn: str | None = None,
366
+ title: str | None = None,
367
+ authors: list[str] | None = None,
368
+ ) -> dict[str, Any] | None:
369
+ """Look up an existing reference by structured IDs, then fuzzy title+author.
370
+
371
+ Checks in priority order: DOI → arXiv → PMID → PMCID → ISBN → LCCN → fuzzy.
372
+
373
+ **Strict matching:** when a candidate is found by one ID, ALL other provided
374
+ IDs and title/authors are cross-checked. If any conflict is detected, the
375
+ candidate is rejected (returns None). Worst case: duplicate download. Better
376
+ than merging two different papers and suppressing real findings.
377
+
378
+ Returns dict with ref_key, workspace_path, depth, parent_key on match, or None.
379
+ """
380
+ query_ids = {
381
+ "doi": doi,
382
+ "arxiv_id": arxiv_id,
383
+ "pmid": pmid,
384
+ "pmcid": pmcid,
385
+ "isbn": isbn,
386
+ "lccn": lccn,
387
+ }
388
+
389
+ # Structured ID lookups (exact match)
390
+ for col, val in query_ids.items():
391
+ if not val:
392
+ continue
393
+ row = conn.execute(
394
+ "SELECT ref_key, workspace_path, depth, parent_key, " # noqa: S608
395
+ f"doi, arxiv_id, pmid, pmcid, isbn, lccn, title, authors_json "
396
+ f"FROM ref_registry WHERE {col} = ? LIMIT 1",
397
+ (val,),
398
+ ).fetchone()
399
+ if row and _all_metadata_agrees(row, query_ids, title, authors):
400
+ return _row_to_result(row)
401
+
402
+ # Fuzzy title + author match
403
+ if title:
404
+ return _fuzzy_lookup(conn, title, authors)
405
+
406
+ return None
407
+
408
+
409
+ _RESULT_COLS = ("ref_key", "workspace_path", "depth", "parent_key")
410
+ _ID_COLS = ("doi", "arxiv_id", "pmid", "pmcid", "isbn", "lccn")
411
+
412
+
413
+ def _row_to_result(row: tuple[Any, ...]) -> dict[str, Any]:
414
+ """Convert a ref_registry full row to a result dict."""
415
+ return dict(zip(_RESULT_COLS, row[:4]))
416
+
417
+
418
+ def _all_metadata_agrees(
419
+ row: tuple[Any, ...],
420
+ query_ids: dict[str, str | None],
421
+ title: str | None,
422
+ authors: list[str] | None,
423
+ ) -> bool:
424
+ """Check that ALL provided metadata agrees with the stored row.
425
+
426
+ Row layout: ref_key, workspace_path, depth, parent_key,
427
+ doi, arxiv_id, pmid, pmcid, isbn, lccn, title, authors_json
428
+ """
429
+ # Cross-check structured IDs: if both sides have a value, they must match
430
+ stored_ids = dict(zip(_ID_COLS, row[4:10]))
431
+ for col, query_val in query_ids.items():
432
+ if not query_val:
433
+ continue
434
+ stored_val = stored_ids.get(col)
435
+ if stored_val and stored_val != query_val:
436
+ logger.debug(
437
+ "Dedup conflict on {}: query={!r} vs stored={!r} for ref_key={!r}",
438
+ col,
439
+ query_val,
440
+ stored_val,
441
+ row[0],
442
+ )
443
+ return False
444
+
445
+ # Cross-check title if provided
446
+ if title:
447
+ stored_title = row[10]
448
+ if stored_title:
449
+ from sciwrite_lint.references.matching import (
450
+ TITLE_THRESHOLD,
451
+ title_similarity,
452
+ )
453
+
454
+ if title_similarity(title, stored_title) < TITLE_THRESHOLD:
455
+ logger.debug(
456
+ "Dedup conflict on title: query={!r} vs stored={!r} for ref_key={!r}",
457
+ title[:60],
458
+ stored_title[:60],
459
+ row[0],
460
+ )
461
+ return False
462
+
463
+ # Cross-check authors if provided
464
+ if authors:
465
+ stored_authors_json = row[11]
466
+ if stored_authors_json:
467
+ stored_authors = json.loads(stored_authors_json)
468
+ from sciwrite_lint.references.matching import (
469
+ AUTHOR_THRESHOLD,
470
+ author_similarity,
471
+ )
472
+
473
+ if author_similarity(authors, stored_authors) < AUTHOR_THRESHOLD:
474
+ logger.debug(
475
+ "Dedup conflict on authors for ref_key={!r}",
476
+ row[0],
477
+ )
478
+ return False
479
+
480
+ return True
481
+
482
+
483
+ def load_bibliography_entries(
484
+ conn: sqlite3.Connection,
485
+ parent_key: str,
486
+ depth: int = 1,
487
+ ) -> list[dict[str, Any]]:
488
+ """Load bibliography entries registered under a parent reference.
489
+
490
+ Returns all ref_registry rows where parent_key and depth match,
491
+ each as a dict with structured metadata fields.
492
+ """
493
+ rows = conn.execute(
494
+ "SELECT ref_key, doi, arxiv_id, pmid, pmcid, isbn, lccn, "
495
+ "title, authors_json, year, venue "
496
+ "FROM ref_registry WHERE parent_key = ? AND depth = ? "
497
+ "ORDER BY ref_key",
498
+ (parent_key, depth),
499
+ ).fetchall()
500
+
501
+ cols = (
502
+ "ref_key",
503
+ "doi",
504
+ "arxiv_id",
505
+ "pmid",
506
+ "pmcid",
507
+ "isbn",
508
+ "lccn",
509
+ "title",
510
+ "authors_json",
511
+ "year",
512
+ "venue",
513
+ )
514
+ results: list[dict[str, Any]] = []
515
+ for row in rows:
516
+ entry = dict(zip(cols, row))
517
+ # Deserialize authors
518
+ aj = entry.pop("authors_json")
519
+ entry["authors"] = json.loads(aj) if aj else []
520
+ results.append(entry)
521
+ return results
522
+
523
+
524
+ def _fuzzy_lookup(
525
+ conn: sqlite3.Connection,
526
+ title: str,
527
+ authors: list[str] | None,
528
+ ) -> dict[str, Any] | None:
529
+ """Find a reference by fuzzy title + author matching.
530
+
531
+ Scans all registry entries with a non-null title. Returns the best match
532
+ if it meets thresholds, or None.
533
+ """
534
+ from sciwrite_lint.references.matching import (
535
+ AUTHOR_THRESHOLD,
536
+ TITLE_THRESHOLD,
537
+ author_similarity,
538
+ title_similarity,
539
+ )
540
+
541
+ rows = conn.execute(
542
+ "SELECT ref_key, workspace_path, depth, parent_key, title, authors_json "
543
+ "FROM ref_registry WHERE title IS NOT NULL"
544
+ ).fetchall()
545
+
546
+ best_match: dict[str, Any] | None = None
547
+ best_title_score = 0.0
548
+
549
+ for row in rows:
550
+ stored_title = row[4]
551
+ t_score = title_similarity(title, stored_title)
552
+ if t_score < TITLE_THRESHOLD:
553
+ continue
554
+
555
+ # Title matches — check authors if available
556
+ if authors:
557
+ stored_authors_json = row[5]
558
+ if stored_authors_json:
559
+ stored_authors = json.loads(stored_authors_json)
560
+ a_score = author_similarity(authors, stored_authors)
561
+ if a_score < AUTHOR_THRESHOLD:
562
+ continue
563
+
564
+ if t_score > best_title_score:
565
+ best_title_score = t_score
566
+ best_match = _row_to_result(row[:4])
567
+
568
+ return best_match
569
+
570
+
571
+ # ---------------------------------------------------------------------------
572
+ # Bibliography verification results
573
+ # ---------------------------------------------------------------------------
574
+
575
+
576
+ def save_bib_checks(
577
+ conn: sqlite3.Connection,
578
+ checks: list[dict[str, Any]],
579
+ parse_hashes: dict[str, str] | None = None,
580
+ ) -> None:
581
+ """Save bibliography verification results to workspace.db.
582
+
583
+ Each check is a RefBibCheck dict. parse_hashes maps ref_key to the
584
+ parsed markdown hash — used for cache invalidation on re-parse.
585
+ """
586
+ hashes = parse_hashes or {}
587
+ for c in checks:
588
+ conn.execute(
589
+ """INSERT INTO bib_checks
590
+ (ref_key, parse_hash, total_entries, found, not_found, retracted,
591
+ metadata_mismatches, mismatch_details_json)
592
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
593
+ ON CONFLICT (ref_key) DO UPDATE SET
594
+ parse_hash = excluded.parse_hash,
595
+ total_entries = excluded.total_entries,
596
+ found = excluded.found,
597
+ not_found = excluded.not_found,
598
+ retracted = excluded.retracted,
599
+ metadata_mismatches = excluded.metadata_mismatches,
600
+ mismatch_details_json = excluded.mismatch_details_json
601
+ """,
602
+ (
603
+ c["key"],
604
+ hashes.get(c["key"], ""),
605
+ c["total_entries"],
606
+ c["found"],
607
+ c["not_found"],
608
+ c.get("retracted", 0),
609
+ c.get("metadata_mismatches", 0),
610
+ json.dumps(c.get("mismatch_details", [])),
611
+ ),
612
+ )
613
+ conn.commit()
614
+
615
+
616
+ def load_bib_checks(
617
+ conn: sqlite3.Connection,
618
+ parse_hashes: dict[str, str] | None = None,
619
+ ) -> list[dict[str, Any]]:
620
+ """Load bibliography verification results from workspace.db.
621
+
622
+ If parse_hashes is provided, only returns results where the stored
623
+ hash matches the current parse hash (invalidates stale entries).
624
+ """
625
+ rows = conn.execute(
626
+ "SELECT ref_key, parse_hash, total_entries, found, not_found, retracted, "
627
+ "metadata_mismatches, mismatch_details_json FROM bib_checks"
628
+ ).fetchall()
629
+
630
+ results: list[dict[str, Any]] = []
631
+ for row in rows:
632
+ # Invalidate if parse hash changed
633
+ if parse_hashes:
634
+ current_hash = parse_hashes.get(row[0])
635
+ if current_hash and row[1] and current_hash != row[1]:
636
+ continue # stale — re-parsed since last bib check
637
+
638
+ results.append(
639
+ {
640
+ "key": row[0],
641
+ "total_entries": row[2],
642
+ "found": row[3],
643
+ "not_found": row[4],
644
+ "retracted": row[5],
645
+ "metadata_mismatches": row[6],
646
+ "mismatch_details": json.loads(row[7]),
647
+ }
648
+ )
649
+ return results
650
+
651
+
652
+ # ---------------------------------------------------------------------------
653
+ # Citation metadata CRUD
654
+ # ---------------------------------------------------------------------------
655
+
656
+
657
+ def save_citation_metadata(
658
+ conn: sqlite3.Connection,
659
+ meta: CitationMetadata,
660
+ ) -> None:
661
+ """Upsert a CitationMetadata record into workspace.db."""
662
+ conn.execute(
663
+ """INSERT INTO citation_metadata
664
+ (ref_key, verified_date, api_source, api_match,
665
+ canonical_json, bibitem_json, access_json,
666
+ mismatches_json, issues_json, manual_override_json)
667
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
668
+ ON CONFLICT (ref_key) DO UPDATE SET
669
+ verified_date = excluded.verified_date,
670
+ api_source = excluded.api_source,
671
+ api_match = excluded.api_match,
672
+ canonical_json = excluded.canonical_json,
673
+ bibitem_json = excluded.bibitem_json,
674
+ access_json = excluded.access_json,
675
+ mismatches_json = excluded.mismatches_json,
676
+ issues_json = excluded.issues_json,
677
+ manual_override_json = excluded.manual_override_json
678
+ """,
679
+ (
680
+ meta.key,
681
+ meta.verified_date,
682
+ meta.api_source,
683
+ meta.api_match,
684
+ json.dumps(meta.canonical, ensure_ascii=False),
685
+ json.dumps(meta.bibitem, ensure_ascii=False),
686
+ json.dumps(meta.access, ensure_ascii=False),
687
+ json.dumps(meta.mismatches, ensure_ascii=False),
688
+ json.dumps(meta.issues, ensure_ascii=False),
689
+ json.dumps(meta.manual_override, ensure_ascii=False),
690
+ ),
691
+ )
692
+ conn.commit()
693
+
694
+
695
+ def load_citation_metadata(
696
+ conn: sqlite3.Connection,
697
+ ref_key: str,
698
+ ) -> "CitationMetadata | None":
699
+ """Load a single CitationMetadata by key. Returns None if not found."""
700
+ row = conn.execute(
701
+ "SELECT ref_key, verified_date, api_source, api_match, "
702
+ "canonical_json, bibitem_json, access_json, "
703
+ "mismatches_json, issues_json, manual_override_json "
704
+ "FROM citation_metadata WHERE ref_key = ?",
705
+ (ref_key,),
706
+ ).fetchone()
707
+ if not row:
708
+ return None
709
+ return _row_to_citation_metadata(row)
710
+
711
+
712
+ def load_all_citation_metadata(
713
+ conn: sqlite3.Connection,
714
+ ) -> "dict[str, CitationMetadata]":
715
+ """Load all CitationMetadata records. Returns {key: CitationMetadata}."""
716
+ rows = conn.execute(
717
+ "SELECT ref_key, verified_date, api_source, api_match, "
718
+ "canonical_json, bibitem_json, access_json, "
719
+ "mismatches_json, issues_json, manual_override_json "
720
+ "FROM citation_metadata"
721
+ ).fetchall()
722
+ result: dict[str, CitationMetadata] = {}
723
+ for row in rows:
724
+ meta = _row_to_citation_metadata(row)
725
+ result[meta.key] = meta
726
+ return result
727
+
728
+
729
+ def delete_citation_metadata(conn: sqlite3.Connection, ref_key: str) -> None:
730
+ """Delete a single citation metadata record (used by --fresh)."""
731
+ conn.execute("DELETE FROM citation_metadata WHERE ref_key = ?", (ref_key,))
732
+ conn.commit()
733
+
734
+
735
+ def _row_to_citation_metadata(row: tuple[Any, ...]) -> CitationMetadata:
736
+ """Convert a DB row to CitationMetadata."""
737
+ from sciwrite_lint.models import CitationMetadata
738
+
739
+ return CitationMetadata(
740
+ key=row[0],
741
+ verified_date=row[1],
742
+ api_source=row[2],
743
+ api_match=row[3],
744
+ canonical=json.loads(row[4]),
745
+ bibitem=json.loads(row[5]),
746
+ access=json.loads(row[6]),
747
+ mismatches=json.loads(row[7]),
748
+ issues=json.loads(row[8]),
749
+ manual_override=json.loads(row[9]),
750
+ )
751
+
752
+
753
+ # ---------------------------------------------------------------------------
754
+ # Targeted citation metadata queries
755
+ # ---------------------------------------------------------------------------
756
+
757
+
758
+ def query_verified_metadata(
759
+ conn: sqlite3.Connection,
760
+ ) -> dict[str, CitationMetadata]:
761
+ """Load only already-verified metadata (verified/mismatch/not_found/web_*)."""
762
+ rows = conn.execute(
763
+ "SELECT ref_key, verified_date, api_source, api_match, "
764
+ "canonical_json, bibitem_json, access_json, "
765
+ "mismatches_json, issues_json, manual_override_json "
766
+ "FROM citation_metadata "
767
+ "WHERE api_match IN ('verified', 'mismatch', 'not_found', "
768
+ "'web_verified', 'web_dead')"
769
+ ).fetchall()
770
+ return {row[0]: _row_to_citation_metadata(row) for row in rows}
771
+
772
+
773
+ def query_refs_with_local_pdfs(
774
+ conn: sqlite3.Connection,
775
+ ) -> dict[str, tuple[str, str]]:
776
+ """Return {ref_key: (local_file, entry_type)} for refs with local PDFs.
777
+
778
+ Uses json_extract on access_json to filter server-side.
779
+ """
780
+ rows = conn.execute(
781
+ "SELECT ref_key, "
782
+ "json_extract(access_json, '$.local_file'), "
783
+ "COALESCE(json_extract(bibitem_json, '$.entry_type'), '') "
784
+ "FROM citation_metadata "
785
+ "WHERE json_extract(access_json, '$.local_file') LIKE '%.pdf'"
786
+ ).fetchall()
787
+ return {row[0]: (row[1], row[2]) for row in rows}
788
+
789
+
790
+ def query_refs_by_match(
791
+ conn: sqlite3.Connection,
792
+ api_match: str,
793
+ ) -> dict[str, CitationMetadata]:
794
+ """Load metadata for refs with a specific api_match value."""
795
+ rows = conn.execute(
796
+ "SELECT ref_key, verified_date, api_source, api_match, "
797
+ "canonical_json, bibitem_json, access_json, "
798
+ "mismatches_json, issues_json, manual_override_json "
799
+ "FROM citation_metadata WHERE api_match = ?",
800
+ (api_match,),
801
+ ).fetchall()
802
+ return {row[0]: _row_to_citation_metadata(row) for row in rows}
803
+
804
+
805
+ def query_retracted_refs(
806
+ conn: sqlite3.Connection,
807
+ ) -> dict[str, CitationMetadata]:
808
+ """Load only refs that have a retraction_status in canonical."""
809
+ rows = conn.execute(
810
+ "SELECT ref_key, verified_date, api_source, api_match, "
811
+ "canonical_json, bibitem_json, access_json, "
812
+ "mismatches_json, issues_json, manual_override_json "
813
+ "FROM citation_metadata "
814
+ "WHERE json_extract(canonical_json, '$.retraction_status') IS NOT NULL"
815
+ ).fetchall()
816
+ return {row[0]: _row_to_citation_metadata(row) for row in rows}
817
+
818
+
819
+ def query_refs_with_mismatches(
820
+ conn: sqlite3.Connection,
821
+ ) -> dict[str, CitationMetadata]:
822
+ """Load only refs that have non-empty mismatches."""
823
+ rows = conn.execute(
824
+ "SELECT ref_key, verified_date, api_source, api_match, "
825
+ "canonical_json, bibitem_json, access_json, "
826
+ "mismatches_json, issues_json, manual_override_json "
827
+ "FROM citation_metadata WHERE mismatches_json != '[]'"
828
+ ).fetchall()
829
+ return {row[0]: _row_to_citation_metadata(row) for row in rows}
830
+
831
+
832
+ # ---------------------------------------------------------------------------
833
+ # Parse cache CRUD
834
+ # ---------------------------------------------------------------------------
835
+
836
+
837
+ def save_parse_cache(
838
+ conn: sqlite3.Connection,
839
+ ref_key: str,
840
+ *,
841
+ pdf_hash: str,
842
+ parse_date: str = "",
843
+ parser: str = "grobid",
844
+ sections_count: int = 0,
845
+ char_count: int = 0,
846
+ is_formal: bool = False,
847
+ has_embeddings: bool = False,
848
+ embedding_model: str = "",
849
+ chunks_count: int = 0,
850
+ ) -> None:
851
+ """Upsert a parse cache record."""
852
+ conn.execute(
853
+ """INSERT INTO parse_cache
854
+ (ref_key, pdf_hash, parse_date, parser, sections_count, char_count,
855
+ is_formal, has_embeddings, embedding_model, chunks_count)
856
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
857
+ ON CONFLICT (ref_key) DO UPDATE SET
858
+ pdf_hash = excluded.pdf_hash,
859
+ parse_date = excluded.parse_date,
860
+ parser = excluded.parser,
861
+ sections_count = excluded.sections_count,
862
+ char_count = excluded.char_count,
863
+ is_formal = excluded.is_formal,
864
+ has_embeddings = excluded.has_embeddings,
865
+ embedding_model = excluded.embedding_model,
866
+ chunks_count = excluded.chunks_count
867
+ """,
868
+ (
869
+ ref_key,
870
+ pdf_hash,
871
+ parse_date,
872
+ parser,
873
+ sections_count,
874
+ char_count,
875
+ int(is_formal),
876
+ int(has_embeddings),
877
+ embedding_model,
878
+ chunks_count,
879
+ ),
880
+ )
881
+ conn.commit()
882
+
883
+
884
+ def load_parse_cache(
885
+ conn: sqlite3.Connection,
886
+ ref_key: str,
887
+ ) -> dict[str, Any] | None:
888
+ """Load a single parse cache record. Returns dict or None."""
889
+ row = conn.execute(
890
+ "SELECT ref_key, pdf_hash, parse_date, parser, sections_count, char_count, "
891
+ "is_formal, has_embeddings, embedding_model, chunks_count "
892
+ "FROM parse_cache WHERE ref_key = ?",
893
+ (ref_key,),
894
+ ).fetchone()
895
+ if not row:
896
+ return None
897
+ return {
898
+ "ref_key": row[0],
899
+ "pdf_hash": row[1],
900
+ "parse_date": row[2],
901
+ "parser": row[3],
902
+ "sections_count": row[4],
903
+ "char_count": row[5],
904
+ "is_formal": bool(row[6]),
905
+ "has_embeddings": bool(row[7]),
906
+ "embedding_model": row[8],
907
+ "chunks_count": row[9],
908
+ }
909
+
910
+
911
+ def load_all_parse_cache(
912
+ conn: sqlite3.Connection,
913
+ ) -> dict[str, dict[str, Any]]:
914
+ """Load all parse cache records. Returns {ref_key: dict}."""
915
+ rows = conn.execute(
916
+ "SELECT ref_key, pdf_hash, parse_date, parser, sections_count, char_count, "
917
+ "is_formal, has_embeddings, embedding_model, chunks_count "
918
+ "FROM parse_cache"
919
+ ).fetchall()
920
+ result: dict[str, dict[str, Any]] = {}
921
+ for row in rows:
922
+ result[row[0]] = {
923
+ "pdf_hash": row[1],
924
+ "parse_date": row[2],
925
+ "parser": row[3],
926
+ "sections_count": row[4],
927
+ "char_count": row[5],
928
+ "is_formal": bool(row[6]),
929
+ "has_embeddings": bool(row[7]),
930
+ "embedding_model": row[8],
931
+ "chunks_count": row[9],
932
+ }
933
+ return result
934
+
935
+
936
+ def update_parse_cache_embeddings(
937
+ conn: sqlite3.Connection,
938
+ ref_key: str,
939
+ *,
940
+ has_embeddings: bool,
941
+ embedding_model: str,
942
+ chunks_count: int,
943
+ ) -> None:
944
+ """Update embedding fields on an existing parse cache record."""
945
+ conn.execute(
946
+ "UPDATE parse_cache SET has_embeddings = ?, embedding_model = ?, "
947
+ "chunks_count = ? WHERE ref_key = ?",
948
+ (int(has_embeddings), embedding_model, chunks_count, ref_key),
949
+ )
950
+ conn.commit()
951
+
952
+
953
+ def is_formal_cached_db(
954
+ conn: sqlite3.Connection,
955
+ ref_key: str,
956
+ ) -> bool:
957
+ """Check if a parsed reference is a formal academic document via DB."""
958
+ row = conn.execute(
959
+ "SELECT is_formal FROM parse_cache WHERE ref_key = ?",
960
+ (ref_key,),
961
+ ).fetchone()
962
+ if not row:
963
+ return False
964
+ return bool(row[0])
965
+
966
+
967
+ # ---------------------------------------------------------------------------
968
+ # Ref internal cache CRUD
969
+ # ---------------------------------------------------------------------------
970
+
971
+
972
+ def save_ref_internal_cache(
973
+ conn: sqlite3.Connection,
974
+ ref_key: str,
975
+ *,
976
+ md_hash: str,
977
+ cache_version: str,
978
+ internal_score: float,
979
+ contribution_score: float | None = None,
980
+ contribution_json: str = "{}",
981
+ findings_json: str = "[]",
982
+ sections_found: int = 0,
983
+ checks_run_json: str = "[]",
984
+ ) -> None:
985
+ """Upsert a ref internal cache record."""
986
+ conn.execute(
987
+ """INSERT INTO ref_internal_cache
988
+ (ref_key, md_hash, cache_version, internal_score, contribution_score,
989
+ contribution_json, findings_json, sections_found, checks_run_json)
990
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
991
+ ON CONFLICT (ref_key) DO UPDATE SET
992
+ md_hash = excluded.md_hash,
993
+ cache_version = excluded.cache_version,
994
+ internal_score = excluded.internal_score,
995
+ contribution_score = excluded.contribution_score,
996
+ contribution_json = excluded.contribution_json,
997
+ findings_json = excluded.findings_json,
998
+ sections_found = excluded.sections_found,
999
+ checks_run_json = excluded.checks_run_json
1000
+ """,
1001
+ (
1002
+ ref_key,
1003
+ md_hash,
1004
+ cache_version,
1005
+ internal_score,
1006
+ contribution_score,
1007
+ contribution_json,
1008
+ findings_json,
1009
+ sections_found,
1010
+ checks_run_json,
1011
+ ),
1012
+ )
1013
+ conn.commit()
1014
+
1015
+
1016
+ def load_ref_internal_cache(
1017
+ conn: sqlite3.Connection,
1018
+ ref_key: str,
1019
+ *,
1020
+ expected_version: str,
1021
+ expected_md_hash: str,
1022
+ ) -> dict[str, Any] | None:
1023
+ """Load a ref internal cache record if version and hash match."""
1024
+ row = conn.execute(
1025
+ "SELECT ref_key, md_hash, cache_version, internal_score, contribution_score, "
1026
+ "contribution_json, findings_json, sections_found, checks_run_json "
1027
+ "FROM ref_internal_cache WHERE ref_key = ?",
1028
+ (ref_key,),
1029
+ ).fetchone()
1030
+ if not row:
1031
+ return None
1032
+ if row[2] != expected_version:
1033
+ return None
1034
+ if row[1] != expected_md_hash:
1035
+ return None
1036
+ return {
1037
+ "ref_key": row[0],
1038
+ "md_hash": row[1],
1039
+ "cache_version": row[2],
1040
+ "internal_score": row[3],
1041
+ "contribution_score": row[4] if row[4] is not None else 1.0,
1042
+ "contribution_json": row[5],
1043
+ "findings_json": row[6],
1044
+ "sections_found": row[7],
1045
+ "checks_run_json": row[8],
1046
+ }
1047
+
1048
+
1049
+ def load_all_ref_internal_scores(
1050
+ conn: sqlite3.Connection,
1051
+ ) -> dict[str, float]:
1052
+ """Load all ref internal scores as {ref_key: combined_score}.
1053
+
1054
+ Combined score = internal_score × contribution_score.
1055
+ """
1056
+ rows = conn.execute(
1057
+ "SELECT ref_key, internal_score, contribution_score FROM ref_internal_cache"
1058
+ ).fetchall()
1059
+ result: dict[str, float] = {}
1060
+ for row in rows:
1061
+ i_score = row[1]
1062
+ c_score = row[2] if row[2] is not None else 1.0
1063
+ result[row[0]] = i_score * c_score
1064
+ return result
1065
+
1066
+
1067
+ # ---------------------------------------------------------------------------
1068
+ # Claim results CRUD
1069
+ # ---------------------------------------------------------------------------
1070
+
1071
+
1072
+ def save_claim_results(
1073
+ conn: sqlite3.Connection,
1074
+ results: list[dict[str, Any]],
1075
+ ) -> None:
1076
+ """Replace all claim results with new data.
1077
+
1078
+ Deletes existing rows first (full replacement per run).
1079
+ """
1080
+ conn.execute("DELETE FROM claim_results")
1081
+ for r in results:
1082
+ conn.execute(
1083
+ """INSERT INTO claim_results
1084
+ (ref_key, claim_text, line, context, verdict, confidence,
1085
+ relevant_quote, explanation, backend, model, ref_type,
1086
+ cite_purpose, dismissed, reviewer_comment, dismissed_date)
1087
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1088
+ """,
1089
+ (
1090
+ r.get("key", ""),
1091
+ r.get("claim_text", ""),
1092
+ r.get("line"),
1093
+ r.get("context", ""),
1094
+ r.get("verdict", ""),
1095
+ r.get("confidence", 0.0),
1096
+ r.get("relevant_quote", ""),
1097
+ r.get("explanation", ""),
1098
+ r.get("backend", ""),
1099
+ r.get("model", ""),
1100
+ r.get("ref_type", ""),
1101
+ r.get("cite_purpose", r.get("citation_purpose", "")),
1102
+ int(r.get("dismissed", False)),
1103
+ r.get("reviewer_comment", ""),
1104
+ r.get("dismissed_date", ""),
1105
+ ),
1106
+ )
1107
+ conn.commit()
1108
+
1109
+
1110
+ def load_claim_results(
1111
+ conn: sqlite3.Connection,
1112
+ ) -> list[dict[str, Any]]:
1113
+ """Load all claim results as a list of dicts."""
1114
+ rows = conn.execute(
1115
+ "SELECT id, ref_key, claim_text, line, context, verdict, confidence, "
1116
+ "relevant_quote, explanation, backend, model, ref_type, cite_purpose, "
1117
+ "dismissed, reviewer_comment, dismissed_date "
1118
+ "FROM claim_results ORDER BY id"
1119
+ ).fetchall()
1120
+ results: list[dict[str, Any]] = []
1121
+ for row in rows:
1122
+ d: dict[str, Any] = {
1123
+ "id": row[0],
1124
+ "key": row[1],
1125
+ "claim_text": row[2],
1126
+ "line": row[3],
1127
+ "context": row[4],
1128
+ "verdict": row[5],
1129
+ "confidence": row[6],
1130
+ "relevant_quote": row[7],
1131
+ "explanation": row[8],
1132
+ "backend": row[9],
1133
+ "model": row[10],
1134
+ "ref_type": row[11],
1135
+ "cite_purpose": row[12],
1136
+ }
1137
+ if row[13]:
1138
+ d["dismissed"] = True
1139
+ d["reviewer_comment"] = row[14]
1140
+ d["dismissed_date"] = row[15]
1141
+ results.append(d)
1142
+ return results
1143
+
1144
+
1145
+ def dismiss_claim(
1146
+ conn: sqlite3.Connection,
1147
+ claim_id: int,
1148
+ *,
1149
+ reason: str,
1150
+ date_str: str,
1151
+ ) -> bool:
1152
+ """Mark a claim as dismissed. Returns True if found."""
1153
+ cur = conn.execute(
1154
+ "UPDATE claim_results SET dismissed = 1, reviewer_comment = ?, "
1155
+ "dismissed_date = ? WHERE id = ?",
1156
+ (reason, date_str, claim_id),
1157
+ )
1158
+ conn.commit()
1159
+ return cur.rowcount > 0
1160
+
1161
+
1162
+ def clear_claim_dismissal(
1163
+ conn: sqlite3.Connection,
1164
+ claim_id: int,
1165
+ ) -> bool:
1166
+ """Clear dismissal on a claim. Returns True if found."""
1167
+ cur = conn.execute(
1168
+ "UPDATE claim_results SET dismissed = 0, reviewer_comment = '', "
1169
+ "dismissed_date = '' WHERE id = ?",
1170
+ (claim_id,),
1171
+ )
1172
+ conn.commit()
1173
+ return cur.rowcount > 0
1174
+
1175
+
1176
+ def find_claim(
1177
+ conn: sqlite3.Connection,
1178
+ ref_key: str,
1179
+ line: int,
1180
+ ) -> dict[str, Any] | None:
1181
+ """Find a claim by ref_key and line number."""
1182
+ row = conn.execute(
1183
+ "SELECT id, ref_key, claim_text, line, context, verdict, confidence, "
1184
+ "relevant_quote, explanation, backend, model, ref_type, cite_purpose, "
1185
+ "dismissed, reviewer_comment, dismissed_date "
1186
+ "FROM claim_results WHERE ref_key = ? AND line = ? LIMIT 1",
1187
+ (ref_key, line),
1188
+ ).fetchone()
1189
+ if not row:
1190
+ return None
1191
+ d: dict[str, Any] = {
1192
+ "id": row[0],
1193
+ "key": row[1],
1194
+ "claim_text": row[2],
1195
+ "line": row[3],
1196
+ "context": row[4],
1197
+ "verdict": row[5],
1198
+ "confidence": row[6],
1199
+ "relevant_quote": row[7],
1200
+ "explanation": row[8],
1201
+ "backend": row[9],
1202
+ "model": row[10],
1203
+ "ref_type": row[11],
1204
+ "cite_purpose": row[12],
1205
+ }
1206
+ if row[13]:
1207
+ d["dismissed"] = True
1208
+ d["reviewer_comment"] = row[14]
1209
+ d["dismissed_date"] = row[15]
1210
+ return d
1211
+
1212
+
1213
+ def list_claims_for_key(
1214
+ conn: sqlite3.Connection,
1215
+ ref_key: str,
1216
+ ) -> list[dict[str, Any]]:
1217
+ """List all claims for a given ref_key."""
1218
+ rows = conn.execute(
1219
+ "SELECT id, line, verdict, context FROM claim_results WHERE ref_key = ?",
1220
+ (ref_key,),
1221
+ ).fetchall()
1222
+ return [
1223
+ {"id": row[0], "line": row[1], "verdict": row[2], "context": row[3]}
1224
+ for row in rows
1225
+ ]
1226
+
1227
+
1228
+ # ---------------------------------------------------------------------------
1229
+ # Vision cache operations
1230
+ # ---------------------------------------------------------------------------
1231
+
1232
+ _VISION_CACHE_VERSION = "1"
1233
+
1234
+
1235
+ def save_vision_entry(
1236
+ conn: sqlite3.Connection,
1237
+ image_key: str,
1238
+ *,
1239
+ image_hash: str,
1240
+ label: str = "",
1241
+ caption: str = "",
1242
+ description: str = "",
1243
+ ) -> None:
1244
+ """Upsert a single vision cache entry."""
1245
+ conn.execute(
1246
+ """INSERT INTO vision_cache
1247
+ (image_key, image_hash, label, caption, description, cache_version)
1248
+ VALUES (?, ?, ?, ?, ?, ?)
1249
+ ON CONFLICT (image_key) DO UPDATE SET
1250
+ image_hash = excluded.image_hash,
1251
+ label = excluded.label,
1252
+ caption = excluded.caption,
1253
+ description = excluded.description,
1254
+ cache_version = excluded.cache_version
1255
+ """,
1256
+ (image_key, image_hash, label, caption, description, _VISION_CACHE_VERSION),
1257
+ )
1258
+ conn.commit()
1259
+
1260
+
1261
+ def load_vision_entry(
1262
+ conn: sqlite3.Connection,
1263
+ image_key: str,
1264
+ *,
1265
+ expected_hash: str,
1266
+ ) -> dict[str, str] | None:
1267
+ """Load a vision cache entry if hash matches. Returns None on miss."""
1268
+ row = conn.execute(
1269
+ "SELECT image_hash, label, caption, description, cache_version "
1270
+ "FROM vision_cache WHERE image_key = ?",
1271
+ (image_key,),
1272
+ ).fetchone()
1273
+ if not row:
1274
+ return None
1275
+ if row[4] != _VISION_CACHE_VERSION:
1276
+ return None
1277
+ if row[0] != expected_hash:
1278
+ return None
1279
+ return {
1280
+ "image_hash": row[0],
1281
+ "label": row[1],
1282
+ "caption": row[2],
1283
+ "description": row[3],
1284
+ }
1285
+
1286
+
1287
+ def load_all_vision_entries(
1288
+ conn: sqlite3.Connection,
1289
+ ) -> dict[str, dict[str, str]]:
1290
+ """Load all vision cache entries. Returns {image_key: {...}}."""
1291
+ rows = conn.execute(
1292
+ "SELECT image_key, image_hash, label, caption, description "
1293
+ "FROM vision_cache WHERE cache_version = ?",
1294
+ (_VISION_CACHE_VERSION,),
1295
+ ).fetchall()
1296
+ return {
1297
+ row[0]: {
1298
+ "image_hash": row[1],
1299
+ "label": row[2],
1300
+ "caption": row[3],
1301
+ "description": row[4],
1302
+ }
1303
+ for row in rows
1304
+ }
1305
+
1306
+
1307
+ def clear_vision_cache(conn: sqlite3.Connection) -> None:
1308
+ """Delete all vision cache entries (for --fresh)."""
1309
+ conn.execute("DELETE FROM vision_cache")
1310
+ conn.commit()
1311
+
1312
+
1313
+ # ---------------------------------------------------------------------------
1314
+ # Pipeline stage tracking
1315
+ # ---------------------------------------------------------------------------
1316
+
1317
+ # Canonical stage names and their display order.
1318
+ PIPELINE_STAGES: list[str] = [
1319
+ "vision", # Stage 0.5: manuscript figure descriptions
1320
+ "text_checks", # Stage 1a: regex-based text rules
1321
+ "llm_checks", # Stage 1b: vLLM batch checks
1322
+ "verify", # Stage 2: API verification
1323
+ "fetch", # Stage 3: full-text acquisition
1324
+ "parse", # Stage 4: GROBID parse + embeddings
1325
+ "cited_vision", # Stage 4.2: VL on cited paper figures
1326
+ "ref_internal", # Stage 4.5: ref internal consistency (vLLM)
1327
+ "bib_verify", # Stage 4.6: bibliography verification
1328
+ "claims", # Stage 5: claim verification (vLLM)
1329
+ "unreliable", # Stage 6: reference-unreliable aggregation
1330
+ ]
1331
+
1332
+
1333
+ def init_pipeline_stages(conn: sqlite3.Connection) -> None:
1334
+ """Reset all stages to 'pending' at the start of a run."""
1335
+ conn.execute("DELETE FROM pipeline_stage")
1336
+ for stage in PIPELINE_STAGES:
1337
+ conn.execute(
1338
+ "INSERT INTO pipeline_stage (stage, status) VALUES (?, 'pending')",
1339
+ (stage,),
1340
+ )
1341
+ conn.commit()
1342
+
1343
+
1344
+ def update_pipeline_stage(
1345
+ conn: sqlite3.Connection,
1346
+ stage: str,
1347
+ status: str,
1348
+ detail: str = "",
1349
+ ) -> None:
1350
+ """Update a stage's status. status: 'running', 'done', 'failed', 'skipped'."""
1351
+ import time
1352
+
1353
+ if status == "running":
1354
+ conn.execute(
1355
+ "UPDATE pipeline_stage SET status = ?, start_time = ?, detail = ? "
1356
+ "WHERE stage = ?",
1357
+ (status, time.time(), detail, stage),
1358
+ )
1359
+ elif status in ("done", "failed", "skipped"):
1360
+ conn.execute(
1361
+ "UPDATE pipeline_stage SET status = ?, end_time = ?, detail = ? "
1362
+ "WHERE stage = ?",
1363
+ (status, time.time(), detail, stage),
1364
+ )
1365
+ else:
1366
+ conn.execute(
1367
+ "UPDATE pipeline_stage SET status = ?, detail = ? WHERE stage = ?",
1368
+ (status, detail, stage),
1369
+ )
1370
+ conn.commit()
1371
+
1372
+
1373
+ def load_pipeline_stages(
1374
+ conn: sqlite3.Connection,
1375
+ ) -> list[dict[str, str | float | None]]:
1376
+ """Load all pipeline stages in order. Returns list of dicts."""
1377
+ rows = conn.execute(
1378
+ "SELECT stage, status, start_time, end_time, detail "
1379
+ "FROM pipeline_stage ORDER BY rowid"
1380
+ ).fetchall()
1381
+ return [
1382
+ {
1383
+ "stage": r[0],
1384
+ "status": r[1],
1385
+ "start_time": r[2],
1386
+ "end_time": r[3],
1387
+ "detail": r[4],
1388
+ }
1389
+ for r in rows
1390
+ ]