-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathsqlite_store.py
More file actions
116 lines (96 loc) · 4.02 KB
/
Copy pathsqlite_store.py
File metadata and controls
116 lines (96 loc) · 4.02 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
"""Local SQLite persistence for article records.
Storage only: connection handling, schema bootstrap, embedding BLOB
encode/decode and raw CRUD. It knows nothing about similarity, thresholds or
what an embedding means — that lives in `data_service.py`.
Connections are short-lived (one per operation), which sidesteps
`check_same_thread` and holds no long-running locks.
"""
import logging
import sqlite3
from contextlib import contextmanager
import numpy as np
logger = logging.getLogger(__name__)
_SCHEMA = """
CREATE TABLE IF NOT EXISTS articles (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
date TEXT NOT NULL,
url TEXT,
embedding BLOB,
title_translated TEXT
);
CREATE UNIQUE INDEX IF NOT EXISTS articles_url_idx ON articles(url) WHERE url IS NOT NULL;
CREATE INDEX IF NOT EXISTS articles_date_idx ON articles(date);
"""
def encode_embedding(values):
"""Pack an embedding into a float32 little-endian BLOB.
Accepts plain lists (from `DataService._embed`) and numpy arrays alike.
`None` passes through so a failed embedding stays NULL.
"""
if values is None:
return None
return np.asarray(values, dtype=np.float32).tobytes()
def decode_embedding(blob):
"""Unpack a float32 BLOB. Dimension is inferred from the byte length."""
if blob is None:
return None
return np.frombuffer(blob, dtype=np.float32)
class SqliteStore:
def __init__(self, db_path):
self.db_path = db_path
self._bootstrap()
@contextmanager
def _connect(self):
conn = sqlite3.connect(self.db_path, timeout=15)
try:
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA synchronous=NORMAL")
yield conn
conn.commit()
finally:
conn.close()
def _bootstrap(self):
"""Create the schema on first connect. Logged, never raised — a broken
database must degrade to empty reads and failed writes, not crash the bot."""
try:
with self._connect() as conn:
conn.executescript(_SCHEMA)
except Exception as e:
logger.error(f"Error initialising database at '{self.db_path}': {e}")
def fetch_all(self):
"""Every row as a dict of exactly title/url/embedding — the columns the
dedup pass reads. Other columns are write-only."""
try:
with self._connect() as conn:
rows = conn.execute("SELECT title, url, embedding FROM articles").fetchall()
return [
{"title": title, "url": url, "embedding": decode_embedding(embedding)}
for title, url, embedding in rows
]
except Exception as e:
logger.error(f"Error fetching articles: {e}")
return []
def insert(self, article_id, title, date, url=None, embedding=None, title_translated=None):
"""Insert one row. Returns False on any failure — including a duplicate
id or url — which is what tells `main.py` the save did not happen."""
try:
with self._connect() as conn:
conn.execute(
"INSERT INTO articles (id, title, date, url, embedding, title_translated)"
" VALUES (?, ?, ?, ?, ?, ?)",
(article_id, title, date, url, encode_embedding(embedding), title_translated),
)
return True
except Exception as e:
logger.error(f"Error inserting article '{title}': {e}")
return False
def delete_older_than(self, cutoff_iso):
"""Delete rows dated strictly before `cutoff_iso`; returns the row count.
`date` is ISO-8601 text, so the lexicographic comparison is chronological.
"""
try:
with self._connect() as conn:
return conn.execute("DELETE FROM articles WHERE date < ?", (cutoff_iso,)).rowcount
except Exception as e:
logger.error(f"Error deleting old articles: {e}")
return 0