Lnadeem commited on
Commit
e5fa38c
·
verified ·
1 Parent(s): 7d0374e

Upload 2 files

Browse files
Files changed (2) hide show
  1. app.py +137 -151
  2. report_workflow.py +188 -484
app.py CHANGED
@@ -10,7 +10,13 @@ import gradio as gr
10
  import spaces
11
 
12
  import database
13
- from report_workflow import list_reports_api, get_report_api, review_report_api, _identity
 
 
 
 
 
 
14
  from inference import classes_info, health_info, predict_bytes, root_info
15
 
16
  logging.basicConfig(
@@ -19,16 +25,19 @@ logging.basicConfig(
19
  )
20
  logger = logging.getLogger("featurex-zerogpu-server")
21
 
22
- SERVER_VERSION = "3.5.0-authenticated-workflow-status"
23
  MAX_UPLOAD = 15 * 1024 * 1024
24
 
 
25
  def _runtime_info():
26
  info = {}
27
  try:
28
  import torch
29
  info["torch"] = torch.__version__
30
  info["cuda_available"] = bool(torch.cuda.is_available())
31
- info["cuda_device"] = torch.cuda.get_device_name(0) if torch.cuda.is_available() else None
 
 
32
  except Exception as exc:
33
  info["torch_error"] = str(exc)
34
  try:
@@ -38,23 +47,19 @@ def _runtime_info():
38
  info["transformers_error"] = str(exc)
39
  info["janus_enabled"] = os.environ.get("JANUS_ENABLED", "true")
40
  info["janus_model_id"] = os.environ.get("JANUS_MODEL_ID", "ZrH42/Janus-Pro-CXR-Final")
41
- info["model_path"] = os.environ.get("MODEL_PATH", os.path.join(os.path.dirname(os.path.abspath(__file__)), "best_efficientnet.pth"))
 
 
 
42
  info["model_file_exists"] = os.path.isfile(info["model_path"])
43
  return info
44
 
45
 
46
-
47
  def _json(payload: Any) -> str:
48
  return json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
49
 
50
 
51
  def _read_uploaded_image(image_path: str) -> tuple[bytes, str]:
52
- """Read a real uploaded image file supplied by Gradio.
53
-
54
- The browser/phone uploads the binary file to Gradio; the analysis
55
- function receives the temporary server-side file path. No base64 is
56
- used in the public UI or analysis function.
57
- """
58
  if not image_path:
59
  raise ValueError("Please upload an X-ray image.")
60
 
@@ -168,8 +173,6 @@ def _build_unified(result: Dict[str, Any]) -> Dict[str, Any]:
168
  "quality_metrics": quality,
169
  "heatmap_stats": result.get("heatmap_stats", {}),
170
  "model_info": result.get("model_info", {}),
171
- # Only Storage URLs are returned. Raw image/base64 payloads are never
172
- # exposed through the public API response.
173
  "visualizations": {
174
  **((result.get("database") or {}).get("visualization_urls") or {}),
175
  },
@@ -183,7 +186,11 @@ def _build_unified(result: Dict[str, Any]) -> Dict[str, Any]:
183
  "recommendations": selected.get("recommendations", []),
184
  "raw_text": "\n".join(
185
  str(x)
186
- for x in [selected.get("title"), selected.get("findings"), selected.get("impression")]
 
 
 
 
187
  if x
188
  ),
189
  "structured": selected,
@@ -194,17 +201,16 @@ def _build_unified(result: Dict[str, Any]) -> Dict[str, Any]:
194
  "gradcam": {
195
  "enabled": True,
196
  "heatmap_stats": result.get("heatmap_stats", {}),
197
- # Only Storage URLs are returned. Raw image/base64 payloads are never
198
- # exposed through the public API response.
199
- "visualizations": {
200
- **((result.get("database") or {}).get("visualization_urls") or {}),
201
- },
202
  "visualization_keys": list((result.get("visualizations") or {}).keys()),
203
  },
204
  "discrepancies": {"flags": [], "count": 0},
205
  "safety": {
206
  "wording_flags": confidence_flags,
207
- "manual_review_required": bool(safety_flags) or bool((result.get("report_integrity") or {}).get("manual_review_required")),
 
208
  "reasons": safety_flags,
209
  },
210
  "database": result.get("database", {}),
@@ -234,15 +240,21 @@ def analyze(
234
  ) -> str:
235
  request_id = str(uuid.uuid4())
236
  try:
237
- # Do not trust client-supplied user_id/role/department. Firebase ID token
238
- # is verified server-side; role metadata comes from verified claims.
239
  identity = _identity(firebase_token, user_id)
240
  raw, filename = _read_uploaded_image(image_path)
241
  language = (language or "ar").strip().lower()
242
  if language not in {"ar", "en"}:
243
  language = "ar"
244
  patient, _client_user = _metadata(
245
- patient_name, birth_date, gender, age, user_id, username, department, role, supervisor_id
 
 
 
 
 
 
 
 
246
  )
247
  user = {
248
  "user_id": identity["uid"],
@@ -277,17 +289,15 @@ def analyze(
277
  patient=patient,
278
  user=user,
279
  )
280
- db_info.update(
281
- {
282
- "saved": bool(saved.get("saved", False)),
283
- "analysis_id": saved.get("analysis_id", result["analysis_id"]),
284
- "original_image_url": saved.get("original_image_url"),
285
- "visualization_urls": saved.get("visualization_urls", {}),
286
- "storage_saved": bool(
287
- saved.get("storage_saved", saved.get("original_image_url"))
288
- ),
289
- }
290
- )
291
  if saved.get("reason"):
292
  db_info["reason"] = saved["reason"]
293
  if saved.get("error"):
@@ -295,19 +305,15 @@ def analyze(
295
  db_info["error_type"] = saved.get("error_type")
296
  except Exception as exc:
297
  logger.exception("SUPABASE STORAGE/DATABASE SAVE FAILED request_id=%s", request_id)
298
- db_info.update(
299
- {
300
- "saved": False,
301
- "storage_saved": False,
302
- "error": str(exc),
303
- "error_type": type(exc).__name__,
304
- }
305
- )
306
 
307
  result["database"] = db_info
308
 
309
- # A patient may submit an analysis request, but cannot receive diagnostic
310
- # content until the protected get_report endpoint confirms approval.
311
  if identity["role"] == "patient":
312
  return _json({
313
  "success": True,
@@ -320,65 +326,60 @@ def analyze(
320
  "request_id": request_id,
321
  })
322
 
323
- # Visualization bytes are server-side only. They are uploaded by
324
- # database.save_analysis(); the public response contains Storage URLs only.
325
  result["visualizations"] = {}
326
  return _json(_build_unified(result))
327
 
328
  except PermissionError as exc:
329
  logger.warning("ANALYZE AUTH REJECTED request_id=%s reason=%s", request_id, str(exc))
330
- return _json({"success": False, "status": "unauthorized", "request_id": request_id,
331
- "error": "Authentication required", "message": "يرجى تسجيل الدخول مجددًا ثم المحاولة."})
 
 
 
 
 
332
  except ValueError as exc:
333
  logger.warning("ANALYZE REJECTED request_id=%s reason=%s", request_id, str(exc))
334
- return _json(
335
- {
336
- "success": False,
337
- "status": "rejected",
338
- "request_id": request_id,
339
- "error": "Image validation failed",
340
- "message": str(exc),
341
- }
342
- )
343
  except Exception as exc:
344
  logger.exception("ANALYZE ERROR request_id=%s", request_id)
345
- return _json(
346
- {
347
- "success": False,
348
- "status": "error",
349
- "request_id": request_id,
350
- "error": "Internal server error",
351
- "message": "حدث خطأ داخلي أثناء التحليل. يرجى المحاولة لاحقًا.",
352
- }
353
- )
354
 
355
 
356
  @spaces.GPU(duration=30)
357
  def gpu_health() -> str:
358
  info = health_info()
359
- info.update(
360
- {
361
- "server_version": SERVER_VERSION,
362
- "transport": "Gradio Server + ZeroGPU",
363
- "database": database.config_info(),
364
- "runtime": _runtime_info(),
365
- "api": {"analyze": True, "health": True, "classes": True},
366
- }
367
- )
368
  return _json(info)
369
 
370
 
371
  def health() -> str:
372
  info = root_info()
373
- info.update(
374
- {
375
- "server_version": SERVER_VERSION,
376
- "transport": "Gradio Server + ZeroGPU",
377
- "zero_gpu": True,
378
- "database": database.config_info(),
379
- "runtime": _runtime_info(),
380
- }
381
- )
382
  return _json(info)
383
 
384
 
@@ -387,12 +388,6 @@ def classes() -> str:
387
 
388
 
389
  def db_health() -> str:
390
- """
391
- CPU-only Neon/R2 diagnostic.
392
-
393
- This endpoint intentionally runs without @spaces.GPU so Storage/DB can be
394
- tested before spending ZeroGPU time on EfficientNet/Janus inference.
395
- """
396
  try:
397
  result = database.health_check()
398
  except Exception as exc:
@@ -406,45 +401,32 @@ def db_health() -> str:
406
  return _json(result)
407
 
408
 
409
- # ---------------------------------------------------------------------------
410
- # Gradio 6 Blocks API / ZeroGPU
411
- # ---------------------------------------------------------------------------
412
- # Hugging Face currently injects the Space-selected Gradio runtime (the
413
- # observed runtime is Gradio 6.x). Do not pin Gradio, spaces, or
414
- # huggingface-hub in requirements.txt.
415
- #
416
- # The public Android endpoint is the current Gradio queued API:
417
- # POST /gradio_api/call/analyze
418
- # GET /gradio_api/call/analyze/<event_id>
419
- #
420
- # SSR is disabled deliberately. The API does not need the experimental SSR
421
- # frontend, and this removes one unnecessary HTTP/proxy layer for an
422
- # API-first medical inference Space.
423
-
424
  def _health_endpoint() -> str:
425
- """Return a lightweight server health JSON document."""
426
  info = root_info()
427
- info.update(
428
- {
429
- "server_version": SERVER_VERSION,
430
- "transport": "Gradio Blocks + ZeroGPU",
431
- "zero_gpu": True,
432
- "database": database.config_info(),
433
- "runtime": _runtime_info(),
434
- "api": {"analyze": True, "health": True, "gpu_health": True, "classes": True},
435
- }
436
- )
 
 
 
 
 
 
 
437
  return _json(info)
438
 
439
 
440
  def _classes_endpoint() -> str:
441
- """Return the supported chest X-ray classification classes as JSON."""
442
  return _json(classes_info())
443
 
444
 
445
- # A small Gradio UI is intentionally kept here. The API event is the actual
446
- # integration point for Android. The browser/mobile UI uses a real Gradio file upload. The analysis function
447
- # receives a temporary server-side filepath; no base64 image field is used.
448
  with gr.Blocks(
449
  title="FeatureX Clinical AI — ZeroGPU",
450
  analytics_enabled=False,
@@ -462,11 +444,7 @@ with gr.Blocks(
462
  format="png",
463
  height=360,
464
  )
465
- language = gr.Radio(
466
- ["ar", "en"],
467
- value="ar",
468
- label="Report Language",
469
- )
470
 
471
  with gr.Row():
472
  patient_name = gr.Textbox(label="patient_name")
@@ -485,7 +463,6 @@ with gr.Blocks(
485
  analyze_button = gr.Button("Analyze X-Ray", variant="primary")
486
  analyze_output = gr.Textbox(label="JSON response", lines=20)
487
 
488
- # This is the public endpoint consumed by FeatureX Android.
489
  analyze_button.click(
490
  analyze,
491
  inputs=[
@@ -510,41 +487,28 @@ with gr.Blocks(
510
  health_button = gr.Button("Health")
511
  health_output = gr.Textbox(label="Health JSON")
512
  health_button.click(
513
- _health_endpoint,
514
- inputs=[],
515
- outputs=health_output,
516
- api_name="health",
517
  )
518
 
519
  classes_button = gr.Button("Classes")
520
  classes_output = gr.Textbox(label="Classes JSON")
521
  classes_button.click(
522
- _classes_endpoint,
523
- inputs=[],
524
- outputs=classes_output,
525
- api_name="classes",
526
  )
527
 
528
  gpu_health_button = gr.Button("GPU Health")
529
  gpu_health_output = gr.Textbox(label="GPU Health JSON")
530
  gpu_health_button.click(
531
- gpu_health,
532
- inputs=[],
533
- outputs=gpu_health_output,
534
- api_name="gpu_health",
535
  )
536
 
537
  db_health_button = gr.Button("Database / Storage Health")
538
  db_health_output = gr.Textbox(label="Database / Storage Health JSON", lines=12)
539
  db_health_button.click(
540
- db_health,
541
- inputs=[],
542
- outputs=db_health_output,
543
- api_name="db_health",
544
  )
545
 
546
- # Protected report workflow APIs. Each endpoint verifies the Firebase ID token
547
- # server-side; client-supplied role/user_id fields are never authorization proof.
548
  report_user_id = gr.Textbox(visible=False)
549
  report_token = gr.Textbox(visible=False)
550
  report_id = gr.Textbox(visible=False)
@@ -554,27 +518,49 @@ with gr.Blocks(
554
  list_reports_button = gr.Button("List Reports API", visible=False)
555
  list_reports_output = gr.Textbox(visible=False)
556
  list_reports_button.click(
557
- list_reports_api, inputs=[report_user_id, report_token],
558
- outputs=list_reports_output, api_name="list_reports"
 
 
559
  )
560
 
561
  get_report_button = gr.Button("Get Report API", visible=False)
562
  get_report_output = gr.Textbox(visible=False)
563
  get_report_button.click(
564
- get_report_api, inputs=[report_id, report_token],
565
- outputs=get_report_output, api_name="get_report"
 
 
566
  )
567
 
568
  review_report_button = gr.Button("Review Report API", visible=False)
569
  review_report_output = gr.Textbox(visible=False)
570
  review_report_button.click(
571
- review_report_api, inputs=[report_id, review_action, review_notes, report_token],
572
- outputs=review_report_output, api_name="review_report"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
573
  )
574
 
 
575
  if __name__ == "__main__":
576
  demo.launch(
577
  show_error=True,
578
  max_file_size="15mb",
579
  ssr_mode=False,
580
- )
 
10
  import spaces
11
 
12
  import database
13
+ from report_workflow import (
14
+ list_reports_api,
15
+ get_report_api,
16
+ review_report_api,
17
+ update_report_content_api,
18
+ _identity,
19
+ )
20
  from inference import classes_info, health_info, predict_bytes, root_info
21
 
22
  logging.basicConfig(
 
25
  )
26
  logger = logging.getLogger("featurex-zerogpu-server")
27
 
28
+ SERVER_VERSION = "3.5.1-authenticated-workflow-status"
29
  MAX_UPLOAD = 15 * 1024 * 1024
30
 
31
+
32
  def _runtime_info():
33
  info = {}
34
  try:
35
  import torch
36
  info["torch"] = torch.__version__
37
  info["cuda_available"] = bool(torch.cuda.is_available())
38
+ info["cuda_device"] = (
39
+ torch.cuda.get_device_name(0) if torch.cuda.is_available() else None
40
+ )
41
  except Exception as exc:
42
  info["torch_error"] = str(exc)
43
  try:
 
47
  info["transformers_error"] = str(exc)
48
  info["janus_enabled"] = os.environ.get("JANUS_ENABLED", "true")
49
  info["janus_model_id"] = os.environ.get("JANUS_MODEL_ID", "ZrH42/Janus-Pro-CXR-Final")
50
+ info["model_path"] = os.environ.get(
51
+ "MODEL_PATH",
52
+ os.path.join(os.path.dirname(os.path.abspath(__file__)), "best_efficientnet.pth"),
53
+ )
54
  info["model_file_exists"] = os.path.isfile(info["model_path"])
55
  return info
56
 
57
 
 
58
  def _json(payload: Any) -> str:
59
  return json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
60
 
61
 
62
  def _read_uploaded_image(image_path: str) -> tuple[bytes, str]:
 
 
 
 
 
 
63
  if not image_path:
64
  raise ValueError("Please upload an X-ray image.")
65
 
 
173
  "quality_metrics": quality,
174
  "heatmap_stats": result.get("heatmap_stats", {}),
175
  "model_info": result.get("model_info", {}),
 
 
176
  "visualizations": {
177
  **((result.get("database") or {}).get("visualization_urls") or {}),
178
  },
 
186
  "recommendations": selected.get("recommendations", []),
187
  "raw_text": "\n".join(
188
  str(x)
189
+ for x in [
190
+ selected.get("title"),
191
+ selected.get("findings"),
192
+ selected.get("impression"),
193
+ ]
194
  if x
195
  ),
196
  "structured": selected,
 
201
  "gradcam": {
202
  "enabled": True,
203
  "heatmap_stats": result.get("heatmap_stats", {}),
204
+ "visualizations": {
205
+ **((result.get("database") or {}).get("visualization_urls") or {}),
206
+ },
 
 
207
  "visualization_keys": list((result.get("visualizations") or {}).keys()),
208
  },
209
  "discrepancies": {"flags": [], "count": 0},
210
  "safety": {
211
  "wording_flags": confidence_flags,
212
+ "manual_review_required": bool(safety_flags)
213
+ or bool((result.get("report_integrity") or {}).get("manual_review_required")),
214
  "reasons": safety_flags,
215
  },
216
  "database": result.get("database", {}),
 
240
  ) -> str:
241
  request_id = str(uuid.uuid4())
242
  try:
 
 
243
  identity = _identity(firebase_token, user_id)
244
  raw, filename = _read_uploaded_image(image_path)
245
  language = (language or "ar").strip().lower()
246
  if language not in {"ar", "en"}:
247
  language = "ar"
248
  patient, _client_user = _metadata(
249
+ patient_name,
250
+ birth_date,
251
+ gender,
252
+ age,
253
+ user_id,
254
+ username,
255
+ department,
256
+ role,
257
+ supervisor_id,
258
  )
259
  user = {
260
  "user_id": identity["uid"],
 
289
  patient=patient,
290
  user=user,
291
  )
292
+ db_info.update({
293
+ "saved": bool(saved.get("saved", False)),
294
+ "analysis_id": saved.get("analysis_id", result["analysis_id"]),
295
+ "original_image_url": saved.get("original_image_url"),
296
+ "visualization_urls": saved.get("visualization_urls", {}),
297
+ "storage_saved": bool(
298
+ saved.get("storage_saved", saved.get("original_image_url"))
299
+ ),
300
+ })
 
 
301
  if saved.get("reason"):
302
  db_info["reason"] = saved["reason"]
303
  if saved.get("error"):
 
305
  db_info["error_type"] = saved.get("error_type")
306
  except Exception as exc:
307
  logger.exception("SUPABASE STORAGE/DATABASE SAVE FAILED request_id=%s", request_id)
308
+ db_info.update({
309
+ "saved": False,
310
+ "storage_saved": False,
311
+ "error": str(exc),
312
+ "error_type": type(exc).__name__,
313
+ })
 
 
314
 
315
  result["database"] = db_info
316
 
 
 
317
  if identity["role"] == "patient":
318
  return _json({
319
  "success": True,
 
326
  "request_id": request_id,
327
  })
328
 
 
 
329
  result["visualizations"] = {}
330
  return _json(_build_unified(result))
331
 
332
  except PermissionError as exc:
333
  logger.warning("ANALYZE AUTH REJECTED request_id=%s reason=%s", request_id, str(exc))
334
+ return _json({
335
+ "success": False,
336
+ "status": "unauthorized",
337
+ "request_id": request_id,
338
+ "error": "Authentication required",
339
+ "message": "يرجى تسجيل الدخول مجددًا ثم المحاولة.",
340
+ })
341
  except ValueError as exc:
342
  logger.warning("ANALYZE REJECTED request_id=%s reason=%s", request_id, str(exc))
343
+ return _json({
344
+ "success": False,
345
+ "status": "rejected",
346
+ "request_id": request_id,
347
+ "error": "Image validation failed",
348
+ "message": str(exc),
349
+ })
 
 
350
  except Exception as exc:
351
  logger.exception("ANALYZE ERROR request_id=%s", request_id)
352
+ return _json({
353
+ "success": False,
354
+ "status": "error",
355
+ "request_id": request_id,
356
+ "error": "Internal server error",
357
+ "message": "حدث خطأ داخلي أثناء التحليل. يرجى المحاولة لاحقًا.",
358
+ })
 
 
359
 
360
 
361
  @spaces.GPU(duration=30)
362
  def gpu_health() -> str:
363
  info = health_info()
364
+ info.update({
365
+ "server_version": SERVER_VERSION,
366
+ "transport": "Gradio Server + ZeroGPU",
367
+ "database": database.config_info(),
368
+ "runtime": _runtime_info(),
369
+ "api": {"analyze": True, "health": True, "classes": True},
370
+ })
 
 
371
  return _json(info)
372
 
373
 
374
  def health() -> str:
375
  info = root_info()
376
+ info.update({
377
+ "server_version": SERVER_VERSION,
378
+ "transport": "Gradio Server + ZeroGPU",
379
+ "zero_gpu": True,
380
+ "database": database.config_info(),
381
+ "runtime": _runtime_info(),
382
+ })
 
 
383
  return _json(info)
384
 
385
 
 
388
 
389
 
390
  def db_health() -> str:
 
 
 
 
 
 
391
  try:
392
  result = database.health_check()
393
  except Exception as exc:
 
401
  return _json(result)
402
 
403
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
404
  def _health_endpoint() -> str:
 
405
  info = root_info()
406
+ info.update({
407
+ "server_version": SERVER_VERSION,
408
+ "transport": "Gradio Blocks + ZeroGPU",
409
+ "zero_gpu": True,
410
+ "database": database.config_info(),
411
+ "runtime": _runtime_info(),
412
+ "api": {
413
+ "analyze": True,
414
+ "health": True,
415
+ "gpu_health": True,
416
+ "classes": True,
417
+ "list_reports": True,
418
+ "get_report": True,
419
+ "review_report": True,
420
+ "update_report_content": True,
421
+ },
422
+ })
423
  return _json(info)
424
 
425
 
426
  def _classes_endpoint() -> str:
 
427
  return _json(classes_info())
428
 
429
 
 
 
 
430
  with gr.Blocks(
431
  title="FeatureX Clinical AI — ZeroGPU",
432
  analytics_enabled=False,
 
444
  format="png",
445
  height=360,
446
  )
447
+ language = gr.Radio(["ar", "en"], value="ar", label="Report Language")
 
 
 
 
448
 
449
  with gr.Row():
450
  patient_name = gr.Textbox(label="patient_name")
 
463
  analyze_button = gr.Button("Analyze X-Ray", variant="primary")
464
  analyze_output = gr.Textbox(label="JSON response", lines=20)
465
 
 
466
  analyze_button.click(
467
  analyze,
468
  inputs=[
 
487
  health_button = gr.Button("Health")
488
  health_output = gr.Textbox(label="Health JSON")
489
  health_button.click(
490
+ _health_endpoint, inputs=[], outputs=health_output, api_name="health"
 
 
 
491
  )
492
 
493
  classes_button = gr.Button("Classes")
494
  classes_output = gr.Textbox(label="Classes JSON")
495
  classes_button.click(
496
+ _classes_endpoint, inputs=[], outputs=classes_output, api_name="classes"
 
 
 
497
  )
498
 
499
  gpu_health_button = gr.Button("GPU Health")
500
  gpu_health_output = gr.Textbox(label="GPU Health JSON")
501
  gpu_health_button.click(
502
+ gpu_health, inputs=[], outputs=gpu_health_output, api_name="gpu_health"
 
 
 
503
  )
504
 
505
  db_health_button = gr.Button("Database / Storage Health")
506
  db_health_output = gr.Textbox(label="Database / Storage Health JSON", lines=12)
507
  db_health_button.click(
508
+ db_health, inputs=[], outputs=db_health_output, api_name="db_health"
 
 
 
509
  )
510
 
511
+ # ---------- Protected report workflow APIs ----------
 
512
  report_user_id = gr.Textbox(visible=False)
513
  report_token = gr.Textbox(visible=False)
514
  report_id = gr.Textbox(visible=False)
 
518
  list_reports_button = gr.Button("List Reports API", visible=False)
519
  list_reports_output = gr.Textbox(visible=False)
520
  list_reports_button.click(
521
+ list_reports_api,
522
+ inputs=[report_user_id, report_token],
523
+ outputs=list_reports_output,
524
+ api_name="list_reports",
525
  )
526
 
527
  get_report_button = gr.Button("Get Report API", visible=False)
528
  get_report_output = gr.Textbox(visible=False)
529
  get_report_button.click(
530
+ get_report_api,
531
+ inputs=[report_id, report_token],
532
+ outputs=get_report_output,
533
+ api_name="get_report",
534
  )
535
 
536
  review_report_button = gr.Button("Review Report API", visible=False)
537
  review_report_output = gr.Textbox(visible=False)
538
  review_report_button.click(
539
+ review_report_api,
540
+ inputs=[report_id, review_action, review_notes, report_token],
541
+ outputs=review_report_output,
542
+ api_name="review_report",
543
+ )
544
+
545
+ # ---------- Report content editing (before approval) ----------
546
+ edit_id = gr.Textbox(visible=False)
547
+ edit_findings = gr.Textbox(visible=False)
548
+ edit_impression = gr.Textbox(visible=False)
549
+ edit_recs = gr.Textbox(visible=False)
550
+ edit_token = gr.Textbox(visible=False)
551
+ edit_output = gr.Textbox(visible=False)
552
+ edit_button = gr.Button("Update Report Content API", visible=False)
553
+ edit_button.click(
554
+ update_report_content_api,
555
+ inputs=[edit_id, edit_findings, edit_impression, edit_recs, edit_token],
556
+ outputs=edit_output,
557
+ api_name="update_report_content",
558
  )
559
 
560
+
561
  if __name__ == "__main__":
562
  demo.launch(
563
  show_error=True,
564
  max_file_size="15mb",
565
  ssr_mode=False,
566
+ )
report_workflow.py CHANGED
@@ -1,5 +1,4 @@
1
  """Firebase-authenticated report workflow for FeatureX Gradio APIs."""
2
-
3
  import json
4
  import logging
5
  import os
@@ -8,556 +7,212 @@ from functools import lru_cache
8
 
9
  import database
10
 
11
-
12
  logger = logging.getLogger("featurex-report-workflow")
13
 
14
 
15
- # ============================================================
16
- # Firebase Admin initialization
17
- # ============================================================
18
-
19
  @lru_cache(maxsize=1)
20
  def _firebase_admin():
21
- """Initialize Firebase Admin using server-side credentials only."""
22
  import firebase_admin
23
  from firebase_admin import credentials
24
-
25
  if firebase_admin._apps:
26
  return firebase_admin
27
-
28
- raw = os.environ.get(
29
- "FIREBASE_SERVICE_ACCOUNT_JSON", ""
30
- ).strip()
31
-
32
  if not raw:
33
- raise RuntimeError(
34
- "FIREBASE_SERVICE_ACCOUNT_JSON is not configured on the server."
35
- )
36
-
37
  try:
38
  service_account = json.loads(raw)
39
  except json.JSONDecodeError as exc:
40
- raise RuntimeError(
41
- "FIREBASE_SERVICE_ACCOUNT_JSON is invalid JSON."
42
- ) from exc
43
-
44
- firebase_admin.initialize_app(
45
- credentials.Certificate(service_account)
46
- )
47
-
48
  return firebase_admin
49
 
50
 
51
- # ============================================================
52
- # Authentication and role normalization
53
- # ============================================================
54
-
55
  def _identity(token: str, claimed_user_id: str = "") -> dict:
56
- """
57
- Verify the Firebase ID token and normalize the user's role.
58
-
59
- Legacy roles such as clinical and clinician are mapped to doctor.
60
- The Firebase UID is the authoritative user identifier.
61
- """
62
-
63
  token = (token or "").strip()
64
-
65
  if not token:
66
- raise PermissionError(
67
- "unauthorized: Firebase token is required"
68
- )
69
-
70
  admin = _firebase_admin()
71
-
72
  from firebase_admin import auth
73
-
74
- decoded = auth.verify_id_token(
75
- token,
76
- check_revoked=True,
77
- )
78
-
79
  uid = str(decoded.get("uid") or "").strip()
80
-
81
  if not uid:
82
- raise PermissionError(
83
- "unauthorized: token has no uid"
84
- )
85
-
86
- claimed_user_id = (claimed_user_id or "").strip()
87
-
88
- if claimed_user_id and claimed_user_id != uid:
89
- raise PermissionError(
90
- "forbidden: user_id does not match Firebase token"
91
- )
92
-
93
- raw_role = str(
94
- decoded.get("role")
95
- or decoded.get("user_role")
96
- or "patient"
97
- ).strip().lower()
98
-
99
  role_aliases = {
100
- # Doctor role and legacy aliases
101
- "doctor": "doctor",
102
- "clinical": "doctor",
103
- "clinician": "doctor",
104
- "specialist": "doctor",
105
- "physician": "doctor",
106
- "radiologist": "doctor",
107
-
108
- # Department management
109
  "department head": "department_head",
110
  "head_of_department": "department_head",
111
- "department_head": "department_head",
112
- "department_manager": "department_manager",
113
-
114
- # Other roles
115
  "supervisor": "supervisor",
116
  "admin": "admin",
117
  "administrator": "admin",
118
  "patient": "patient",
119
  "user": "patient",
 
 
120
  }
121
-
122
- role = role_aliases.get(raw_role, raw_role)
123
-
124
  return {
125
  "uid": uid,
126
  "role": role,
127
- "department": str(
128
- decoded.get("department") or ""
129
- ).strip(),
130
- "supervisor_id": str(
131
- decoded.get("supervisor_id") or ""
132
- ).strip(),
133
  }
134
 
135
 
136
- # ============================================================
137
- # JSON response helper
138
- # ============================================================
139
-
140
  def _ok(payload):
141
- return json.dumps(
142
- payload,
143
- ensure_ascii=False,
144
- separators=(",", ":"),
145
- )
146
-
147
 
148
- # ============================================================
149
- # Database helpers
150
- # ============================================================
151
 
152
  def _row(analysis_id):
153
- """Retrieve one analysis record by its ID."""
154
-
155
- analysis_id = str(analysis_id or "").strip()
156
-
157
- if not analysis_id:
158
- return None
159
-
160
  rows = database._json_request(
161
- "GET",
162
- "analyses",
163
- params={
164
- "id": f"eq.{analysis_id}",
165
- "limit": "1",
166
- },
167
  )
 
168
 
169
- if isinstance(rows, list) and rows:
170
- row = rows[0]
171
-
172
- if isinstance(row, dict):
173
- return row
174
-
175
- return None
176
-
177
-
178
- # ============================================================
179
- # Report access authorization
180
- # ============================================================
181
 
182
  def _can_read(identity, row):
183
- """
184
- Check whether the authenticated user can access a report.
185
-
186
- Patient access requires ownership, approval, and visibility.
187
- Doctor access requires ownership or explicit assignment.
188
- """
189
-
190
- role = identity["role"]
191
- uid = identity["uid"]
192
-
193
  if role == "admin":
194
  return True
195
-
196
  if role == "patient":
197
  return (
198
  str(row.get("user_id") or "") == uid
199
  and row.get("visible_to_patient") is True
200
  and row.get("workflow_status") == "approved"
201
  )
202
-
203
- if role == "doctor":
204
  return (
205
  str(row.get("user_id") or "") == uid
206
  or str(row.get("assigned_to") or "") == uid
207
  )
208
-
209
- if role in {
210
- "supervisor",
211
- "department_head",
212
- "department_manager",
213
- }:
214
  same_department = (
215
  bool(identity.get("department"))
216
- and str(row.get("department") or "")
217
- == identity["department"]
218
- )
219
-
220
- assigned = (
221
- str(row.get("assigned_to") or "") == uid
222
  )
223
-
224
- supervised = (
225
- str(row.get("supervisor_id") or "") == uid
226
- )
227
-
228
  return same_department or assigned or supervised
229
-
230
  return False
231
 
232
 
233
- # ============================================================
234
- # List reports API
235
- # ============================================================
236
-
237
- def list_reports_api(
238
- claimed_user_id: str,
239
- firebase_token: str,
240
- ) -> str:
241
- """
242
- Return reports visible to the authenticated user.
243
-
244
- Patient responses contain only basic request metadata.
245
- Clinical report content is not returned to patients before approval.
246
- """
247
-
248
  try:
249
- identity = _identity(
250
- firebase_token,
251
- claimed_user_id,
252
- )
253
-
254
- role = identity["role"]
255
- uid = identity["uid"]
256
-
257
- params = {
258
- "order": "created_at.desc",
259
- "limit": "200",
260
- }
261
-
262
- # ----------------------------------------------------
263
- # Patient: retrieve only the patient's own requests.
264
- # Sensitive report fields are removed below.
265
- # ----------------------------------------------------
266
-
267
  if role == "patient":
268
- params["user_id"] = f"eq.{uid}"
269
-
270
- # ----------------------------------------------------
271
- # Doctor: own reports or reports assigned to the doctor.
272
- # ----------------------------------------------------
273
-
274
- elif role == "doctor":
275
- params["or"] = (
276
- f"(user_id.eq.{uid},"
277
- f"assigned_to.eq.{uid})"
278
- )
279
-
280
- # ----------------------------------------------------
281
- # Supervisor and department management.
282
- # ----------------------------------------------------
283
-
284
- elif role in {
285
- "supervisor",
286
- "department_head",
287
- "department_manager",
288
- }:
289
  department = identity.get("department", "")
290
-
291
  if department:
292
  params["or"] = (
293
  f"(department.eq.{department},"
294
- f"assigned_to.eq.{uid},"
295
- f"supervisor_id.eq.{uid})"
296
  )
297
  else:
298
- params["or"] = (
299
- f"(assigned_to.eq.{uid},"
300
- f"supervisor_id.eq.{uid})"
301
- )
302
-
303
- # ----------------------------------------------------
304
- # Administrator: no additional user filter.
305
- # ----------------------------------------------------
306
-
307
  elif role != "admin":
308
- return _ok({
309
- "status": "forbidden",
310
- "message": (
311
- "Role is not permitted to list reports."
312
- ),
313
- })
314
-
315
- rows = database._json_request(
316
- "GET",
317
- "analyses",
318
- params=params,
319
- )
320
-
321
- if not isinstance(rows, list):
322
- rows = []
323
-
324
- # ----------------------------------------------------
325
- # Minimize patient-facing report data.
326
- # ----------------------------------------------------
327
-
328
  if role == "patient":
329
  safe_rows = []
330
-
331
  for row in rows:
332
- if not isinstance(row, dict):
333
- continue
334
-
335
- if str(row.get("user_id") or "") != uid:
336
  continue
337
-
338
  safe_rows.append({
339
  "id": row.get("id"),
340
  "created_at": row.get("created_at"),
341
  "updated_at": row.get("updated_at"),
342
  "status": row.get("status"),
343
- "workflow_status": (
344
- row.get("workflow_status")
345
- or row.get("status")
346
- or "pending_clinician_review"
347
- ),
348
- "visible_to_patient": bool(
349
- row.get("visible_to_patient")
350
- ),
351
- "approval_required": row.get(
352
- "approval_required",
353
- True,
354
- ),
355
  })
356
-
357
  rows = safe_rows
358
-
359
- return _ok({
360
- "status": "success",
361
- "analyses": rows,
362
- "count": len(rows),
363
- })
364
-
365
  except PermissionError as exc:
366
- message = str(exc)
367
-
368
  return _ok({
369
- "status": (
370
- "unauthorized"
371
- if "unauthorized" in message
372
- else "forbidden"
373
- ),
374
- "message": message,
375
  })
376
-
377
  except Exception:
378
  logger.exception("list_reports failed")
 
379
 
380
- return _ok({
381
- "status": "error",
382
- "message": "Unable to list reports.",
383
- })
384
-
385
-
386
- # ============================================================
387
- # Get report API
388
- # ============================================================
389
-
390
- def get_report_api(
391
- analysis_id: str,
392
- firebase_token: str,
393
- ) -> str:
394
- """Retrieve a report after checking access permissions."""
395
 
 
 
 
 
396
  try:
397
  identity = _identity(firebase_token)
398
-
399
- row = _row(
400
- (analysis_id or "").strip()
401
- )
402
-
403
  if not row or not _can_read(identity, row):
404
  return _ok({
405
  "status": "not_found",
406
- "message": (
407
- "Report not found or access denied."
408
- ),
409
  })
410
-
411
- return _ok({
412
- "status": "success",
413
- "analysis": row,
414
- })
415
-
416
  except PermissionError as exc:
417
- message = str(exc)
418
-
419
  return _ok({
420
- "status": (
421
- "unauthorized"
422
- if "unauthorized" in message
423
- else "forbidden"
424
- ),
425
- "message": message,
426
  })
427
-
428
  except Exception:
429
  logger.exception("get_report failed")
 
430
 
431
- return _ok({
432
- "status": "error",
433
- "message": "Unable to retrieve report.",
434
- })
435
-
436
-
437
- # ============================================================
438
- # Review report API
439
- # ============================================================
440
-
441
- def review_report_api(
442
- analysis_id: str,
443
- action: str,
444
- notes: str,
445
- firebase_token: str,
446
- ) -> str:
447
- """
448
- Review a report.
449
-
450
- Doctors can move eligible reports to supervisor review.
451
- Supervisor, department management, and admin roles can
452
- finalize approval according to the workflow rules below.
453
- """
454
 
 
 
 
 
455
  try:
456
  identity = _identity(firebase_token)
457
-
458
  role = identity["role"]
 
 
459
 
460
- permitted_roles = {
461
- "doctor",
462
- "clinician",
463
- "supervisor",
464
- "department_head",
465
- "department_manager",
466
- "admin",
467
- }
468
-
469
- if role not in permitted_roles:
470
- return _ok({
471
- "status": "forbidden",
472
- "message": (
473
- "Doctor review role required."
474
- ),
475
- })
476
-
477
- analysis_id = (
478
- analysis_id or ""
479
- ).strip()
480
-
481
- action = (
482
- action or ""
483
- ).strip().lower()
484
-
485
- notes = (
486
- notes or ""
487
- ).strip()[:4000]
488
-
489
- if not analysis_id:
490
- return _ok({
491
- "status": "invalid_request",
492
- "message": "Analysis ID is required.",
493
- })
494
-
495
- if action not in {
496
- "approve",
497
- "request_revision",
498
- }:
499
- return _ok({
500
- "status": "invalid_action",
501
- "message": "Unsupported review action.",
502
- })
503
 
504
  row = _row(analysis_id)
505
-
506
  if not row:
507
- return _ok({
508
- "status": "not_found",
509
- "message": "Report not found.",
510
- })
511
-
512
- # ----------------------------------------------------
513
- # Authorization
514
- # ----------------------------------------------------
515
-
516
  if role != "admin" and not _can_read(identity, row):
517
- return _ok({
518
- "status": "forbidden",
519
- "message": (
520
- "Report is outside your assigned scope."
521
- ),
522
- })
523
 
524
  current_status = str(
525
- row.get("workflow_status")
526
- or row.get("status")
527
- or "pending_clinician_review"
528
  )
529
 
530
- # ----------------------------------------------------
531
- # Validate approval transitions.
532
- # ----------------------------------------------------
533
-
534
  if action == "approve":
535
-
536
- if role == "doctor" and current_status not in {
537
- "pending_clinician_review",
538
- "needs_revision",
539
- }:
540
  return _ok({
541
  "status": "invalid_transition",
542
- "message": (
543
- "Doctors can review only reports awaiting "
544
- "doctor review or revision."
545
- ),
546
  })
547
-
548
- if role in {
549
- "supervisor",
550
- "department_head",
551
- "department_manager",
552
- } and current_status != "pending_supervisor_review":
553
  return _ok({
554
  "status": "invalid_transition",
555
- "message": (
556
- "Supervisor approval requires "
557
- "pending_supervisor_review status."
558
- ),
559
  })
560
-
561
  if role == "admin" and current_status not in {
562
  "pending_clinician_review",
563
  "needs_revision",
@@ -566,18 +221,10 @@ def review_report_api(
566
  }:
567
  return _ok({
568
  "status": "invalid_transition",
569
- "message": (
570
- "Report cannot be approved "
571
- "from its current status."
572
- ),
573
  })
574
 
575
- # ----------------------------------------------------
576
- # Prepare update
577
- # ----------------------------------------------------
578
-
579
  now = datetime.now(timezone.utc).isoformat()
580
-
581
  patch = {
582
  "reviewed_by": identity["uid"],
583
  "review_notes": notes,
@@ -585,10 +232,6 @@ def review_report_api(
585
  "updated_at": now,
586
  }
587
 
588
- # ----------------------------------------------------
589
- # Request revisions
590
- # ----------------------------------------------------
591
-
592
  if action == "request_revision":
593
  patch.update({
594
  "workflow_status": "needs_revision",
@@ -596,25 +239,14 @@ def review_report_api(
596
  "visible_to_patient": False,
597
  "approval_required": True,
598
  })
599
-
600
- # ----------------------------------------------------
601
- # Doctor approval is not final patient approval.
602
- # The report proceeds to supervisor review.
603
- # ----------------------------------------------------
604
-
605
- elif role == "doctor":
606
  patch.update({
607
  "workflow_status": "pending_supervisor_review",
608
  "status": "pending_supervisor_review",
609
  "visible_to_patient": False,
610
  "approval_required": True,
611
  })
612
-
613
- # ----------------------------------------------------
614
- # Final approval by supervisor, department management,
615
- # or admin.
616
- # ----------------------------------------------------
617
-
618
  else:
619
  patch.update({
620
  "workflow_status": "approved",
@@ -625,52 +257,124 @@ def review_report_api(
625
  "approved_at": now,
626
  })
627
 
628
- # ----------------------------------------------------
629
- # Persist update
630
- # ----------------------------------------------------
631
-
632
  updated = database._json_request(
633
- "PATCH",
634
- "analyses",
635
- patch,
636
- params={
637
- "id": f"eq.{analysis_id}",
638
- },
639
  )
640
-
641
- updated_row = (
642
  updated[0]
643
  if isinstance(updated, list) and updated
644
- else {
645
- "id": analysis_id,
646
- **patch,
647
- }
648
  )
649
 
 
 
650
  return _ok({
651
  "status": "success",
652
  "message": "Review status updated.",
653
- "analysis": updated_row,
 
 
654
  })
655
-
656
  except PermissionError as exc:
657
- message = str(exc)
658
-
659
  return _ok({
660
- "status": (
661
- "unauthorized"
662
- if "unauthorized" in message
663
- else "forbidden"
664
- ),
665
- "message": message,
666
  })
667
-
668
  except Exception:
669
  logger.exception("review_report failed")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
670
 
671
  return _ok({
672
- "status": "error",
673
- "message": (
674
- "Unable to update report review status."
675
- ),
676
- })
 
 
 
 
 
 
 
 
1
  """Firebase-authenticated report workflow for FeatureX Gradio APIs."""
 
2
  import json
3
  import logging
4
  import os
 
7
 
8
  import database
9
 
 
10
  logger = logging.getLogger("featurex-report-workflow")
11
 
12
 
 
 
 
 
13
  @lru_cache(maxsize=1)
14
  def _firebase_admin():
15
+ """Initialize Firebase Admin from a Hugging Face secret, never from client data."""
16
  import firebase_admin
17
  from firebase_admin import credentials
 
18
  if firebase_admin._apps:
19
  return firebase_admin
20
+ raw = os.environ.get("FIREBASE_SERVICE_ACCOUNT_JSON", "").strip()
 
 
 
 
21
  if not raw:
22
+ raise RuntimeError("FIREBASE_SERVICE_ACCOUNT_JSON is not configured on the server.")
 
 
 
23
  try:
24
  service_account = json.loads(raw)
25
  except json.JSONDecodeError as exc:
26
+ raise RuntimeError("FIREBASE_SERVICE_ACCOUNT_JSON is invalid JSON.") from exc
27
+ firebase_admin.initialize_app(credentials.Certificate(service_account))
 
 
 
 
 
 
28
  return firebase_admin
29
 
30
 
 
 
 
 
31
  def _identity(token: str, claimed_user_id: str = "") -> dict:
 
 
 
 
 
 
 
32
  token = (token or "").strip()
 
33
  if not token:
34
+ raise PermissionError("unauthorized: Firebase token is required")
 
 
 
35
  admin = _firebase_admin()
 
36
  from firebase_admin import auth
37
+ decoded = auth.verify_id_token(token, check_revoked=True)
 
 
 
 
 
38
  uid = str(decoded.get("uid") or "").strip()
 
39
  if not uid:
40
+ raise PermissionError("unauthorized: token has no uid")
41
+ if claimed_user_id and claimed_user_id.strip() != uid:
42
+ raise PermissionError("forbidden: user_id does not match Firebase token")
43
+ role = str(decoded.get("role") or decoded.get("user_role") or "patient").strip().lower()
 
 
 
 
 
 
 
 
 
 
 
 
 
44
  role_aliases = {
45
+ "doctor": "clinician",
46
+ "specialist": "clinician",
47
+ "physician": "clinician",
48
+ "radiologist": "clinician",
 
 
 
 
 
49
  "department head": "department_head",
50
  "head_of_department": "department_head",
 
 
 
 
51
  "supervisor": "supervisor",
52
  "admin": "admin",
53
  "administrator": "admin",
54
  "patient": "patient",
55
  "user": "patient",
56
+ "clinician": "clinician",
57
+ "department_head": "department_head",
58
  }
59
+ role = role_aliases.get(role, role)
 
 
60
  return {
61
  "uid": uid,
62
  "role": role,
63
+ "department": str(decoded.get("department") or "").strip(),
64
+ "supervisor_id": str(decoded.get("supervisor_id") or "").strip(),
 
 
 
 
65
  }
66
 
67
 
 
 
 
 
68
  def _ok(payload):
69
+ return json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
 
 
 
 
 
70
 
 
 
 
71
 
72
  def _row(analysis_id):
 
 
 
 
 
 
 
73
  rows = database._json_request(
74
+ "GET", "analyses", params={"id": f"eq.{analysis_id}", "limit": "1"}
 
 
 
 
 
75
  )
76
+ return rows[0] if isinstance(rows, list) and rows else None
77
 
 
 
 
 
 
 
 
 
 
 
 
 
78
 
79
  def _can_read(identity, row):
80
+ role, uid = identity["role"], identity["uid"]
 
 
 
 
 
 
 
 
 
81
  if role == "admin":
82
  return True
 
83
  if role == "patient":
84
  return (
85
  str(row.get("user_id") or "") == uid
86
  and row.get("visible_to_patient") is True
87
  and row.get("workflow_status") == "approved"
88
  )
89
+ if role == "clinician":
 
90
  return (
91
  str(row.get("user_id") or "") == uid
92
  or str(row.get("assigned_to") or "") == uid
93
  )
94
+ if role in {"supervisor", "department_head"}:
 
 
 
 
 
95
  same_department = (
96
  bool(identity.get("department"))
97
+ and str(row.get("department") or "") == identity["department"]
 
 
 
 
 
98
  )
99
+ assigned = str(row.get("assigned_to") or "") == uid
100
+ supervised = str(row.get("supervisor_id") or "") == uid
 
 
 
101
  return same_department or assigned or supervised
 
102
  return False
103
 
104
 
105
+ # ----------------------------------------------------------------
106
+ # LIST
107
+ # ----------------------------------------------------------------
108
+ def list_reports_api(claimed_user_id: str, firebase_token: str) -> str:
 
 
 
 
 
 
 
 
 
 
 
109
  try:
110
+ identity = _identity(firebase_token, claimed_user_id)
111
+ role, uid = identity["role"], identity["uid"]
112
+ params = {"order": "created_at.desc", "limit": "200"}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
113
  if role == "patient":
114
+ params.update({"user_id": f"eq.{uid}"})
115
+ elif role == "clinician":
116
+ params["or"] = f"(user_id.eq.{uid},assigned_to.eq.{uid})"
117
+ elif role in {"supervisor", "department_head"}:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
118
  department = identity.get("department", "")
 
119
  if department:
120
  params["or"] = (
121
  f"(department.eq.{department},"
122
+ f"assigned_to.eq.{uid},supervisor_id.eq.{uid})"
 
123
  )
124
  else:
125
+ params["or"] = f"(assigned_to.eq.{uid},supervisor_id.eq.{uid})"
 
 
 
 
 
 
 
 
126
  elif role != "admin":
127
+ return _ok({"status": "forbidden", "message": "Role is not permitted to list reports."})
128
+ rows = database._json_request("GET", "analyses", params=params)
129
+ rows = rows if isinstance(rows, list) else []
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
130
  if role == "patient":
131
  safe_rows = []
 
132
  for row in rows:
133
+ if not isinstance(row, dict) or str(row.get("user_id") or "") != uid:
 
 
 
134
  continue
 
135
  safe_rows.append({
136
  "id": row.get("id"),
137
  "created_at": row.get("created_at"),
138
  "updated_at": row.get("updated_at"),
139
  "status": row.get("status"),
140
+ "workflow_status": row.get("workflow_status") or row.get("status") or "pending_clinician_review",
141
+ "visible_to_patient": bool(row.get("visible_to_patient")),
142
+ "approval_required": row.get("approval_required", True),
 
 
 
 
 
 
 
 
 
143
  })
 
144
  rows = safe_rows
145
+ return _ok({"status": "success", "analyses": rows, "count": len(rows)})
 
 
 
 
 
 
146
  except PermissionError as exc:
 
 
147
  return _ok({
148
+ "status": "unauthorized" if "unauthorized" in str(exc) else "forbidden",
149
+ "message": str(exc),
 
 
 
 
150
  })
 
151
  except Exception:
152
  logger.exception("list_reports failed")
153
+ return _ok({"status": "error", "message": "Unable to list reports."})
154
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
155
 
156
+ # ----------------------------------------------------------------
157
+ # GET
158
+ # ----------------------------------------------------------------
159
+ def get_report_api(analysis_id: str, firebase_token: str) -> str:
160
  try:
161
  identity = _identity(firebase_token)
162
+ row = _row((analysis_id or "").strip())
 
 
 
 
163
  if not row or not _can_read(identity, row):
164
  return _ok({
165
  "status": "not_found",
166
+ "message": "Report not found or access denied.",
 
 
167
  })
168
+ return _ok({"status": "success", "analysis": row})
 
 
 
 
 
169
  except PermissionError as exc:
 
 
170
  return _ok({
171
+ "status": "unauthorized" if "unauthorized" in str(exc) else "forbidden",
172
+ "message": str(exc),
 
 
 
 
173
  })
 
174
  except Exception:
175
  logger.exception("get_report failed")
176
+ return _ok({"status": "error", "message": "Unable to retrieve report."})
177
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
178
 
179
+ # ----------------------------------------------------------------
180
+ # REVIEW (approve / request_revision)
181
+ # ----------------------------------------------------------------
182
+ def review_report_api(analysis_id: str, action: str, notes: str, firebase_token: str) -> str:
183
  try:
184
  identity = _identity(firebase_token)
 
185
  role = identity["role"]
186
+ if role not in {"clinician", "supervisor", "department_head", "admin"}:
187
+ return _ok({"status": "forbidden", "message": "Clinician review role required."})
188
 
189
+ analysis_id = (analysis_id or "").strip()
190
+ action = (action or "").strip().lower()
191
+ notes = (notes or "").strip()[:4000]
192
+ if action not in {"approve", "request_revision"}:
193
+ return _ok({"status": "invalid_action", "message": "Unsupported review action."})
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
194
 
195
  row = _row(analysis_id)
 
196
  if not row:
197
+ return _ok({"status": "not_found", "message": "Report not found."})
 
 
 
 
 
 
 
 
198
  if role != "admin" and not _can_read(identity, row):
199
+ return _ok({"status": "forbidden", "message": "Report is outside your assigned scope."})
 
 
 
 
 
200
 
201
  current_status = str(
202
+ row.get("workflow_status") or row.get("status") or "pending_clinician_review"
 
 
203
  )
204
 
 
 
 
 
205
  if action == "approve":
206
+ if role == "clinician" and current_status not in {"pending_clinician_review", "needs_revision"}:
 
 
 
 
207
  return _ok({
208
  "status": "invalid_transition",
209
+ "message": "Clinicians can review only reports awaiting clinician review or revision.",
 
 
 
210
  })
211
+ if role in {"supervisor", "department_head"} and current_status != "pending_supervisor_review":
 
 
 
 
 
212
  return _ok({
213
  "status": "invalid_transition",
214
+ "message": "Supervisor approval requires pending_supervisor_review status.",
 
 
 
215
  })
 
216
  if role == "admin" and current_status not in {
217
  "pending_clinician_review",
218
  "needs_revision",
 
221
  }:
222
  return _ok({
223
  "status": "invalid_transition",
224
+ "message": "Report cannot be approved from its current status.",
 
 
 
225
  })
226
 
 
 
 
 
227
  now = datetime.now(timezone.utc).isoformat()
 
228
  patch = {
229
  "reviewed_by": identity["uid"],
230
  "review_notes": notes,
 
232
  "updated_at": now,
233
  }
234
 
 
 
 
 
235
  if action == "request_revision":
236
  patch.update({
237
  "workflow_status": "needs_revision",
 
239
  "visible_to_patient": False,
240
  "approval_required": True,
241
  })
242
+ elif role == "clinician":
243
+ # A clinician review is not final patient approval.
 
 
 
 
 
244
  patch.update({
245
  "workflow_status": "pending_supervisor_review",
246
  "status": "pending_supervisor_review",
247
  "visible_to_patient": False,
248
  "approval_required": True,
249
  })
 
 
 
 
 
 
250
  else:
251
  patch.update({
252
  "workflow_status": "approved",
 
257
  "approved_at": now,
258
  })
259
 
 
 
 
 
260
  updated = database._json_request(
261
+ "PATCH", "analyses", patch, params={"id": f"eq.{analysis_id}"}
 
 
 
 
 
262
  )
263
+ saved_row = (
 
264
  updated[0]
265
  if isinstance(updated, list) and updated
266
+ else {"id": analysis_id, **patch}
 
 
 
267
  )
268
 
269
+ # Return the resolved workflow status so the client knows exactly
270
+ # what state the report is in after this action.
271
  return _ok({
272
  "status": "success",
273
  "message": "Review status updated.",
274
+ "workflow_status": saved_row.get("workflow_status") or patch.get("workflow_status"),
275
+ "visible_to_patient": bool(saved_row.get("visible_to_patient")),
276
+ "analysis": saved_row,
277
  })
 
278
  except PermissionError as exc:
 
 
279
  return _ok({
280
+ "status": "unauthorized" if "unauthorized" in str(exc) else "forbidden",
281
+ "message": str(exc),
 
 
 
 
282
  })
 
283
  except Exception:
284
  logger.exception("review_report failed")
285
+ return _ok({"status": "error", "message": "Unable to update report review status."})
286
+
287
+
288
+ # ----------------------------------------------------------------
289
+ # UPDATE REPORT CONTENT (clinician/supervisor editing before approval)
290
+ # ----------------------------------------------------------------
291
+ def update_report_content_api(
292
+ analysis_id: str,
293
+ findings: str,
294
+ impression: str,
295
+ recommendations_json: str,
296
+ firebase_token: str,
297
+ ) -> str:
298
+ """Allow a clinician/supervisor to edit report content before approval.
299
+
300
+ Approval is final; once approved the report cannot be edited via this
301
+ endpoint. The edit is applied to both report_ar and report_en so both
302
+ language payloads remain consistent, and the editor identity is stored
303
+ in each payload for audit purposes.
304
+ """
305
+ try:
306
+ identity = _identity(firebase_token)
307
+ role = identity["role"]
308
+ if role not in {"clinician", "supervisor", "department_head", "admin"}:
309
+ return _ok({"status": "forbidden", "message": "Review role required."})
310
+
311
+ analysis_id = (analysis_id or "").strip()
312
+ if not analysis_id:
313
+ return _ok({"status": "invalid_action", "message": "analysis_id is required."})
314
+
315
+ row = _row(analysis_id)
316
+ if not row:
317
+ return _ok({"status": "not_found", "message": "Report not found."})
318
+ if role != "admin" and not _can_read(identity, row):
319
+ return _ok({"status": "forbidden", "message": "Report is outside your assigned scope."})
320
+
321
+ current_status = str(row.get("workflow_status") or "").strip().lower()
322
+ if current_status == "approved":
323
+ return _ok({
324
+ "status": "invalid_transition",
325
+ "message": "Approved reports cannot be edited.",
326
+ })
327
+
328
+ # Parse recommendations JSON. Accept either a JSON array of strings
329
+ # or a single string; fall back to an empty list on parse failure.
330
+ recommendations: list = []
331
+ if recommendations_json:
332
+ try:
333
+ parsed = json.loads(recommendations_json)
334
+ if isinstance(parsed, list):
335
+ recommendations = [str(x) for x in parsed if str(x).strip()]
336
+ elif isinstance(parsed, str) and parsed.strip():
337
+ recommendations = [parsed]
338
+ except Exception:
339
+ recommendations = [recommendations_json]
340
+
341
+ now = datetime.now(timezone.utc).isoformat()
342
+ updated_reports = {}
343
+
344
+ for lang_key in ("report_ar", "report_en"):
345
+ existing = row.get(lang_key) or {}
346
+ if not isinstance(existing, dict):
347
+ existing = {}
348
+
349
+ if (findings or "").strip():
350
+ existing["findings"] = findings.strip()
351
+ if (impression or "").strip():
352
+ existing["impression"] = impression.strip()
353
+ if recommendations:
354
+ existing["recommendations"] = recommendations
355
+
356
+ existing["edited_by"] = identity["uid"]
357
+ existing["edited_at"] = now
358
+ updated_reports[lang_key] = existing
359
+
360
+ patch = {
361
+ "report_ar": updated_reports.get("report_ar"),
362
+ "report_en": updated_reports.get("report_en"),
363
+ "updated_at": now,
364
+ }
365
+
366
+ database._json_request("PATCH", "analyses", patch, params={"id": f"eq.{analysis_id}"})
367
 
368
  return _ok({
369
+ "status": "success",
370
+ "message": "Report content updated.",
371
+ "analysis_id": analysis_id,
372
+ })
373
+ except PermissionError as exc:
374
+ return _ok({
375
+ "status": "unauthorized" if "unauthorized" in str(exc) else "forbidden",
376
+ "message": str(exc),
377
+ })
378
+ except Exception:
379
+ logger.exception("update_report_content failed")
380
+ return _ok({"status": "error", "message": "Unable to update report content."})