Shahabul commited on
Commit
1ead0df
·
verified ·
1 Parent(s): 29db30b

Upload src/demo/streamlit_app.py with huggingface_hub

Browse files
Files changed (1) hide show
  1. src/demo/streamlit_app.py +1191 -1189
src/demo/streamlit_app.py CHANGED
@@ -1,1189 +1,1191 @@
1
- """
2
- NEXUS Streamlit Demo Application
3
-
4
- Interactive demo for the NEXUS Maternal-Neonatal Care Platform.
5
- Built with Google HAI-DEF models for the MedGemma Impact Challenge.
6
-
7
- HAI-DEF Models Used:
8
- - MedSigLIP: Medical image analysis (anemia, jaundice detection)
9
- - HeAR: Health acoustic representations (cry analysis)
10
- - MedGemma: Clinical reasoning and synthesis
11
- """
12
-
13
- import streamlit as st
14
- from pathlib import Path
15
- import sys
16
- import os
17
- import tempfile
18
- import json
19
-
20
- # Add parent directory to path for imports
21
- sys.path.insert(0, str(Path(__file__).parent.parent))
22
-
23
- # Page configuration
24
- st.set_page_config(
25
- page_title="NEXUS - Maternal-Neonatal Care",
26
- page_icon="👶",
27
- layout="wide",
28
- initial_sidebar_state="expanded",
29
- )
30
-
31
- # Custom CSS
32
- st.markdown("""
33
- <style>
34
- .main-header {
35
- font-size: 2.5rem;
36
- font-weight: bold;
37
- color: #1f77b4;
38
- text-align: center;
39
- margin-bottom: 1rem;
40
- }
41
- .sub-header {
42
- font-size: 1.2rem;
43
- color: #666;
44
- text-align: center;
45
- margin-bottom: 2rem;
46
- }
47
- .risk-high {
48
- background-color: #ffcccc;
49
- border: 2px solid #ff0000;
50
- padding: 1rem;
51
- border-radius: 10px;
52
- }
53
- .risk-medium {
54
- background-color: #fff3cd;
55
- border: 2px solid #ffc107;
56
- padding: 1rem;
57
- border-radius: 10px;
58
- }
59
- .risk-low {
60
- background-color: #d4edda;
61
- border: 2px solid #28a745;
62
- padding: 1rem;
63
- border-radius: 10px;
64
- }
65
- .metric-card {
66
- background-color: #f8f9fa;
67
- padding: 1rem;
68
- border-radius: 10px;
69
- text-align: center;
70
- }
71
- .model-badge {
72
- display: inline-block;
73
- padding: 2px 10px;
74
- border-radius: 12px;
75
- font-size: 0.78rem;
76
- font-weight: 600;
77
- color: white;
78
- letter-spacing: 0.3px;
79
- }
80
- .stMetric > div {
81
- background-color: #f8f9fa;
82
- padding: 0.5rem;
83
- border-radius: 8px;
84
- }
85
- </style>
86
- """, unsafe_allow_html=True)
87
-
88
-
89
- @st.cache_resource
90
- def load_anemia_detector():
91
- """Load anemia detector model with error handling."""
92
- try:
93
- from nexus.anemia_detector import AnemiaDetector
94
- detector = AnemiaDetector()
95
- return detector, None
96
- except Exception as e:
97
- return None, str(e)
98
-
99
-
100
- @st.cache_resource
101
- def load_jaundice_detector():
102
- """Load jaundice detector model with error handling."""
103
- try:
104
- from nexus.jaundice_detector import JaundiceDetector
105
- detector = JaundiceDetector()
106
- return detector, None
107
- except Exception as e:
108
- return None, str(e)
109
-
110
-
111
- @st.cache_resource
112
- def load_cry_analyzer():
113
- """Load cry analyzer with error handling."""
114
- try:
115
- from nexus.cry_analyzer import CryAnalyzer
116
- analyzer = CryAnalyzer()
117
- return analyzer, None
118
- except Exception as e:
119
- return None, str(e)
120
-
121
-
122
- @st.cache_resource
123
- def load_clinical_synthesizer():
124
- """Load clinical synthesizer (MedGemma) with error handling."""
125
- try:
126
- import os
127
- from nexus.clinical_synthesizer import ClinicalSynthesizer
128
- use_medgemma = os.environ.get("NEXUS_USE_MEDGEMMA", "true").lower() != "false"
129
- synthesizer = ClinicalSynthesizer(use_medgemma=use_medgemma)
130
- return synthesizer, None
131
- except Exception as e:
132
- return None, str(e)
133
-
134
-
135
- def get_hai_def_info():
136
- """Get HAI-DEF models information with validated accuracy numbers."""
137
- return {
138
- "MedSigLIP": {
139
- "name": "MedSigLIP (google/medsiglip-448)",
140
- "use": "Image analysis for anemia and jaundice detection + bilirubin regression",
141
- "method": "Zero-shot classification (max-similarity, 8 prompts/class) + trained SVM/LR classifiers on embeddings",
142
- "accuracy": "Anemia: trained classifier on augmented data, Jaundice: trained classifier on 2,235 images, Bilirubin: MAE 2.67 mg/dL (r=0.77)",
143
- "badge": "Vision",
144
- "badge_color": "#388e3c",
145
- },
146
- "HeAR": {
147
- "name": "HeAR (google/hear-pytorch)",
148
- "use": "Infant cry analysis for asphyxia and cry type classification",
149
- "method": "512-dim health acoustic embeddings + trained linear classifier on donate-a-cry dataset (5-class: hungry, belly_pain, burping, discomfort, tired)",
150
- "accuracy": "Trained cry type classifier with asphyxia risk derivation from distress patterns",
151
- "badge": "Audio",
152
- "badge_color": "#f57c00",
153
- },
154
- "MedGemma": {
155
- "name": "MedGemma 1.5 4B (google/medgemma-1.5-4b-it)",
156
- "use": "Clinical reasoning and recommendation synthesis",
157
- "method": "4-bit NF4 quantized inference with WHO IMNCI protocol-aligned synthesis and 6-agent reasoning traces",
158
- "accuracy": "Protocol-aligned clinical recommendations with structured reasoning chains",
159
- "badge": "Language",
160
- "badge_color": "#1976d2",
161
- },
162
- }
163
-
164
-
165
- def main():
166
- """Main application."""
167
-
168
- # Header
169
- st.markdown('<div class="main-header">NEXUS</div>', unsafe_allow_html=True)
170
- st.markdown(
171
- '<div class="sub-header">AI-Powered Maternal-Neonatal Care Platform</div>',
172
- unsafe_allow_html=True
173
- )
174
-
175
- # Sidebar
176
- with st.sidebar:
177
- st.markdown("## 🏥 NEXUS")
178
- st.markdown("---")
179
-
180
- assessment_type = st.radio(
181
- "Select Assessment Type",
182
- [
183
- "Maternal Anemia Screening",
184
- "Neonatal Jaundice Detection",
185
- "Cry Analysis",
186
- "Combined Assessment",
187
- "Agentic Workflow",
188
- "HAI-DEF Models Info"
189
- ],
190
- index=0,
191
- )
192
-
193
- st.markdown("---")
194
- st.markdown("### About NEXUS")
195
- st.markdown("""
196
- NEXUS uses AI to provide non-invasive screening for:
197
- - **Maternal Anemia** via conjunctiva imaging
198
- - **Neonatal Jaundice** via skin color analysis
199
- - **Birth Asphyxia** via cry pattern analysis
200
-
201
- Built with **Google HAI-DEF models** for the MedGemma Impact Challenge 2026.
202
- """)
203
-
204
- st.markdown("---")
205
- st.markdown("### Edge AI Mode")
206
- edge_mode = st.toggle("Enable Edge AI Mode", value=False, key="edge_mode")
207
- if edge_mode:
208
- st.success("Edge AI: INT8 quantized models + offline inference")
209
- else:
210
- st.info("Cloud mode: Full-precision HAI-DEF models")
211
-
212
- st.markdown("---")
213
- st.markdown("### HAI-DEF Models")
214
- st.markdown("""
215
- - **MedSigLIP**: Vision (trained classifiers)
216
- - **HeAR**: Audio (trained cry classifier)
217
- - **MedGemma 1.5**: Clinical AI (4-bit NF4)
218
- """)
219
-
220
- # Show Edge AI banner when enabled
221
- if edge_mode:
222
- render_edge_ai_banner()
223
-
224
- # Main content based on selection
225
- if assessment_type == "Maternal Anemia Screening":
226
- render_anemia_screening()
227
- elif assessment_type == "Neonatal Jaundice Detection":
228
- render_jaundice_detection()
229
- elif assessment_type == "Cry Analysis":
230
- render_cry_analysis()
231
- elif assessment_type == "Combined Assessment":
232
- render_combined_assessment()
233
- elif assessment_type == "Agentic Workflow":
234
- render_agentic_workflow()
235
- else:
236
- render_hai_def_info()
237
-
238
-
239
- def render_edge_ai_banner():
240
- """Show Edge AI mode status and model metrics."""
241
- st.markdown("""
242
- <div style="background: linear-gradient(135deg, #1a237e 0%, #0d47a1 100%);
243
- color: white; padding: 1rem 1.5rem; border-radius: 10px; margin-bottom: 1rem;">
244
- <h4 style="margin:0; color: white;">Edge AI Mode Active</h4>
245
- <p style="margin: 0.3rem 0 0 0; opacity: 0.9; font-size: 0.9rem;">
246
- Running INT8 quantized models for offline-capable inference on low-resource devices.
247
- </p>
248
- </div>
249
- """, unsafe_allow_html=True)
250
-
251
- col1, col2, col3, col4 = st.columns(4)
252
- with col1:
253
- st.metric("MedSigLIP INT8", "111.2 MB", "-86% memory")
254
- with col2:
255
- st.metric("Acoustic Model", "0.6 MB", "INT8 quantized")
256
- with col3:
257
- st.metric("Text Embeddings", "12 KB", "Pre-computed")
258
- with col4:
259
- st.metric("Total Edge Size", "~289 MB", "Offline-ready")
260
-
261
- with st.expander("Edge AI Details"):
262
- st.markdown("""
263
- **Quantization**: Dynamic INT8 (PyTorch `quantize_dynamic`, qnnpack backend)
264
-
265
- | Component | Cloud (FP32) | Edge (INT8) | Compression |
266
- |-----------|-------------|-------------|-------------|
267
- | MedSigLIP Vision | 812.6 MB | 111.2 MB | **7.31x** |
268
- | Acoustic Model | 0.665 MB | 0.599 MB | 1.11x |
269
- | CPU Latency | 97.7 ms | ~65 ms (ARM est.) | ~1.5x faster |
270
-
271
- **Target Devices**: Android 8.0+, ARM Cortex-A53, 2GB RAM
272
-
273
- **Offline Capabilities**:
274
- - Image analysis via INT8 MedSigLIP + pre-computed binary text embeddings
275
- - Audio analysis via INT8 acoustic feature extractor
276
- - Clinical reasoning via rule-based WHO IMNCI protocols (no MedGemma required)
277
- """)
278
-
279
-
280
- def _cleanup_temp(path: str) -> None:
281
- """Safely remove a temporary file."""
282
- try:
283
- if path and os.path.exists(path):
284
- os.unlink(path)
285
- except OSError:
286
- pass
287
-
288
-
289
- def _save_upload_to_temp(uploaded_file, suffix: str) -> str:
290
- """Save an uploaded file to a temporary path and return the path."""
291
- tmp = tempfile.NamedTemporaryFile(delete=False, suffix=suffix)
292
- try:
293
- tmp.write(uploaded_file.getvalue())
294
- tmp.close()
295
- return tmp.name
296
- except Exception:
297
- tmp.close()
298
- _cleanup_temp(tmp.name)
299
- raise
300
-
301
-
302
- def _model_badge(name: str, color: str) -> str:
303
- """Return an HTML badge for displaying which HAI-DEF model is active."""
304
- return (
305
- f'<span style="background:{color}; color:white; padding:2px 10px; '
306
- f'border-radius:12px; font-size:0.78rem; font-weight:600; '
307
- f'letter-spacing:0.3px;">{name}</span>'
308
- )
309
-
310
-
311
- def render_anemia_screening():
312
- """Render anemia screening interface."""
313
- st.header("Maternal Anemia Screening")
314
- st.markdown(
315
- f"Upload a clear image of the inner eyelid (conjunctiva) for anemia screening. "
316
- f'{_model_badge("MedSigLIP", "#388e3c")}',
317
- unsafe_allow_html=True,
318
- )
319
-
320
- col1, col2 = st.columns([1, 1])
321
-
322
- with col1:
323
- st.subheader("Upload Image")
324
- uploaded_file = st.file_uploader(
325
- "Choose a conjunctiva image",
326
- type=["jpg", "jpeg", "png"],
327
- key="anemia_upload"
328
- )
329
-
330
- if uploaded_file:
331
- st.image(uploaded_file, caption="Uploaded Image", use_container_width=True)
332
-
333
- with col2:
334
- st.subheader("Analysis Results")
335
-
336
- if uploaded_file:
337
- with st.spinner("Analyzing image..."):
338
- tmp_path = None
339
- try:
340
- detector, load_err = load_anemia_detector()
341
- if detector is None:
342
- st.error(f"Could not load model: {load_err}")
343
- return
344
-
345
- tmp_path = _save_upload_to_temp(uploaded_file, ".jpg")
346
-
347
- result = detector.detect(tmp_path)
348
- color_info = detector.analyze_color_features(tmp_path)
349
-
350
- # Display results
351
- risk_class = f"risk-{result['risk_level']}"
352
- st.markdown(f'<div class="{risk_class}">', unsafe_allow_html=True)
353
-
354
- if result["is_anemic"]:
355
- st.error("⚠️ ANEMIA DETECTED")
356
- else:
357
- st.success("✅ No Anemia Detected")
358
-
359
- st.markdown("</div>", unsafe_allow_html=True)
360
-
361
- # Metrics
362
- col_a, col_b, col_c = st.columns(3)
363
- with col_a:
364
- st.metric("Confidence", f"{result['confidence']:.1%}")
365
- with col_b:
366
- st.metric("Risk Level", result['risk_level'].upper())
367
- with col_c:
368
- st.metric("Est. Hemoglobin", f"{color_info['estimated_hemoglobin']} g/dL")
369
-
370
- # Recommendation
371
- st.markdown("### Recommendation")
372
- st.info(result["recommendation"])
373
-
374
- # Color analysis
375
- with st.expander("Technical Details"):
376
- st.json({
377
- "anemia_score": round(result["anemia_score"], 3),
378
- "healthy_score": round(result["healthy_score"], 3),
379
- "red_ratio": round(color_info["red_ratio"], 3),
380
- "pallor_index": round(color_info["pallor_index"], 3),
381
- })
382
-
383
- except Exception as e:
384
- st.error(f"Error analyzing image: {e}")
385
- finally:
386
- _cleanup_temp(tmp_path)
387
- else:
388
- st.info("👆 Upload an image to begin analysis")
389
-
390
-
391
- def render_jaundice_detection():
392
- """Render jaundice detection interface."""
393
- st.header("Neonatal Jaundice Detection")
394
- st.markdown(
395
- f"Upload an image of the newborn's skin or sclera for jaundice assessment. "
396
- f'{_model_badge("MedSigLIP", "#388e3c")}',
397
- unsafe_allow_html=True,
398
- )
399
-
400
- col1, col2 = st.columns([1, 1])
401
-
402
- with col1:
403
- st.subheader("Upload Image")
404
- uploaded_file = st.file_uploader(
405
- "Choose a neonatal image",
406
- type=["jpg", "jpeg", "png"],
407
- key="jaundice_upload"
408
- )
409
-
410
- if uploaded_file:
411
- st.image(uploaded_file, caption="Uploaded Image", use_container_width=True)
412
-
413
- # Patient info
414
- st.subheader("Patient Information (Optional)")
415
- age_days = st.number_input("Age (days)", min_value=0, max_value=28, value=3)
416
- birth_weight = st.number_input("Birth weight (grams)", min_value=500, max_value=5000, value=3000)
417
-
418
- with col2:
419
- st.subheader("Analysis Results")
420
-
421
- if uploaded_file:
422
- with st.spinner("Analyzing image..."):
423
- tmp_path = None
424
- try:
425
- detector, load_err = load_jaundice_detector()
426
- if detector is None:
427
- st.error(f"Could not load model: {load_err}")
428
- return
429
-
430
- tmp_path = _save_upload_to_temp(uploaded_file, ".jpg")
431
-
432
- result = detector.detect(tmp_path)
433
- zone_info = detector.analyze_kramer_zones(tmp_path)
434
-
435
- # Display results
436
- risk_class = "risk-high" if result["needs_phototherapy"] else (
437
- "risk-medium" if result["severity"] in ["moderate", "mild"] else "risk-low"
438
- )
439
- st.markdown(f'<div class="{risk_class}">', unsafe_allow_html=True)
440
-
441
- if result["has_jaundice"]:
442
- st.warning(f"⚠️ JAUNDICE DETECTED - {result['severity'].upper()}")
443
- else:
444
- st.success(" No Significant Jaundice")
445
-
446
- st.markdown("</div>", unsafe_allow_html=True)
447
-
448
- # Metrics - show ML bilirubin if available
449
- col_a, col_b, col_c = st.columns(3)
450
- with col_a:
451
- bili_value = result.get('estimated_bilirubin_ml', result.get('estimated_bilirubin', 0))
452
- bili_method = result.get('bilirubin_method', 'Color Analysis')
453
- st.metric("Est. Bilirubin", f"{bili_value} mg/dL")
454
- st.caption(f"Method: {bili_method}")
455
- with col_b:
456
- st.metric("Severity", result['severity'].upper())
457
- with col_c:
458
- st.metric("Kramer Zone", zone_info['kramer_zone'])
459
-
460
- # Phototherapy indicator
461
- if result["needs_phototherapy"]:
462
- st.error("🔆 PHOTOTHERAPY RECOMMENDED")
463
-
464
- # Recommendation
465
- st.markdown("### Recommendation")
466
- st.info(result["recommendation"])
467
-
468
- # Zone analysis
469
- with st.expander("Kramer Zone Analysis"):
470
- st.write(f"**Zone**: {zone_info['kramer_zone']} - {zone_info['zone_description']}")
471
- st.write(f"**Yellow Index**: {zone_info['yellow_index']}")
472
- st.progress(min(zone_info['yellow_index'] * 2, 1.0))
473
-
474
- # Technical details
475
- with st.expander("Technical Details"):
476
- details = {
477
- "jaundice_score": round(result["jaundice_score"], 3),
478
- "confidence": round(result["confidence"], 3),
479
- "model": result.get("model", "unknown"),
480
- "model_type": result.get("model_type", "unknown"),
481
- "bilirubin_method": result.get("bilirubin_method", "Color Analysis"),
482
- }
483
- if result.get("estimated_bilirubin_ml") is not None:
484
- details["bilirubin_ml"] = result["estimated_bilirubin_ml"]
485
- details["bilirubin_color"] = result["estimated_bilirubin"]
486
- st.json(details)
487
-
488
- except Exception as e:
489
- st.error(f"Error analyzing image: {e}")
490
- finally:
491
- _cleanup_temp(tmp_path)
492
- else:
493
- st.info("👆 Upload an image to begin analysis")
494
-
495
-
496
- def render_cry_analysis():
497
- """Render cry analysis interface."""
498
- st.header("Infant Cry Analysis")
499
- st.markdown(
500
- f"Upload an audio recording of the infant's cry for analysis. "
501
- f'{_model_badge("HeAR", "#f57c00")}',
502
- unsafe_allow_html=True,
503
- )
504
-
505
- col1, col2 = st.columns([1, 1])
506
-
507
- with col1:
508
- st.subheader("Upload Audio")
509
- uploaded_file = st.file_uploader(
510
- "Choose a cry audio file",
511
- type=["wav", "mp3", "ogg"],
512
- key="cry_upload"
513
- )
514
-
515
- if uploaded_file:
516
- st.audio(uploaded_file)
517
-
518
- with col2:
519
- st.subheader("Analysis Results")
520
-
521
- if uploaded_file:
522
- with st.spinner("Analyzing cry..."):
523
- tmp_path = None
524
- try:
525
- analyzer, load_err = load_cry_analyzer()
526
- if analyzer is None:
527
- st.error(f"Could not load model: {load_err}")
528
- return
529
-
530
- tmp_path = _save_upload_to_temp(uploaded_file, ".wav")
531
-
532
- result = analyzer.analyze(tmp_path)
533
-
534
- # Display results
535
- risk_class = f"risk-{result['risk_level']}"
536
- st.markdown(f'<div class="{risk_class}">', unsafe_allow_html=True)
537
-
538
- if result["is_abnormal"]:
539
- st.error("⚠️ ABNORMAL CRY PATTERN DETECTED")
540
- else:
541
- st.success(" Normal Cry Pattern")
542
-
543
- st.markdown("</div>", unsafe_allow_html=True)
544
-
545
- # Metrics
546
- col_a, col_b, col_c = st.columns(3)
547
- with col_a:
548
- st.metric("Asphyxia Risk", f"{result['asphyxia_risk']:.1%}")
549
- with col_b:
550
- st.metric("Cry Type", result['cry_type'].title())
551
- with col_c:
552
- st.metric("F0 (Pitch)", f"{result['features']['f0_mean']:.0f} Hz")
553
-
554
- # Recommendation
555
- st.markdown("### Recommendation")
556
- st.info(result["recommendation"])
557
-
558
- # Acoustic features
559
- with st.expander("Acoustic Features"):
560
- st.json(result["features"])
561
-
562
- except Exception as e:
563
- st.error(f"Error analyzing audio: {e}")
564
- finally:
565
- _cleanup_temp(tmp_path)
566
- else:
567
- st.info("👆 Upload an audio file to begin analysis")
568
-
569
-
570
- def render_combined_assessment():
571
- """Render combined assessment interface using Clinical Synthesizer."""
572
- st.header("Combined Clinical Assessment")
573
- st.markdown(
574
- f"Upload multiple inputs for a comprehensive assessment using **MedGemma Clinical Synthesizer**. "
575
- f"This combines findings from all HAI-DEF models to provide integrated clinical recommendations. "
576
- f'{_model_badge("MedSigLIP", "#388e3c")} '
577
- f'{_model_badge("HeAR", "#f57c00")} '
578
- f'{_model_badge("MedGemma", "#1976d2")}',
579
- unsafe_allow_html=True,
580
- )
581
-
582
- # Reset findings each time this tab is rendered to prevent
583
- # stale data from previous patients contaminating results
584
- st.session_state.findings = {
585
- "anemia": None,
586
- "jaundice": None,
587
- "cry": None
588
- }
589
-
590
- col1, col2, col3 = st.columns(3)
591
-
592
- with col1:
593
- st.subheader("🩸 Anemia Screening")
594
- anemia_file = st.file_uploader(
595
- "Conjunctiva image",
596
- type=["jpg", "jpeg", "png"],
597
- key="combined_anemia"
598
- )
599
- if anemia_file:
600
- st.image(anemia_file, use_container_width=True)
601
- with st.spinner("Analyzing..."):
602
- try:
603
- detector, load_err = load_anemia_detector()
604
- if detector is None:
605
- st.error(f"Model error: {load_err}")
606
- else:
607
- with tempfile.NamedTemporaryFile(delete=False, suffix=".jpg") as tmp:
608
- tmp.write(anemia_file.getvalue())
609
- result = detector.detect(tmp.name)
610
- st.session_state.findings["anemia"] = result
611
- if result["is_anemic"]:
612
- st.error(f"Anemia: {result['risk_level'].upper()}")
613
- else:
614
- st.success("No Anemia")
615
- except Exception as e:
616
- st.error(f"Error: {e}")
617
-
618
- with col2:
619
- st.subheader("👶 Jaundice Detection")
620
- jaundice_file = st.file_uploader(
621
- "Neonatal skin image",
622
- type=["jpg", "jpeg", "png"],
623
- key="combined_jaundice"
624
- )
625
- if jaundice_file:
626
- st.image(jaundice_file, use_container_width=True)
627
- with st.spinner("Analyzing..."):
628
- try:
629
- detector, load_err = load_jaundice_detector()
630
- if detector is None:
631
- st.error(f"Model error: {load_err}")
632
- else:
633
- with tempfile.NamedTemporaryFile(delete=False, suffix=".jpg") as tmp:
634
- tmp.write(jaundice_file.getvalue())
635
- result = detector.detect(tmp.name)
636
- st.session_state.findings["jaundice"] = result
637
- if result["has_jaundice"]:
638
- st.warning(f"Jaundice: {result['severity'].upper()}")
639
- else:
640
- st.success("No Jaundice")
641
- except Exception as e:
642
- st.error(f"Error: {e}")
643
-
644
- with col3:
645
- st.subheader("🔊 Cry Analysis")
646
- cry_file = st.file_uploader(
647
- "Cry audio",
648
- type=["wav", "mp3", "ogg"],
649
- key="combined_cry"
650
- )
651
- if cry_file:
652
- st.audio(cry_file)
653
- with st.spinner("Analyzing..."):
654
- try:
655
- analyzer, load_err = load_cry_analyzer()
656
- if analyzer is None:
657
- st.error(f"Model error: {load_err}")
658
- raise RuntimeError(load_err)
659
- with tempfile.NamedTemporaryFile(delete=False, suffix=".wav") as tmp:
660
- tmp.write(cry_file.getvalue())
661
- result = analyzer.analyze(tmp.name)
662
- st.session_state.findings["cry"] = result
663
- if result["is_abnormal"]:
664
- st.error(f"Abnormal Cry: {result['risk_level'].upper()}")
665
- else:
666
- st.success("Normal Cry")
667
- except Exception as e:
668
- st.error(f"Error: {e}")
669
-
670
- # Clinical Synthesis Section
671
- st.markdown("---")
672
- st.subheader("🏥 Clinical Synthesis (MedGemma)")
673
-
674
- # Check if any findings are available
675
- has_findings = any(v is not None for v in st.session_state.findings.values())
676
-
677
- if has_findings:
678
- if st.button("Generate Clinical Synthesis", type="primary"):
679
- with st.spinner("Synthesizing findings with MedGemma..."):
680
- try:
681
- synthesizer, load_err = load_clinical_synthesizer()
682
- if synthesizer is None:
683
- st.error(f"Could not load synthesizer: {load_err}")
684
- return
685
-
686
- # Prepare findings dict
687
- findings = {}
688
- if st.session_state.findings["anemia"]:
689
- findings["anemia"] = st.session_state.findings["anemia"]
690
- if st.session_state.findings["jaundice"]:
691
- findings["jaundice"] = st.session_state.findings["jaundice"]
692
- if st.session_state.findings["cry"]:
693
- findings["cry"] = st.session_state.findings["cry"]
694
-
695
- synthesis = synthesizer.synthesize(findings)
696
-
697
- # Display synthesis results
698
- severity_level = synthesis.get("severity_level", "GREEN")
699
- severity_colors = {
700
- "GREEN": ("🟢", "#d4edda", "#155724"),
701
- "YELLOW": ("🟡", "#fff3cd", "#856404"),
702
- "RED": ("🔴", "#f8d7da", "#721c24")
703
- }
704
- emoji, bg_color, text_color = severity_colors.get(severity_level, ("", "#f8f9fa", "#000"))
705
-
706
- st.markdown(f"""
707
- <div style="background-color: {bg_color}; padding: 1.5rem; border-radius: 10px; margin: 1rem 0;">
708
- <h3 style="color: {text_color}; margin: 0;">{emoji} Severity: {severity_level}</h3>
709
- <p style="color: {text_color}; font-size: 1.1rem; margin-top: 0.5rem;">{synthesis.get('severity_description', '')}</p>
710
- </div>
711
- """, unsafe_allow_html=True)
712
-
713
- # Summary
714
- st.markdown("### Summary")
715
- st.info(synthesis.get("summary", "No summary available"))
716
-
717
- # Actions
718
- if synthesis.get("immediate_actions"):
719
- st.markdown("### Immediate Actions")
720
- for action in synthesis["immediate_actions"]:
721
- st.markdown(f"- {action}")
722
-
723
- # Referral
724
- col_a, col_b = st.columns(2)
725
- with col_a:
726
- st.markdown("### Referral Status")
727
- if synthesis.get("referral_needed"):
728
- st.error(f"⚠️ REFERRAL NEEDED: {synthesis.get('referral_urgency', 'standard').upper()}")
729
- else:
730
- st.success(" No referral needed")
731
-
732
- with col_b:
733
- st.markdown("### Follow-up")
734
- st.info(synthesis.get("follow_up", "Schedule routine follow-up"))
735
-
736
- # Technical details
737
- with st.expander("Technical Details"):
738
- model_name = synthesis.get("model", "unknown")
739
- st.json({
740
- "model": model_name,
741
- "model_id": synthesis.get("model_id", ""),
742
- "generated_at": synthesis.get("generated_at"),
743
- "urgent_conditions": synthesis.get("urgent_conditions", []),
744
- })
745
- if model_name and "Fallback" not in str(model_name):
746
- st.success(f"Synthesis powered by {model_name}")
747
- elif "Fallback" in str(model_name):
748
- st.warning("Using rule-based fallback (MedGemma unavailable)")
749
-
750
- except Exception as e:
751
- st.error(f"Error generating synthesis: {e}")
752
- else:
753
- st.info("👆 Upload at least one input (image or audio) to generate clinical synthesis")
754
-
755
-
756
- def render_hai_def_info():
757
- """Render HAI-DEF models information."""
758
- st.header("Google HAI-DEF Models")
759
- st.markdown("""
760
- NEXUS is built using **Google Health AI Developer Foundations (HAI-DEF)** models,
761
- designed specifically for healthcare applications in resource-limited settings.
762
- """)
763
-
764
- hai_def = get_hai_def_info()
765
-
766
- # MedSigLIP
767
- st.markdown("---")
768
- col1, col2 = st.columns([1, 2])
769
- with col1:
770
- st.markdown("### 🖼️ MedSigLIP")
771
- st.info("google/medsiglip-448\n\nHAI-DEF Vision Model")
772
- with col2:
773
- info = hai_def["MedSigLIP"]
774
- st.markdown(f"**Model**: {info['name']}")
775
- st.markdown(f"**Use Case**: {info['use']}")
776
- st.markdown(f"**Method**: {info['method']}")
777
- st.markdown(f"**Validated Performance**: {info['accuracy']}")
778
- st.markdown("""
779
- MedSigLIP enables zero-shot medical image classification using
780
- text prompts. NEXUS extends this with trained SVM/LR classifiers
781
- on MedSigLIP embeddings (with data augmentation) for improved
782
- accuracy, plus a novel 3-layer MLP regression head for continuous
783
- bilirubin prediction from frozen embeddings.
784
- """)
785
-
786
- # HeAR
787
- st.markdown("---")
788
- col1, col2 = st.columns([1, 2])
789
- with col1:
790
- st.markdown("### 🔊 HeAR")
791
- st.info("google/hear-pytorch\n\nHAI-DEF Audio Model")
792
- with col2:
793
- info = hai_def["HeAR"]
794
- st.markdown(f"**Model**: {info['name']}")
795
- st.markdown(f"**Use Case**: {info['use']}")
796
- st.markdown(f"**Method**: {info['method']}")
797
- st.markdown(f"**Validated Performance**: {info['accuracy']}")
798
- st.markdown("""
799
- HeAR (Health Acoustic Representations) produces 512-dim embeddings
800
- from 2-second audio clips at 16kHz. NEXUS trains a linear classifier
801
- on HeAR embeddings for 5-class cry type classification (hungry,
802
- belly_pain, burping, discomfort, tired) and derives asphyxia risk
803
- from distress patterns.
804
- """)
805
-
806
- # MedGemma
807
- st.markdown("---")
808
- col1, col2 = st.columns([1, 2])
809
- with col1:
810
- st.markdown("### 🧠 MedGemma")
811
- st.info("google/medgemma-1.5-4b-it\n\nHAI-DEF Language Model")
812
- with col2:
813
- info = hai_def["MedGemma"]
814
- st.markdown(f"**Model**: {info['name']}")
815
- st.markdown(f"**Use Case**: {info['use']}")
816
- st.markdown(f"**Method**: {info['method']}")
817
- st.markdown(f"**Validated Performance**: {info['accuracy']}")
818
- st.markdown("""
819
- MedGemma 1.5 provides clinical reasoning capabilities via 4-bit NF4
820
- quantized inference (~2 GB VRAM). It synthesizes multi-modal findings
821
- into actionable recommendations following WHO IMNCI protocols,
822
- producing structured reasoning chains within the 6-agent pipeline.
823
- """)
824
-
825
- # Competition Info
826
- st.markdown("---")
827
- st.subheader("🏆 MedGemma Impact Challenge 2026")
828
- st.markdown("""
829
- NEXUS is being developed for the [MedGemma Impact Challenge](https://www.kaggle.com/competitions/medgemma-impact-challenge-2026)
830
- on Kaggle.
831
-
832
- **Competition Focus**: Solutions for resource-limited healthcare settings using HAI-DEF models.
833
-
834
- **NEXUS Impact**:
835
- - 📍 Target: Sub-Saharan Africa and South Asia
836
- - 👩‍⚕️ Users: Community Health Workers
837
- - 🎯 Goals: Reduce maternal/neonatal mortality
838
- - 📱 Deployment: Offline-capable mobile app
839
- """)
840
-
841
-
842
- def render_agentic_workflow():
843
- """Render the agentic workflow interface with reasoning traces."""
844
- st.header("Agentic Clinical Workflow")
845
- st.markdown(
846
- f"**6-Agent Pipeline** with step-by-step reasoning traces. "
847
- f"Each agent explains its clinical decision process, providing a full audit trail. "
848
- f'{_model_badge("MedSigLIP", "#388e3c")} '
849
- f'{_model_badge("HeAR", "#f57c00")} '
850
- f'{_model_badge("MedGemma", "#1976d2")}',
851
- unsafe_allow_html=True,
852
- )
853
-
854
- # Pipeline diagram
855
- st.markdown("""
856
- <div style="display: flex; align-items: center; justify-content: center; gap: 0.5rem; flex-wrap: wrap; margin: 1rem 0;">
857
- <div style="background: #e3f2fd; padding: 0.5rem 1rem; border-radius: 8px; font-weight: bold; border: 2px solid #1976d2;">Triage</div>
858
- <span style="font-size: 1.5rem;">&#8594;</span>
859
- <div style="background: #e8f5e9; padding: 0.5rem 1rem; border-radius: 8px; font-weight: bold; border: 2px solid #388e3c;">Image (MedSigLIP)</div>
860
- <span style="font-size: 1.5rem;">&#8594;</span>
861
- <div style="background: #fff3e0; padding: 0.5rem 1rem; border-radius: 8px; font-weight: bold; border: 2px solid #f57c00;">Audio (HeAR)</div>
862
- <span style="font-size: 1.5rem;">&#8594;</span>
863
- <div style="background: #f3e5f5; padding: 0.5rem 1rem; border-radius: 8px; font-weight: bold; border: 2px solid #7b1fa2;">Protocol (WHO)</div>
864
- <span style="font-size: 1.5rem;">&#8594;</span>
865
- <div style="background: #fce4ec; padding: 0.5rem 1rem; border-radius: 8px; font-weight: bold; border: 2px solid #c62828;">Referral</div>
866
- <span style="font-size: 1.5rem;">&#8594;</span>
867
- <div style="background: #e0f7fa; padding: 0.5rem 1rem; border-radius: 8px; font-weight: bold; border: 2px solid #00838f;">Synthesis (MedGemma)</div>
868
- </div>
869
- """, unsafe_allow_html=True)
870
-
871
- st.markdown("---")
872
-
873
- # Input section
874
- col_left, col_right = st.columns([1, 1])
875
-
876
- with col_left:
877
- st.subheader("Patient & Inputs")
878
- patient_type = st.selectbox("Patient Type", ["newborn", "pregnant"], key="agentic_patient")
879
-
880
- # Danger signs
881
- st.markdown("**Danger Signs**")
882
- danger_signs = []
883
- if patient_type == "pregnant":
884
- sign_options = [
885
- ("Severe headache", "high"),
886
- ("Blurred vision", "high"),
887
- ("Convulsions", "critical"),
888
- ("Severe abdominal pain", "high"),
889
- ("Vaginal bleeding", "critical"),
890
- ("High fever", "high"),
891
- ("Severe pallor", "medium"),
892
- ]
893
- else:
894
- sign_options = [
895
- ("Not breathing at birth", "critical"),
896
- ("Convulsions", "critical"),
897
- ("Severe chest indrawing", "high"),
898
- ("Not feeding", "high"),
899
- ("High fever (>38C)", "high"),
900
- ("Hypothermia (<35.5C)", "high"),
901
- ("Lethargy / unconscious", "critical"),
902
- ("Umbilical redness", "medium"),
903
- ]
904
-
905
- selected_signs = st.multiselect(
906
- "Select present danger signs",
907
- [s[0] for s in sign_options],
908
- key="agentic_signs"
909
- )
910
- for label, severity in sign_options:
911
- if label in selected_signs:
912
- danger_signs.append({
913
- "id": label.lower().replace(" ", "_"),
914
- "label": label,
915
- "severity": severity,
916
- "present": True,
917
- })
918
-
919
- # Image uploads
920
- st.markdown("**Clinical Images**")
921
- conjunctiva_file = st.file_uploader(
922
- "Conjunctiva image (anemia)", type=["jpg", "jpeg", "png"],
923
- key="agentic_conjunctiva"
924
- )
925
- skin_file = st.file_uploader(
926
- "Skin image (jaundice)", type=["jpg", "jpeg", "png"],
927
- key="agentic_skin"
928
- )
929
- cry_file = st.file_uploader(
930
- "Cry audio", type=["wav", "mp3", "ogg"],
931
- key="agentic_cry"
932
- )
933
-
934
- with col_right:
935
- st.subheader("Workflow Execution")
936
-
937
- if st.button("Run Agentic Assessment", type="primary", key="run_agentic"):
938
- with st.spinner("Running 6-agent workflow..."):
939
- try:
940
- from nexus.agentic_workflow import (
941
- AgenticWorkflowEngine,
942
- AgentPatientInfo,
943
- DangerSign,
944
- WorkflowInput,
945
- )
946
-
947
- # Save uploaded files (track for cleanup)
948
- _temp_paths = []
949
- conjunctiva_path = None
950
- skin_path = None
951
- cry_path = None
952
-
953
- if conjunctiva_file:
954
- conjunctiva_path = _save_upload_to_temp(conjunctiva_file, ".jpg")
955
- _temp_paths.append(conjunctiva_path)
956
-
957
- if skin_file:
958
- skin_path = _save_upload_to_temp(skin_file, ".jpg")
959
- _temp_paths.append(skin_path)
960
-
961
- if cry_file:
962
- cry_path = _save_upload_to_temp(cry_file, ".wav")
963
- _temp_paths.append(cry_path)
964
-
965
- # Build workflow input
966
- signs = [
967
- DangerSign(
968
- id=s["id"], label=s["label"],
969
- severity=s["severity"], present=True,
970
- )
971
- for s in danger_signs
972
- ]
973
-
974
- info = AgentPatientInfo(patient_type=patient_type)
975
- workflow_input = WorkflowInput(
976
- patient_type=patient_type,
977
- patient_info=info,
978
- danger_signs=signs,
979
- conjunctiva_image=conjunctiva_path,
980
- skin_image=skin_path,
981
- cry_audio=cry_path,
982
- )
983
-
984
- # Run workflow — reuse cached model instances when available
985
- anemia_det, _ = load_anemia_detector()
986
- jaundice_det, _ = load_jaundice_detector()
987
- cry_ana, _ = load_cry_analyzer()
988
- synth, _ = load_clinical_synthesizer()
989
-
990
- engine = AgenticWorkflowEngine(
991
- anemia_detector=anemia_det,
992
- jaundice_detector=jaundice_det,
993
- cry_analyzer=cry_ana,
994
- synthesizer=synth,
995
- )
996
- result = engine.execute(workflow_input)
997
-
998
- st.session_state["agentic_result"] = result
999
- st.success("Workflow complete!")
1000
-
1001
- except Exception as e:
1002
- st.error(f"Workflow error: {e}")
1003
- finally:
1004
- for p in _temp_paths:
1005
- _cleanup_temp(p)
1006
-
1007
- # Results display
1008
- if "agentic_result" in st.session_state:
1009
- result = st.session_state["agentic_result"]
1010
-
1011
- st.markdown("---")
1012
-
1013
- # Overall classification
1014
- severity_colors = {
1015
- "GREEN": ("#d4edda", "#155724", "Routine care"),
1016
- "YELLOW": ("#fff3cd", "#856404", "Close monitoring"),
1017
- "RED": ("#f8d7da", "#721c24", "Urgent referral"),
1018
- }
1019
- bg, fg, desc = severity_colors.get(result.who_classification, ("#f8f9fa", "#000", "Unknown"))
1020
-
1021
- st.markdown(f"""
1022
- <div style="background: {bg}; color: {fg}; padding: 1.5rem; border-radius: 10px; text-align: center; margin: 1rem 0;">
1023
- <h2 style="margin: 0;">WHO Classification: {result.who_classification}</h2>
1024
- <p style="margin: 0.5rem 0 0 0; font-size: 1.1rem;">{desc}</p>
1025
- </div>
1026
- """, unsafe_allow_html=True)
1027
-
1028
- # Key metrics
1029
- m1, m2, m3, m4 = st.columns(4)
1030
- with m1:
1031
- st.metric("Agents Run", len(result.agent_traces))
1032
- with m2:
1033
- st.metric("Total Time", f"{result.processing_time_ms:.0f} ms")
1034
- with m3:
1035
- referral_text = "Yes" if (result.referral_result and result.referral_result.referral_needed) else "No"
1036
- st.metric("Referral Needed", referral_text)
1037
- with m4:
1038
- triage_score = result.triage_result.score if result.triage_result else 0
1039
- st.metric("Triage Score", triage_score)
1040
-
1041
- # Clinical synthesis
1042
- st.subheader("Clinical Synthesis")
1043
- st.info(result.clinical_synthesis)
1044
-
1045
- if result.immediate_actions:
1046
- st.subheader("Immediate Actions")
1047
- for action in result.immediate_actions:
1048
- st.markdown(f"- {action}")
1049
-
1050
- # Visual pipeline flow with status indicators
1051
- st.markdown("---")
1052
- st.subheader("Agent Pipeline Execution")
1053
-
1054
- agent_meta = {
1055
- "TriageAgent": {"color": "#1976d2", "bg": "#e3f2fd", "icon": "1", "label": "Triage"},
1056
- "ImageAnalysisAgent": {"color": "#388e3c", "bg": "#e8f5e9", "icon": "2", "label": "Image (MedSigLIP)"},
1057
- "AudioAnalysisAgent": {"color": "#f57c00", "bg": "#fff3e0", "icon": "3", "label": "Audio (HeAR)"},
1058
- "ProtocolAgent": {"color": "#7b1fa2", "bg": "#f3e5f5", "icon": "4", "label": "WHO Protocol"},
1059
- "ReferralAgent": {"color": "#c62828", "bg": "#fce4ec", "icon": "5", "label": "Referral"},
1060
- "SynthesisAgent": {"color": "#00838f", "bg": "#e0f7fa", "icon": "6", "label": "Synthesis (MedGemma)"},
1061
- }
1062
- status_symbols = {"success": "OK", "skipped": "SKIP", "error": "ERR"}
1063
-
1064
- # Build trace lookup
1065
- trace_lookup = {t.agent_name: t for t in result.agent_traces}
1066
-
1067
- # Pipeline status bar
1068
- pipeline_html_parts = []
1069
- for agent_name, meta in agent_meta.items():
1070
- trace = trace_lookup.get(agent_name)
1071
- if trace:
1072
- status_sym = status_symbols.get(trace.status, "?")
1073
- opacity = "1.0" if trace.status == "success" else "0.5"
1074
- border_style = f"3px solid {meta['color']}" if trace.status == "success" else "2px dashed #999"
1075
- time_label = f"{trace.processing_time_ms:.0f}ms"
1076
- else:
1077
- status_sym = "---"
1078
- opacity = "0.3"
1079
- border_style = "2px dashed #ccc"
1080
- time_label = ""
1081
-
1082
- pipeline_html_parts.append(f"""
1083
- <div style="background: {meta['bg']}; padding: 0.4rem 0.7rem; border-radius: 8px;
1084
- border: {border_style}; opacity: {opacity}; text-align: center; min-width: 90px;">
1085
- <div style="font-weight: bold; font-size: 0.8rem; color: {meta['color']};">{meta['label']}</div>
1086
- <div style="font-size: 0.7rem; color: #666;">{status_sym} {time_label}</div>
1087
- </div>
1088
- """)
1089
-
1090
- pipeline_html = '<div style="display: flex; align-items: center; justify-content: center; gap: 0.3rem; flex-wrap: wrap; margin: 0.5rem 0;">'
1091
- for i, part in enumerate(pipeline_html_parts):
1092
- pipeline_html += part
1093
- if i < len(pipeline_html_parts) - 1:
1094
- pipeline_html += '<span style="font-size: 1.2rem; color: #999;">&#8594;</span>'
1095
- pipeline_html += "</div>"
1096
- st.markdown(pipeline_html, unsafe_allow_html=True)
1097
-
1098
- # Agent reasoning traces (key feature for Agentic Workflow prize)
1099
- st.markdown("---")
1100
- st.subheader("Agent Reasoning Traces")
1101
-
1102
- for trace in result.agent_traces:
1103
- meta = agent_meta.get(trace.agent_name, {"color": "#666", "bg": "#f5f5f5", "label": trace.agent_name})
1104
- status_emoji = {"success": "OK", "skipped": "SKIP", "error": "ERR"}.get(trace.status, "?")
1105
-
1106
- header_label = f"{meta['label']} [{status_emoji}] - {trace.confidence:.0%} confidence - {trace.processing_time_ms:.0f}ms"
1107
- with st.expander(header_label, expanded=(trace.status == "success")):
1108
- # Status bar
1109
- st.markdown(f"""
1110
- <div style="background: {meta['bg']}; padding: 0.8rem 1rem; border-radius: 8px;
1111
- border-left: 4px solid {meta['color']}; margin-bottom: 0.5rem;">
1112
- <strong style="color: {meta['color']};">{trace.agent_name}</strong> &nbsp;|&nbsp;
1113
- Status: <strong>{trace.status}</strong> &nbsp;|&nbsp;
1114
- Confidence: <strong>{trace.confidence:.1%}</strong> &nbsp;|&nbsp;
1115
- Time: <strong>{trace.processing_time_ms:.1f}ms</strong>
1116
- </div>
1117
- """, unsafe_allow_html=True)
1118
-
1119
- # Reasoning steps with numbered styling
1120
- if trace.reasoning:
1121
- st.markdown("**Reasoning Chain:**")
1122
- for i, step in enumerate(trace.reasoning, 1):
1123
- st.markdown(f"**Step {i}.** {step}")
1124
-
1125
- # Key findings
1126
- if trace.findings:
1127
- st.markdown("**Key Findings:**")
1128
- st.json(trace.findings)
1129
-
1130
- # Processing time breakdown
1131
- st.markdown("---")
1132
- col_chart, col_summary = st.columns([2, 1])
1133
-
1134
- with col_chart:
1135
- st.subheader("Processing Time by Agent")
1136
- import pandas as pd
1137
- chart_data = pd.DataFrame({
1138
- "Agent": [agent_meta.get(t.agent_name, {}).get("label", t.agent_name) for t in result.agent_traces],
1139
- "Time (ms)": [t.processing_time_ms for t in result.agent_traces],
1140
- })
1141
- st.bar_chart(chart_data.set_index("Agent"))
1142
-
1143
- with col_summary:
1144
- st.subheader("Workflow Summary")
1145
- total_time = result.processing_time_ms
1146
- successful = sum(1 for t in result.agent_traces if t.status == "success")
1147
- skipped = sum(1 for t in result.agent_traces if t.status == "skipped")
1148
- errors = sum(1 for t in result.agent_traces if t.status == "error")
1149
- st.markdown(f"""
1150
- | Metric | Value |
1151
- |--------|-------|
1152
- | Total agents | {len(result.agent_traces)} |
1153
- | Successful | {successful} |
1154
- | Skipped | {skipped} |
1155
- | Errors | {errors} |
1156
- | Total time | {total_time:.0f} ms |
1157
- | Avg per agent | {total_time / max(len(result.agent_traces), 1):.0f} ms |
1158
- """)
1159
-
1160
- # Referral details
1161
- if result.referral_result and result.referral_result.referral_needed:
1162
- st.markdown("---")
1163
- st.subheader("Referral Details")
1164
- ref = result.referral_result
1165
- r1, r2, r3 = st.columns(3)
1166
- with r1:
1167
- st.metric("Urgency", ref.urgency.upper())
1168
- with r2:
1169
- st.metric("Facility", ref.facility_level.title())
1170
- with r3:
1171
- st.metric("Timeframe", ref.timeframe)
1172
- st.warning(f"Reason: {ref.reason}")
1173
-
1174
-
1175
- # Footer
1176
- def render_footer():
1177
- """Render footer."""
1178
- st.markdown("---")
1179
- st.markdown("""
1180
- <div style="text-align: center; color: #666; font-size: 0.9rem;">
1181
- <p>NEXUS - Built with Google HAI-DEF for MedGemma Impact Challenge 2026</p>
1182
- <p>⚠️ This is a screening tool only. Always confirm with laboratory tests.</p>
1183
- </div>
1184
- """, unsafe_allow_html=True)
1185
-
1186
-
1187
- if __name__ == "__main__":
1188
- main()
1189
- render_footer()
 
 
 
1
+ """
2
+ NEXUS Streamlit Demo Application
3
+
4
+ Interactive demo for the NEXUS Maternal-Neonatal Care Platform.
5
+ Built with Google HAI-DEF models for the MedGemma Impact Challenge.
6
+
7
+ HAI-DEF Models Used:
8
+ - MedSigLIP: Medical image analysis (anemia, jaundice detection)
9
+ - HeAR: Health acoustic representations (cry analysis)
10
+ - MedGemma: Clinical reasoning and synthesis
11
+ """
12
+
13
+ import streamlit as st
14
+ from pathlib import Path
15
+ import sys
16
+ import os
17
+ import tempfile
18
+ import json
19
+
20
+ # Add parent directory to path for imports
21
+ sys.path.insert(0, str(Path(__file__).parent.parent))
22
+
23
+ # Page configuration
24
+ st.set_page_config(
25
+ page_title="NEXUS - Maternal-Neonatal Care",
26
+ page_icon="👶",
27
+ layout="wide",
28
+ initial_sidebar_state="expanded",
29
+ )
30
+
31
+ # Custom CSS
32
+ st.markdown("""
33
+ <style>
34
+ .main-header {
35
+ font-size: 2.5rem;
36
+ font-weight: bold;
37
+ color: #1f77b4;
38
+ text-align: center;
39
+ margin-bottom: 1rem;
40
+ }
41
+ .sub-header {
42
+ font-size: 1.2rem;
43
+ color: #666;
44
+ text-align: center;
45
+ margin-bottom: 2rem;
46
+ }
47
+ .risk-high {
48
+ background-color: #ffcccc;
49
+ border: 2px solid #ff0000;
50
+ padding: 1rem;
51
+ border-radius: 10px;
52
+ }
53
+ .risk-medium {
54
+ background-color: #fff3cd;
55
+ border: 2px solid #ffc107;
56
+ padding: 1rem;
57
+ border-radius: 10px;
58
+ }
59
+ .risk-low {
60
+ background-color: #d4edda;
61
+ border: 2px solid #28a745;
62
+ padding: 1rem;
63
+ border-radius: 10px;
64
+ }
65
+ .metric-card {
66
+ background-color: #f8f9fa;
67
+ padding: 1rem;
68
+ border-radius: 10px;
69
+ text-align: center;
70
+ }
71
+ .model-badge {
72
+ display: inline-block;
73
+ padding: 2px 10px;
74
+ border-radius: 12px;
75
+ font-size: 0.78rem;
76
+ font-weight: 600;
77
+ color: white;
78
+ letter-spacing: 0.3px;
79
+ }
80
+ .stMetric > div {
81
+ background-color: #f8f9fa;
82
+ padding: 0.5rem;
83
+ border-radius: 8px;
84
+ }
85
+ </style>
86
+ """, unsafe_allow_html=True)
87
+
88
+
89
+ @st.cache_resource
90
+ def load_anemia_detector():
91
+ """Load anemia detector model with error handling."""
92
+ try:
93
+ from nexus.anemia_detector import AnemiaDetector
94
+ detector = AnemiaDetector()
95
+ return detector, None
96
+ except Exception as e:
97
+ return None, str(e)
98
+
99
+
100
+ @st.cache_resource
101
+ def load_jaundice_detector():
102
+ """Load jaundice detector model with error handling."""
103
+ try:
104
+ from nexus.jaundice_detector import JaundiceDetector
105
+ detector = JaundiceDetector()
106
+ return detector, None
107
+ except Exception as e:
108
+ return None, str(e)
109
+
110
+
111
+ @st.cache_resource
112
+ def load_cry_analyzer():
113
+ """Load cry analyzer with error handling."""
114
+ try:
115
+ from nexus.cry_analyzer import CryAnalyzer
116
+ analyzer = CryAnalyzer()
117
+ return analyzer, None
118
+ except Exception as e:
119
+ return None, str(e)
120
+
121
+
122
+ @st.cache_resource
123
+ def load_clinical_synthesizer():
124
+ """Load clinical synthesizer (MedGemma) with error handling."""
125
+ try:
126
+ import os
127
+ from nexus.clinical_synthesizer import ClinicalSynthesizer
128
+ use_medgemma = os.environ.get("NEXUS_USE_MEDGEMMA", "true").lower() != "false"
129
+ synthesizer = ClinicalSynthesizer(use_medgemma=use_medgemma)
130
+ return synthesizer, None
131
+ except Exception as e:
132
+ return None, str(e)
133
+
134
+
135
+ def get_hai_def_info():
136
+ """Get HAI-DEF models information with validated accuracy numbers."""
137
+ return {
138
+ "MedSigLIP": {
139
+ "name": "MedSigLIP (google/medsiglip-448)",
140
+ "use": "Image analysis for anemia and jaundice detection + bilirubin regression",
141
+ "method": "Zero-shot classification (max-similarity, 8 prompts/class) + trained SVM/LR classifiers on embeddings",
142
+ "accuracy": "Anemia: trained classifier on augmented data, Jaundice: trained classifier on 2,235 images, Bilirubin: MAE 2.67 mg/dL (r=0.77)",
143
+ "badge": "Vision",
144
+ "badge_color": "#388e3c",
145
+ },
146
+ "HeAR": {
147
+ "name": "HeAR (google/hear-pytorch)",
148
+ "use": "Infant cry analysis for asphyxia and cry type classification",
149
+ "method": "512-dim health acoustic embeddings + trained linear classifier on donate-a-cry dataset (5-class: hungry, belly_pain, burping, discomfort, tired)",
150
+ "accuracy": "Trained cry type classifier with asphyxia risk derivation from distress patterns",
151
+ "badge": "Audio",
152
+ "badge_color": "#f57c00",
153
+ },
154
+ "MedGemma": {
155
+ "name": "MedGemma 1.5 4B (google/medgemma-1.5-4b-it)",
156
+ "use": "Clinical reasoning and recommendation synthesis",
157
+ "method": "4-bit NF4 quantized inference with WHO IMNCI protocol-aligned synthesis and 6-agent reasoning traces",
158
+ "accuracy": "Protocol-aligned clinical recommendations with structured reasoning chains",
159
+ "badge": "Language",
160
+ "badge_color": "#1976d2",
161
+ },
162
+ }
163
+
164
+
165
+ def main():
166
+ """Main application."""
167
+
168
+ # Header
169
+ st.markdown('<div class="main-header">NEXUS</div>', unsafe_allow_html=True)
170
+ st.markdown(
171
+ '<div class="sub-header">AI-Powered Maternal-Neonatal Care Platform</div>',
172
+ unsafe_allow_html=True
173
+ )
174
+
175
+ # Sidebar
176
+ with st.sidebar:
177
+ st.markdown("## 🏥 NEXUS")
178
+ st.markdown("---")
179
+
180
+ assessment_type = st.radio(
181
+ "Select Assessment Type",
182
+ [
183
+ "Maternal Anemia Screening",
184
+ "Neonatal Jaundice Detection",
185
+ "Cry Analysis",
186
+ "Combined Assessment",
187
+ "Agentic Workflow",
188
+ "HAI-DEF Models Info"
189
+ ],
190
+ index=0,
191
+ )
192
+
193
+ st.markdown("---")
194
+ st.markdown("### About NEXUS")
195
+ st.markdown("""
196
+ NEXUS uses AI to provide non-invasive screening for:
197
+ - **Maternal Anemia** via conjunctiva imaging
198
+ - **Neonatal Jaundice** via skin color analysis
199
+ - **Birth Asphyxia** via cry pattern analysis
200
+
201
+ Built with **Google HAI-DEF models** for the MedGemma Impact Challenge 2026.
202
+ """)
203
+
204
+ st.markdown("---")
205
+ st.markdown("### Edge AI Mode")
206
+ edge_mode = st.toggle("Enable Edge AI Mode", value=False, key="edge_mode")
207
+ if edge_mode:
208
+ st.success("Edge AI: INT8 quantized models + offline inference")
209
+ else:
210
+ st.info("Cloud mode: Full-precision HAI-DEF models")
211
+
212
+ st.markdown("---")
213
+ st.markdown("### HAI-DEF Models")
214
+ st.markdown("""
215
+ - **MedSigLIP**: Vision (trained classifiers)
216
+ - **HeAR**: Audio (trained cry classifier)
217
+ - **MedGemma 1.5**: Clinical AI (4-bit NF4)
218
+ """)
219
+
220
+ # Show Edge AI banner when enabled
221
+ if edge_mode:
222
+ render_edge_ai_banner()
223
+
224
+ # Main content based on selection
225
+ if assessment_type == "Maternal Anemia Screening":
226
+ render_anemia_screening()
227
+ elif assessment_type == "Neonatal Jaundice Detection":
228
+ render_jaundice_detection()
229
+ elif assessment_type == "Cry Analysis":
230
+ render_cry_analysis()
231
+ elif assessment_type == "Combined Assessment":
232
+ render_combined_assessment()
233
+ elif assessment_type == "Agentic Workflow":
234
+ render_agentic_workflow()
235
+ else:
236
+ render_hai_def_info()
237
+
238
+
239
+ def render_edge_ai_banner():
240
+ """Show Edge AI mode status and model metrics."""
241
+ st.markdown("""
242
+ <div style="background: linear-gradient(135deg, #1a237e 0%, #0d47a1 100%);
243
+ color: white; padding: 1rem 1.5rem; border-radius: 10px; margin-bottom: 1rem;">
244
+ <h4 style="margin:0; color: white;">Edge AI Mode Active</h4>
245
+ <p style="margin: 0.3rem 0 0 0; opacity: 0.9; font-size: 0.9rem;">
246
+ Running INT8 quantized models for offline-capable inference on low-resource devices.
247
+ </p>
248
+ </div>
249
+ """, unsafe_allow_html=True)
250
+
251
+ col1, col2, col3, col4 = st.columns(4)
252
+ with col1:
253
+ st.metric("MedSigLIP INT8", "111.2 MB", "-86% memory")
254
+ with col2:
255
+ st.metric("Acoustic Model", "0.6 MB", "INT8 quantized")
256
+ with col3:
257
+ st.metric("Text Embeddings", "12 KB", "Pre-computed")
258
+ with col4:
259
+ st.metric("Total Edge Size", "~289 MB", "Offline-ready")
260
+
261
+ with st.expander("Edge AI Details"):
262
+ st.markdown("""
263
+ **Quantization**: Dynamic INT8 (PyTorch `quantize_dynamic`, qnnpack backend)
264
+
265
+ | Component | Cloud (FP32) | Edge (INT8) | Compression |
266
+ |-----------|-------------|-------------|-------------|
267
+ | MedSigLIP Vision | 812.6 MB | 111.2 MB | **7.31x** |
268
+ | Acoustic Model | 0.665 MB | 0.599 MB | 1.11x |
269
+ | CPU Latency | 97.7 ms | ~65 ms (ARM est.) | ~1.5x faster |
270
+
271
+ **Target Devices**: Android 8.0+, ARM Cortex-A53, 2GB RAM
272
+
273
+ **Offline Capabilities**:
274
+ - Image analysis via INT8 MedSigLIP + pre-computed binary text embeddings
275
+ - Audio analysis via INT8 acoustic feature extractor
276
+ - Clinical reasoning via rule-based WHO IMNCI protocols (no MedGemma required)
277
+ """)
278
+
279
+
280
+ def _cleanup_temp(path: str) -> None:
281
+ """Safely remove a temporary file."""
282
+ try:
283
+ if path and os.path.exists(path):
284
+ os.unlink(path)
285
+ except OSError:
286
+ pass
287
+
288
+
289
+ def _save_upload_to_temp(uploaded_file, suffix: str) -> str:
290
+ """Save an uploaded file to a temporary path and return the path."""
291
+ tmp = tempfile.NamedTemporaryFile(delete=False, suffix=suffix)
292
+ try:
293
+ tmp.write(uploaded_file.getvalue())
294
+ tmp.close()
295
+ return tmp.name
296
+ except Exception:
297
+ tmp.close()
298
+ _cleanup_temp(tmp.name)
299
+ raise
300
+
301
+
302
+ def _model_badge(name: str, color: str) -> str:
303
+ """Return an HTML badge for displaying which HAI-DEF model is active."""
304
+ return (
305
+ f'<span style="background:{color}; color:white; padding:2px 10px; '
306
+ f'border-radius:12px; font-size:0.78rem; font-weight:600; '
307
+ f'letter-spacing:0.3px;">{name}</span>'
308
+ )
309
+
310
+
311
+ def render_anemia_screening():
312
+ """Render anemia screening interface."""
313
+ st.header("Maternal Anemia Screening")
314
+ st.markdown(
315
+ f"Upload a clear image of the inner eyelid (conjunctiva) for anemia screening. "
316
+ f'{_model_badge("MedSigLIP", "#388e3c")}',
317
+ unsafe_allow_html=True,
318
+ )
319
+
320
+ col1, col2 = st.columns([1, 1])
321
+
322
+ with col1:
323
+ st.subheader("Upload Image")
324
+ uploaded_file = st.file_uploader(
325
+ "Choose a conjunctiva image",
326
+ type=["jpg", "jpeg", "png"],
327
+ key="anemia_upload"
328
+ )
329
+
330
+ if uploaded_file:
331
+ image_bytes = uploaded_file.getvalue()
332
+ st.image(image_bytes, caption="Uploaded Image", use_container_width=True)
333
+
334
+ with col2:
335
+ st.subheader("Analysis Results")
336
+
337
+ if uploaded_file:
338
+ with st.spinner("Analyzing image..."):
339
+ tmp_path = None
340
+ try:
341
+ detector, load_err = load_anemia_detector()
342
+ if detector is None:
343
+ st.error(f"Could not load model: {load_err}")
344
+ return
345
+
346
+ tmp_path = _save_upload_to_temp(uploaded_file, ".jpg")
347
+
348
+ result = detector.detect(tmp_path)
349
+ color_info = detector.analyze_color_features(tmp_path)
350
+
351
+ # Display results
352
+ risk_class = f"risk-{result['risk_level']}"
353
+ st.markdown(f'<div class="{risk_class}">', unsafe_allow_html=True)
354
+
355
+ if result["is_anemic"]:
356
+ st.error("⚠️ ANEMIA DETECTED")
357
+ else:
358
+ st.success("✅ No Anemia Detected")
359
+
360
+ st.markdown("</div>", unsafe_allow_html=True)
361
+
362
+ # Metrics
363
+ col_a, col_b, col_c = st.columns(3)
364
+ with col_a:
365
+ st.metric("Confidence", f"{result['confidence']:.1%}")
366
+ with col_b:
367
+ st.metric("Risk Level", result['risk_level'].upper())
368
+ with col_c:
369
+ st.metric("Est. Hemoglobin", f"{color_info['estimated_hemoglobin']} g/dL")
370
+
371
+ # Recommendation
372
+ st.markdown("### Recommendation")
373
+ st.info(result["recommendation"])
374
+
375
+ # Color analysis
376
+ with st.expander("Technical Details"):
377
+ st.json({
378
+ "anemia_score": round(result["anemia_score"], 3),
379
+ "healthy_score": round(result["healthy_score"], 3),
380
+ "red_ratio": round(color_info["red_ratio"], 3),
381
+ "pallor_index": round(color_info["pallor_index"], 3),
382
+ })
383
+
384
+ except Exception as e:
385
+ st.error(f"Error analyzing image: {e}")
386
+ finally:
387
+ _cleanup_temp(tmp_path)
388
+ else:
389
+ st.info("👆 Upload an image to begin analysis")
390
+
391
+
392
+ def render_jaundice_detection():
393
+ """Render jaundice detection interface."""
394
+ st.header("Neonatal Jaundice Detection")
395
+ st.markdown(
396
+ f"Upload an image of the newborn's skin or sclera for jaundice assessment. "
397
+ f'{_model_badge("MedSigLIP", "#388e3c")}',
398
+ unsafe_allow_html=True,
399
+ )
400
+
401
+ col1, col2 = st.columns([1, 1])
402
+
403
+ with col1:
404
+ st.subheader("Upload Image")
405
+ uploaded_file = st.file_uploader(
406
+ "Choose a neonatal image",
407
+ type=["jpg", "jpeg", "png"],
408
+ key="jaundice_upload"
409
+ )
410
+
411
+ if uploaded_file:
412
+ image_bytes = uploaded_file.getvalue()
413
+ st.image(image_bytes, caption="Uploaded Image", use_container_width=True)
414
+
415
+ # Patient info
416
+ st.subheader("Patient Information (Optional)")
417
+ age_days = st.number_input("Age (days)", min_value=0, max_value=28, value=3)
418
+ birth_weight = st.number_input("Birth weight (grams)", min_value=500, max_value=5000, value=3000)
419
+
420
+ with col2:
421
+ st.subheader("Analysis Results")
422
+
423
+ if uploaded_file:
424
+ with st.spinner("Analyzing image..."):
425
+ tmp_path = None
426
+ try:
427
+ detector, load_err = load_jaundice_detector()
428
+ if detector is None:
429
+ st.error(f"Could not load model: {load_err}")
430
+ return
431
+
432
+ tmp_path = _save_upload_to_temp(uploaded_file, ".jpg")
433
+
434
+ result = detector.detect(tmp_path)
435
+ zone_info = detector.analyze_kramer_zones(tmp_path)
436
+
437
+ # Display results
438
+ risk_class = "risk-high" if result["needs_phototherapy"] else (
439
+ "risk-medium" if result["severity"] in ["moderate", "mild"] else "risk-low"
440
+ )
441
+ st.markdown(f'<div class="{risk_class}">', unsafe_allow_html=True)
442
+
443
+ if result["has_jaundice"]:
444
+ st.warning(f"⚠️ JAUNDICE DETECTED - {result['severity'].upper()}")
445
+ else:
446
+ st.success(" No Significant Jaundice")
447
+
448
+ st.markdown("</div>", unsafe_allow_html=True)
449
+
450
+ # Metrics - show ML bilirubin if available
451
+ col_a, col_b, col_c = st.columns(3)
452
+ with col_a:
453
+ bili_value = result.get('estimated_bilirubin_ml', result.get('estimated_bilirubin', 0))
454
+ bili_method = result.get('bilirubin_method', 'Color Analysis')
455
+ st.metric("Est. Bilirubin", f"{bili_value} mg/dL")
456
+ st.caption(f"Method: {bili_method}")
457
+ with col_b:
458
+ st.metric("Severity", result['severity'].upper())
459
+ with col_c:
460
+ st.metric("Kramer Zone", zone_info['kramer_zone'])
461
+
462
+ # Phototherapy indicator
463
+ if result["needs_phototherapy"]:
464
+ st.error("🔆 PHOTOTHERAPY RECOMMENDED")
465
+
466
+ # Recommendation
467
+ st.markdown("### Recommendation")
468
+ st.info(result["recommendation"])
469
+
470
+ # Zone analysis
471
+ with st.expander("Kramer Zone Analysis"):
472
+ st.write(f"**Zone**: {zone_info['kramer_zone']} - {zone_info['zone_description']}")
473
+ st.write(f"**Yellow Index**: {zone_info['yellow_index']}")
474
+ st.progress(min(zone_info['yellow_index'] * 2, 1.0))
475
+
476
+ # Technical details
477
+ with st.expander("Technical Details"):
478
+ details = {
479
+ "jaundice_score": round(result["jaundice_score"], 3),
480
+ "confidence": round(result["confidence"], 3),
481
+ "model": result.get("model", "unknown"),
482
+ "model_type": result.get("model_type", "unknown"),
483
+ "bilirubin_method": result.get("bilirubin_method", "Color Analysis"),
484
+ }
485
+ if result.get("estimated_bilirubin_ml") is not None:
486
+ details["bilirubin_ml"] = result["estimated_bilirubin_ml"]
487
+ details["bilirubin_color"] = result["estimated_bilirubin"]
488
+ st.json(details)
489
+
490
+ except Exception as e:
491
+ st.error(f"Error analyzing image: {e}")
492
+ finally:
493
+ _cleanup_temp(tmp_path)
494
+ else:
495
+ st.info("👆 Upload an image to begin analysis")
496
+
497
+
498
+ def render_cry_analysis():
499
+ """Render cry analysis interface."""
500
+ st.header("Infant Cry Analysis")
501
+ st.markdown(
502
+ f"Upload an audio recording of the infant's cry for analysis. "
503
+ f'{_model_badge("HeAR", "#f57c00")}',
504
+ unsafe_allow_html=True,
505
+ )
506
+
507
+ col1, col2 = st.columns([1, 1])
508
+
509
+ with col1:
510
+ st.subheader("Upload Audio")
511
+ uploaded_file = st.file_uploader(
512
+ "Choose a cry audio file",
513
+ type=["wav", "mp3", "ogg"],
514
+ key="cry_upload"
515
+ )
516
+
517
+ if uploaded_file:
518
+ st.audio(uploaded_file)
519
+
520
+ with col2:
521
+ st.subheader("Analysis Results")
522
+
523
+ if uploaded_file:
524
+ with st.spinner("Analyzing cry..."):
525
+ tmp_path = None
526
+ try:
527
+ analyzer, load_err = load_cry_analyzer()
528
+ if analyzer is None:
529
+ st.error(f"Could not load model: {load_err}")
530
+ return
531
+
532
+ tmp_path = _save_upload_to_temp(uploaded_file, ".wav")
533
+
534
+ result = analyzer.analyze(tmp_path)
535
+
536
+ # Display results
537
+ risk_class = f"risk-{result['risk_level']}"
538
+ st.markdown(f'<div class="{risk_class}">', unsafe_allow_html=True)
539
+
540
+ if result["is_abnormal"]:
541
+ st.error("⚠️ ABNORMAL CRY PATTERN DETECTED")
542
+ else:
543
+ st.success(" Normal Cry Pattern")
544
+
545
+ st.markdown("</div>", unsafe_allow_html=True)
546
+
547
+ # Metrics
548
+ col_a, col_b, col_c = st.columns(3)
549
+ with col_a:
550
+ st.metric("Asphyxia Risk", f"{result['asphyxia_risk']:.1%}")
551
+ with col_b:
552
+ st.metric("Cry Type", result['cry_type'].title())
553
+ with col_c:
554
+ st.metric("F0 (Pitch)", f"{result['features']['f0_mean']:.0f} Hz")
555
+
556
+ # Recommendation
557
+ st.markdown("### Recommendation")
558
+ st.info(result["recommendation"])
559
+
560
+ # Acoustic features
561
+ with st.expander("Acoustic Features"):
562
+ st.json(result["features"])
563
+
564
+ except Exception as e:
565
+ st.error(f"Error analyzing audio: {e}")
566
+ finally:
567
+ _cleanup_temp(tmp_path)
568
+ else:
569
+ st.info("👆 Upload an audio file to begin analysis")
570
+
571
+
572
+ def render_combined_assessment():
573
+ """Render combined assessment interface using Clinical Synthesizer."""
574
+ st.header("Combined Clinical Assessment")
575
+ st.markdown(
576
+ f"Upload multiple inputs for a comprehensive assessment using **MedGemma Clinical Synthesizer**. "
577
+ f"This combines findings from all HAI-DEF models to provide integrated clinical recommendations. "
578
+ f'{_model_badge("MedSigLIP", "#388e3c")} '
579
+ f'{_model_badge("HeAR", "#f57c00")} '
580
+ f'{_model_badge("MedGemma", "#1976d2")}',
581
+ unsafe_allow_html=True,
582
+ )
583
+
584
+ # Reset findings each time this tab is rendered to prevent
585
+ # stale data from previous patients contaminating results
586
+ st.session_state.findings = {
587
+ "anemia": None,
588
+ "jaundice": None,
589
+ "cry": None
590
+ }
591
+
592
+ col1, col2, col3 = st.columns(3)
593
+
594
+ with col1:
595
+ st.subheader("🩸 Anemia Screening")
596
+ anemia_file = st.file_uploader(
597
+ "Conjunctiva image",
598
+ type=["jpg", "jpeg", "png"],
599
+ key="combined_anemia"
600
+ )
601
+ if anemia_file:
602
+ st.image(anemia_file.getvalue(), use_container_width=True)
603
+ with st.spinner("Analyzing..."):
604
+ try:
605
+ detector, load_err = load_anemia_detector()
606
+ if detector is None:
607
+ st.error(f"Model error: {load_err}")
608
+ else:
609
+ with tempfile.NamedTemporaryFile(delete=False, suffix=".jpg") as tmp:
610
+ tmp.write(anemia_file.getvalue())
611
+ result = detector.detect(tmp.name)
612
+ st.session_state.findings["anemia"] = result
613
+ if result["is_anemic"]:
614
+ st.error(f"Anemia: {result['risk_level'].upper()}")
615
+ else:
616
+ st.success("No Anemia")
617
+ except Exception as e:
618
+ st.error(f"Error: {e}")
619
+
620
+ with col2:
621
+ st.subheader("👶 Jaundice Detection")
622
+ jaundice_file = st.file_uploader(
623
+ "Neonatal skin image",
624
+ type=["jpg", "jpeg", "png"],
625
+ key="combined_jaundice"
626
+ )
627
+ if jaundice_file:
628
+ st.image(jaundice_file.getvalue(), use_container_width=True)
629
+ with st.spinner("Analyzing..."):
630
+ try:
631
+ detector, load_err = load_jaundice_detector()
632
+ if detector is None:
633
+ st.error(f"Model error: {load_err}")
634
+ else:
635
+ with tempfile.NamedTemporaryFile(delete=False, suffix=".jpg") as tmp:
636
+ tmp.write(jaundice_file.getvalue())
637
+ result = detector.detect(tmp.name)
638
+ st.session_state.findings["jaundice"] = result
639
+ if result["has_jaundice"]:
640
+ st.warning(f"Jaundice: {result['severity'].upper()}")
641
+ else:
642
+ st.success("No Jaundice")
643
+ except Exception as e:
644
+ st.error(f"Error: {e}")
645
+
646
+ with col3:
647
+ st.subheader("🔊 Cry Analysis")
648
+ cry_file = st.file_uploader(
649
+ "Cry audio",
650
+ type=["wav", "mp3", "ogg"],
651
+ key="combined_cry"
652
+ )
653
+ if cry_file:
654
+ st.audio(cry_file)
655
+ with st.spinner("Analyzing..."):
656
+ try:
657
+ analyzer, load_err = load_cry_analyzer()
658
+ if analyzer is None:
659
+ st.error(f"Model error: {load_err}")
660
+ raise RuntimeError(load_err)
661
+ with tempfile.NamedTemporaryFile(delete=False, suffix=".wav") as tmp:
662
+ tmp.write(cry_file.getvalue())
663
+ result = analyzer.analyze(tmp.name)
664
+ st.session_state.findings["cry"] = result
665
+ if result["is_abnormal"]:
666
+ st.error(f"Abnormal Cry: {result['risk_level'].upper()}")
667
+ else:
668
+ st.success("Normal Cry")
669
+ except Exception as e:
670
+ st.error(f"Error: {e}")
671
+
672
+ # Clinical Synthesis Section
673
+ st.markdown("---")
674
+ st.subheader("🏥 Clinical Synthesis (MedGemma)")
675
+
676
+ # Check if any findings are available
677
+ has_findings = any(v is not None for v in st.session_state.findings.values())
678
+
679
+ if has_findings:
680
+ if st.button("Generate Clinical Synthesis", type="primary"):
681
+ with st.spinner("Synthesizing findings with MedGemma..."):
682
+ try:
683
+ synthesizer, load_err = load_clinical_synthesizer()
684
+ if synthesizer is None:
685
+ st.error(f"Could not load synthesizer: {load_err}")
686
+ return
687
+
688
+ # Prepare findings dict
689
+ findings = {}
690
+ if st.session_state.findings["anemia"]:
691
+ findings["anemia"] = st.session_state.findings["anemia"]
692
+ if st.session_state.findings["jaundice"]:
693
+ findings["jaundice"] = st.session_state.findings["jaundice"]
694
+ if st.session_state.findings["cry"]:
695
+ findings["cry"] = st.session_state.findings["cry"]
696
+
697
+ synthesis = synthesizer.synthesize(findings)
698
+
699
+ # Display synthesis results
700
+ severity_level = synthesis.get("severity_level", "GREEN")
701
+ severity_colors = {
702
+ "GREEN": ("🟢", "#d4edda", "#155724"),
703
+ "YELLOW": ("🟡", "#fff3cd", "#856404"),
704
+ "RED": ("🔴", "#f8d7da", "#721c24")
705
+ }
706
+ emoji, bg_color, text_color = severity_colors.get(severity_level, ("", "#f8f9fa", "#000"))
707
+
708
+ st.markdown(f"""
709
+ <div style="background-color: {bg_color}; padding: 1.5rem; border-radius: 10px; margin: 1rem 0;">
710
+ <h3 style="color: {text_color}; margin: 0;">{emoji} Severity: {severity_level}</h3>
711
+ <p style="color: {text_color}; font-size: 1.1rem; margin-top: 0.5rem;">{synthesis.get('severity_description', '')}</p>
712
+ </div>
713
+ """, unsafe_allow_html=True)
714
+
715
+ # Summary
716
+ st.markdown("### Summary")
717
+ st.info(synthesis.get("summary", "No summary available"))
718
+
719
+ # Actions
720
+ if synthesis.get("immediate_actions"):
721
+ st.markdown("### Immediate Actions")
722
+ for action in synthesis["immediate_actions"]:
723
+ st.markdown(f"- {action}")
724
+
725
+ # Referral
726
+ col_a, col_b = st.columns(2)
727
+ with col_a:
728
+ st.markdown("### Referral Status")
729
+ if synthesis.get("referral_needed"):
730
+ st.error(f"⚠️ REFERRAL NEEDED: {synthesis.get('referral_urgency', 'standard').upper()}")
731
+ else:
732
+ st.success("✅ No referral needed")
733
+
734
+ with col_b:
735
+ st.markdown("### Follow-up")
736
+ st.info(synthesis.get("follow_up", "Schedule routine follow-up"))
737
+
738
+ # Technical details
739
+ with st.expander("Technical Details"):
740
+ model_name = synthesis.get("model", "unknown")
741
+ st.json({
742
+ "model": model_name,
743
+ "model_id": synthesis.get("model_id", ""),
744
+ "generated_at": synthesis.get("generated_at"),
745
+ "urgent_conditions": synthesis.get("urgent_conditions", []),
746
+ })
747
+ if model_name and "Fallback" not in str(model_name):
748
+ st.success(f"Synthesis powered by {model_name}")
749
+ elif "Fallback" in str(model_name):
750
+ st.warning("Using rule-based fallback (MedGemma unavailable)")
751
+
752
+ except Exception as e:
753
+ st.error(f"Error generating synthesis: {e}")
754
+ else:
755
+ st.info("👆 Upload at least one input (image or audio) to generate clinical synthesis")
756
+
757
+
758
+ def render_hai_def_info():
759
+ """Render HAI-DEF models information."""
760
+ st.header("Google HAI-DEF Models")
761
+ st.markdown("""
762
+ NEXUS is built using **Google Health AI Developer Foundations (HAI-DEF)** models,
763
+ designed specifically for healthcare applications in resource-limited settings.
764
+ """)
765
+
766
+ hai_def = get_hai_def_info()
767
+
768
+ # MedSigLIP
769
+ st.markdown("---")
770
+ col1, col2 = st.columns([1, 2])
771
+ with col1:
772
+ st.markdown("### 🖼️ MedSigLIP")
773
+ st.info("google/medsiglip-448\n\nHAI-DEF Vision Model")
774
+ with col2:
775
+ info = hai_def["MedSigLIP"]
776
+ st.markdown(f"**Model**: {info['name']}")
777
+ st.markdown(f"**Use Case**: {info['use']}")
778
+ st.markdown(f"**Method**: {info['method']}")
779
+ st.markdown(f"**Validated Performance**: {info['accuracy']}")
780
+ st.markdown("""
781
+ MedSigLIP enables zero-shot medical image classification using
782
+ text prompts. NEXUS extends this with trained SVM/LR classifiers
783
+ on MedSigLIP embeddings (with data augmentation) for improved
784
+ accuracy, plus a novel 3-layer MLP regression head for continuous
785
+ bilirubin prediction from frozen embeddings.
786
+ """)
787
+
788
+ # HeAR
789
+ st.markdown("---")
790
+ col1, col2 = st.columns([1, 2])
791
+ with col1:
792
+ st.markdown("### 🔊 HeAR")
793
+ st.info("google/hear-pytorch\n\nHAI-DEF Audio Model")
794
+ with col2:
795
+ info = hai_def["HeAR"]
796
+ st.markdown(f"**Model**: {info['name']}")
797
+ st.markdown(f"**Use Case**: {info['use']}")
798
+ st.markdown(f"**Method**: {info['method']}")
799
+ st.markdown(f"**Validated Performance**: {info['accuracy']}")
800
+ st.markdown("""
801
+ HeAR (Health Acoustic Representations) produces 512-dim embeddings
802
+ from 2-second audio clips at 16kHz. NEXUS trains a linear classifier
803
+ on HeAR embeddings for 5-class cry type classification (hungry,
804
+ belly_pain, burping, discomfort, tired) and derives asphyxia risk
805
+ from distress patterns.
806
+ """)
807
+
808
+ # MedGemma
809
+ st.markdown("---")
810
+ col1, col2 = st.columns([1, 2])
811
+ with col1:
812
+ st.markdown("### 🧠 MedGemma")
813
+ st.info("google/medgemma-1.5-4b-it\n\nHAI-DEF Language Model")
814
+ with col2:
815
+ info = hai_def["MedGemma"]
816
+ st.markdown(f"**Model**: {info['name']}")
817
+ st.markdown(f"**Use Case**: {info['use']}")
818
+ st.markdown(f"**Method**: {info['method']}")
819
+ st.markdown(f"**Validated Performance**: {info['accuracy']}")
820
+ st.markdown("""
821
+ MedGemma 1.5 provides clinical reasoning capabilities via 4-bit NF4
822
+ quantized inference (~2 GB VRAM). It synthesizes multi-modal findings
823
+ into actionable recommendations following WHO IMNCI protocols,
824
+ producing structured reasoning chains within the 6-agent pipeline.
825
+ """)
826
+
827
+ # Competition Info
828
+ st.markdown("---")
829
+ st.subheader("🏆 MedGemma Impact Challenge 2026")
830
+ st.markdown("""
831
+ NEXUS is being developed for the [MedGemma Impact Challenge](https://www.kaggle.com/competitions/medgemma-impact-challenge-2026)
832
+ on Kaggle.
833
+
834
+ **Competition Focus**: Solutions for resource-limited healthcare settings using HAI-DEF models.
835
+
836
+ **NEXUS Impact**:
837
+ - 📍 Target: Sub-Saharan Africa and South Asia
838
+ - 👩‍⚕️ Users: Community Health Workers
839
+ - 🎯 Goals: Reduce maternal/neonatal mortality
840
+ - 📱 Deployment: Offline-capable mobile app
841
+ """)
842
+
843
+
844
+ def render_agentic_workflow():
845
+ """Render the agentic workflow interface with reasoning traces."""
846
+ st.header("Agentic Clinical Workflow")
847
+ st.markdown(
848
+ f"**6-Agent Pipeline** with step-by-step reasoning traces. "
849
+ f"Each agent explains its clinical decision process, providing a full audit trail. "
850
+ f'{_model_badge("MedSigLIP", "#388e3c")} '
851
+ f'{_model_badge("HeAR", "#f57c00")} '
852
+ f'{_model_badge("MedGemma", "#1976d2")}',
853
+ unsafe_allow_html=True,
854
+ )
855
+
856
+ # Pipeline diagram
857
+ st.markdown("""
858
+ <div style="display: flex; align-items: center; justify-content: center; gap: 0.5rem; flex-wrap: wrap; margin: 1rem 0;">
859
+ <div style="background: #e3f2fd; padding: 0.5rem 1rem; border-radius: 8px; font-weight: bold; border: 2px solid #1976d2;">Triage</div>
860
+ <span style="font-size: 1.5rem;">&#8594;</span>
861
+ <div style="background: #e8f5e9; padding: 0.5rem 1rem; border-radius: 8px; font-weight: bold; border: 2px solid #388e3c;">Image (MedSigLIP)</div>
862
+ <span style="font-size: 1.5rem;">&#8594;</span>
863
+ <div style="background: #fff3e0; padding: 0.5rem 1rem; border-radius: 8px; font-weight: bold; border: 2px solid #f57c00;">Audio (HeAR)</div>
864
+ <span style="font-size: 1.5rem;">&#8594;</span>
865
+ <div style="background: #f3e5f5; padding: 0.5rem 1rem; border-radius: 8px; font-weight: bold; border: 2px solid #7b1fa2;">Protocol (WHO)</div>
866
+ <span style="font-size: 1.5rem;">&#8594;</span>
867
+ <div style="background: #fce4ec; padding: 0.5rem 1rem; border-radius: 8px; font-weight: bold; border: 2px solid #c62828;">Referral</div>
868
+ <span style="font-size: 1.5rem;">&#8594;</span>
869
+ <div style="background: #e0f7fa; padding: 0.5rem 1rem; border-radius: 8px; font-weight: bold; border: 2px solid #00838f;">Synthesis (MedGemma)</div>
870
+ </div>
871
+ """, unsafe_allow_html=True)
872
+
873
+ st.markdown("---")
874
+
875
+ # Input section
876
+ col_left, col_right = st.columns([1, 1])
877
+
878
+ with col_left:
879
+ st.subheader("Patient & Inputs")
880
+ patient_type = st.selectbox("Patient Type", ["newborn", "pregnant"], key="agentic_patient")
881
+
882
+ # Danger signs
883
+ st.markdown("**Danger Signs**")
884
+ danger_signs = []
885
+ if patient_type == "pregnant":
886
+ sign_options = [
887
+ ("Severe headache", "high"),
888
+ ("Blurred vision", "high"),
889
+ ("Convulsions", "critical"),
890
+ ("Severe abdominal pain", "high"),
891
+ ("Vaginal bleeding", "critical"),
892
+ ("High fever", "high"),
893
+ ("Severe pallor", "medium"),
894
+ ]
895
+ else:
896
+ sign_options = [
897
+ ("Not breathing at birth", "critical"),
898
+ ("Convulsions", "critical"),
899
+ ("Severe chest indrawing", "high"),
900
+ ("Not feeding", "high"),
901
+ ("High fever (>38C)", "high"),
902
+ ("Hypothermia (<35.5C)", "high"),
903
+ ("Lethargy / unconscious", "critical"),
904
+ ("Umbilical redness", "medium"),
905
+ ]
906
+
907
+ selected_signs = st.multiselect(
908
+ "Select present danger signs",
909
+ [s[0] for s in sign_options],
910
+ key="agentic_signs"
911
+ )
912
+ for label, severity in sign_options:
913
+ if label in selected_signs:
914
+ danger_signs.append({
915
+ "id": label.lower().replace(" ", "_"),
916
+ "label": label,
917
+ "severity": severity,
918
+ "present": True,
919
+ })
920
+
921
+ # Image uploads
922
+ st.markdown("**Clinical Images**")
923
+ conjunctiva_file = st.file_uploader(
924
+ "Conjunctiva image (anemia)", type=["jpg", "jpeg", "png"],
925
+ key="agentic_conjunctiva"
926
+ )
927
+ skin_file = st.file_uploader(
928
+ "Skin image (jaundice)", type=["jpg", "jpeg", "png"],
929
+ key="agentic_skin"
930
+ )
931
+ cry_file = st.file_uploader(
932
+ "Cry audio", type=["wav", "mp3", "ogg"],
933
+ key="agentic_cry"
934
+ )
935
+
936
+ with col_right:
937
+ st.subheader("Workflow Execution")
938
+
939
+ if st.button("Run Agentic Assessment", type="primary", key="run_agentic"):
940
+ with st.spinner("Running 6-agent workflow..."):
941
+ try:
942
+ from nexus.agentic_workflow import (
943
+ AgenticWorkflowEngine,
944
+ AgentPatientInfo,
945
+ DangerSign,
946
+ WorkflowInput,
947
+ )
948
+
949
+ # Save uploaded files (track for cleanup)
950
+ _temp_paths = []
951
+ conjunctiva_path = None
952
+ skin_path = None
953
+ cry_path = None
954
+
955
+ if conjunctiva_file:
956
+ conjunctiva_path = _save_upload_to_temp(conjunctiva_file, ".jpg")
957
+ _temp_paths.append(conjunctiva_path)
958
+
959
+ if skin_file:
960
+ skin_path = _save_upload_to_temp(skin_file, ".jpg")
961
+ _temp_paths.append(skin_path)
962
+
963
+ if cry_file:
964
+ cry_path = _save_upload_to_temp(cry_file, ".wav")
965
+ _temp_paths.append(cry_path)
966
+
967
+ # Build workflow input
968
+ signs = [
969
+ DangerSign(
970
+ id=s["id"], label=s["label"],
971
+ severity=s["severity"], present=True,
972
+ )
973
+ for s in danger_signs
974
+ ]
975
+
976
+ info = AgentPatientInfo(patient_type=patient_type)
977
+ workflow_input = WorkflowInput(
978
+ patient_type=patient_type,
979
+ patient_info=info,
980
+ danger_signs=signs,
981
+ conjunctiva_image=conjunctiva_path,
982
+ skin_image=skin_path,
983
+ cry_audio=cry_path,
984
+ )
985
+
986
+ # Run workflow — reuse cached model instances when available
987
+ anemia_det, _ = load_anemia_detector()
988
+ jaundice_det, _ = load_jaundice_detector()
989
+ cry_ana, _ = load_cry_analyzer()
990
+ synth, _ = load_clinical_synthesizer()
991
+
992
+ engine = AgenticWorkflowEngine(
993
+ anemia_detector=anemia_det,
994
+ jaundice_detector=jaundice_det,
995
+ cry_analyzer=cry_ana,
996
+ synthesizer=synth,
997
+ )
998
+ result = engine.execute(workflow_input)
999
+
1000
+ st.session_state["agentic_result"] = result
1001
+ st.success("Workflow complete!")
1002
+
1003
+ except Exception as e:
1004
+ st.error(f"Workflow error: {e}")
1005
+ finally:
1006
+ for p in _temp_paths:
1007
+ _cleanup_temp(p)
1008
+
1009
+ # Results display
1010
+ if "agentic_result" in st.session_state:
1011
+ result = st.session_state["agentic_result"]
1012
+
1013
+ st.markdown("---")
1014
+
1015
+ # Overall classification
1016
+ severity_colors = {
1017
+ "GREEN": ("#d4edda", "#155724", "Routine care"),
1018
+ "YELLOW": ("#fff3cd", "#856404", "Close monitoring"),
1019
+ "RED": ("#f8d7da", "#721c24", "Urgent referral"),
1020
+ }
1021
+ bg, fg, desc = severity_colors.get(result.who_classification, ("#f8f9fa", "#000", "Unknown"))
1022
+
1023
+ st.markdown(f"""
1024
+ <div style="background: {bg}; color: {fg}; padding: 1.5rem; border-radius: 10px; text-align: center; margin: 1rem 0;">
1025
+ <h2 style="margin: 0;">WHO Classification: {result.who_classification}</h2>
1026
+ <p style="margin: 0.5rem 0 0 0; font-size: 1.1rem;">{desc}</p>
1027
+ </div>
1028
+ """, unsafe_allow_html=True)
1029
+
1030
+ # Key metrics
1031
+ m1, m2, m3, m4 = st.columns(4)
1032
+ with m1:
1033
+ st.metric("Agents Run", len(result.agent_traces))
1034
+ with m2:
1035
+ st.metric("Total Time", f"{result.processing_time_ms:.0f} ms")
1036
+ with m3:
1037
+ referral_text = "Yes" if (result.referral_result and result.referral_result.referral_needed) else "No"
1038
+ st.metric("Referral Needed", referral_text)
1039
+ with m4:
1040
+ triage_score = result.triage_result.score if result.triage_result else 0
1041
+ st.metric("Triage Score", triage_score)
1042
+
1043
+ # Clinical synthesis
1044
+ st.subheader("Clinical Synthesis")
1045
+ st.info(result.clinical_synthesis)
1046
+
1047
+ if result.immediate_actions:
1048
+ st.subheader("Immediate Actions")
1049
+ for action in result.immediate_actions:
1050
+ st.markdown(f"- {action}")
1051
+
1052
+ # Visual pipeline flow with status indicators
1053
+ st.markdown("---")
1054
+ st.subheader("Agent Pipeline Execution")
1055
+
1056
+ agent_meta = {
1057
+ "TriageAgent": {"color": "#1976d2", "bg": "#e3f2fd", "icon": "1", "label": "Triage"},
1058
+ "ImageAnalysisAgent": {"color": "#388e3c", "bg": "#e8f5e9", "icon": "2", "label": "Image (MedSigLIP)"},
1059
+ "AudioAnalysisAgent": {"color": "#f57c00", "bg": "#fff3e0", "icon": "3", "label": "Audio (HeAR)"},
1060
+ "ProtocolAgent": {"color": "#7b1fa2", "bg": "#f3e5f5", "icon": "4", "label": "WHO Protocol"},
1061
+ "ReferralAgent": {"color": "#c62828", "bg": "#fce4ec", "icon": "5", "label": "Referral"},
1062
+ "SynthesisAgent": {"color": "#00838f", "bg": "#e0f7fa", "icon": "6", "label": "Synthesis (MedGemma)"},
1063
+ }
1064
+ status_symbols = {"success": "OK", "skipped": "SKIP", "error": "ERR"}
1065
+
1066
+ # Build trace lookup
1067
+ trace_lookup = {t.agent_name: t for t in result.agent_traces}
1068
+
1069
+ # Pipeline status bar
1070
+ pipeline_html_parts = []
1071
+ for agent_name, meta in agent_meta.items():
1072
+ trace = trace_lookup.get(agent_name)
1073
+ if trace:
1074
+ status_sym = status_symbols.get(trace.status, "?")
1075
+ opacity = "1.0" if trace.status == "success" else "0.5"
1076
+ border_style = f"3px solid {meta['color']}" if trace.status == "success" else "2px dashed #999"
1077
+ time_label = f"{trace.processing_time_ms:.0f}ms"
1078
+ else:
1079
+ status_sym = "---"
1080
+ opacity = "0.3"
1081
+ border_style = "2px dashed #ccc"
1082
+ time_label = ""
1083
+
1084
+ pipeline_html_parts.append(f"""
1085
+ <div style="background: {meta['bg']}; padding: 0.4rem 0.7rem; border-radius: 8px;
1086
+ border: {border_style}; opacity: {opacity}; text-align: center; min-width: 90px;">
1087
+ <div style="font-weight: bold; font-size: 0.8rem; color: {meta['color']};">{meta['label']}</div>
1088
+ <div style="font-size: 0.7rem; color: #666;">{status_sym} {time_label}</div>
1089
+ </div>
1090
+ """)
1091
+
1092
+ pipeline_html = '<div style="display: flex; align-items: center; justify-content: center; gap: 0.3rem; flex-wrap: wrap; margin: 0.5rem 0;">'
1093
+ for i, part in enumerate(pipeline_html_parts):
1094
+ pipeline_html += part
1095
+ if i < len(pipeline_html_parts) - 1:
1096
+ pipeline_html += '<span style="font-size: 1.2rem; color: #999;">&#8594;</span>'
1097
+ pipeline_html += "</div>"
1098
+ st.markdown(pipeline_html, unsafe_allow_html=True)
1099
+
1100
+ # Agent reasoning traces (key feature for Agentic Workflow prize)
1101
+ st.markdown("---")
1102
+ st.subheader("Agent Reasoning Traces")
1103
+
1104
+ for trace in result.agent_traces:
1105
+ meta = agent_meta.get(trace.agent_name, {"color": "#666", "bg": "#f5f5f5", "label": trace.agent_name})
1106
+ status_emoji = {"success": "OK", "skipped": "SKIP", "error": "ERR"}.get(trace.status, "?")
1107
+
1108
+ header_label = f"{meta['label']} [{status_emoji}] - {trace.confidence:.0%} confidence - {trace.processing_time_ms:.0f}ms"
1109
+ with st.expander(header_label, expanded=(trace.status == "success")):
1110
+ # Status bar
1111
+ st.markdown(f"""
1112
+ <div style="background: {meta['bg']}; padding: 0.8rem 1rem; border-radius: 8px;
1113
+ border-left: 4px solid {meta['color']}; margin-bottom: 0.5rem;">
1114
+ <strong style="color: {meta['color']};">{trace.agent_name}</strong> &nbsp;|&nbsp;
1115
+ Status: <strong>{trace.status}</strong> &nbsp;|&nbsp;
1116
+ Confidence: <strong>{trace.confidence:.1%}</strong> &nbsp;|&nbsp;
1117
+ Time: <strong>{trace.processing_time_ms:.1f}ms</strong>
1118
+ </div>
1119
+ """, unsafe_allow_html=True)
1120
+
1121
+ # Reasoning steps with numbered styling
1122
+ if trace.reasoning:
1123
+ st.markdown("**Reasoning Chain:**")
1124
+ for i, step in enumerate(trace.reasoning, 1):
1125
+ st.markdown(f"**Step {i}.** {step}")
1126
+
1127
+ # Key findings
1128
+ if trace.findings:
1129
+ st.markdown("**Key Findings:**")
1130
+ st.json(trace.findings)
1131
+
1132
+ # Processing time breakdown
1133
+ st.markdown("---")
1134
+ col_chart, col_summary = st.columns([2, 1])
1135
+
1136
+ with col_chart:
1137
+ st.subheader("Processing Time by Agent")
1138
+ import pandas as pd
1139
+ chart_data = pd.DataFrame({
1140
+ "Agent": [agent_meta.get(t.agent_name, {}).get("label", t.agent_name) for t in result.agent_traces],
1141
+ "Time (ms)": [t.processing_time_ms for t in result.agent_traces],
1142
+ })
1143
+ st.bar_chart(chart_data.set_index("Agent"))
1144
+
1145
+ with col_summary:
1146
+ st.subheader("Workflow Summary")
1147
+ total_time = result.processing_time_ms
1148
+ successful = sum(1 for t in result.agent_traces if t.status == "success")
1149
+ skipped = sum(1 for t in result.agent_traces if t.status == "skipped")
1150
+ errors = sum(1 for t in result.agent_traces if t.status == "error")
1151
+ st.markdown(f"""
1152
+ | Metric | Value |
1153
+ |--------|-------|
1154
+ | Total agents | {len(result.agent_traces)} |
1155
+ | Successful | {successful} |
1156
+ | Skipped | {skipped} |
1157
+ | Errors | {errors} |
1158
+ | Total time | {total_time:.0f} ms |
1159
+ | Avg per agent | {total_time / max(len(result.agent_traces), 1):.0f} ms |
1160
+ """)
1161
+
1162
+ # Referral details
1163
+ if result.referral_result and result.referral_result.referral_needed:
1164
+ st.markdown("---")
1165
+ st.subheader("Referral Details")
1166
+ ref = result.referral_result
1167
+ r1, r2, r3 = st.columns(3)
1168
+ with r1:
1169
+ st.metric("Urgency", ref.urgency.upper())
1170
+ with r2:
1171
+ st.metric("Facility", ref.facility_level.title())
1172
+ with r3:
1173
+ st.metric("Timeframe", ref.timeframe)
1174
+ st.warning(f"Reason: {ref.reason}")
1175
+
1176
+
1177
+ # Footer
1178
+ def render_footer():
1179
+ """Render footer."""
1180
+ st.markdown("---")
1181
+ st.markdown("""
1182
+ <div style="text-align: center; color: #666; font-size: 0.9rem;">
1183
+ <p>NEXUS - Built with Google HAI-DEF for MedGemma Impact Challenge 2026</p>
1184
+ <p>⚠️ This is a screening tool only. Always confirm with laboratory tests.</p>
1185
+ </div>
1186
+ """, unsafe_allow_html=True)
1187
+
1188
+
1189
+ if __name__ == "__main__":
1190
+ main()
1191
+ render_footer()