goctests0 commited on
Commit
1ab9f7f
·
verified ·
1 Parent(s): 5b5ceb5

Upload app.py

Browse files
Files changed (1) hide show
  1. app.py +56 -215
app.py CHANGED
@@ -10,33 +10,14 @@ 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
- 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,12 +28,17 @@ import tts
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():
54
  flask_app = Flask(__name__)
55
  flask_app.secret_key = os.environ["SECRET_KEY"]
 
 
 
 
 
 
56
  os.makedirs(AUDIO_DIR, exist_ok=True)
57
  return flask_app
58
 
@@ -68,100 +54,6 @@ database.init_db()
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,28 +73,16 @@ def get_current_user_id(language):
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,51 +154,15 @@ def patient_input():
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,10 +188,16 @@ def preview_response():
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,14 +216,9 @@ def provider_response():
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,58 +228,54 @@ def provider_response():
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__":
 
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
  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():
34
  flask_app = Flask(__name__)
35
  flask_app.secret_key = os.environ["SECRET_KEY"]
36
+ # Hugging Face Spaces (and most production hosts) serve over HTTPS via a
37
+ # reverse proxy. Without these two flags the browser drops the session
38
+ # cookie on cross-request navigation, so session["user_id"] is missing
39
+ # when /history is called and the consultation tab shows nothing.
40
+ flask_app.config["SESSION_COOKIE_SECURE"] = True
41
+ flask_app.config["SESSION_COOKIE_SAMESITE"] = "Lax"
42
  os.makedirs(AUDIO_DIR, exist_ok=True)
43
  return flask_app
44
 
 
54
  tts.preload_models()
55
 
56
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
57
  def get_current_user_id(language):
58
  """
59
  Returns the user_id for the current browser session, creating a new
 
73
  return session["user_id"]
74
 
75
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
76
  @app.route("/")
77
  def index():
78
  return render_template("patient.html", languages=translation.SUPPORTED_LANGUAGES)
79
 
80
 
81
+ @app.route("/provider")
82
+ def provider_page():
83
+ return render_template("provider.html")
84
+
85
+
86
  @app.route("/transcribe", methods=["POST"])
87
  def transcribe_only():
88
  """
 
154
  })
155
 
156
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
157
  @app.route("/preview-response", methods=["POST"])
 
158
  def preview_response():
159
  """
160
+ Runs the exact same translate + synthesize pipeline as
161
+ /provider-response below, so the provider can read and hear precisely
162
+ what the patient will receive before committing to it. Unlike
163
+ /provider-response, this never touches the database - only "Send
164
+ reply" actually saves a response. Each preview click is a real
165
+ translation + speech synthesis call, same cost as an actual send.
166
  """
167
  interaction_id = request.form.get("interaction_id")
168
  response_text = request.form.get("response_text")
 
188
 
189
 
190
  @app.route("/provider-response", methods=["POST"])
 
191
  def provider_response():
192
  interaction_id = request.form.get("interaction_id")
193
  response_text = request.form.get("response_text")
194
+ # Optional cache-reuse fields: if the provider already generated a
195
+ # preview of this exact reply via /preview-response and didn't edit
196
+ # it afterward, the frontend passes the already-computed translation
197
+ # back here so this route can skip re-translating and re-synthesizing
198
+ # speech. Both are best-effort - if the preview audio isn't actually
199
+ # on disk, this quietly falls back to doing the full work itself,
200
+ # exactly as if these fields had never been sent.
201
  cached_translation = request.form.get("translated_response")
202
  reuse_audio = request.form.get("reuse_audio") == "true"
203
 
 
216
  if cached_translation and reuse_audio and os.path.exists(preview_path):
217
  translated_response = cached_translation
218
  os.replace(preview_path, audio_path)
 
 
219
  else:
220
  translated_response = translation.translate(response_text, "english", patient_language)
221
  tts.synthesize_speech(translated_response, patient_language, audio_path)
 
 
 
222
 
223
  database.update_interaction_response(interaction_id, response_text, translated_response)
224
 
 
228
  })
229
 
230
 
231
+ @app.route("/interaction/<interaction_id>")
232
+ def get_interaction(interaction_id):
233
+ interaction = database.get_interaction(interaction_id)
234
+ if not interaction:
235
+ return jsonify({"error": "interaction not found"}), 404
236
+
237
+ audio_filename = f"response_{interaction_id}.wav"
238
+ audio_path = os.path.join(AUDIO_DIR, audio_filename)
239
+ interaction["audio_url"] = f"/static/audio/{audio_filename}" if os.path.exists(audio_path) else None
240
+
241
+ return jsonify(interaction)
242
+
243
+
244
  @app.route("/pending-interactions")
 
245
  def pending_interactions():
246
  return jsonify(database.get_pending_interactions())
247
 
248
 
249
  @app.route("/completed-interactions")
 
250
  def completed_interactions():
251
  return jsonify(database.get_completed_interactions())
252
 
253
 
254
+ @app.route("/history")
255
+ def history():
256
+ if "user_id" not in session:
257
+ return jsonify([])
258
+ return jsonify(database.get_interactions_for_user(session["user_id"]))
259
+
260
+
261
  @app.route("/patient-history/<user_id>")
 
262
  def patient_history(user_id):
263
  """
264
  All of one patient's interactions, for the provider workspace's
265
+ "conversation history" panel. Distinct from /history above: that route
266
+ always looks up the current browser's own session, which only works
267
+ for the patient viewing their own messages. The provider needs to look
268
+ up a specific patient's history by id instead.
269
  """
270
  return jsonify(database.get_interactions_for_user(user_id))
271
 
272
 
273
+ @app.route("/end-session", methods=["POST"])
274
+ def end_session_route():
275
+ if "session_id" in session:
276
+ database.end_session(session["session_id"], datetime.now(timezone.utc).isoformat())
277
+ session.clear()
278
+ return jsonify({"status": "ended"})
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
279
 
280
 
281
  if __name__ == "__main__":