goctests0 commited on
Commit
7b5809f
·
verified ·
1 Parent(s): 36a42f1

Upload 2 files

Browse files
Files changed (2) hide show
  1. app.py +215 -50
  2. database.py +156 -95
app.py CHANGED
@@ -10,14 +10,33 @@ Two-step flow:
10
  summarizes the likely symptoms or intent for the provider.
11
  2. Provider submits a reply in English. The system translates it back
12
  into the patient's language and generates spoken audio for it.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
13
  """
14
 
15
  import os
16
  import uuid
17
- from datetime import datetime, timezone
 
 
18
 
19
  from dotenv import load_dotenv
20
- from flask import Flask, request, jsonify, render_template, session
21
 
22
  import database
23
  import stt
@@ -28,6 +47,7 @@ import tts
28
  load_dotenv("env")
29
 
30
  AUDIO_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "static", "audio")
 
31
 
32
 
33
  def create_app():
@@ -48,6 +68,100 @@ database.init_db()
48
  tts.preload_models()
49
 
50
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
51
  def get_current_user_id(language):
52
  """
53
  Returns the user_id for the current browser session, creating a new
@@ -67,16 +181,28 @@ def get_current_user_id(language):
67
  return session["user_id"]
68
 
69
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
70
  @app.route("/")
71
  def index():
72
  return render_template("patient.html", languages=translation.SUPPORTED_LANGUAGES)
73
 
74
 
75
- @app.route("/provider")
76
- def provider_page():
77
- return render_template("provider.html")
78
-
79
-
80
  @app.route("/transcribe", methods=["POST"])
81
  def transcribe_only():
82
  """
@@ -148,15 +274,51 @@ def patient_input():
148
  })
149
 
150
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
151
  @app.route("/preview-response", methods=["POST"])
 
152
  def preview_response():
153
  """
154
- Runs the exact same translate + synthesize pipeline as
155
- /provider-response below, so the provider can read and hear precisely
156
- what the patient will receive before committing to it. Unlike
157
- /provider-response, this never touches the database - only "Send
158
- reply" actually saves a response. Each preview click is a real
159
- translation + speech synthesis call, same cost as an actual send.
160
  """
161
  interaction_id = request.form.get("interaction_id")
162
  response_text = request.form.get("response_text")
@@ -182,16 +344,10 @@ def preview_response():
182
 
183
 
184
  @app.route("/provider-response", methods=["POST"])
 
185
  def provider_response():
186
  interaction_id = request.form.get("interaction_id")
187
  response_text = request.form.get("response_text")
188
- # Optional cache-reuse fields: if the provider already generated a
189
- # preview of this exact reply via /preview-response and didn't edit
190
- # it afterward, the frontend passes the already-computed translation
191
- # back here so this route can skip re-translating and re-synthesizing
192
- # speech. Both are best-effort - if the preview audio isn't actually
193
- # on disk, this quietly falls back to doing the full work itself,
194
- # exactly as if these fields had never been sent.
195
  cached_translation = request.form.get("translated_response")
196
  reuse_audio = request.form.get("reuse_audio") == "true"
197
 
@@ -210,9 +366,14 @@ def provider_response():
210
  if cached_translation and reuse_audio and os.path.exists(preview_path):
211
  translated_response = cached_translation
212
  os.replace(preview_path, audio_path)
 
 
213
  else:
214
  translated_response = translation.translate(response_text, "english", patient_language)
215
  tts.synthesize_speech(translated_response, patient_language, audio_path)
 
 
 
216
 
217
  database.update_interaction_response(interaction_id, response_text, translated_response)
218
 
@@ -222,54 +383,58 @@ def provider_response():
222
  })
223
 
224
 
225
- @app.route("/interaction/<interaction_id>")
226
- def get_interaction(interaction_id):
227
- interaction = database.get_interaction(interaction_id)
228
- if not interaction:
229
- return jsonify({"error": "interaction not found"}), 404
230
-
231
- audio_filename = f"response_{interaction_id}.wav"
232
- audio_path = os.path.join(AUDIO_DIR, audio_filename)
233
- interaction["audio_url"] = f"/static/audio/{audio_filename}" if os.path.exists(audio_path) else None
234
-
235
- return jsonify(interaction)
236
-
237
-
238
  @app.route("/pending-interactions")
 
239
  def pending_interactions():
240
  return jsonify(database.get_pending_interactions())
241
 
242
 
243
  @app.route("/completed-interactions")
 
244
  def completed_interactions():
245
  return jsonify(database.get_completed_interactions())
246
 
247
 
248
- @app.route("/history")
249
- def history():
250
- if "user_id" not in session:
251
- return jsonify([])
252
- return jsonify(database.get_interactions_for_user(session["user_id"]))
253
-
254
-
255
  @app.route("/patient-history/<user_id>")
 
256
  def patient_history(user_id):
257
  """
258
  All of one patient's interactions, for the provider workspace's
259
- "conversation history" panel. Distinct from /history above: that route
260
- always looks up the current browser's own session, which only works
261
- for the patient viewing their own messages. The provider needs to look
262
- up a specific patient's history by id instead.
263
  """
264
  return jsonify(database.get_interactions_for_user(user_id))
265
 
266
 
267
- @app.route("/end-session", methods=["POST"])
268
- def end_session_route():
269
- if "session_id" in session:
270
- database.end_session(session["session_id"], datetime.now(timezone.utc).isoformat())
271
- session.clear()
272
- return jsonify({"status": "ended"})
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
273
 
274
 
275
  if __name__ == "__main__":
 
10
  summarizes the likely symptoms or intent for the provider.
11
  2. Provider submits a reply in English. The system translates it back
12
  into the patient's language and generates spoken audio for it.
13
+
14
+ Provider authentication: all provider-side routes check for a
15
+ PROVIDER_TOKEN in the env file. The provider enters this token once
16
+ per browser session via the /provider-login page. It is stored in a
17
+ signed Flask session cookie, same as the patient's user_id. This is
18
+ not a full authentication system - it is a single shared credential
19
+ that closes the unauthenticated IDOR exposure on provider routes
20
+ without introducing a user accounts table or an auth framework.
21
+ Set PROVIDER_TOKEN to any long random string in the env file before
22
+ deploying with real patient data.
23
+
24
+ Audio cleanup: preview_*.wav files are cleaned up automatically after
25
+ /provider-response either promotes them (os.replace) or falls back to
26
+ a fresh synthesis. response_*.wav files are retained because the
27
+ patient needs to be able to play them back from /interaction/<id>.
28
+ A separate /cleanup-audio admin route deletes response audio older
29
+ than 30 days for disk management on long-running deployments.
30
  """
31
 
32
  import os
33
  import uuid
34
+ import glob
35
+ import functools
36
+ from datetime import datetime, timezone, timedelta
37
 
38
  from dotenv import load_dotenv
39
+ from flask import Flask, request, jsonify, render_template, render_template_string, session, redirect, url_for
40
 
41
  import database
42
  import stt
 
47
  load_dotenv("env")
48
 
49
  AUDIO_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "static", "audio")
50
+ PROVIDER_TOKEN = os.environ.get("PROVIDER_TOKEN", "")
51
 
52
 
53
  def create_app():
 
68
  tts.preload_models()
69
 
70
 
71
+ # ── Provider authentication ───────────────────────────────────────────────────
72
+
73
+ def provider_required(f):
74
+ """
75
+ Decorator for provider-side routes. If PROVIDER_TOKEN is not set in
76
+ the env file, the route is unprotected (preserving the existing
77
+ behaviour for local development and demos). If it is set, the request
78
+ must carry a matching token in the provider_session cookie, set by
79
+ /provider-login. Returns 401 JSON for API routes, redirects to the
80
+ login page for the /provider HTML route.
81
+ """
82
+ @functools.wraps(f)
83
+ def decorated(*args, **kwargs):
84
+ if not PROVIDER_TOKEN:
85
+ # Token not configured: unprotected, existing behaviour preserved.
86
+ return f(*args, **kwargs)
87
+ if session.get("provider_authenticated"):
88
+ return f(*args, **kwargs)
89
+ # HTML route gets a redirect; JSON routes get a 401.
90
+ if request.accept_mimetypes.accept_html and not request.is_json:
91
+ return redirect(url_for("provider_login"))
92
+ return jsonify({"error": "provider authentication required"}), 401
93
+ return decorated
94
+
95
+
96
+ @app.route("/provider-login", methods=["GET", "POST"])
97
+ def provider_login():
98
+ """
99
+ Simple token entry page. The provider enters the PROVIDER_TOKEN value
100
+ from the env file. On success, sets provider_authenticated in the
101
+ signed session cookie and redirects to /provider.
102
+ """
103
+ error = None
104
+ if request.method == "POST":
105
+ token = request.form.get("token", "").strip()
106
+ if token and token == PROVIDER_TOKEN:
107
+ session["provider_authenticated"] = True
108
+ return redirect(url_for("provider_page"))
109
+ error = "Incorrect token. Please try again."
110
+
111
+ # Inline template so this fix does not require a new file in templates/.
112
+ return render_template_string("""
113
+ <!DOCTYPE html>
114
+ <html lang="en">
115
+ <head>
116
+ <meta charset="UTF-8">
117
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
118
+ <title>TalkToDoc — Provider Login</title>
119
+ <link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
120
+ </head>
121
+ <body>
122
+ <div class="app-shell">
123
+ <div class="app-header provider">
124
+ <div class="brand">
125
+ <div class="brand-mark">
126
+ <svg viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2"
127
+ stroke-linecap="round" stroke-linejoin="round">
128
+ <path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7
129
+ 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8
130
+ 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48
131
+ 0 0 1 8 8v.5z"/>
132
+ </svg>
133
+ </div>
134
+ <div>
135
+ <h1>TalkToDoc</h1>
136
+ <span class="role-tag">Provider</span>
137
+ </div>
138
+ </div>
139
+ </div>
140
+ <main class="app-main" id="main-content">
141
+ <div class="card" style="max-width:400px;margin:0 auto;">
142
+ <h2>Provider access</h2>
143
+ <p class="helper-text">Enter the provider token to continue.</p>
144
+ {% if error %}
145
+ <div class="status-banner error">{{ error }}</div>
146
+ {% endif %}
147
+ <form method="post">
148
+ <label for="token">Access token</label>
149
+ <input type="password" id="token" name="token"
150
+ placeholder="Enter provider token" autocomplete="current-password">
151
+ <button type="submit" class="btn btn-provider" style="margin-top:16px;width:100%">
152
+ Continue
153
+ </button>
154
+ </form>
155
+ </div>
156
+ </main>
157
+ </div>
158
+ </body>
159
+ </html>
160
+ """, error=error)
161
+
162
+
163
+ # ── Helpers ───────────────────────────────────────────────────────────────────
164
+
165
  def get_current_user_id(language):
166
  """
167
  Returns the user_id for the current browser session, creating a new
 
181
  return session["user_id"]
182
 
183
 
184
+ def _cleanup_preview(interaction_id):
185
+ """
186
+ Deletes the preview audio file for an interaction if it still exists.
187
+ Called after /provider-response either promotes the file (os.replace)
188
+ or falls back to fresh synthesis - in the fresh-synthesis path the
189
+ preview file is left behind unused, so we clean it here.
190
+ """
191
+ preview_path = os.path.join(AUDIO_DIR, f"preview_{interaction_id}.wav")
192
+ try:
193
+ if os.path.exists(preview_path):
194
+ os.remove(preview_path)
195
+ except OSError:
196
+ pass # If the remove fails, it is not worth surfacing as an error.
197
+
198
+
199
+ # ── Patient routes ────────────────────────────────────────────────────────────
200
+
201
  @app.route("/")
202
  def index():
203
  return render_template("patient.html", languages=translation.SUPPORTED_LANGUAGES)
204
 
205
 
 
 
 
 
 
206
  @app.route("/transcribe", methods=["POST"])
207
  def transcribe_only():
208
  """
 
274
  })
275
 
276
 
277
+ @app.route("/interaction/<interaction_id>")
278
+ def get_interaction(interaction_id):
279
+ interaction = database.get_interaction(interaction_id)
280
+ if not interaction:
281
+ return jsonify({"error": "interaction not found"}), 404
282
+
283
+ audio_filename = f"response_{interaction_id}.wav"
284
+ audio_path = os.path.join(AUDIO_DIR, audio_filename)
285
+ interaction["audio_url"] = f"/static/audio/{audio_filename}" if os.path.exists(audio_path) else None
286
+
287
+ return jsonify(interaction)
288
+
289
+
290
+ @app.route("/history")
291
+ def history():
292
+ if "user_id" not in session:
293
+ return jsonify([])
294
+ return jsonify(database.get_interactions_for_user(session["user_id"]))
295
+
296
+
297
+ @app.route("/end-session", methods=["POST"])
298
+ def end_session_route():
299
+ if "session_id" in session:
300
+ database.end_session(session["session_id"], datetime.now(timezone.utc).isoformat())
301
+ session.clear()
302
+ return jsonify({"status": "ended"})
303
+
304
+
305
+ # ── Provider routes (all protected by @provider_required) ─────────────────────
306
+
307
+ @app.route("/provider")
308
+ @provider_required
309
+ def provider_page():
310
+ return render_template("provider.html")
311
+
312
+
313
  @app.route("/preview-response", methods=["POST"])
314
+ @provider_required
315
  def preview_response():
316
  """
317
+ Runs the exact same translate + synthesize pipeline as /provider-response,
318
+ so the provider can read and hear precisely what the patient will receive
319
+ before committing. Unlike /provider-response, this never touches the
320
+ database. The preview audio file is cleaned up automatically by
321
+ /provider-response regardless of which code path it takes.
 
322
  """
323
  interaction_id = request.form.get("interaction_id")
324
  response_text = request.form.get("response_text")
 
344
 
345
 
346
  @app.route("/provider-response", methods=["POST"])
347
+ @provider_required
348
  def provider_response():
349
  interaction_id = request.form.get("interaction_id")
350
  response_text = request.form.get("response_text")
 
 
 
 
 
 
 
351
  cached_translation = request.form.get("translated_response")
352
  reuse_audio = request.form.get("reuse_audio") == "true"
353
 
 
366
  if cached_translation and reuse_audio and os.path.exists(preview_path):
367
  translated_response = cached_translation
368
  os.replace(preview_path, audio_path)
369
+ # preview file was promoted via os.replace so it no longer exists;
370
+ # _cleanup_preview is a no-op here but called for consistency.
371
  else:
372
  translated_response = translation.translate(response_text, "english", patient_language)
373
  tts.synthesize_speech(translated_response, patient_language, audio_path)
374
+ # preview file (if one existed from a prior Preview click) was not
375
+ # promoted and is now orphaned; delete it.
376
+ _cleanup_preview(interaction_id)
377
 
378
  database.update_interaction_response(interaction_id, response_text, translated_response)
379
 
 
383
  })
384
 
385
 
 
 
 
 
 
 
 
 
 
 
 
 
 
386
  @app.route("/pending-interactions")
387
+ @provider_required
388
  def pending_interactions():
389
  return jsonify(database.get_pending_interactions())
390
 
391
 
392
  @app.route("/completed-interactions")
393
+ @provider_required
394
  def completed_interactions():
395
  return jsonify(database.get_completed_interactions())
396
 
397
 
 
 
 
 
 
 
 
398
  @app.route("/patient-history/<user_id>")
399
+ @provider_required
400
  def patient_history(user_id):
401
  """
402
  All of one patient's interactions, for the provider workspace's
403
+ conversation history panel. Distinct from /history: that route looks
404
+ up the current browser session, which only works for patients viewing
405
+ their own messages. The provider needs to look up by explicit user_id.
 
406
  """
407
  return jsonify(database.get_interactions_for_user(user_id))
408
 
409
 
410
+ @app.route("/cleanup-audio", methods=["POST"])
411
+ @provider_required
412
+ def cleanup_audio():
413
+ """
414
+ Deletes response_*.wav files older than 30 days. Intended for
415
+ long-running deployments where response audio accumulates on disk.
416
+ Call this from a cron job or a scheduled task; it does not run
417
+ automatically. Returns the number of files deleted and their
418
+ total size freed in bytes.
419
+ """
420
+ cutoff = datetime.now(timezone.utc) - timedelta(days=30)
421
+ deleted_count = 0
422
+ freed_bytes = 0
423
+
424
+ for path in glob.glob(os.path.join(AUDIO_DIR, "response_*.wav")):
425
+ try:
426
+ mtime = datetime.fromtimestamp(os.path.getmtime(path), tz=timezone.utc)
427
+ if mtime < cutoff:
428
+ freed_bytes += os.path.getsize(path)
429
+ os.remove(path)
430
+ deleted_count += 1
431
+ except OSError:
432
+ pass
433
+
434
+ return jsonify({
435
+ "deleted": deleted_count,
436
+ "freed_bytes": freed_bytes,
437
+ })
438
 
439
 
440
  if __name__ == "__main__":
database.py CHANGED
@@ -1,147 +1,208 @@
1
  """
2
- Database layer for TalkToDoc.
3
- Local development and testing use SQLite (no setup needed). When deployed,
4
- DATABASE_URL is set by Render and the app uses Postgres instead, since
5
- Render's free tier wipes local files like a SQLite database on every
6
- restart.
7
-
8
- Queries are written once using SQLite-style '?' placeholders and adapted
9
- automatically for Postgres, so there's a single query per function rather
10
- than two versions of everything.
 
 
 
 
 
11
  """
12
 
13
  import os
14
- from pathlib import Path
15
 
16
- import sqlite3
17
 
18
- DB_PATH = Path(__file__).parent / "talktodoc.db"
19
- SCHEMA_PATH = Path(__file__).parent / "schema.sql"
20
- SCHEMA_PATH_POSTGRES = Path(__file__).parent / "schema_postgres.sql"
21
 
22
  DATABASE_URL = os.environ.get("DATABASE_URL")
23
 
24
- if DATABASE_URL:
25
- import psycopg2
26
- import psycopg2.extras
27
-
28
 
29
  def get_connection():
 
 
 
 
30
  if DATABASE_URL:
31
- return psycopg2.connect(DATABASE_URL, cursor_factory=psycopg2.extras.RealDictCursor)
32
- connection = sqlite3.connect(DB_PATH)
33
- connection.row_factory = sqlite3.Row
34
- connection.execute("PRAGMA foreign_keys = ON")
35
- return connection
36
-
37
-
38
- def _run(connection, query, params=()):
39
- if DATABASE_URL:
40
- query = query.replace("?", "%s")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
41
  cursor = connection.cursor()
42
- cursor.execute(query, params)
43
  return cursor
44
 
45
 
46
- def _insert_and_get_id(connection, query, params):
47
- if DATABASE_URL:
48
- cursor = _run(connection, query + " RETURNING id", params)
49
- return cursor.fetchone()["id"]
50
- cursor = _run(connection, query, params)
51
- return cursor.lastrowid
52
 
53
 
54
  def init_db():
55
- schema_path = SCHEMA_PATH_POSTGRES if DATABASE_URL else SCHEMA_PATH
56
- schema_sql = schema_path.read_text()
57
- with get_connection() as connection:
 
 
58
  if DATABASE_URL:
59
- connection.cursor().execute(schema_sql)
 
 
 
60
  else:
61
- connection.executescript(schema_sql)
62
 
63
 
64
  def add_user(name, preferred_language, role):
65
- with get_connection() as connection:
66
- return _insert_and_get_id(
67
- connection,
68
- "INSERT INTO app_user (name, preferred_language, role) VALUES (?, ?, ?)",
69
- (name, preferred_language, role),
70
- )
 
 
 
 
 
 
 
 
 
 
71
 
72
 
73
  def add_session(user_id, start_time):
74
- with get_connection() as connection:
75
- return _insert_and_get_id(
76
- connection,
77
- "INSERT INTO session (user_id, start_time) VALUES (?, ?)",
78
- (user_id, start_time),
79
- )
 
 
 
 
 
 
 
 
 
 
80
 
81
 
82
  def end_session(session_id, end_time):
83
- with get_connection() as connection:
84
- _run(connection, "UPDATE session SET end_time = ? WHERE id = ?", (end_time, session_id))
85
-
86
-
87
- def add_interaction(user_id, input_text, detected_language, translated_text, nlu_summary, timestamp):
88
- with get_connection() as connection:
89
- return _insert_and_get_id(
90
- connection,
91
- """INSERT INTO interaction
92
- (user_id, input_text, detected_language, translated_text, nlu_summary, timestamp)
93
- VALUES (?, ?, ?, ?, ?, ?)""",
94
- (user_id, input_text, detected_language, translated_text, nlu_summary, timestamp),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
95
  )
96
 
97
 
98
  def get_interactions_for_user(user_id):
99
- with get_connection() as connection:
 
100
  cursor = _run(
101
- connection,
102
- "SELECT * FROM interaction WHERE user_id = ? ORDER BY timestamp",
103
  (user_id,),
104
  )
105
  return [dict(row) for row in cursor.fetchall()]
106
 
107
 
108
  def get_pending_interactions():
109
- with get_connection() as connection:
110
  cursor = _run(
111
- connection,
112
  "SELECT * FROM interaction WHERE provider_response IS NULL ORDER BY timestamp",
113
  )
114
  return [dict(row) for row in cursor.fetchall()]
115
 
116
 
117
  def get_completed_interactions():
118
- with get_connection() as connection:
119
  cursor = _run(
120
- connection,
121
  "SELECT * FROM interaction WHERE provider_response IS NOT NULL ORDER BY timestamp DESC",
122
  )
123
  return [dict(row) for row in cursor.fetchall()]
124
-
125
-
126
- def get_interaction(interaction_id):
127
- with get_connection() as connection:
128
- cursor = _run(connection, "SELECT * FROM interaction WHERE id = ?", (interaction_id,))
129
- row = cursor.fetchone()
130
- return dict(row) if row else None
131
-
132
-
133
- def update_interaction_response(interaction_id, provider_response, translated_response):
134
- with get_connection() as connection:
135
- _run(
136
- connection,
137
- "UPDATE interaction SET provider_response = ?, translated_response = ? WHERE id = ?",
138
- (provider_response, translated_response, interaction_id),
139
- )
140
-
141
-
142
- if __name__ == "__main__":
143
- init_db()
144
- if DATABASE_URL:
145
- print("Database initialized (Postgres)")
146
- else:
147
- print("Database created at:", DB_PATH)
 
1
  """
2
+ Database access layer for TalkToDoc.
3
+ Handles both SQLite (local development) and PostgreSQL (production) through
4
+ a single shared query interface. The only difference between the two is how
5
+ get_connection() builds the connection object; every function above that is
6
+ identical regardless of which database is running underneath.
7
+
8
+ Connection management: every public function in this module uses the
9
+ _connection() context manager (not get_connection() directly). That
10
+ context manager commits on success, rolls back on exception, and always
11
+ closes the connection before returning control. The bare
12
+ `with get_connection() as conn:` pattern that psycopg2 and sqlite3 both
13
+ support commits/rolls back correctly but never closes the connection,
14
+ which leaks file handles against SQLite and exhausts the Postgres
15
+ connection pool in production over time.
16
  """
17
 
18
  import os
19
+ import contextlib
20
 
21
+ from dotenv import load_dotenv
22
 
23
+ load_dotenv("env")
 
 
24
 
25
  DATABASE_URL = os.environ.get("DATABASE_URL")
26
 
 
 
 
 
27
 
28
  def get_connection():
29
+ """
30
+ Returns a raw database connection. Callers should use _connection()
31
+ instead, which wraps this and guarantees the connection is closed.
32
+ """
33
  if DATABASE_URL:
34
+ import psycopg2
35
+ import psycopg2.extras
36
+ conn = psycopg2.connect(DATABASE_URL, cursor_factory=psycopg2.extras.RealDictCursor)
37
+ return conn
38
+ else:
39
+ import sqlite3
40
+ conn = sqlite3.connect(
41
+ os.path.join(os.path.dirname(os.path.abspath(__file__)), "talktodoc.db")
42
+ )
43
+ conn.row_factory = sqlite3.Row
44
+ return conn
45
+
46
+
47
+ @contextlib.contextmanager
48
+ def _connection():
49
+ """
50
+ Context manager that opens a connection, yields it, commits on clean
51
+ exit, rolls back on exception, and always closes. Use this everywhere
52
+ instead of `with get_connection() as conn:`.
53
+ """
54
+ conn = get_connection()
55
+ try:
56
+ yield conn
57
+ conn.commit()
58
+ except Exception:
59
+ conn.rollback()
60
+ raise
61
+ finally:
62
+ conn.close()
63
+
64
+
65
+ def _run(connection, sql, params=()):
66
  cursor = connection.cursor()
67
+ cursor.execute(sql, params)
68
  return cursor
69
 
70
 
71
+ def _placeholder():
72
+ """
73
+ SQLite uses ? for bind parameters; psycopg2 uses %s.
74
+ Returns the right one for whichever database is active.
75
+ """
76
+ return "%s" if DATABASE_URL else "?"
77
 
78
 
79
  def init_db():
80
+ schema_file = "schema_postgres.sql" if DATABASE_URL else "schema.sql"
81
+ schema_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), schema_file)
82
+ with open(schema_path) as f:
83
+ schema = f.read()
84
+ with _connection() as conn:
85
  if DATABASE_URL:
86
+ for statement in schema.split(";"):
87
+ stmt = statement.strip()
88
+ if stmt:
89
+ _run(conn, stmt)
90
  else:
91
+ conn.executescript(schema)
92
 
93
 
94
  def add_user(name, preferred_language, role):
95
+ p = _placeholder()
96
+ with _connection() as conn:
97
+ if DATABASE_URL:
98
+ cursor = _run(
99
+ conn,
100
+ f"INSERT INTO app_user (name, preferred_language, role) VALUES ({p}, {p}, {p}) RETURNING id",
101
+ (name, preferred_language, role),
102
+ )
103
+ return cursor.fetchone()["id"]
104
+ else:
105
+ cursor = _run(
106
+ conn,
107
+ f"INSERT INTO app_user (name, preferred_language, role) VALUES ({p}, {p}, {p})",
108
+ (name, preferred_language, role),
109
+ )
110
+ return cursor.lastrowid
111
 
112
 
113
  def add_session(user_id, start_time):
114
+ p = _placeholder()
115
+ with _connection() as conn:
116
+ if DATABASE_URL:
117
+ cursor = _run(
118
+ conn,
119
+ f"INSERT INTO session (user_id, start_time) VALUES ({p}, {p}) RETURNING id",
120
+ (user_id, start_time),
121
+ )
122
+ return cursor.fetchone()["id"]
123
+ else:
124
+ cursor = _run(
125
+ conn,
126
+ f"INSERT INTO session (user_id, start_time) VALUES ({p}, {p})",
127
+ (user_id, start_time),
128
+ )
129
+ return cursor.lastrowid
130
 
131
 
132
  def end_session(session_id, end_time):
133
+ p = _placeholder()
134
+ with _connection() as conn:
135
+ _run(
136
+ conn,
137
+ f"UPDATE session SET end_time = {p} WHERE id = {p}",
138
+ (end_time, session_id),
139
+ )
140
+
141
+
142
+ def add_interaction(user_id, input_text, detected_language, translated_text,
143
+ nlu_summary, timestamp):
144
+ p = _placeholder()
145
+ sql = (
146
+ f"INSERT INTO interaction "
147
+ f"(user_id, input_text, detected_language, translated_text, nlu_summary, timestamp) "
148
+ f"VALUES ({p}, {p}, {p}, {p}, {p}, {p})"
149
+ )
150
+ params = (user_id, input_text, detected_language, translated_text, nlu_summary, timestamp)
151
+ with _connection() as conn:
152
+ if DATABASE_URL:
153
+ cursor = _run(conn, sql.replace(")", " RETURNING id)", 1), params)
154
+ return cursor.fetchone()["id"]
155
+ else:
156
+ cursor = _run(conn, sql, params)
157
+ return cursor.lastrowid
158
+
159
+
160
+ def get_interaction(interaction_id):
161
+ p = _placeholder()
162
+ with _connection() as conn:
163
+ cursor = _run(
164
+ conn,
165
+ f"SELECT * FROM interaction WHERE id = {p}",
166
+ (interaction_id,),
167
+ )
168
+ row = cursor.fetchone()
169
+ return dict(row) if row else None
170
+
171
+
172
+ def update_interaction_response(interaction_id, provider_response, translated_response):
173
+ p = _placeholder()
174
+ with _connection() as conn:
175
+ _run(
176
+ conn,
177
+ f"UPDATE interaction SET provider_response = {p}, translated_response = {p} WHERE id = {p}",
178
+ (provider_response, translated_response, interaction_id),
179
  )
180
 
181
 
182
  def get_interactions_for_user(user_id):
183
+ p = _placeholder()
184
+ with _connection() as conn:
185
  cursor = _run(
186
+ conn,
187
+ f"SELECT * FROM interaction WHERE user_id = {p} ORDER BY timestamp",
188
  (user_id,),
189
  )
190
  return [dict(row) for row in cursor.fetchall()]
191
 
192
 
193
  def get_pending_interactions():
194
+ with _connection() as conn:
195
  cursor = _run(
196
+ conn,
197
  "SELECT * FROM interaction WHERE provider_response IS NULL ORDER BY timestamp",
198
  )
199
  return [dict(row) for row in cursor.fetchall()]
200
 
201
 
202
  def get_completed_interactions():
203
+ with _connection() as conn:
204
  cursor = _run(
205
+ conn,
206
  "SELECT * FROM interaction WHERE provider_response IS NOT NULL ORDER BY timestamp DESC",
207
  )
208
  return [dict(row) for row in cursor.fetchall()]