vojmahdal commited on
Commit
116dfb4
·
verified ·
1 Parent(s): acb49a1

update db to add postgresql

Browse files
Files changed (1) hide show
  1. db.py +93 -24
db.py CHANGED
@@ -1,14 +1,19 @@
1
  """
2
- Database layer (SQLite).
3
 
4
  Stores the structured output of the pipeline in a pseudonymized form. The
5
  original text is never stored in readable form: only a SHA-256 hash (for
6
  deduplication) and the anonymized text are persisted, together with the
7
  extracted entities, topic and sentiment.
8
 
9
- SQLite was chosen for the prototype (serverless, zero-config, portable). The
10
- layer is intentionally small so it can be swapped for PostgreSQL in a
11
- production deployment without changing the rest of the application.
 
 
 
 
 
12
  """
13
 
14
  from __future__ import annotations
@@ -27,6 +32,9 @@ from xml.etree import ElementTree as ET
27
 
28
  from processors import anonymizer
29
 
 
 
 
30
  # Export formats supported by export_records(), each mapped to its HTTP
31
  # media type. Adding a new format only requires a new "_export_<fmt>"
32
  # function plus an entry here.
@@ -38,6 +46,8 @@ EXPORT_FORMATS = {
38
 
39
  # ---------------------------------------------------------------------------
40
  # Database location: project folder on Windows, /tmp on Linux (HF Spaces).
 
 
41
  # ---------------------------------------------------------------------------
42
  if os.name == "nt":
43
  DB_PATH = os.getenv(
@@ -49,8 +59,21 @@ else:
49
 
50
  _db_lock = threading.Lock()
51
 
52
-
53
- def _get_connection() -> sqlite3.Connection:
 
 
 
 
 
 
 
 
 
 
 
 
 
54
  conn = sqlite3.connect(DB_PATH, check_same_thread=False)
55
  conn.execute(
56
  """
@@ -72,24 +95,70 @@ def _get_connection() -> sqlite3.Connection:
72
  # Since V8: which engine produced the record ('local' or 'llm') and,
73
  # for the LLM engine, which provider/model answered plus its latency and
74
  # cost. Added via ALTER TABLE so existing conversation_logs.db files from
75
- # earlier versions keep working; SQLite has no "ADD COLUMN IF NOT
76
- # EXISTS", so a duplicate-column error on an already-migrated database
77
- # is expected and ignored.
78
- for column_def in (
79
- "engine TEXT DEFAULT 'local'",
80
- "provider TEXT",
81
- "model TEXT",
82
- "latency_ms INTEGER",
83
- "cost_usd REAL",
84
- ):
85
  try:
86
- conn.execute(f"ALTER TABLE records ADD COLUMN {column_def}")
87
  except sqlite3.OperationalError:
88
  pass # column already exists
89
  conn.commit()
90
  return conn
91
 
92
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
93
  _db_conn = _get_connection()
94
 
95
 
@@ -118,7 +187,7 @@ def save_record(result: dict[str, Any], source: str = "single") -> None:
118
  entities_json = json.dumps(result.get("entities", []), ensure_ascii=False)
119
 
120
  with _db_lock:
121
- _db_conn.execute(
122
  """
123
  INSERT INTO records (
124
  created_at, original_hash, anonymized_text, entities,
@@ -210,7 +279,7 @@ def get_records(
210
  params = where_params + (limit,)
211
 
212
  with _db_lock:
213
- cur = _db_conn.execute(query, params)
214
  rows = cur.fetchall()
215
 
216
  records = []
@@ -244,14 +313,14 @@ def get_records(
244
  def stats() -> dict[str, Any]:
245
  """Aggregate statistics for the dashboard (counts by sentiment / topic / provider)."""
246
  with _db_lock:
247
- total = _db_conn.execute("SELECT COUNT(*) FROM records").fetchone()[0]
248
- by_sentiment = _db_conn.execute(
249
  "SELECT sentiment, COUNT(*) FROM records GROUP BY sentiment"
250
  ).fetchall()
251
- by_topic = _db_conn.execute(
252
  "SELECT topic, COUNT(*) FROM records GROUP BY topic ORDER BY COUNT(*) DESC LIMIT 10"
253
  ).fetchall()
254
- by_provider = _db_conn.execute(
255
  "SELECT provider, COUNT(*) FROM records WHERE provider IS NOT NULL GROUP BY provider"
256
  ).fetchall()
257
 
@@ -285,7 +354,7 @@ def _fetch_export_rows(
285
  params += (limit,)
286
 
287
  with _db_lock:
288
- return _db_conn.execute(query, params).fetchall()
289
 
290
 
291
  def _row_entities(row: tuple[Any, ...]) -> list[dict[str, Any]]:
 
1
  """
2
+ Database layer (SQLite by default, PostgreSQL optional).
3
 
4
  Stores the structured output of the pipeline in a pseudonymized form. The
5
  original text is never stored in readable form: only a SHA-256 hash (for
6
  deduplication) and the anonymized text are persisted, together with the
7
  extracted entities, topic and sentiment.
8
 
9
+ SQLite was chosen for the prototype (serverless, zero-config, portable) and
10
+ remains the default. Setting the ``DATABASE_URL`` environment variable to a
11
+ ``postgres://`` / ``postgresql://`` connection string switches this module to
12
+ PostgreSQL instead - the rest of the application (main.py, pipeline.py) is
13
+ completely unaware of which backend is active, since every query below still
14
+ goes through the same functions. This mirrors how the LLM engine is selected
15
+ (env var, both branches supported side by side) rather than a one-way
16
+ migration.
17
  """
18
 
19
  from __future__ import annotations
 
32
 
33
  from processors import anonymizer
34
 
35
+ DATABASE_URL = os.getenv("DATABASE_URL")
36
+ IS_POSTGRES = bool(DATABASE_URL and DATABASE_URL.startswith(("postgres://", "postgresql://")))
37
+
38
  # Export formats supported by export_records(), each mapped to its HTTP
39
  # media type. Adding a new format only requires a new "_export_<fmt>"
40
  # function plus an entry here.
 
46
 
47
  # ---------------------------------------------------------------------------
48
  # Database location: project folder on Windows, /tmp on Linux (HF Spaces).
49
+ # Only relevant for the SQLite backend - ignored when DATABASE_URL selects
50
+ # PostgreSQL instead.
51
  # ---------------------------------------------------------------------------
52
  if os.name == "nt":
53
  DB_PATH = os.getenv(
 
59
 
60
  _db_lock = threading.Lock()
61
 
62
+ # SQLite columns are added one at a time via ALTER TABLE (no "ADD COLUMN IF
63
+ # NOT EXISTS" support), so a duplicate-column error on an already-migrated
64
+ # database is expected and ignored. PostgreSQL supports "IF NOT EXISTS"
65
+ # directly, so its migration loop (in _get_postgres_connection) needs no
66
+ # such try/except.
67
+ _NEW_COLUMNS = (
68
+ ("engine", "TEXT DEFAULT 'local'"),
69
+ ("provider", "TEXT"),
70
+ ("model", "TEXT"),
71
+ ("latency_ms", "INTEGER"),
72
+ ("cost_usd", "REAL"),
73
+ )
74
+
75
+
76
+ def _get_sqlite_connection() -> sqlite3.Connection:
77
  conn = sqlite3.connect(DB_PATH, check_same_thread=False)
78
  conn.execute(
79
  """
 
95
  # Since V8: which engine produced the record ('local' or 'llm') and,
96
  # for the LLM engine, which provider/model answered plus its latency and
97
  # cost. Added via ALTER TABLE so existing conversation_logs.db files from
98
+ # earlier versions keep working.
99
+ for name, coltype in _NEW_COLUMNS:
 
 
 
 
 
 
 
 
100
  try:
101
+ conn.execute(f"ALTER TABLE records ADD COLUMN {name} {coltype}")
102
  except sqlite3.OperationalError:
103
  pass # column already exists
104
  conn.commit()
105
  return conn
106
 
107
 
108
+ def _get_postgres_connection():
109
+ try:
110
+ import psycopg2
111
+ except ImportError as exc: # pragma: no cover
112
+ raise RuntimeError(
113
+ "DATABASE_URL points to PostgreSQL but the 'psycopg2-binary' "
114
+ "package is not installed (pip install psycopg2-binary)."
115
+ ) from exc
116
+
117
+ conn = psycopg2.connect(DATABASE_URL)
118
+ cur = conn.cursor()
119
+ cur.execute(
120
+ """
121
+ CREATE TABLE IF NOT EXISTS records (
122
+ id SERIAL PRIMARY KEY,
123
+ created_at TEXT NOT NULL,
124
+ original_hash TEXT NOT NULL,
125
+ anonymized_text TEXT NOT NULL,
126
+ entities TEXT,
127
+ topic TEXT,
128
+ topic_score REAL,
129
+ sentiment TEXT,
130
+ sentiment_score REAL,
131
+ conversation_id TEXT,
132
+ source TEXT
133
+ )
134
+ """
135
+ )
136
+ for name, coltype in _NEW_COLUMNS:
137
+ cur.execute(f"ALTER TABLE records ADD COLUMN IF NOT EXISTS {name} {coltype}")
138
+ conn.commit()
139
+ cur.close()
140
+ return conn
141
+
142
+
143
+ def _get_connection():
144
+ return _get_postgres_connection() if IS_POSTGRES else _get_sqlite_connection()
145
+
146
+
147
+ def _execute(query: str, params: tuple = ()):
148
+ """
149
+ Run a query on whichever backend is active. Every query in this module
150
+ is written with SQLite's ``?`` placeholder; PostgreSQL (via psycopg2)
151
+ expects ``%s`` instead, so it is translated here - the one place that
152
+ needs to know the difference. Both backends return a cursor-like object
153
+ supporting ``fetchone()``/``fetchall()``.
154
+ """
155
+ if IS_POSTGRES:
156
+ cur = _db_conn.cursor()
157
+ cur.execute(query.replace("?", "%s"), params)
158
+ return cur
159
+ return _db_conn.execute(query, params)
160
+
161
+
162
  _db_conn = _get_connection()
163
 
164
 
 
187
  entities_json = json.dumps(result.get("entities", []), ensure_ascii=False)
188
 
189
  with _db_lock:
190
+ _execute(
191
  """
192
  INSERT INTO records (
193
  created_at, original_hash, anonymized_text, entities,
 
279
  params = where_params + (limit,)
280
 
281
  with _db_lock:
282
+ cur = _execute(query, params)
283
  rows = cur.fetchall()
284
 
285
  records = []
 
313
  def stats() -> dict[str, Any]:
314
  """Aggregate statistics for the dashboard (counts by sentiment / topic / provider)."""
315
  with _db_lock:
316
+ total = _execute("SELECT COUNT(*) FROM records").fetchone()[0]
317
+ by_sentiment = _execute(
318
  "SELECT sentiment, COUNT(*) FROM records GROUP BY sentiment"
319
  ).fetchall()
320
+ by_topic = _execute(
321
  "SELECT topic, COUNT(*) FROM records GROUP BY topic ORDER BY COUNT(*) DESC LIMIT 10"
322
  ).fetchall()
323
+ by_provider = _execute(
324
  "SELECT provider, COUNT(*) FROM records WHERE provider IS NOT NULL GROUP BY provider"
325
  ).fetchall()
326
 
 
354
  params += (limit,)
355
 
356
  with _db_lock:
357
+ return _execute(query, params).fetchall()
358
 
359
 
360
  def _row_entities(row: tuple[Any, ...]) -> list[dict[str, Any]]: