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