dikheng dikheng commited on
Commit
ea938cc
·
1 Parent(s): 7bcc411

feat: 3-step agentic pipeline — A/B comparison, red-flag triage, SOAP note

Browse files

Architecture upgrade from single-prompt to 3-step micro-pipeline:
- Step 1 Vision Agent: objective visual description (supports 1 or 2 images)
- Step 2 Clinical Agent: clinical reasoning → strict triage JSON
- Step 3 Format Agent: patient-friendly message + SOAP note

New features:
- A/B Image Comparison: toggle between Standard (1 image) and Compare (2 images)
mode; Vision Agent describes progression between Day 1 and Day X
- Red-Flag Triage: flashing animated banner when triage_level == High
- SOAP Note: auto-generated structured clinical export with copy button
- Output split into Patient View tab and Export for Doctor (SOAP) tab
- Possible conditions shown as chips alongside triage severity badge

Co-Authored-By: Duy Khang <dikheng@users.noreply.huggingface.co>

Files changed (5) hide show
  1. app.py +229 -50
  2. src/agents.py +64 -0
  3. src/inference.py +31 -12
  4. src/model_loader.py +20 -15
  5. src/prompts.py +53 -0
app.py CHANGED
@@ -60,6 +60,17 @@ _I18N = {
60
  "map_label": "Anatomical Map",
61
  "map_select": "click to select",
62
  "map_selected": "{n} region(s) selected",
 
 
 
 
 
 
 
 
 
 
 
63
  },
64
  "vn": {
65
  "img_label": "Tải lên hình ảnh y tế",
@@ -89,6 +100,17 @@ _I18N = {
89
  "map_label": "Bản đồ giải phẫu",
90
  "map_select": "nhấn để chọn",
91
  "map_selected": "{n} vùng đã chọn",
 
 
 
 
 
 
 
 
 
 
 
92
  },
93
  "zh": {
94
  "img_label": "上传医学图像",
@@ -118,6 +140,17 @@ _I18N = {
118
  "map_label": "解剖图",
119
  "map_select": "点击选择",
120
  "map_selected": "已选 {n} 个部位",
 
 
 
 
 
 
 
 
 
 
 
121
  },
122
  "es": {
123
  "img_label": "Subir imagen médica",
@@ -147,6 +180,17 @@ _I18N = {
147
  "map_label": "Mapa anatómico",
148
  "map_select": "haga clic para seleccionar",
149
  "map_selected": "{n} región(es) seleccionada(s)",
 
 
 
 
 
 
 
 
 
 
 
150
  },
151
  "fr": {
152
  "img_label": "Télécharger une image médicale",
@@ -176,6 +220,17 @@ _I18N = {
176
  "map_label": "Carte anatomique",
177
  "map_select": "cliquer pour sélectionner",
178
  "map_selected": "{n} région(s) sélectionnée(s)",
 
 
 
 
 
 
 
 
 
 
 
179
  },
180
  "ja": {
181
  "img_label": "医療画像をアップロード",
@@ -205,6 +260,17 @@ _I18N = {
205
  "map_label": "解剖マップ",
206
  "map_select": "クリックして選択",
207
  "map_selected": "{n} 部位選択中",
 
 
 
 
 
 
 
 
 
 
 
208
  },
209
  }
210
 
@@ -515,18 +581,16 @@ def _empty_output_html(lang: str) -> str:
515
  )
516
 
517
 
 
 
 
 
518
  def _build_result_html(result: dict, lang: str) -> str:
519
- t = _I18N.get(lang, _I18N["en"])
520
- diag = result.get("diagnosis", "")
521
- sev_en = result.get("severity", "Low")
522
- sev = _SEVERITY_TRANSLATE.get(lang, _SEVERITY_TRANSLATE["en"]).get(sev_en, sev_en)
523
- actions = result.get("recommended_actions", [])
524
- score = result.get("confidence_score", 0)
525
- metrics = result.get("_metrics", {})
526
-
527
- actions_html = "".join(
528
- f"<li style='margin:5px 0; color:#d1d5db;'>{a}</li>" for a in actions
529
- ) if actions else "<li style='color:#6b7280;'>—</li>"
530
 
531
  backend_tag = (
532
  "<span style='font-size:0.7rem; background:#052e16; color:#86efac; "
@@ -534,6 +598,39 @@ def _build_result_html(result: dict, lang: str) -> str:
534
  "border:1px solid #16a34a;'>AMD Cloud</span>"
535
  )
536
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
537
  return f"""
538
  <div style='background:#111827; border:1px solid #ED1C24; border-radius:12px;
539
  padding:20px; font-family:Arial,sans-serif; color:#f9fafb;'>
@@ -544,34 +641,31 @@ def _build_result_html(result: dict, lang: str) -> str:
544
  <div style='font-size:1.1rem; font-weight:700; color:#ED1C24;'>
545
  MediVision {backend_tag}
546
  </div>
547
- <div style='font-size:0.75rem; color:#6b7280;'>AMD MI300X · ROCm · Qwen2.5-VL-7B</div>
548
  </div>
549
  </div>
550
 
551
  {_metrics_bar(metrics, t)}
 
552
 
553
  <div style='background:#1f2937; border-radius:8px; padding:14px; margin-bottom:12px;'>
554
  <div style='font-size:0.75rem; text-transform:uppercase; letter-spacing:.05em;
555
- color:#9ca3af; margin-bottom:6px;'>{t['diag_label']}</div>
556
- <div style='font-size:1.05rem; font-weight:600; color:#f9fafb;'>{diag}</div>
 
 
557
  </div>
558
 
559
  <div style='background:#1f2937; border-radius:8px; padding:14px; margin-bottom:12px;'>
560
  <div style='font-size:0.75rem; text-transform:uppercase; letter-spacing:.05em;
561
- color:#9ca3af; margin-bottom:8px;'>{t['severity_label']}</div>
562
- {_severity_badge(sev)}
563
- </div>
564
-
565
- <div style='background:#1f2937; border-radius:8px; padding:14px; margin-bottom:12px;'>
566
- {_confidence_bar(score, t['confidence_label'])}
567
  </div>
568
 
569
  <div style='background:#1f2937; border-radius:8px; padding:14px; margin-bottom:12px;'>
570
  <div style='font-size:0.75rem; text-transform:uppercase; letter-spacing:.05em;
571
  color:#9ca3af; margin-bottom:8px;'>{t['actions_label']}</div>
572
- <ul style='margin:0; padding-left:20px; list-style-type:disc;'>
573
- {actions_html}
574
- </ul>
575
  </div>
576
 
577
  <div style='background:#1a1a2e; border-left:4px solid #ED1C24; border-radius:4px;
@@ -583,6 +677,37 @@ def _build_result_html(result: dict, lang: str) -> str:
583
  """
584
 
585
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
586
  # ---------------------------------------------------------------------------
587
  # UI update helpers
588
  # ---------------------------------------------------------------------------
@@ -615,7 +740,9 @@ def _ui_updates(lang_choice: str, current_regions=None):
615
  f"<p style='font-size:0.75rem; color:#6b7280; margin:4px 0 10px;'>{t['input_hint']}</p>"
616
  )
617
  return (
618
- gr.update(label=t["img_label"]),
 
 
619
  gr.update(label=t["symptoms_label"], placeholder=t["symptoms_placeholder"]),
620
  gr.update(value=t["analyze_btn"]),
621
  gr.update(label=t["region_optional_label"], choices=new_choices, value=translated),
@@ -670,50 +797,71 @@ def on_svg_click(svg_id: str, current_regions: list, lang_choice: str) -> tuple:
670
  def on_lang_change(lang_choice: str, image, symptoms: str, selected_regions):
671
  lang = _LANG_MAP.get(lang_choice, "en")
672
  t = _I18N[lang]
673
- img_upd, sym_upd, btn_upd, region_upd, hint_upd = _ui_updates(lang_choice, current_regions=selected_regions)
 
 
674
 
675
  region = _regions_to_prompt(selected_regions)
676
 
677
  has_content = bool(image) or bool(symptoms and symptoms.strip())
678
  if has_content:
679
  try:
680
- result = get_pipeline().process(image, (symptoms or "").strip(), lang=lang, region=region)
681
- out_upd = _build_result_html(result, lang)
 
682
  except Exception as exc:
683
- out_upd = _error_html(t, exc)
 
684
  else:
685
- out_upd = _empty_output_html(lang)
 
686
 
687
- return img_upd, sym_upd, btn_upd, region_upd, hint_upd, out_upd, get_backend_status_html(lang)
688
 
689
 
690
  def on_load(request: gr.Request):
691
  lang_display = _detect_lang_from_header(
692
  request.headers.get("accept-language", "")
693
  )
694
- img_upd, sym_upd, btn_upd, region_upd, hint_upd = _ui_updates(lang_display, current_regions=[])
 
 
695
  lang = _LANG_MAP.get(lang_display, "en")
696
- return lang_display, img_upd, sym_upd, btn_upd, region_upd, hint_upd, _body_map_svg([], lang), _empty_output_html(lang), get_backend_status_html(lang)
 
 
 
 
 
 
 
 
697
 
698
 
699
  # ---------------------------------------------------------------------------
700
  # Predict
701
  # ---------------------------------------------------------------------------
702
 
703
- def predict(image, symptoms: str, lang_choice: str, selected_regions):
704
  lang = _LANG_MAP.get(lang_choice, "en")
705
  t = _I18N[lang]
706
 
707
- if not image and not symptoms.strip():
708
- return _empty_output_html(lang), get_backend_status_html(lang)
709
 
710
  region = _regions_to_prompt(selected_regions)
711
 
712
  try:
713
- result = get_pipeline().process(image, symptoms.strip(), lang=lang, region=region)
714
- return _build_result_html(result, lang), get_backend_status_html(lang)
 
 
 
 
 
 
715
  except Exception as exc:
716
- return _error_html(t, exc), get_backend_status_html(lang)
717
 
718
 
719
  # ---------------------------------------------------------------------------
@@ -771,6 +919,10 @@ label span, .gr-form > label {
771
  letter-spacing: 0.04em;
772
  }
773
  footer { display: none !important; }
 
 
 
 
774
  ::-webkit-scrollbar { width: 6px; }
775
  ::-webkit-scrollbar-track { background: #111827; }
776
  ::-webkit-scrollbar-thumb { background: #374151; border-radius: 3px; }
@@ -914,11 +1066,24 @@ with gr.Blocks(css=CSS, theme=gr.themes.Base(), title="MediVision — Dermatolog
914
  with gr.Row(equal_height=False):
915
 
916
  with gr.Column(scale=1, min_width=300):
917
- input_img = gr.Image(
918
- type="filepath",
919
- label="Upload Medical Image",
920
- height=230,
 
921
  )
 
 
 
 
 
 
 
 
 
 
 
 
922
  symptoms_txt = gr.Textbox(
923
  label="Symptoms Description",
924
  placeholder="Describe what you feel — e.g. itchy red patch for 3 days...",
@@ -960,13 +1125,21 @@ with gr.Blocks(css=CSS, theme=gr.themes.Base(), title="MediVision — Dermatolog
960
  )
961
 
962
  with gr.Column(scale=1, min_width=340):
963
- output_html = gr.HTML(
964
- value=_empty_output_html("en"),
965
- label="Analysis Result",
966
- )
 
967
 
968
  # ── Events ───────────────────────────────────────────────────────────────
969
 
 
 
 
 
 
 
 
970
  # SVG click → toggle region in dropdown + re-render SVG
971
  svg_click_bridge.input(
972
  fn=on_svg_click,
@@ -984,20 +1157,26 @@ with gr.Blocks(css=CSS, theme=gr.themes.Base(), title="MediVision — Dermatolog
984
  lang_radio.change(
985
  fn=on_lang_change,
986
  inputs=[lang_radio, input_img, symptoms_txt, region_selector],
987
- outputs=[input_img, symptoms_txt, submit_btn, region_selector, input_hint_html, output_html, status_bar],
 
988
  )
989
 
990
  submit_btn.click(
991
  fn=predict,
992
- inputs=[input_img, symptoms_txt, lang_radio, region_selector],
993
- outputs=[output_html, status_bar],
994
  api_name="analyze",
995
  )
996
 
997
  demo.load(
998
  fn=on_load,
999
  inputs=[],
1000
- outputs=[lang_radio, input_img, symptoms_txt, submit_btn, region_selector, input_hint_html, body_map_html, output_html, status_bar],
 
 
 
 
 
1001
  )
1002
 
1003
  gr.HTML(FOOTER_HTML)
 
60
  "map_label": "Anatomical Map",
61
  "map_select": "click to select",
62
  "map_selected": "{n} region(s) selected",
63
+ "img_mode_label": "Upload Mode",
64
+ "img_mode_standard": "Standard (1 image)",
65
+ "img_mode_compare": "Compare (2 images — before & after)",
66
+ "img_label_day1": "Medical Image (Day 1)",
67
+ "img_label_dayx": "Comparison Image (Day X)",
68
+ "tab_patient": "Patient View",
69
+ "tab_doctor": "Export for Doctor (SOAP)",
70
+ "critical_warning": "⚠️ CRITICAL: Severe symptoms detected. Please visit a medical facility within 24 hours.",
71
+ "conditions_label": "Possible Conditions",
72
+ "soap_copy_btn": "Copy SOAP Note",
73
+ "soap_empty": "Run an analysis to generate the SOAP note.",
74
  },
75
  "vn": {
76
  "img_label": "Tải lên hình ảnh y tế",
 
100
  "map_label": "Bản đồ giải phẫu",
101
  "map_select": "nhấn để chọn",
102
  "map_selected": "{n} vùng đã chọn",
103
+ "img_mode_label": "Chế độ tải ảnh",
104
+ "img_mode_standard": "Tiêu chuẩn (1 ảnh)",
105
+ "img_mode_compare": "So sánh (2 ảnh — trước & sau)",
106
+ "img_label_day1": "Ảnh y tế (Ngày 1)",
107
+ "img_label_dayx": "Ảnh so sánh (Ngày X)",
108
+ "tab_patient": "Dành cho bệnh nhân",
109
+ "tab_doctor": "Xuất cho bác sĩ (SOAP)",
110
+ "critical_warning": "⚠️ CẢNH BÁO: Triệu chứng nghiêm trọng được phát hiện. Vui lòng đến cơ sở y tế trong vòng 24 giờ.",
111
+ "conditions_label": "Tình trạng có thể",
112
+ "soap_copy_btn": "Sao chép SOAP",
113
+ "soap_empty": "Thực hiện phân tích để tạo ghi chú SOAP.",
114
  },
115
  "zh": {
116
  "img_label": "上传医学图像",
 
140
  "map_label": "解剖图",
141
  "map_select": "点击选择",
142
  "map_selected": "已选 {n} 个部位",
143
+ "img_mode_label": "上传模式",
144
+ "img_mode_standard": "标准模式(1张图片)",
145
+ "img_mode_compare": "对比模式(2张图片 — 前后对比)",
146
+ "img_label_day1": "医疗图像(第1天)",
147
+ "img_label_dayx": "对比图像(第X天)",
148
+ "tab_patient": "患者视图",
149
+ "tab_doctor": "导出给医生(SOAP)",
150
+ "critical_warning": "⚠️ 严重警告:检测到严重症状。请在24小时内前往医疗机构就诊。",
151
+ "conditions_label": "可能的病症",
152
+ "soap_copy_btn": "复制SOAP记录",
153
+ "soap_empty": "运行分析以生成SOAP记录。",
154
  },
155
  "es": {
156
  "img_label": "Subir imagen médica",
 
180
  "map_label": "Mapa anatómico",
181
  "map_select": "haga clic para seleccionar",
182
  "map_selected": "{n} región(es) seleccionada(s)",
183
+ "img_mode_label": "Modo de carga",
184
+ "img_mode_standard": "Estándar (1 imagen)",
185
+ "img_mode_compare": "Comparar (2 imágenes — antes y después)",
186
+ "img_label_day1": "Imagen médica (Día 1)",
187
+ "img_label_dayx": "Imagen de comparación (Día X)",
188
+ "tab_patient": "Vista del paciente",
189
+ "tab_doctor": "Exportar para médico (SOAP)",
190
+ "critical_warning": "⚠️ CRÍTICO: Síntomas graves detectados. Por favor, acuda a un centro médico en las próximas 24 horas.",
191
+ "conditions_label": "Posibles condiciones",
192
+ "soap_copy_btn": "Copiar nota SOAP",
193
+ "soap_empty": "Ejecute un análisis para generar la nota SOAP.",
194
  },
195
  "fr": {
196
  "img_label": "Télécharger une image médicale",
 
220
  "map_label": "Carte anatomique",
221
  "map_select": "cliquer pour sélectionner",
222
  "map_selected": "{n} région(s) sélectionnée(s)",
223
+ "img_mode_label": "Mode de téléchargement",
224
+ "img_mode_standard": "Standard (1 image)",
225
+ "img_mode_compare": "Comparer (2 images — avant et après)",
226
+ "img_label_day1": "Image médicale (Jour 1)",
227
+ "img_label_dayx": "Image de comparaison (Jour X)",
228
+ "tab_patient": "Vue patient",
229
+ "tab_doctor": "Exporter pour le médecin (SOAP)",
230
+ "critical_warning": "⚠️ CRITIQUE : Symptômes graves détectés. Veuillez vous rendre dans un établissement médical dans les 24 heures.",
231
+ "conditions_label": "Conditions possibles",
232
+ "soap_copy_btn": "Copier la note SOAP",
233
+ "soap_empty": "Lancez une analyse pour générer la note SOAP.",
234
  },
235
  "ja": {
236
  "img_label": "医療画像をアップロード",
 
260
  "map_label": "解剖マップ",
261
  "map_select": "クリックして選択",
262
  "map_selected": "{n} 部位選択中",
263
+ "img_mode_label": "アップロードモード",
264
+ "img_mode_standard": "標準(画像1枚)",
265
+ "img_mode_compare": "比較(画像2枚 — 経過観察)",
266
+ "img_label_day1": "医療画像(第1日)",
267
+ "img_label_dayx": "比較画像(第X日)",
268
+ "tab_patient": "患者向け",
269
+ "tab_doctor": "医師向けエクスポート(SOAP)",
270
+ "critical_warning": "⚠️ 重大:重篤な症状が検出されました。24時間以内に医療機関を受診してください。",
271
+ "conditions_label": "考えられる疾患",
272
+ "soap_copy_btn": "SOAPノートをコピー",
273
+ "soap_empty": "分析を実行してSOAPノートを生成します。",
274
  },
275
  }
276
 
 
581
  )
582
 
583
 
584
+ def _empty_soap_html(lang: str) -> str:
585
+ return _build_soap_html("", lang)
586
+
587
+
588
  def _build_result_html(result: dict, lang: str) -> str:
589
+ t = _I18N.get(lang, _I18N["en"])
590
+ triage = result.get("triage_level", "Low")
591
+ patient_msg = result.get("patient_message", "")
592
+ conditions = result.get("possible_conditions", [])
593
+ metrics = result.get("_metrics", {})
 
 
 
 
 
 
594
 
595
  backend_tag = (
596
  "<span style='font-size:0.7rem; background:#052e16; color:#86efac; "
 
598
  "border:1px solid #16a34a;'>AMD Cloud</span>"
599
  )
600
 
601
+ # Triage color
602
+ triage_colors = {
603
+ "High": ("#ef4444", "#7f1d1d"),
604
+ "Medium": ("#f97316", "#431407"),
605
+ "Low": ("#22c55e", "#052e16"),
606
+ }
607
+ t_color, t_bg = triage_colors.get(triage, ("#22c55e", "#052e16"))
608
+
609
+ # Red-flag flashing banner
610
+ critical_banner = ""
611
+ if triage == "High":
612
+ critical_banner = f"""
613
+ <div style='animation:redflash 1s ease-in-out infinite;
614
+ background:#7f1d1d; border:2px solid #ef4444; border-radius:8px;
615
+ padding:14px 18px; margin-bottom:16px; text-align:center;'>
616
+ <span style='color:#fca5a5; font-weight:900; font-size:0.95rem; line-height:1.5;'>
617
+ {t['critical_warning']}
618
+ </span>
619
+ </div>"""
620
+
621
+ # Possible conditions chips
622
+ cond_chips = "".join(
623
+ f"<span style='background:#1e3a5f; color:#93c5fd; font-size:0.72rem; "
624
+ f"padding:3px 10px; border-radius:999px; border:1px solid #2563eb;'>{c}</span>"
625
+ for c in conditions
626
+ ) if conditions else "<span style='color:#6b7280;'>—</span>"
627
+
628
+ # Patient message paragraphs
629
+ msg_html = "".join(
630
+ f"<p style='margin:0 0 8px; color:#d1d5db; line-height:1.6;'>{line}</p>"
631
+ for line in patient_msg.split("\n") if line.strip()
632
+ ) if patient_msg else "<p style='color:#6b7280;'>—</p>"
633
+
634
  return f"""
635
  <div style='background:#111827; border:1px solid #ED1C24; border-radius:12px;
636
  padding:20px; font-family:Arial,sans-serif; color:#f9fafb;'>
 
641
  <div style='font-size:1.1rem; font-weight:700; color:#ED1C24;'>
642
  MediVision {backend_tag}
643
  </div>
644
+ <div style='font-size:0.75rem; color:#6b7280;'>AMD MI300X · ROCm · Qwen2.5-VL-7B · 3-Step Pipeline</div>
645
  </div>
646
  </div>
647
 
648
  {_metrics_bar(metrics, t)}
649
+ {critical_banner}
650
 
651
  <div style='background:#1f2937; border-radius:8px; padding:14px; margin-bottom:12px;'>
652
  <div style='font-size:0.75rem; text-transform:uppercase; letter-spacing:.05em;
653
+ color:#9ca3af; margin-bottom:6px;'>{t['severity_label']}</div>
654
+ <span style='background:{t_bg}; color:{t_color}; font-weight:700;
655
+ padding:4px 16px; border-radius:999px; font-size:0.9rem;
656
+ border:2px solid {t_color};'>{triage}</span>
657
  </div>
658
 
659
  <div style='background:#1f2937; border-radius:8px; padding:14px; margin-bottom:12px;'>
660
  <div style='font-size:0.75rem; text-transform:uppercase; letter-spacing:.05em;
661
+ color:#9ca3af; margin-bottom:8px;'>{t['conditions_label']}</div>
662
+ <div style='display:flex; flex-wrap:wrap; gap:6px;'>{cond_chips}</div>
 
 
 
 
663
  </div>
664
 
665
  <div style='background:#1f2937; border-radius:8px; padding:14px; margin-bottom:12px;'>
666
  <div style='font-size:0.75rem; text-transform:uppercase; letter-spacing:.05em;
667
  color:#9ca3af; margin-bottom:8px;'>{t['actions_label']}</div>
668
+ {msg_html}
 
 
669
  </div>
670
 
671
  <div style='background:#1a1a2e; border-left:4px solid #ED1C24; border-radius:4px;
 
677
  """
678
 
679
 
680
+ def _build_soap_html(soap_text: str, lang: str = "en") -> str:
681
+ t = _I18N.get(lang, _I18N["en"])
682
+ if not soap_text:
683
+ return (
684
+ f"<div style='color:#4b5563; text-align:center; padding:40px 0; font-size:0.9rem;'>"
685
+ f"{t['soap_empty']}</div>"
686
+ )
687
+ lines_html = "".join(
688
+ f"<div style='padding:3px 0; color:{'#ED1C24' if line.startswith('S ') or line.startswith('O ') or line.startswith('A ') or line.startswith('P ') else '#d1d5db'}; "
689
+ f"font-weight:{'700' if line[:2] in ('S ', 'O ', 'A ', 'P ') else '400'};'>{line}</div>"
690
+ for line in soap_text.split("\n") if line.strip()
691
+ )
692
+ return f"""
693
+ <div style='background:#0f172a; border:1px solid #1e3a5f; border-radius:12px;
694
+ padding:20px; font-family:monospace; font-size:0.82rem; line-height:1.7;'>
695
+ <div style='display:flex; justify-content:space-between; align-items:center; margin-bottom:14px;'>
696
+ <span style='color:#ED1C24; font-weight:700; font-size:0.9rem; font-family:sans-serif;'>
697
+ SOAP Clinical Note
698
+ </span>
699
+ <button onclick="navigator.clipboard.writeText(this.dataset.text).then(()=>this.textContent='{t['soap_copy_btn']} ✓').catch(()=>null)"
700
+ data-text="{soap_text.replace(chr(34), '&quot;')}"
701
+ style='background:#1e3a5f; color:#93c5fd; border:1px solid #2563eb; border-radius:6px;
702
+ padding:4px 12px; cursor:pointer; font-size:0.72rem;'>
703
+ {t['soap_copy_btn']}
704
+ </button>
705
+ </div>
706
+ {lines_html}
707
+ </div>
708
+ """
709
+
710
+
711
  # ---------------------------------------------------------------------------
712
  # UI update helpers
713
  # ---------------------------------------------------------------------------
 
740
  f"<p style='font-size:0.75rem; color:#6b7280; margin:4px 0 10px;'>{t['input_hint']}</p>"
741
  )
742
  return (
743
+ gr.update(label=t["img_label"], choices=[t["img_mode_standard"], t["img_mode_compare"]]),
744
+ gr.update(label=t["img_label_day1"]),
745
+ gr.update(label=t["img_label_dayx"]),
746
  gr.update(label=t["symptoms_label"], placeholder=t["symptoms_placeholder"]),
747
  gr.update(value=t["analyze_btn"]),
748
  gr.update(label=t["region_optional_label"], choices=new_choices, value=translated),
 
797
  def on_lang_change(lang_choice: str, image, symptoms: str, selected_regions):
798
  lang = _LANG_MAP.get(lang_choice, "en")
799
  t = _I18N[lang]
800
+ mode_upd, day1_upd, dayx_upd, sym_upd, btn_upd, region_upd, hint_upd = _ui_updates(
801
+ lang_choice, current_regions=selected_regions
802
+ )
803
 
804
  region = _regions_to_prompt(selected_regions)
805
 
806
  has_content = bool(image) or bool(symptoms and symptoms.strip())
807
  if has_content:
808
  try:
809
+ result = get_pipeline().process(image, None, (symptoms or "").strip(), lang=lang, region=region)
810
+ out_upd = _build_result_html(result, lang)
811
+ soap_upd = _build_soap_html(result.get("soap_note", ""), lang)
812
  except Exception as exc:
813
+ out_upd = _error_html(t, exc)
814
+ soap_upd = _empty_soap_html(lang)
815
  else:
816
+ out_upd = _empty_output_html(lang)
817
+ soap_upd = _empty_soap_html(lang)
818
 
819
+ return mode_upd, day1_upd, dayx_upd, sym_upd, btn_upd, region_upd, hint_upd, out_upd, soap_upd, get_backend_status_html(lang)
820
 
821
 
822
  def on_load(request: gr.Request):
823
  lang_display = _detect_lang_from_header(
824
  request.headers.get("accept-language", "")
825
  )
826
+ mode_upd, day1_upd, dayx_upd, sym_upd, btn_upd, region_upd, hint_upd = _ui_updates(
827
+ lang_display, current_regions=[]
828
+ )
829
  lang = _LANG_MAP.get(lang_display, "en")
830
+ return (
831
+ lang_display,
832
+ mode_upd, day1_upd, dayx_upd,
833
+ sym_upd, btn_upd, region_upd, hint_upd,
834
+ _body_map_svg([], lang),
835
+ _empty_output_html(lang),
836
+ _empty_soap_html(lang),
837
+ get_backend_status_html(lang),
838
+ )
839
 
840
 
841
  # ---------------------------------------------------------------------------
842
  # Predict
843
  # ---------------------------------------------------------------------------
844
 
845
+ def predict(image_1, image_2, symptoms: str, lang_choice: str, selected_regions):
846
  lang = _LANG_MAP.get(lang_choice, "en")
847
  t = _I18N[lang]
848
 
849
+ if not image_1 and not image_2 and not (symptoms or "").strip():
850
+ return _empty_output_html(lang), _empty_soap_html(lang), get_backend_status_html(lang)
851
 
852
  region = _regions_to_prompt(selected_regions)
853
 
854
  try:
855
+ result = get_pipeline().process(
856
+ image_1, image_2, (symptoms or "").strip(), lang=lang, region=region
857
+ )
858
+ return (
859
+ _build_result_html(result, lang),
860
+ _build_soap_html(result.get("soap_note", ""), lang),
861
+ get_backend_status_html(lang),
862
+ )
863
  except Exception as exc:
864
+ return _error_html(t, exc), _empty_soap_html(lang), get_backend_status_html(lang)
865
 
866
 
867
  # ---------------------------------------------------------------------------
 
919
  letter-spacing: 0.04em;
920
  }
921
  footer { display: none !important; }
922
+ @keyframes redflash {
923
+ 0%, 100% { opacity: 1; box-shadow: 0 0 12px rgba(239,68,68,0.6); }
924
+ 50% { opacity: 0.7; box-shadow: 0 0 24px rgba(239,68,68,0.9); }
925
+ }
926
  ::-webkit-scrollbar { width: 6px; }
927
  ::-webkit-scrollbar-track { background: #111827; }
928
  ::-webkit-scrollbar-thumb { background: #374151; border-radius: 3px; }
 
1066
  with gr.Row(equal_height=False):
1067
 
1068
  with gr.Column(scale=1, min_width=300):
1069
+ img_mode = gr.Radio(
1070
+ choices=[_I18N["en"]["img_mode_standard"], _I18N["en"]["img_mode_compare"]],
1071
+ value=_I18N["en"]["img_mode_standard"],
1072
+ label=_I18N["en"]["img_mode_label"],
1073
+ elem_id="img-mode-radio",
1074
  )
1075
+ with gr.Row(equal_height=True):
1076
+ input_img = gr.Image(
1077
+ type="filepath",
1078
+ label=_I18N["en"]["img_label_day1"],
1079
+ height=200,
1080
+ )
1081
+ input_img_2 = gr.Image(
1082
+ type="filepath",
1083
+ label=_I18N["en"]["img_label_dayx"],
1084
+ height=200,
1085
+ visible=False,
1086
+ )
1087
  symptoms_txt = gr.Textbox(
1088
  label="Symptoms Description",
1089
  placeholder="Describe what you feel — e.g. itchy red patch for 3 days...",
 
1125
  )
1126
 
1127
  with gr.Column(scale=1, min_width=340):
1128
+ with gr.Tabs(elem_id="output-tabs"):
1129
+ with gr.TabItem(_I18N["en"]["tab_patient"], elem_id="tab-patient"):
1130
+ output_html = gr.HTML(value=_empty_output_html("en"))
1131
+ with gr.TabItem(_I18N["en"]["tab_doctor"], elem_id="tab-doctor"):
1132
+ soap_html = gr.HTML(value=_empty_soap_html("en"))
1133
 
1134
  # ── Events ───────────────────────────────────────────────────────────────
1135
 
1136
+ # Image mode toggle: show/hide second image upload
1137
+ img_mode.change(
1138
+ fn=lambda m: gr.update(visible=_I18N["en"]["img_mode_compare"] in m),
1139
+ inputs=[img_mode],
1140
+ outputs=[input_img_2],
1141
+ )
1142
+
1143
  # SVG click → toggle region in dropdown + re-render SVG
1144
  svg_click_bridge.input(
1145
  fn=on_svg_click,
 
1157
  lang_radio.change(
1158
  fn=on_lang_change,
1159
  inputs=[lang_radio, input_img, symptoms_txt, region_selector],
1160
+ outputs=[img_mode, input_img, input_img_2, symptoms_txt, submit_btn,
1161
+ region_selector, input_hint_html, output_html, soap_html, status_bar],
1162
  )
1163
 
1164
  submit_btn.click(
1165
  fn=predict,
1166
+ inputs=[input_img, input_img_2, symptoms_txt, lang_radio, region_selector],
1167
+ outputs=[output_html, soap_html, status_bar],
1168
  api_name="analyze",
1169
  )
1170
 
1171
  demo.load(
1172
  fn=on_load,
1173
  inputs=[],
1174
+ outputs=[
1175
+ lang_radio,
1176
+ img_mode, input_img, input_img_2,
1177
+ symptoms_txt, submit_btn, region_selector, input_hint_html,
1178
+ body_map_html, output_html, soap_html, status_bar,
1179
+ ],
1180
  )
1181
 
1182
  gr.HTML(FOOTER_HTML)
src/agents.py ADDED
@@ -0,0 +1,64 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ import re
3
+
4
+ from src.model_loader import generate_response, generate_text
5
+ from src.prompts import VISION_AGENT_SYSTEM, CLINICAL_AGENT_SYSTEM, FORMAT_AGENT_SYSTEM
6
+
7
+ _LANG_NAMES = {
8
+ "en": "English",
9
+ "vn": "Vietnamese",
10
+ "zh": "Simplified Chinese",
11
+ "es": "Spanish",
12
+ "fr": "French",
13
+ "ja": "Japanese",
14
+ }
15
+
16
+
17
+ def vision_agent(image_path_1, image_path_2, symptoms: str) -> tuple[str, dict]:
18
+ """Step 1: strictly objective visual description. Returns (description_text, metrics)."""
19
+ two_images = bool(image_path_2)
20
+ user_msg = VISION_AGENT_SYSTEM + "\n\n"
21
+ if two_images:
22
+ user_msg += "TWO images are provided: the first image is Day 1, the second image is Day X.\n\n"
23
+ user_msg += f"Patient symptom text: {symptoms or '(none provided)'}"
24
+ return generate_response(user_msg, image_path=image_path_1 or None,
25
+ image_path_2=image_path_2 or None)
26
+
27
+
28
+ def clinical_agent(visual_description: str, symptoms: str) -> tuple[dict, dict]:
29
+ """Step 2: clinical reasoning → strict JSON. Returns (parsed_dict, metrics)."""
30
+ prompt = (
31
+ CLINICAL_AGENT_SYSTEM + "\n\n"
32
+ f"VISUAL DESCRIPTION:\n{visual_description}\n\n"
33
+ f"PATIENT SYMPTOMS:\n{symptoms or '(none provided)'}"
34
+ )
35
+ raw, metrics = generate_text(prompt)
36
+ match = re.search(r'\{.*\}', raw, re.DOTALL)
37
+ if not match:
38
+ raise ValueError(f"Clinical agent did not return JSON: {raw[:300]}")
39
+ data = json.loads(match.group())
40
+ return {
41
+ "triage_level": data.get("triage_level", "Low"),
42
+ "possible_conditions": data.get("possible_conditions", []),
43
+ "clinical_assessment": data.get("clinical_assessment", ""),
44
+ "recommendation": data.get("recommendation", ""),
45
+ }, metrics
46
+
47
+
48
+ def format_agent(clinical_json: dict, visual_description: str,
49
+ symptoms: str, lang: str) -> tuple[str, str, dict]:
50
+ """Step 3: patient-friendly message + SOAP note. Returns (patient_msg, soap_text, metrics)."""
51
+ lang_name = _LANG_NAMES.get(lang, "English")
52
+ prompt = (
53
+ FORMAT_AGENT_SYSTEM + "\n\n"
54
+ f"TARGET LANGUAGE: {lang_name}\n\n"
55
+ f"PATIENT ORIGINAL COMPLAINT: {symptoms or '(none)'}\n\n"
56
+ f"VISUAL DESCRIPTION (Objective):\n{visual_description}\n\n"
57
+ f"CLINICAL JSON:\n{json.dumps(clinical_json, ensure_ascii=False, indent=2)}"
58
+ )
59
+ raw, metrics = generate_text(prompt)
60
+ if "===SOAP===" in raw:
61
+ patient_msg, soap = raw.split("===SOAP===", 1)
62
+ else:
63
+ patient_msg, soap = raw, ""
64
+ return patient_msg.strip(), soap.strip(), metrics
src/inference.py CHANGED
@@ -1,18 +1,37 @@
1
- from src.agent import analyze_image_and_text
2
 
3
 
4
  class MediVisionPipeline:
5
- def process(self, image_path, symptoms: str, lang: str = "en", region: str = "") -> dict:
 
6
  """
7
- Run the full analysis pipeline.
8
- Raises RuntimeError if the AMD Cloud backend is unreachable.
 
 
9
 
10
- Returns:
11
- dict with keys: diagnosis, severity, recommended_actions, confidence_score, _metrics
 
12
  """
13
- return analyze_image_and_text(
14
- image_path=image_path,
15
- text_description=symptoms,
16
- language=lang,
17
- region=region,
18
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from src.agents import vision_agent, clinical_agent, format_agent
2
 
3
 
4
  class MediVisionPipeline:
5
+ def process(self, image_path_1, image_path_2, symptoms: str,
6
+ lang: str = "en", region: str = "") -> dict:
7
  """
8
+ Run the 3-step agentic pipeline:
9
+ Step 1 Vision Agent: objective visual description
10
+ Step 2 — Clinical Agent: triage JSON
11
+ Step 3 — Format Agent: patient message + SOAP note
12
 
13
+ Returns dict with keys:
14
+ triage_level, possible_conditions, patient_message,
15
+ soap_note, visual_description, _metrics
16
  """
17
+ symptoms_full = f"{'Region: ' + region + '. ' if region else ''}{symptoms}"
18
+
19
+ visual_desc, m1 = vision_agent(image_path_1, image_path_2, symptoms_full)
20
+ clinical, m2 = clinical_agent(visual_desc, symptoms_full)
21
+ patient_msg, soap, m3 = format_agent(clinical, visual_desc, symptoms_full, lang)
22
+
23
+ metrics = {
24
+ "latency_ms": m1["latency_ms"] + m2["latency_ms"] + m3["latency_ms"],
25
+ "total_tokens": m1["total_tokens"] + m2["total_tokens"] + m3["total_tokens"],
26
+ "tokens_per_sec": round(
27
+ (m1.get("tokens_per_sec", 0) + m2.get("tokens_per_sec", 0) + m3.get("tokens_per_sec", 0)) / 3, 1
28
+ ),
29
+ }
30
+ return {
31
+ "triage_level": clinical["triage_level"],
32
+ "possible_conditions": clinical["possible_conditions"],
33
+ "patient_message": patient_msg,
34
+ "soap_note": soap,
35
+ "visual_description": visual_desc,
36
+ "_metrics": metrics,
37
+ }
src/model_loader.py CHANGED
@@ -62,9 +62,11 @@ def check_connection() -> tuple[bool, str]:
62
  return False, f"{type(exc).__name__}: {exc}"
63
 
64
 
65
- def generate_response(prompt: str, image_path: str = None) -> tuple[str, dict]:
 
66
  """
67
  Send a request to the vLLM endpoint and return (text_output, metrics).
 
68
 
69
  metrics keys:
70
  latency_ms – wall-clock time for the API call in milliseconds
@@ -76,20 +78,18 @@ def generate_response(prompt: str, image_path: str = None) -> tuple[str, dict]:
76
  try:
77
  client = _get_client()
78
 
79
- if image_path:
80
- b64, mime = _encode_image(image_path)
81
- messages = [
82
- {
83
- "role": "user",
84
- "content": [
85
- {
86
- "type": "image_url",
87
- "image_url": {"url": f"data:{mime};base64,{b64}"},
88
- },
89
- {"type": "text", "text": prompt},
90
- ],
91
- }
92
- ]
93
  else:
94
  messages = [{"role": "user", "content": prompt}]
95
 
@@ -116,3 +116,8 @@ def generate_response(prompt: str, image_path: str = None) -> tuple[str, dict]:
116
 
117
  except Exception as exc:
118
  raise RuntimeError(f"AMD Cloud backend unreachable: {exc}") from exc
 
 
 
 
 
 
62
  return False, f"{type(exc).__name__}: {exc}"
63
 
64
 
65
+ def generate_response(prompt: str, image_path: str = None,
66
+ image_path_2: str = None) -> tuple[str, dict]:
67
  """
68
  Send a request to the vLLM endpoint and return (text_output, metrics).
69
+ Supports 0, 1, or 2 images (image_path_2 for A/B comparison).
70
 
71
  metrics keys:
72
  latency_ms – wall-clock time for the API call in milliseconds
 
78
  try:
79
  client = _get_client()
80
 
81
+ if image_path or image_path_2:
82
+ content = []
83
+ if image_path:
84
+ b64, mime = _encode_image(image_path)
85
+ content.append({"type": "image_url",
86
+ "image_url": {"url": f"data:{mime};base64,{b64}"}})
87
+ if image_path_2:
88
+ b64, mime = _encode_image(image_path_2)
89
+ content.append({"type": "image_url",
90
+ "image_url": {"url": f"data:{mime};base64,{b64}"}})
91
+ content.append({"type": "text", "text": prompt})
92
+ messages = [{"role": "user", "content": content}]
 
 
93
  else:
94
  messages = [{"role": "user", "content": prompt}]
95
 
 
116
 
117
  except Exception as exc:
118
  raise RuntimeError(f"AMD Cloud backend unreachable: {exc}") from exc
119
+
120
+
121
+ def generate_text(prompt: str) -> tuple[str, dict]:
122
+ """Text-only call — same endpoint as generate_response(), no image encoding."""
123
+ return generate_response(prompt, image_path=None)
src/prompts.py ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ VISION_AGENT_SYSTEM = """You are a medical imaging assistant performing STRICTLY OBJECTIVE visual analysis.
2
+ Do NOT diagnose. Do NOT give medical advice. Do NOT speculate on conditions.
3
+ Your ONLY job: describe exactly what you see in the image(s) using clinical descriptive language.
4
+
5
+ If ONE image is provided, describe:
6
+ - Lesion size (estimated), shape, border characteristics
7
+ - Color(s), texture, surface features (scaling, crusting, ulceration, exudate)
8
+ - Surrounding skin condition
9
+ - Any signs of inflammation, swelling, or structural abnormality
10
+
11
+ If TWO images are provided (Day 1 vs Day X), describe BOTH images separately, then compare:
12
+ - Changes in size (larger / smaller / same)
13
+ - Changes in color or border definition
14
+ - Changes in surface features (scaling, crusting, exudate)
15
+ - Overall progression verdict: IMPROVED / UNCHANGED / WORSENED
16
+
17
+ Output: plain text only. No JSON. No diagnosis. No recommendations."""
18
+
19
+ CLINICAL_AGENT_SYSTEM = """You are a clinical reasoning engine for a dermatology triage system.
20
+ You receive: (1) an objective visual description and (2) the patient's symptom text.
21
+ You perform clinical reasoning and output ONLY a JSON object — no extra text, no markdown fences.
22
+
23
+ JSON schema (strict):
24
+ {
25
+ "triage_level": "High" | "Medium" | "Low",
26
+ "possible_conditions": ["condition 1", "condition 2"],
27
+ "clinical_assessment": "brief medical reasoning (2-3 sentences max)",
28
+ "recommendation": "immediate actions or home care advice (2-4 sentences)"
29
+ }
30
+
31
+ triage_level rules:
32
+ - "High": suspected melanoma, necrosis, severe cellulitis, rapidly spreading infection, deep burn
33
+ - "Medium": moderate infection signs, non-healing wound >2 weeks, significant inflammation
34
+ - "Low": minor abrasion, mild rash, superficial wound with no infection signs
35
+
36
+ Return ONLY the JSON object. No explanation before or after."""
37
+
38
+ FORMAT_AGENT_SYSTEM = """You are a medical communication specialist. You receive clinical data and
39
+ format it into two outputs separated by the EXACT delimiter line: ===SOAP===
40
+
41
+ Output structure (follow exactly):
42
+ [PATIENT section — warm, empathetic, easy-to-understand message in the TARGET LANGUAGE]
43
+ ===SOAP===
44
+ S (Subjective): [patient's original complaint, verbatim or close paraphrase]
45
+ O (Objective): [1-2 sentence summary of the visual description]
46
+ A (Assessment): [possible conditions and brief clinical reasoning]
47
+ P (Plan): [recommended actions from clinical assessment]
48
+
49
+ Rules:
50
+ - Patient section: non-technical language, supportive tone, in the TARGET LANGUAGE specified
51
+ - SOAP section: professional clinical English regardless of target language
52
+ - Do NOT add any text outside this structure
53
+ - Do NOT add a header or title line before the patient section"""