academic-refchecker 1.2.64__py3-none-any.whl → 1.2.66__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.
backend/database.py ADDED
@@ -0,0 +1,686 @@
1
+ """
2
+ Database module for storing check history and LLM configurations
3
+ """
4
+ import aiosqlite
5
+ import json
6
+ import os
7
+ from datetime import datetime
8
+ from typing import List, Optional, Dict, Any
9
+ from pathlib import Path
10
+ from cryptography.fernet import Fernet
11
+
12
+
13
+ def get_encryption_key() -> bytes:
14
+ """Get or create encryption key for API keys"""
15
+ key_file = Path(__file__).parent / ".encryption_key"
16
+ if key_file.exists():
17
+ return key_file.read_bytes()
18
+ else:
19
+ key = Fernet.generate_key()
20
+ key_file.write_bytes(key)
21
+ return key
22
+
23
+
24
+ class Database:
25
+ """Handles SQLite database operations for check history and LLM configs"""
26
+
27
+ def __init__(self, db_path: str = "refchecker_history.db"):
28
+ self.db_path = db_path
29
+ self._fernet = Fernet(get_encryption_key())
30
+
31
+ def _encrypt(self, value: str) -> str:
32
+ """Encrypt a string value"""
33
+ return self._fernet.encrypt(value.encode()).decode()
34
+
35
+ def _decrypt(self, value: str) -> str:
36
+ """Decrypt a string value"""
37
+ return self._fernet.decrypt(value.encode()).decode()
38
+
39
+ async def _get_connection(self):
40
+ """Get a database connection with proper settings for concurrent access"""
41
+ db = await aiosqlite.connect(self.db_path)
42
+ # Enable WAL mode for better concurrent read/write
43
+ await db.execute("PRAGMA journal_mode=WAL")
44
+ # Set busy timeout to 5 seconds
45
+ await db.execute("PRAGMA busy_timeout=5000")
46
+ return db
47
+
48
+ async def init_db(self):
49
+ """Initialize database schema"""
50
+ async with aiosqlite.connect(self.db_path) as db:
51
+ # Enable WAL mode for better concurrent access
52
+ await db.execute("PRAGMA journal_mode=WAL")
53
+ await db.execute("PRAGMA busy_timeout=5000")
54
+ # Check history table
55
+ await db.execute("""
56
+ CREATE TABLE IF NOT EXISTS check_history (
57
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
58
+ paper_title TEXT NOT NULL,
59
+ paper_source TEXT NOT NULL,
60
+ source_type TEXT DEFAULT 'url',
61
+ custom_label TEXT,
62
+ timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
63
+ total_refs INTEGER,
64
+ errors_count INTEGER,
65
+ warnings_count INTEGER,
66
+ suggestions_count INTEGER DEFAULT 0,
67
+ unverified_count INTEGER,
68
+ refs_with_errors INTEGER DEFAULT 0,
69
+ refs_with_warnings_only INTEGER DEFAULT 0,
70
+ refs_verified INTEGER DEFAULT 0,
71
+ results_json TEXT,
72
+ llm_provider TEXT,
73
+ llm_model TEXT,
74
+ extraction_method TEXT,
75
+ status TEXT DEFAULT 'completed'
76
+ )
77
+ """)
78
+
79
+ # LLM configurations table
80
+ await db.execute("""
81
+ CREATE TABLE IF NOT EXISTS llm_configs (
82
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
83
+ name TEXT NOT NULL,
84
+ provider TEXT NOT NULL,
85
+ model TEXT,
86
+ api_key_encrypted TEXT,
87
+ endpoint TEXT,
88
+ is_default BOOLEAN DEFAULT 0,
89
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP
90
+ )
91
+ """)
92
+
93
+ # App settings table (for Semantic Scholar key, etc.)
94
+ await db.execute("""
95
+ CREATE TABLE IF NOT EXISTS app_settings (
96
+ key TEXT PRIMARY KEY,
97
+ value_encrypted TEXT,
98
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
99
+ )
100
+ """)
101
+
102
+ # Verification cache table - stores results keyed by reference content hash
103
+ await db.execute("""
104
+ CREATE TABLE IF NOT EXISTS verification_cache (
105
+ cache_key TEXT PRIMARY KEY,
106
+ result_json TEXT NOT NULL,
107
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP
108
+ )
109
+ """)
110
+
111
+ await self._ensure_columns(db)
112
+ await db.commit()
113
+
114
+ async def _ensure_columns(self, db: aiosqlite.Connection):
115
+ """Ensure new columns exist for older databases."""
116
+ async with db.execute("PRAGMA table_info(check_history)") as cursor:
117
+ columns = {row[1] async for row in cursor}
118
+ if "source_type" not in columns:
119
+ await db.execute("ALTER TABLE check_history ADD COLUMN source_type TEXT DEFAULT 'url'")
120
+ if "custom_label" not in columns:
121
+ await db.execute("ALTER TABLE check_history ADD COLUMN custom_label TEXT")
122
+ if "suggestions_count" not in columns:
123
+ await db.execute("ALTER TABLE check_history ADD COLUMN suggestions_count INTEGER DEFAULT 0")
124
+ if "refs_with_errors" not in columns:
125
+ await db.execute("ALTER TABLE check_history ADD COLUMN refs_with_errors INTEGER DEFAULT 0")
126
+ if "refs_with_warnings_only" not in columns:
127
+ await db.execute("ALTER TABLE check_history ADD COLUMN refs_with_warnings_only INTEGER DEFAULT 0")
128
+ if "refs_verified" not in columns:
129
+ await db.execute("ALTER TABLE check_history ADD COLUMN refs_verified INTEGER DEFAULT 0")
130
+ if "extraction_method" not in columns:
131
+ await db.execute("ALTER TABLE check_history ADD COLUMN extraction_method TEXT")
132
+ if "thumbnail_path" not in columns:
133
+ await db.execute("ALTER TABLE check_history ADD COLUMN thumbnail_path TEXT")
134
+ if "bibliography_source_path" not in columns:
135
+ await db.execute("ALTER TABLE check_history ADD COLUMN bibliography_source_path TEXT")
136
+
137
+ async def save_check(self,
138
+ paper_title: str,
139
+ paper_source: str,
140
+ source_type: str,
141
+ total_refs: int,
142
+ errors_count: int,
143
+ warnings_count: int,
144
+ suggestions_count: int,
145
+ unverified_count: int,
146
+ refs_with_errors: int,
147
+ refs_with_warnings_only: int,
148
+ refs_verified: int,
149
+ results: List[Dict[str, Any]],
150
+ llm_provider: Optional[str] = None,
151
+ llm_model: Optional[str] = None,
152
+ extraction_method: Optional[str] = None) -> int:
153
+ """Save a check result to database"""
154
+ async with aiosqlite.connect(self.db_path) as db:
155
+ cursor = await db.execute("""
156
+ INSERT INTO check_history
157
+ (paper_title, paper_source, source_type, total_refs, errors_count, warnings_count,
158
+ suggestions_count, unverified_count, refs_with_errors, refs_with_warnings_only,
159
+ refs_verified, results_json, llm_provider, llm_model, extraction_method)
160
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
161
+ """, (
162
+ paper_title,
163
+ paper_source,
164
+ source_type,
165
+ total_refs,
166
+ errors_count,
167
+ warnings_count,
168
+ suggestions_count,
169
+ unverified_count,
170
+ refs_with_errors,
171
+ refs_with_warnings_only,
172
+ refs_verified,
173
+ json.dumps(results),
174
+ llm_provider,
175
+ llm_model,
176
+ extraction_method
177
+ ))
178
+ await db.commit()
179
+ return cursor.lastrowid
180
+
181
+ async def get_history(self, limit: int = 50) -> List[Dict[str, Any]]:
182
+ """Get recent check history"""
183
+ async with aiosqlite.connect(self.db_path) as db:
184
+ await db.execute("PRAGMA busy_timeout=5000")
185
+ db.row_factory = aiosqlite.Row
186
+ async with db.execute("""
187
+ SELECT id, paper_title, paper_source, custom_label, timestamp,
188
+ total_refs, errors_count, warnings_count, suggestions_count, unverified_count,
189
+ refs_with_errors, refs_with_warnings_only, refs_verified,
190
+ llm_provider, llm_model, status, source_type
191
+ FROM check_history
192
+ ORDER BY timestamp DESC
193
+ LIMIT ?
194
+ """, (limit,)) as cursor:
195
+ rows = await cursor.fetchall()
196
+ return [dict(row) for row in rows]
197
+
198
+ async def get_check_by_id(self, check_id: int) -> Optional[Dict[str, Any]]:
199
+ """Get specific check result by ID"""
200
+ async with aiosqlite.connect(self.db_path) as db:
201
+ await db.execute("PRAGMA busy_timeout=5000")
202
+ db.row_factory = aiosqlite.Row
203
+ async with db.execute("""
204
+ SELECT * FROM check_history WHERE id = ?
205
+ """, (check_id,)) as cursor:
206
+ row = await cursor.fetchone()
207
+ if row:
208
+ result = dict(row)
209
+ # Parse JSON results
210
+ if result['results_json']:
211
+ result['results'] = json.loads(result['results_json'])
212
+ return result
213
+ return None
214
+
215
+ async def delete_check(self, check_id: int) -> bool:
216
+ """Delete a check from history"""
217
+ async with aiosqlite.connect(self.db_path) as db:
218
+ await db.execute("PRAGMA busy_timeout=5000")
219
+ await db.execute("DELETE FROM check_history WHERE id = ?", (check_id,))
220
+ await db.commit()
221
+ return True
222
+
223
+ async def update_check_label(self, check_id: int, label: str) -> bool:
224
+ """Update the custom label for a check"""
225
+ async with aiosqlite.connect(self.db_path) as db:
226
+ await db.execute("PRAGMA busy_timeout=5000")
227
+ await db.execute(
228
+ "UPDATE check_history SET custom_label = ? WHERE id = ?",
229
+ (label, check_id)
230
+ )
231
+ await db.commit()
232
+ return True
233
+
234
+ async def update_check_title(self, check_id: int, paper_title: str) -> bool:
235
+ """Update the paper title for a check"""
236
+ async with aiosqlite.connect(self.db_path) as db:
237
+ await db.execute("PRAGMA busy_timeout=5000")
238
+ await db.execute(
239
+ "UPDATE check_history SET paper_title = ? WHERE id = ?",
240
+ (paper_title, check_id)
241
+ )
242
+ await db.commit()
243
+ return True
244
+
245
+ async def create_pending_check(self,
246
+ paper_title: str,
247
+ paper_source: str,
248
+ source_type: str,
249
+ llm_provider: Optional[str] = None,
250
+ llm_model: Optional[str] = None) -> int:
251
+ """Create a pending check entry before verification starts"""
252
+ async with aiosqlite.connect(self.db_path) as db:
253
+ cursor = await db.execute("""
254
+ INSERT INTO check_history
255
+ (paper_title, paper_source, source_type, total_refs, errors_count, warnings_count,
256
+ suggestions_count, unverified_count, results_json, llm_provider, llm_model, status)
257
+ VALUES (?, ?, ?, 0, 0, 0, 0, 0, '[]', ?, ?, 'in_progress')
258
+ """, (
259
+ paper_title,
260
+ paper_source,
261
+ source_type,
262
+ llm_provider,
263
+ llm_model
264
+ ))
265
+ await db.commit()
266
+ return cursor.lastrowid
267
+
268
+ async def update_check_results(self,
269
+ check_id: int,
270
+ paper_title: Optional[str],
271
+ total_refs: int,
272
+ errors_count: int,
273
+ warnings_count: int,
274
+ suggestions_count: int,
275
+ unverified_count: int,
276
+ refs_with_errors: int,
277
+ refs_with_warnings_only: int,
278
+ refs_verified: int,
279
+ results: List[Dict[str, Any]],
280
+ status: str = 'completed',
281
+ extraction_method: Optional[str] = None) -> bool:
282
+ """Update a check with its results. If paper_title is None, don't update it."""
283
+ async with aiosqlite.connect(self.db_path) as db:
284
+ if paper_title is not None:
285
+ await db.execute("""
286
+ UPDATE check_history
287
+ SET paper_title = ?, total_refs = ?, errors_count = ?, warnings_count = ?,
288
+ suggestions_count = ?, unverified_count = ?, refs_with_errors = ?,
289
+ refs_with_warnings_only = ?, refs_verified = ?, results_json = ?, status = ?,
290
+ extraction_method = ?
291
+ WHERE id = ?
292
+ """, (
293
+ paper_title,
294
+ total_refs,
295
+ errors_count,
296
+ warnings_count,
297
+ suggestions_count,
298
+ unverified_count,
299
+ refs_with_errors,
300
+ refs_with_warnings_only,
301
+ refs_verified,
302
+ json.dumps(results),
303
+ status,
304
+ extraction_method,
305
+ check_id
306
+ ))
307
+ else:
308
+ # Don't update paper_title if None
309
+ await db.execute("""
310
+ UPDATE check_history
311
+ SET total_refs = ?, errors_count = ?, warnings_count = ?,
312
+ suggestions_count = ?, unverified_count = ?, refs_with_errors = ?,
313
+ refs_with_warnings_only = ?, refs_verified = ?, results_json = ?, status = ?,
314
+ extraction_method = ?
315
+ WHERE id = ?
316
+ """, (
317
+ total_refs,
318
+ errors_count,
319
+ warnings_count,
320
+ suggestions_count,
321
+ unverified_count,
322
+ refs_with_errors,
323
+ refs_with_warnings_only,
324
+ refs_verified,
325
+ json.dumps(results),
326
+ status,
327
+ extraction_method,
328
+ check_id
329
+ ))
330
+ await db.commit()
331
+ return True
332
+
333
+ async def update_check_progress(self,
334
+ check_id: int,
335
+ total_refs: int,
336
+ errors_count: int,
337
+ warnings_count: int,
338
+ suggestions_count: int,
339
+ unverified_count: int,
340
+ refs_with_errors: int,
341
+ refs_with_warnings_only: int,
342
+ refs_verified: int,
343
+ results: List[Dict[str, Any]]) -> bool:
344
+ """Incrementally update a check's results as references are verified.
345
+
346
+ This is called after each reference is checked to persist progress,
347
+ so interrupted checks retain their partial results.
348
+ """
349
+ async with aiosqlite.connect(self.db_path) as db:
350
+ await db.execute("PRAGMA busy_timeout=5000")
351
+ await db.execute("""
352
+ UPDATE check_history
353
+ SET total_refs = ?, errors_count = ?, warnings_count = ?,
354
+ suggestions_count = ?, unverified_count = ?, refs_with_errors = ?,
355
+ refs_with_warnings_only = ?, refs_verified = ?, results_json = ?
356
+ WHERE id = ?
357
+ """, (
358
+ total_refs,
359
+ errors_count,
360
+ warnings_count,
361
+ suggestions_count,
362
+ unverified_count,
363
+ refs_with_errors,
364
+ refs_with_warnings_only,
365
+ refs_verified,
366
+ json.dumps(results),
367
+ check_id
368
+ ))
369
+ await db.commit()
370
+ return True
371
+
372
+ async def update_check_status(self, check_id: int, status: str) -> bool:
373
+ """Update just the status of a check"""
374
+ async with aiosqlite.connect(self.db_path) as db:
375
+ await db.execute(
376
+ "UPDATE check_history SET status = ? WHERE id = ?",
377
+ (status, check_id)
378
+ )
379
+ await db.commit()
380
+ return True
381
+
382
+ async def update_check_extraction_method(self, check_id: int, extraction_method: str) -> bool:
383
+ """Update the extraction method for a check"""
384
+ async with aiosqlite.connect(self.db_path) as db:
385
+ await db.execute(
386
+ "UPDATE check_history SET extraction_method = ? WHERE id = ?",
387
+ (extraction_method, check_id)
388
+ )
389
+ await db.commit()
390
+ return True
391
+
392
+ async def update_check_thumbnail(self, check_id: int, thumbnail_path: str) -> bool:
393
+ """Update the thumbnail path for a check"""
394
+ async with aiosqlite.connect(self.db_path) as db:
395
+ await db.execute(
396
+ "UPDATE check_history SET thumbnail_path = ? WHERE id = ?",
397
+ (thumbnail_path, check_id)
398
+ )
399
+ await db.commit()
400
+ return True
401
+
402
+ async def update_check_bibliography_source(self, check_id: int, bibliography_source_path: str) -> bool:
403
+ """Update the bibliography source file path for a check"""
404
+ async with aiosqlite.connect(self.db_path) as db:
405
+ await db.execute(
406
+ "UPDATE check_history SET bibliography_source_path = ? WHERE id = ?",
407
+ (bibliography_source_path, check_id)
408
+ )
409
+ await db.commit()
410
+ return True
411
+
412
+ async def cancel_stale_in_progress(self) -> int:
413
+ """Mark any in-progress checks as cancelled (e.g., after a server restart)."""
414
+ async with aiosqlite.connect(self.db_path) as db:
415
+ cursor = await db.execute(
416
+ "UPDATE check_history SET status = 'cancelled' WHERE status = 'in_progress'"
417
+ )
418
+ await db.commit()
419
+ return cursor.rowcount
420
+
421
+ # LLM Configuration methods
422
+
423
+ async def get_llm_configs(self) -> List[Dict[str, Any]]:
424
+ """Get all LLM configurations (API keys redacted)"""
425
+ async with aiosqlite.connect(self.db_path) as db:
426
+ db.row_factory = aiosqlite.Row
427
+ async with db.execute("""
428
+ SELECT id, name, provider, model, endpoint, is_default, created_at
429
+ FROM llm_configs
430
+ ORDER BY created_at DESC
431
+ """) as cursor:
432
+ rows = await cursor.fetchall()
433
+ return [dict(row) for row in rows]
434
+
435
+ async def get_llm_config_by_id(self, config_id: int) -> Optional[Dict[str, Any]]:
436
+ """Get a specific LLM config by ID (includes decrypted API key)"""
437
+ async with aiosqlite.connect(self.db_path) as db:
438
+ db.row_factory = aiosqlite.Row
439
+ async with db.execute("""
440
+ SELECT * FROM llm_configs WHERE id = ?
441
+ """, (config_id,)) as cursor:
442
+ row = await cursor.fetchone()
443
+ if row:
444
+ result = dict(row)
445
+ # Decrypt API key if present
446
+ if result.get('api_key_encrypted'):
447
+ try:
448
+ result['api_key'] = self._decrypt(result['api_key_encrypted'])
449
+ except Exception:
450
+ result['api_key'] = None
451
+ del result['api_key_encrypted']
452
+ return result
453
+ return None
454
+
455
+ async def create_llm_config(self,
456
+ name: str,
457
+ provider: str,
458
+ model: Optional[str] = None,
459
+ api_key: Optional[str] = None,
460
+ endpoint: Optional[str] = None) -> int:
461
+ """Create a new LLM configuration"""
462
+ encrypted_key = self._encrypt(api_key) if api_key else None
463
+
464
+ async with aiosqlite.connect(self.db_path) as db:
465
+ cursor = await db.execute("""
466
+ INSERT INTO llm_configs (name, provider, model, api_key_encrypted, endpoint)
467
+ VALUES (?, ?, ?, ?, ?)
468
+ """, (name, provider, model, encrypted_key, endpoint))
469
+ await db.commit()
470
+ return cursor.lastrowid
471
+
472
+ async def update_llm_config(self,
473
+ config_id: int,
474
+ name: Optional[str] = None,
475
+ provider: Optional[str] = None,
476
+ model: Optional[str] = None,
477
+ api_key: Optional[str] = None,
478
+ endpoint: Optional[str] = None) -> bool:
479
+ """Update an existing LLM configuration"""
480
+ updates = []
481
+ params = []
482
+
483
+ if name is not None:
484
+ updates.append("name = ?")
485
+ params.append(name)
486
+ if provider is not None:
487
+ updates.append("provider = ?")
488
+ params.append(provider)
489
+ if model is not None:
490
+ updates.append("model = ?")
491
+ params.append(model)
492
+ if api_key is not None:
493
+ updates.append("api_key_encrypted = ?")
494
+ params.append(self._encrypt(api_key))
495
+ if endpoint is not None:
496
+ updates.append("endpoint = ?")
497
+ params.append(endpoint)
498
+
499
+ if not updates:
500
+ return False
501
+
502
+ params.append(config_id)
503
+
504
+ async with aiosqlite.connect(self.db_path) as db:
505
+ await db.execute(
506
+ f"UPDATE llm_configs SET {', '.join(updates)} WHERE id = ?",
507
+ params
508
+ )
509
+ await db.commit()
510
+ return True
511
+
512
+ async def delete_llm_config(self, config_id: int) -> bool:
513
+ """Delete an LLM configuration"""
514
+ async with aiosqlite.connect(self.db_path) as db:
515
+ await db.execute("DELETE FROM llm_configs WHERE id = ?", (config_id,))
516
+ await db.commit()
517
+ return True
518
+
519
+ async def set_default_llm_config(self, config_id: int) -> bool:
520
+ """Set an LLM config as the default (unsets others)"""
521
+ async with aiosqlite.connect(self.db_path) as db:
522
+ # Unset all defaults
523
+ await db.execute("UPDATE llm_configs SET is_default = 0")
524
+ # Set the new default
525
+ await db.execute(
526
+ "UPDATE llm_configs SET is_default = 1 WHERE id = ?",
527
+ (config_id,)
528
+ )
529
+ await db.commit()
530
+ return True
531
+
532
+ async def get_default_llm_config(self) -> Optional[Dict[str, Any]]:
533
+ """Get the default LLM configuration (with decrypted API key)"""
534
+ async with aiosqlite.connect(self.db_path) as db:
535
+ db.row_factory = aiosqlite.Row
536
+ async with db.execute("""
537
+ SELECT * FROM llm_configs WHERE is_default = 1
538
+ """) as cursor:
539
+ row = await cursor.fetchone()
540
+ if row:
541
+ result = dict(row)
542
+ if result.get('api_key_encrypted'):
543
+ try:
544
+ result['api_key'] = self._decrypt(result['api_key_encrypted'])
545
+ except Exception:
546
+ result['api_key'] = None
547
+ if 'api_key_encrypted' in result:
548
+ del result['api_key_encrypted']
549
+ return result
550
+ return None
551
+
552
+ # App Settings methods (for Semantic Scholar key, etc.)
553
+
554
+ async def get_setting(self, key: str, decrypt: bool = True) -> Optional[str]:
555
+ """Get an app setting value (optionally decrypted)"""
556
+ async with aiosqlite.connect(self.db_path) as db:
557
+ db.row_factory = aiosqlite.Row
558
+ async with db.execute(
559
+ "SELECT value_encrypted FROM app_settings WHERE key = ?",
560
+ (key,)
561
+ ) as cursor:
562
+ row = await cursor.fetchone()
563
+ if row and row['value_encrypted']:
564
+ if decrypt:
565
+ try:
566
+ return self._decrypt(row['value_encrypted'])
567
+ except Exception:
568
+ return None
569
+ return row['value_encrypted']
570
+ return None
571
+
572
+ async def set_setting(self, key: str, value: str) -> bool:
573
+ """Set an app setting value (encrypted)"""
574
+ encrypted = self._encrypt(value) if value else None
575
+ async with aiosqlite.connect(self.db_path) as db:
576
+ await db.execute("""
577
+ INSERT INTO app_settings (key, value_encrypted, updated_at)
578
+ VALUES (?, ?, CURRENT_TIMESTAMP)
579
+ ON CONFLICT(key) DO UPDATE SET
580
+ value_encrypted = excluded.value_encrypted,
581
+ updated_at = CURRENT_TIMESTAMP
582
+ """, (key, encrypted))
583
+ await db.commit()
584
+ return True
585
+
586
+ async def delete_setting(self, key: str) -> bool:
587
+ """Delete an app setting"""
588
+ async with aiosqlite.connect(self.db_path) as db:
589
+ await db.execute("DELETE FROM app_settings WHERE key = ?", (key,))
590
+ await db.commit()
591
+ return True
592
+
593
+ async def has_setting(self, key: str) -> bool:
594
+ """Check if an app setting exists"""
595
+ async with aiosqlite.connect(self.db_path) as db:
596
+ async with db.execute(
597
+ "SELECT 1 FROM app_settings WHERE key = ? AND value_encrypted IS NOT NULL",
598
+ (key,)
599
+ ) as cursor:
600
+ row = await cursor.fetchone()
601
+ return row is not None
602
+
603
+ # Verification cache methods
604
+
605
+ def _compute_reference_cache_key(self, reference: Dict[str, Any]) -> str:
606
+ """
607
+ Compute a cache key from reference data.
608
+
609
+ Key is based on: title, authors (sorted), year, venue, url
610
+ All normalized to lowercase and stripped.
611
+ """
612
+ import hashlib
613
+
614
+ title = (reference.get('title') or '').strip().lower()
615
+ authors = reference.get('authors') or []
616
+ # Normalize authors: lowercase, stripped, sorted for consistency
617
+ authors_normalized = sorted([a.strip().lower() for a in authors if a])
618
+ authors_str = '|'.join(authors_normalized)
619
+ year = str(reference.get('year') or '')
620
+ venue = (reference.get('venue') or '').strip().lower()
621
+ url = (reference.get('url') or '').strip().lower()
622
+
623
+ # Create a deterministic string from reference fields
624
+ cache_input = f"title:{title}|authors:{authors_str}|year:{year}|venue:{venue}|url:{url}"
625
+
626
+ # Hash it for a fixed-length key
627
+ return hashlib.sha256(cache_input.encode('utf-8')).hexdigest()
628
+
629
+ async def get_cached_verification(self, reference: Dict[str, Any]) -> Optional[Dict[str, Any]]:
630
+ """
631
+ Get cached verification result for a reference.
632
+
633
+ Returns the cached result if found, None otherwise.
634
+ """
635
+ cache_key = self._compute_reference_cache_key(reference)
636
+
637
+ async with aiosqlite.connect(self.db_path) as db:
638
+ await db.execute("PRAGMA busy_timeout=5000")
639
+ db.row_factory = aiosqlite.Row
640
+ async with db.execute(
641
+ "SELECT result_json FROM verification_cache WHERE cache_key = ?",
642
+ (cache_key,)
643
+ ) as cursor:
644
+ row = await cursor.fetchone()
645
+ if row and row['result_json']:
646
+ try:
647
+ return json.loads(row['result_json'])
648
+ except json.JSONDecodeError:
649
+ return None
650
+ return None
651
+
652
+ async def store_cached_verification(self, reference: Dict[str, Any], result: Dict[str, Any]) -> bool:
653
+ """
654
+ Store a verification result in the cache.
655
+
656
+ Only caches successful verifications (not errors/timeouts).
657
+ """
658
+ # Don't cache error results or timeouts - only cache verified/warning/suggestion/unverified
659
+ status = result.get('status', '').lower()
660
+ if status in ('error', 'cancelled', 'timeout', 'checking', 'pending'):
661
+ return False
662
+
663
+ cache_key = self._compute_reference_cache_key(reference)
664
+
665
+ async with aiosqlite.connect(self.db_path) as db:
666
+ await db.execute("PRAGMA busy_timeout=5000")
667
+ await db.execute("""
668
+ INSERT INTO verification_cache (cache_key, result_json, created_at)
669
+ VALUES (?, ?, CURRENT_TIMESTAMP)
670
+ ON CONFLICT(cache_key) DO UPDATE SET
671
+ result_json = excluded.result_json,
672
+ created_at = CURRENT_TIMESTAMP
673
+ """, (cache_key, json.dumps(result)))
674
+ await db.commit()
675
+ return True
676
+
677
+ async def clear_verification_cache(self) -> int:
678
+ """Clear all cached verification results. Returns count of deleted entries."""
679
+ async with aiosqlite.connect(self.db_path) as db:
680
+ cursor = await db.execute("DELETE FROM verification_cache")
681
+ await db.commit()
682
+ return cursor.rowcount
683
+
684
+
685
+ # Global database instance
686
+ db = Database()