File size: 27,875 Bytes
ef4cf4a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b6f1745
 
 
 
 
 
ef4cf4a
b6f1745
0e8a63d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b6f1745
 
0e8a63d
b6f1745
0e8a63d
b6f1745
 
 
 
 
 
 
ef4cf4a
b6f1745
 
 
ef4cf4a
b6f1745
 
 
 
 
 
0e8a63d
 
 
 
 
 
 
 
b6f1745
 
 
 
 
ef4cf4a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4a861ef
 
ef4cf4a
4a861ef
 
 
 
 
 
 
 
 
 
ef4cf4a
4a861ef
 
 
 
 
 
ef4cf4a
4a861ef
 
 
 
ef4cf4a
 
b6f1745
 
 
 
95c5aff
28ca4f9
 
 
 
 
 
 
 
 
 
 
 
b6f1745
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1c727f2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
95c5aff
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b6f1745
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4a861ef
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ef4cf4a
 
fc4e33b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ef4cf4a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
fc4e33b
ef4cf4a
 
 
fc4e33b
ef4cf4a
 
 
 
 
 
 
 
fc4e33b
 
ef4cf4a
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
"""NeuroBridge Enterprise — Streamlit B2B dashboard.

Three tabs (Molecule / Signal / Image), each fires a POST request against the
sibling FastAPI service and renders a result card with row counts, runtime,
and a deep link to the corresponding MLflow run.

Design: Trust & Authority — navy + sky CTA + cool-white background, Plus
Jakarta Sans, generous whitespace. Avoids emoji icons, AI gradients, and
playful flourishes (per design-system guidance for clinical-ML B2B).

Launch: `streamlit run src/frontend/app.py`
"""
from __future__ import annotations

import html as _html
import os

import httpx
import streamlit as st


_API_URL = os.environ.get("NEUROBRIDGE_API_URL", "http://localhost:8000")
_MLFLOW_URL = os.environ.get(
    "NEUROBRIDGE_MLFLOW_URL",
    os.environ.get("MLFLOW_TRACKING_URI", "http://localhost:5000"),
)
_MLFLOW_DISABLED = os.environ.get("NEUROBRIDGE_DISABLE_MLFLOW") == "1"


# Trust & Authority custom CSS — overrides Streamlit defaults to lock the
# design-system tokens. Loaded once at app start via st.markdown.
_CUSTOM_CSS = """
<style>
@import url('https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700&display=swap');

html, body, [class*="css"], .stApp, .stMarkdown, .stTabs, .stButton, .stTextInput {
    font-family: 'Plus Jakarta Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif !important;
}

.stApp {
    background-color: #F8FAFC;
}

/* Brand header band */
.brand-header {
    background: linear-gradient(135deg, #0F172A 0%, #1E293B 100%);
    color: #F8FAFC;
    padding: 1.75rem 2rem;
    border-radius: 12px;
    margin-bottom: 1.5rem;
    border: 1px solid #1E293B;
}
.brand-header h1 {
    font-size: 1.75rem;
    font-weight: 700;
    letter-spacing: -0.02em;
    margin: 0;
    color: #FFFFFF;
}
.brand-header p {
    color: #94A3B8;
    margin: 0.25rem 0 0 0;
    font-size: 0.95rem;
    font-weight: 400;
}

/* Status pills */
.status-pill {
    display: inline-block;
    padding: 0.25rem 0.75rem;
    border-radius: 999px;
    font-size: 0.78rem;
    font-weight: 600;
    letter-spacing: 0.02em;
    margin-right: 0.5rem;
}
.status-ok    { background: #DCFCE7; color: #166534; }
.status-warn  { background: #FEF3C7; color: #92400E; }
.status-down  { background: #FEE2E2; color: #991B1B; }

/* Cards / metric containers */
[data-testid="stMetric"] {
    background: #FFFFFF;
    border: 1px solid #E2E8F0;
    border-radius: 10px;
    padding: 1.1rem 1.25rem;
    box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
}
[data-testid="stMetricLabel"] {
    color: #64748B;
    font-size: 0.78rem;
    font-weight: 600;
    text-transform: uppercase;
    letter-spacing: 0.04em;
}
[data-testid="stMetricValue"] {
    color: #0F172A;
    font-weight: 700;
    font-size: 1.85rem;
}

/* Primary action button */
.stButton > button[kind="primary"] {
    background: #0369A1;
    color: #FFFFFF;
    border: 0;
    border-radius: 8px;
    font-weight: 600;
    padding: 0.55rem 1.2rem;
    transition: background 180ms ease, transform 120ms ease;
}
.stButton > button[kind="primary"]:hover {
    background: #075985;
    transform: translateY(-1px);
}
.stButton > button[kind="primary"]:focus {
    outline: 3px solid rgba(3, 105, 161, 0.35);
    outline-offset: 2px;
}

/* Tab strip */
.stTabs [data-baseweb="tab-list"] {
    gap: 0.25rem;
    border-bottom: 1px solid #E2E8F0;
}
.stTabs [data-baseweb="tab"] {
    color: #64748B;
    font-weight: 600;
    padding: 0.65rem 1.25rem;
    border-bottom: 2px solid transparent;
    transition: color 150ms ease, border-color 150ms ease;
}
.stTabs [aria-selected="true"] {
    color: #0F172A !important;
    border-bottom-color: #0369A1 !important;
}

/* Section headers inside tabs */
.section-eyebrow {
    font-size: 0.72rem;
    font-weight: 700;
    color: #0369A1;
    letter-spacing: 0.08em;
    text-transform: uppercase;
    margin: 0;
}
.section-title {
    font-size: 1.35rem;
    font-weight: 700;
    color: #0F172A;
    margin: 0.15rem 0 0.5rem 0;
    letter-spacing: -0.01em;
}
.section-desc {
    color: #475569;
    font-size: 0.95rem;
    margin: 0 0 1.5rem 0;
    line-height: 1.6;
}

/* Result card link styling */
.mlflow-link a {
    color: #0369A1;
    text-decoration: none;
    font-weight: 600;
    border-bottom: 1px solid rgba(3, 105, 161, 0.25);
    transition: border-color 150ms ease;
}
.mlflow-link a:hover {
    border-bottom-color: #0369A1;
}

/* Sidebar */
section[data-testid="stSidebar"] {
    background: #FFFFFF;
    border-right: 1px solid #E2E8F0;
}
section[data-testid="stSidebar"] h3 {
    font-size: 0.78rem;
    font-weight: 700;
    color: #64748B;
    text-transform: uppercase;
    letter-spacing: 0.06em;
}
</style>
"""


def _check_api_health() -> tuple[bool, str]:
    """Ping FastAPI /health endpoint; return (ok, status_text)."""
    try:
        resp = httpx.get(f"{_API_URL}/health", timeout=2.0)
        if resp.status_code == 200:
            return True, "ok"
        return False, f"http {resp.status_code}"
    except httpx.RequestError as e:
        return False, str(type(e).__name__)


def _post(endpoint: str, payload: dict) -> dict:
    """POST to the FastAPI surface; let httpx raise on non-2xx."""
    resp = httpx.post(f"{_API_URL}{endpoint}", json=payload, timeout=120.0)
    resp.raise_for_status()
    return resp.json()


def _render_brand_header() -> None:
    st.markdown(
        """
        <div class="brand-header">
            <h1>NeuroBridge Enterprise</h1>
            <p>Three-modality clinical ML — Data Drift, Missing Modalities, Artifacts</p>
        </div>
        """,
        unsafe_allow_html=True,
    )


def _render_section(eyebrow: str, title: str, desc: str) -> None:
    st.markdown(
        f"""
        <p class="section-eyebrow">{eyebrow}</p>
        <h2 class="section-title">{title}</h2>
        <p class="section-desc">{desc}</p>
        """,
        unsafe_allow_html=True,
    )


def _render_result(body: dict) -> None:
    """Render a 3-metric result card + MLflow deep link."""
    cols = st.columns(3)
    cols[0].metric("Rows", f"{body['rows']:,}")
    cols[1].metric("Columns", f"{body['columns']:,}")
    cols[2].metric("Runtime", f"{body['duration_sec']:.2f} s")

    safe_output_path = _html.escape(str(body["output_path"]))
    st.markdown(
        f"<p style='color:#475569;margin:1rem 0 0.5rem 0;font-size:0.9rem;'>"
        f"Output written to <code style='background:#E8ECF1;padding:2px 6px;border-radius:4px;'>"
        f"{safe_output_path}</code></p>",
        unsafe_allow_html=True,
    )

    run_id = body.get("mlflow_run_id")
    if run_id and not _MLFLOW_DISABLED:
        safe_run_id = _html.escape(str(run_id))
        safe_url = _html.escape(_MLFLOW_URL, quote=True)
        st.markdown(
            f"<p class='mlflow-link'>MLflow run: "
            f"<a href='{safe_url}/#/experiments/0/runs/{safe_run_id}' "
            f"target='_blank' rel='noopener noreferrer'>{safe_run_id[:12]}…</a></p>",
            unsafe_allow_html=True,
        )
    elif _MLFLOW_DISABLED:
        st.markdown(
            "<p style='color:#92400E;font-size:0.85rem;'>"
            "MLflow tracking is disabled (NEUROBRIDGE_DISABLE_MLFLOW=1).</p>",
            unsafe_allow_html=True,
        )


def _render_sidebar(api_ok: bool, api_status: str) -> None:
    with st.sidebar:
        st.markdown("### System Status")
        safe_api_status = _html.escape(api_status)
        api_pill = (
            f"<span class='status-pill status-ok'>API · {safe_api_status}</span>"
            if api_ok
            else f"<span class='status-pill status-down'>API · {safe_api_status}</span>"
        )
        mlflow_pill = (
            "<span class='status-pill status-warn'>MLflow · disabled</span>"
            if _MLFLOW_DISABLED
            else "<span class='status-pill status-ok'>MLflow · tracking</span>"
        )
        st.markdown(api_pill + mlflow_pill, unsafe_allow_html=True)

        st.markdown("### Endpoints")
        st.markdown(
            f"<p style='font-size:0.8rem;color:#475569;line-height:1.7;'>"
            f"FastAPI · <code>{_API_URL}</code><br/>"
            f"MLflow · <code>{_MLFLOW_URL}</code></p>",
            unsafe_allow_html=True,
        )

        st.markdown("### About")
        st.markdown(
            "<p style='font-size:0.85rem;color:#475569;line-height:1.6;'>"
            "Solving Data Drift, Missing Modalities, and Artifacts in clinical "
            "biosignal pipelines. Three production modalities behind one FastAPI "
            "surface, all runs tracked to MLflow.</p>",
            unsafe_allow_html=True,
        )


def _render_bbb_tab() -> None:
    _render_section(
        "MOLECULE — BBBP",
        "Blood-Brain-Barrier permeability decision",
        "Enter a SMILES string. The system computes a 2,048-bit Morgan "
        "fingerprint, runs it through a trained Random Forest classifier, "
        "and returns the predicted permeability label, the model's "
        "self-rated confidence, and the top SHAP feature attributions "
        "explaining the decision.",
    )

    EDGE_CASES = {
        "Custom input (default)": {
            "smiles": "CCO",
            "label": "Ethanol — small, drug-like, BBB-permeable",
            "expectation": "High confidence, label = permeable",
        },
        "Invalid SMILES (parse-error path)": {
            "smiles": "this_is_not_a_valid_molecule_at_all_!!",
            "label": "Garbage string — should not parse",
            "expectation": "API returns HTTP 400 with parse error; UI shows recoverable warning",
        },
        "Empty string (boundary)": {
            "smiles": "",
            "label": "Empty input — boundary condition",
            "expectation": "Pydantic accepts empty; API returns 400 (RDKit cannot parse)",
        },
        "Massive OOD: cyclosporine-like macrocycle": {
            "smiles": (
                "CC[C@H](C)[C@@H]1NC(=O)[C@H](CC(C)C)N(C)C(=O)[C@H](CC(C)C)N(C)C(=O)"
                "[C@@H]2CCCN2C(=O)[C@H](C(C)C)NC(=O)[C@H]([C@@H](C)CC)N(C)C(=O)"
                "[C@H](C)NC(=O)[C@H](C)NC(=O)[C@H](CC(C)C)N(C)C(=O)[C@@H](NC(=O)"
                "[C@H](CC(C)C)N(C)C(=O)CN(C)C1=O)C(C)C"
            ),
            "label": "Cyclosporine — 11-residue macrocycle (~1.2 kDa)",
            "expectation": (
                "Far outside training distribution; model should hedge "
                "with low confidence (well-calibrated systems don't "
                "pretend to know)."
            ),
        },
        "OOD: heavy halogenated aromatic": {
            "smiles": "Fc1c(F)c(F)c(c(F)c1F)c2c(F)c(F)c(F)c(F)c2F",
            "label": "Decafluorobiphenyl — extreme halogen density",
            "expectation": "Rare scaffold; expect lowered confidence vs ethanol",
        },
    }

    case_name = st.selectbox(
        "Test Edge Cases",
        options=list(EDGE_CASES.keys()),
        index=0,
        key="bbb_case",
        help=(
            "Pick a robustness probe. Each case demonstrates how the "
            "system handles a real-world failure mode — invalid input, "
            "out-of-distribution molecules, or boundary conditions."
        ),
    )
    case = EDGE_CASES[case_name]
    st.caption(f"**Probe:** {case['label']}  ·  **Expected:** {case['expectation']}")

    smiles = st.text_input(
        "SMILES string",
        value=case["smiles"],
        key="bbb_smiles",
        help="Examples: CCO (ethanol), CC(=O)Nc1ccc(O)cc1 (paracetamol)",
    )
    top_k = st.slider(
        "SHAP features to display", min_value=3, max_value=10, value=5, key="bbb_topk",
    )

    if st.button("Predict BBB permeability", type="primary", key="bbb_predict"):
        with st.spinner("Computing fingerprint, predicting, and explaining…"):
            try:
                result = _post("/predict/bbb", {"smiles": smiles, "top_k": top_k})
                _render_prediction_card(result)
                st.toast("Prediction complete", icon="✅")
            except httpx.HTTPStatusError as e:
                if e.response.status_code == 503:
                    st.error(
                        "Model artifact not loaded yet. Run "
                        "`python -m src.models.bbb_model` to train it, "
                        "then retry."
                    )
                elif e.response.status_code == 400:
                    # Robustness story: show the WARNING instead of an ERROR
                    # — invalid input is a recoverable path, not a crash.
                    st.warning(
                        f"Robustness check passed: API rejected the input "
                        f"with HTTP 400 (no crash). Detail: "
                        f"{e.response.json().get('detail', e.response.text)}"
                    )
                else:
                    st.error(
                        f"Prediction failed (HTTP {e.response.status_code}): "
                        f"{e.response.text}"
                    )
            except httpx.RequestError as e:
                st.error(f"Cannot reach FastAPI at {_API_URL}: {e!r}")


def _render_eeg_tab() -> None:
    _render_section(
        "SIGNAL — EEG",
        "Electroencephalogram artifact removal",
        "Bandpass-filters raw FIF/EDF recordings, removes EOG artifacts via "
        "ICA decomposition, and extracts per-band PSD + statistical features "
        "across fixed-duration epochs.",
    )
    eeg_in = st.text_input("Input FIF/EDF path", "data/raw/eeg.fif", key="eeg_in")
    eeg_out = st.text_input("Output Parquet path", "data/processed/eeg_features.parquet", key="eeg_out")
    if st.button("Run EEG pipeline", type="primary", key="eeg_run"):
        with st.spinner("Filtering and running ICA…"):
            try:
                _render_result(_post("/pipeline/eeg", {
                    "input_path": eeg_in, "output_path": eeg_out,
                }))
                st.toast("EEG pipeline complete", icon="✅")
            except httpx.HTTPStatusError as e:
                st.error(f"Pipeline failed (HTTP {e.response.status_code}): {e.response.text}")
            except httpx.RequestError as e:
                st.error(f"Cannot reach FastAPI at {_API_URL}: {e!r}")


def _render_mri_tab() -> None:
    _render_section(
        "IMAGE — MRI",
        "Multi-site harmonization via ComBat",
        "Loads NIfTI volumes, masks brain tissue, computes per-ROI summary "
        "statistics, then harmonizes across acquisition sites with neuroHarmonize "
        "to remove scanner-driven domain shift. The diagnostic plot below "
        "compares per-site feature distributions before and after harmonization."
    )
    mri_dir = st.text_input(
        "Input NIfTI directory", "tests/fixtures/mri_sample", key="mri_dir",
        help="Path to a directory of .nii(.gz) files + sites.csv",
    )
    sites_csv = st.text_input(
        "Sites CSV", "tests/fixtures/mri_sample/sites.csv", key="mri_sites",
    )

    if st.button("Run ComBat diagnostics", type="primary", key="mri_diag"):
        with st.spinner("Running pre + post ComBat (×2 the work)…"):
            try:
                result = _post(
                    "/pipeline/mri/diagnostics",
                    {"input_dir": mri_dir, "sites_csv": sites_csv},
                )
                _render_combat_diagnostics(result)
                st.toast("Diagnostics complete", icon="✅")
            except httpx.HTTPStatusError as e:
                st.error(
                    f"Diagnostics failed (HTTP {e.response.status_code}): "
                    f"{e.response.text}"
                )
            except httpx.RequestError as e:
                st.error(f"Cannot reach FastAPI at {_API_URL}: {e!r}")


def _render_prediction_card(result: dict) -> None:
    """Render a B2B-styled decision card: label badge + confidence + SHAP bars."""
    st.session_state["last_bbb_prediction"] = result
    provenance = result.get("provenance")
    if provenance is not None:
        run_id = provenance.get("mlflow_run_id")
        run_label = run_id[:8] if run_id else "—"
        train_date = provenance.get("train_date") or "—"
        n_examples = provenance.get("n_examples")
        n_label = f"n={n_examples}" if n_examples else "n=—"
        st.caption(
            f"🔎 MLflow run **{run_label}** · "
            f"Model **{provenance.get('model_version', 'v1')}** · "
            f"trained {train_date} · {n_label}"
        )
    label_text = _html.escape(str(result["label_text"]))
    badge_color = "#166534" if result["label"] == 1 else "#991B1B"
    badge_bg    = "#DCFCE7" if result["label"] == 1 else "#FEE2E2"
    confidence_pct = result["confidence"] * 100

    st.markdown(
        f"""
        <div style='background:#FFFFFF;border:1px solid #E2E8F0;border-radius:10px;
                    padding:1.5rem;margin:1rem 0;box-shadow:0 1px 2px rgba(15,23,42,0.04);'>
            <p style='font-size:0.72rem;font-weight:700;color:#64748B;
                      letter-spacing:0.08em;text-transform:uppercase;margin:0;'>Prediction</p>
            <div style='display:flex;align-items:center;gap:0.75rem;margin-top:0.4rem;'>
                <span style='background:{badge_bg};color:{badge_color};
                             padding:0.4rem 0.9rem;border-radius:999px;
                             font-size:1rem;font-weight:700;letter-spacing:0.01em;'>
                    {label_text.upper()}
                </span>
                <span style='color:#475569;font-size:0.95rem;'>
                    Model confidence: <strong style='color:#0F172A;'>{confidence_pct:.1f}%</strong>
                </span>
            </div>
        </div>
        """,
        unsafe_allow_html=True,
    )

    # Confidence bar
    st.markdown(
        "<p style='font-size:0.72rem;font-weight:700;color:#64748B;"
        "letter-spacing:0.08em;text-transform:uppercase;margin:1rem 0 0.4rem 0;'>"
        "Confidence</p>",
        unsafe_allow_html=True,
    )
    st.progress(float(result["confidence"]))

    # Trust caption — precision-at-confidence from held-out 20% test split.
    # Silent skip when the API response has no calibration field (legacy models).
    calibration = result.get("calibration")
    if calibration is not None:
        threshold_pct = round(calibration["threshold"] * 100)
        precision_pct = round(calibration["precision"] * 100)
        support = calibration["support"]
        if support == 0:
            st.caption(
                "📊 Bu güven aralığında held-out test örneği yok — "
                "kalibrasyon bilgisi mevcut değil."
            )
        else:
            st.caption(
                f"📊 Test set'te ≥{threshold_pct}% güven üreten tahminlerin "
                f"precision'ı **{precision_pct}%** (n={support})."
            )

    drift_z = result.get("drift_z")
    rolling_n = result.get("rolling_n", 0)
    if drift_z is None and rolling_n < 10:
        st.caption(
            f"📈 Drift: warming up ({rolling_n}/10 predictions buffered)."
        )
    elif drift_z is None:
        st.caption(
            "📈 Drift: unavailable (model lacks train-time confidence stats)."
        )
    else:
        # Sign + magnitude: |z| < 1 in-band, 1–2 mild, >=2 significant.
        if abs(drift_z) < 1.0:
            tag = "within expected range"
        elif abs(drift_z) < 2.0:
            tag = "mild distribution shift"
        else:
            tag = "significant shift — retrain recommended"
        st.caption(
            f"📈 Drift: trailing-{rolling_n} confidence median is "
            f"**{drift_z:+.2f}σ** from train-time distribution ({tag})."
        )

    # SHAP attributions chart
    n_features = len(result["top_features"])
    st.markdown(
        f"<p style='font-size:0.72rem;font-weight:700;color:#64748B;"
        f"letter-spacing:0.08em;text-transform:uppercase;margin:1.5rem 0 0.4rem 0;'>"
        f"Top {n_features} SHAP attributions</p>",
        unsafe_allow_html=True,
    )
    import pandas as pd
    shap_df = pd.DataFrame(result["top_features"]).set_index("feature")
    st.bar_chart(shap_df, height=240, color="#0369A1")

    st.caption(
        "Positive SHAP values pushed the model toward the predicted class; "
        "negative values pushed it away. Feature names are 2,048-bit Morgan "
        "fingerprint indices (`fp_<bit>`)."
    )


def _render_combat_diagnostics(result: dict) -> None:
    """Render the Pre/Post-ComBat KDE comparison + site-gap KPI strip."""
    import altair as alt
    import pandas as pd

    rows = result.get("rows", [])
    if not rows:
        st.info(
            "No data returned. Check that the input directory contains "
            ".nii(.gz) files and a sites.csv with subject_id/site columns."
        )
        return

    cols = st.columns(3)
    cols[0].metric("Site-gap (Pre-ComBat)", f"{result['site_gap_pre']:.4f}")
    cols[1].metric("Site-gap (Post-ComBat)", f"{result['site_gap_post']:.4f}")
    cols[2].metric(
        "Reduction factor",
        f"{result['reduction_factor']:.0f}×",
        help=(
            "Pre-gap / Post-gap. A 100× reduction means ComBat "
            "removed two orders of magnitude of site-driven domain shift."
        ),
    )

    df = pd.DataFrame(rows)
    # Pin the chart to the first feature (most recognizable for the audience).
    feat = df["feature"].iloc[0]
    feat_df = df[df["feature"] == feat]

    # Layered KDE: x = feature_value, color = site, faceted by harmonization_state.
    chart = (
        alt.Chart(feat_df)
        .transform_density(
            density="feature_value",
            groupby=["site", "harmonization_state"],
            as_=["feature_value", "density"],
        )
        .mark_area(opacity=0.55)
        .encode(
            x=alt.X("feature_value:Q", title=f"{feat} (intensity)"),
            y=alt.Y("density:Q", title="Density"),
            color=alt.Color(
                "site:N",
                title="Site",
                scale=alt.Scale(scheme="tableau10"),
            ),
            tooltip=[
                alt.Tooltip("site:N"),
                alt.Tooltip("feature_value:Q", format=".4f"),
                alt.Tooltip("density:Q", format=".3f"),
            ],
        )
        .properties(width=380, height=260)
        .facet(
            column=alt.Column(
                "harmonization_state:N",
                title=None,
                sort=["Pre-ComBat", "Post-ComBat"],
                header=alt.Header(labelFontSize=13, labelFontWeight="bold"),
            )
        )
        .resolve_scale(x="shared", y="shared")
    )
    st.altair_chart(chart, use_container_width=True)

    st.caption(
        f"Per-site density of `{feat}` before and after ComBat. Each "
        f"colored region is one acquisition site. **Convergence of the "
        f"colored regions in the Post-ComBat panel is the visual proof "
        f"of harmonization** — the same property the {result['reduction_factor']:.0f}× "
        f"site-gap reduction quantifies."
    )


def _render_ai_assistant_tab() -> None:
    """Day-7 T3C: chat-style explainer for the most recent BBB prediction."""
    _render_section(
        "AI Assistant",
        "Natural-language rationale (LLM or deterministic template)",
        "Pulls the most recent BBB prediction from this session and asks "
        "the explainer to justify it. Falls back to a deterministic, "
        "auditable template when no LLM is configured."
    )

    last = st.session_state.get("last_bbb_prediction")
    if last is None:
        st.info(
            "Run a BBB prediction first (BBB tab → Predict button), "
            "then come back here to ask the assistant about it."
        )
        return

    # Snapshot card so the user knows which prediction is being explained
    st.caption(
        f"Latest prediction: **{last['label_text']}** "
        f"({float(last['confidence']) * 100:.0f}% confident)  ·  "
        f"Top SHAP: {', '.join(f['feature'] for f in last.get('top_features', [])[:3])}"
    )

    PRESETS = [
        "Why was this molecule predicted as permeable?",
        "Which features pushed the verdict the most?",
        "Is this prediction trustworthy given the drift signal?",
    ]
    preset = st.selectbox("Preset question", options=PRESETS, key="ai_preset")
    custom = st.text_input(
        "Or type your own question (optional)",
        value="",
        key="ai_custom",
        help="Custom questions only affect the LLM path; the template gives a generic SHAP-driven rationale either way.",
    )
    question = custom.strip() or preset

    if st.button("Ask the AI Assistant", type="primary", key="ai_ask"):
        with st.spinner("Composing rationale…"):
            try:
                body = {
                    "smiles": last.get("smiles", ""),
                    "label": last["label"],
                    "label_text": last["label_text"],
                    "confidence": last["confidence"],
                    "top_features": last.get("top_features", []),
                    "calibration": last.get("calibration"),
                    "drift_z": last.get("drift_z"),
                    "user_question": question,
                }
                # The /predict/bbb response payload doesn't include the
                # user-supplied SMILES (only label/confidence/etc.), so
                # pull it from the input widget for paper-trail accuracy.
                # Streamlit text inputs persist via st.session_state.
                if not body["smiles"]:
                    body["smiles"] = st.session_state.get("bbb_smiles", "")
                resp = _post("/explain/bbb", body)
            except httpx.HTTPStatusError as e:
                st.error(
                    f"Explainer failed (HTTP {e.response.status_code}): "
                    f"{e.response.text}"
                )
                return
            except httpx.RequestError as e:
                st.error(f"Cannot reach FastAPI at {_API_URL}: {e!r}")
                return

        history = st.session_state.setdefault("explain_history", [])
        history.insert(0, (question, resp))

    # Render history (most recent first)
    history = st.session_state.get("explain_history", [])
    if history:
        st.markdown("### Conversation")
        for q, r in history[:10]:  # cap at 10 most recent
            with st.container():
                st.markdown(f"**Q:** {q}")
                st.markdown(f"**A:** {r['rationale']}")
                source = r.get("source", "?")
                model = r.get("model") or "—"
                st.caption(f"Source: `{source}`  ·  Model: `{model}`")
                st.divider()


def main() -> None:
    """Streamlit entrypoint. Idempotent — Streamlit re-runs on every interaction."""
    st.set_page_config(
        page_title="NeuroBridge Enterprise",
        page_icon=None,
        layout="wide",
        initial_sidebar_state="expanded",
    )
    st.markdown(_CUSTOM_CSS, unsafe_allow_html=True)

    api_ok, api_status = _check_api_health()
    _render_brand_header()
    _render_sidebar(api_ok, api_status)

    if not api_ok:
        st.warning(
            f"⚠️ FastAPI surface is not reachable at `{_API_URL}` ({api_status}). "
            "Pipeline runs will fail until the API service is up. "
            "Run `uvicorn src.api.main:app --port 8000` or `docker compose up`."
        )

    bbb_tab, eeg_tab, mri_tab, assistant_tab = st.tabs([
        "Molecule (BBB)",
        "Signal (EEG)",
        "Image (MRI)",
        "AI Assistant",
    ])

    with bbb_tab:
        _render_bbb_tab()
    with eeg_tab:
        _render_eeg_tab()
    with mri_tab:
        _render_mri_tab()
    with assistant_tab:
        _render_ai_assistant_tab()


if __name__ == "__main__":
    main()