mekosotto Claude Opus 4.7 (1M context) commited on
Commit
2711297
·
1 Parent(s): fec13e9

feat(frontend): editorial redesign — neutral-gray dark + sand accent + dual theme

Browse files

Replaces the Trust & Authority (navy/sky) palette with a Netflix-inspired
editorial dark system + Apple-HIG warm-paper light system, both driven by
a sand brand accent (#D2C4B1).

Color tokens (CSS custom properties on :root):
- Dark surfaces: #0e0e10 → #161618 → #1e1e21 → #2a2a2e (4-step elevation)
- Light surfaces: #FAF7F2 paper → #FFFFFF cards → #F5F0E8 → #EDE5D5
- Sand accent: #D2C4B1 in dark (CTA, key data); charcoal in light
- Typography: Inter (display + body) + JetBrains Mono (data, run ids, code)

Theme toggle in sidebar: st.toggle persists to session_state["theme"], the
CSS block is rebuilt and re-injected on every rerun, altair theme is
re-registered to match. Default = dark.

Component rebuilds:
- Hero strip: editorial word-mark + tagline + 3 status dots
(api / mlflow / explainer)
- Decision card (BBB): provenance strip → big lowercase verdict in sand →
signals grid (calibration / drift) → SHAP frame
- Sidebar: brand mark + theme toggle + system dots + endpoints + about
- Tabs: left-aligned, sand underline indicator, 5 short labels
(Molecule / Signal / Image / AI Assistant / Experiments)
- Buttons: primary = sand block, secondary = transparent border
- Inputs: flat with sand focus ring
- Metrics, alerts, expanders, dataframes, scrollbar — all re-themed via
data-testid selectors
- Altair: custom 'neurobridge' theme registered on every run; sand-led
category palette, transparent view, Inter typography

Functional behavior (API calls, error mapping, session state, edge-case
dropdown, AI Assistant inline expanders, Experiments diff) is unchanged.
184 tests stay green; UserWarning gate clean; Streamlit boot HTTP 200.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

Files changed (1) hide show
  1. src/frontend/app.py +978 -344
src/frontend/app.py CHANGED
@@ -1,12 +1,14 @@
1
- """NeuroBridge Enterprise — Streamlit B2B dashboard.
2
 
3
- Three tabs (Molecule / Signal / Image), each fires a POST request against the
4
- sibling FastAPI service and renders a result card with row counts, runtime,
5
- and a deep link to the corresponding MLflow run.
6
 
7
- Design: Trust & Authority — navy + sky CTA + cool-white background, Plus
8
- Jakarta Sans, generous whitespace. Avoids emoji icons, AI gradients, and
9
- playful flourishes (per design-system guidance for clinical-ML B2B).
 
 
10
 
11
  Launch: `streamlit run src/frontend/app.py`
12
  """
@@ -25,176 +27,726 @@ _MLFLOW_URL = os.environ.get(
25
  os.environ.get("MLFLOW_TRACKING_URI", "http://localhost:5000"),
26
  )
27
  _MLFLOW_DISABLED = os.environ.get("NEUROBRIDGE_DISABLE_MLFLOW") == "1"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
28
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
29
 
30
- # Trust & Authority custom CSS — overrides Streamlit defaults to lock the
31
- # design-system tokens. Loaded once at app start via st.markdown.
32
- _CUSTOM_CSS = """
33
- <style>
34
- @import url('https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700&display=swap');
35
 
36
- html, body, [class*="css"], .stApp, .stMarkdown, .stTabs, .stButton, .stTextInput {
37
- font-family: 'Plus Jakarta Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif !important;
38
- }
39
 
40
- .stApp {
41
- background-color: #F8FAFC;
42
- }
 
 
43
 
44
- /* Brand header band */
45
- .brand-header {
46
- background: linear-gradient(135deg, #0F172A 0%, #1E293B 100%);
47
- color: #F8FAFC;
48
- padding: 1.75rem 2rem;
49
- border-radius: 12px;
50
- margin-bottom: 1.5rem;
51
- border: 1px solid #1E293B;
52
- }
53
- .brand-header h1 {
54
- font-size: 1.75rem;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
55
  font-weight: 700;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
56
  letter-spacing: -0.02em;
 
 
 
 
 
 
 
57
  margin: 0;
58
- color: #FFFFFF;
59
- }
60
- .brand-header p {
61
- color: #94A3B8;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
62
  margin: 0.25rem 0 0 0;
63
- font-size: 0.95rem;
64
  font-weight: 400;
65
- }
66
-
67
- /* Status pills */
68
- .status-pill {
69
- display: inline-block;
70
- padding: 0.25rem 0.75rem;
71
- border-radius: 999px;
72
- font-size: 0.78rem;
73
- font-weight: 600;
74
- letter-spacing: 0.02em;
75
- margin-right: 0.5rem;
76
- }
77
- .status-ok { background: #DCFCE7; color: #166534; }
78
- .status-warn { background: #FEF3C7; color: #92400E; }
79
- .status-down { background: #FEE2E2; color: #991B1B; }
80
-
81
- /* Cards / metric containers */
82
- [data-testid="stMetric"] {
83
- background: #FFFFFF;
84
- border: 1px solid #E2E8F0;
85
- border-radius: 10px;
86
- padding: 1.1rem 1.25rem;
87
- box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
88
- }
89
- [data-testid="stMetricLabel"] {
90
- color: #64748B;
91
- font-size: 0.78rem;
92
  font-weight: 600;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
93
  text-transform: uppercase;
94
- letter-spacing: 0.04em;
95
- }
96
- [data-testid="stMetricValue"] {
97
- color: #0F172A;
98
- font-weight: 700;
99
- font-size: 1.85rem;
100
- }
101
-
102
- /* Primary action button */
103
- .stButton > button[kind="primary"] {
104
- background: #0369A1;
105
- color: #FFFFFF;
106
- border: 0;
107
- border-radius: 8px;
108
  font-weight: 600;
109
- padding: 0.55rem 1.2rem;
110
- transition: background 180ms ease, transform 120ms ease;
111
- }
112
- .stButton > button[kind="primary"]:hover {
113
- background: #075985;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
114
  transform: translateY(-1px);
115
- }
116
- .stButton > button[kind="primary"]:focus {
117
- outline: 3px solid rgba(3, 105, 161, 0.35);
118
- outline-offset: 2px;
119
- }
120
-
121
- /* Tab strip */
122
- .stTabs [data-baseweb="tab-list"] {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
123
  gap: 0.25rem;
124
- border-bottom: 1px solid #E2E8F0;
125
- }
126
- .stTabs [data-baseweb="tab"] {
127
- color: #64748B;
128
- font-weight: 600;
129
- padding: 0.65rem 1.25rem;
130
- border-bottom: 2px solid transparent;
131
- transition: color 150ms ease, border-color 150ms ease;
132
- }
133
- .stTabs [aria-selected="true"] {
134
- color: #0F172A !important;
135
- border-bottom-color: #0369A1 !important;
136
- }
137
-
138
- /* Section headers inside tabs */
139
- .section-eyebrow {
140
- font-size: 0.72rem;
141
- font-weight: 700;
142
- color: #0369A1;
143
- letter-spacing: 0.08em;
144
- text-transform: uppercase;
145
- margin: 0;
146
- }
147
- .section-title {
148
- font-size: 1.35rem;
149
- font-weight: 700;
150
- color: #0F172A;
151
- margin: 0.15rem 0 0.5rem 0;
152
- letter-spacing: -0.01em;
153
- }
154
- .section-desc {
155
- color: #475569;
156
- font-size: 0.95rem;
157
- margin: 0 0 1.5rem 0;
158
- line-height: 1.6;
159
- }
160
-
161
- /* Result card link styling */
162
- .mlflow-link a {
163
- color: #0369A1;
164
- text-decoration: none;
165
- font-weight: 600;
166
- border-bottom: 1px solid rgba(3, 105, 161, 0.25);
167
- transition: border-color 150ms ease;
168
- }
169
- .mlflow-link a:hover {
170
- border-bottom-color: #0369A1;
171
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
172
 
173
  /* Sidebar */
174
- section[data-testid="stSidebar"] {
175
- background: #FFFFFF;
176
- border-right: 1px solid #E2E8F0;
177
- }
178
- section[data-testid="stSidebar"] h3 {
179
- font-size: 0.78rem;
180
- font-weight: 700;
181
- color: #64748B;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
182
  text-transform: uppercase;
183
- letter-spacing: 0.06em;
184
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
185
  </style>
186
  """
187
 
188
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
189
  def _check_api_health() -> tuple[bool, str]:
190
  """Ping FastAPI /health endpoint; return (ok, status_text)."""
191
  try:
192
  resp = httpx.get(f"{_API_URL}/health", timeout=2.0)
193
  if resp.status_code == 200:
194
- return True, "ok"
195
  return False, f"http {resp.status_code}"
196
  except httpx.RequestError as e:
197
- return False, str(type(e).__name__)
198
 
199
 
200
  def _post(endpoint: str, payload: dict) -> dict:
@@ -211,12 +763,33 @@ def _get(path: str) -> dict:
211
  return resp.json()
212
 
213
 
214
- def _render_brand_header() -> None:
 
 
 
 
 
 
 
 
 
 
 
215
  st.markdown(
216
- """
217
- <div class="brand-header">
218
- <h1>NeuroBridge Enterprise</h1>
219
- <p>Three-modality clinical ML — Data Drift, Missing Modalities, Artifacts</p>
 
 
 
 
 
 
 
 
 
 
220
  </div>
221
  """,
222
  unsafe_allow_html=True,
@@ -226,16 +799,18 @@ def _render_brand_header() -> None:
226
  def _render_section(eyebrow: str, title: str, desc: str) -> None:
227
  st.markdown(
228
  f"""
229
- <p class="section-eyebrow">{eyebrow}</p>
230
- <h2 class="section-title">{title}</h2>
231
- <p class="section-desc">{desc}</p>
 
 
232
  """,
233
  unsafe_allow_html=True,
234
  )
235
 
236
 
237
  def _render_result(body: dict) -> None:
238
- """Render a 3-metric result card + MLflow deep link."""
239
  cols = st.columns(3)
240
  cols[0].metric("Rows", f"{body['rows']:,}")
241
  cols[1].metric("Columns", f"{body['columns']:,}")
@@ -243,9 +818,9 @@ def _render_result(body: dict) -> None:
243
 
244
  safe_output_path = _html.escape(str(body["output_path"]))
245
  st.markdown(
246
- f"<p style='color:#475569;margin:1rem 0 0.5rem 0;font-size:0.9rem;'>"
247
- f"Output written to <code style='background:#E8ECF1;padding:2px 6px;border-radius:4px;'>"
248
- f"{safe_output_path}</code></p>",
249
  unsafe_allow_html=True,
250
  )
251
 
@@ -254,62 +829,88 @@ def _render_result(body: dict) -> None:
254
  safe_run_id = _html.escape(str(run_id))
255
  safe_url = _html.escape(_MLFLOW_URL, quote=True)
256
  st.markdown(
257
- f"<p class='mlflow-link'>MLflow run: "
258
- f"<a href='{safe_url}/#/experiments/0/runs/{safe_run_id}' "
259
- f"target='_blank' rel='noopener noreferrer'>{safe_run_id[:12]}…</a></p>",
 
 
 
260
  unsafe_allow_html=True,
261
  )
262
  elif _MLFLOW_DISABLED:
263
- st.markdown(
264
- "<p style='color:#92400E;font-size:0.85rem;'>"
265
- "MLflow tracking is disabled (NEUROBRIDGE_DISABLE_MLFLOW=1).</p>",
266
- unsafe_allow_html=True,
267
- )
268
 
269
 
270
  def _render_sidebar(api_ok: bool, api_status: str) -> None:
271
  with st.sidebar:
272
- st.markdown("### System Status")
273
- safe_api_status = _html.escape(api_status)
274
- api_pill = (
275
- f"<span class='status-pill status-ok'>API · {safe_api_status}</span>"
276
- if api_ok
277
- else f"<span class='status-pill status-down'>API · {safe_api_status}</span>"
278
  )
279
- mlflow_pill = (
280
- "<span class='status-pill status-warn'>MLflow · disabled</span>"
281
- if _MLFLOW_DISABLED
282
- else "<span class='status-pill status-ok'>MLflow · tracking</span>"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
283
  )
284
- st.markdown(api_pill + mlflow_pill, unsafe_allow_html=True)
285
 
286
  st.markdown("### Endpoints")
287
  st.markdown(
288
- f"<p style='font-size:0.8rem;color:#475569;line-height:1.7;'>"
289
- f"FastAPI · <code>{_API_URL}</code><br/>"
290
- f"MLflow · <code>{_MLFLOW_URL}</code></p>",
 
291
  unsafe_allow_html=True,
292
  )
293
 
294
  st.markdown("### About")
295
  st.markdown(
296
- "<p style='font-size:0.85rem;color:#475569;line-height:1.6;'>"
297
- "Solving Data Drift, Missing Modalities, and Artifacts in clinical "
298
- "biosignal pipelines. Three production modalities behind one FastAPI "
299
- "surface, all runs tracked to MLflow.</p>",
 
300
  unsafe_allow_html=True,
301
  )
302
 
303
 
 
 
 
 
304
  def _render_bbb_tab() -> None:
305
  _render_section(
306
  "MOLECULE — BBBP",
307
  "Blood-Brain-Barrier permeability decision",
308
  "Enter a SMILES string. The system computes a 2,048-bit Morgan "
309
- "fingerprint, runs it through a trained Random Forest classifier, "
310
- "and returns the predicted permeability label, the model's "
311
- "self-rated confidence, and the top SHAP feature attributions "
312
- "explaining the decision.",
313
  )
314
 
315
  EDGE_CASES = {
@@ -350,13 +951,13 @@ def _render_bbb_tab() -> None:
350
  }
351
 
352
  case_name = st.selectbox(
353
- "Test Edge Cases",
354
  options=list(EDGE_CASES.keys()),
355
  index=0,
356
  key="bbb_case",
357
  help=(
358
- "Pick a robustness probe. Each case demonstrates how the "
359
- "system handles a real-world failure mode — invalid input, "
360
  "out-of-distribution molecules, or boundary conditions."
361
  ),
362
  )
@@ -374,11 +975,11 @@ def _render_bbb_tab() -> None:
374
  )
375
 
376
  if st.button("Predict BBB permeability", type="primary", key="bbb_predict"):
377
- with st.spinner("Computing fingerprint, predicting, and explaining…"):
378
  try:
379
  result = _post("/predict/bbb", {"smiles": smiles, "top_k": top_k})
380
  _render_prediction_card(result)
381
- st.toast("Prediction complete", icon="")
382
  except httpx.HTTPStatusError as e:
383
  if e.response.status_code == 503:
384
  st.error(
@@ -387,8 +988,7 @@ def _render_bbb_tab() -> None:
387
  "then retry."
388
  )
389
  elif e.response.status_code == 400:
390
- # Robustness story: show the WARNING instead of an ERROR
391
- # — invalid input is a recoverable path, not a crash.
392
  st.warning(
393
  f"Robustness check passed: API rejected the input "
394
  f"with HTTP 400 (no crash). Detail: "
@@ -412,22 +1012,29 @@ def _render_eeg_tab() -> None:
412
  "across fixed-duration epochs.",
413
  )
414
  eeg_in = st.text_input("Input FIF/EDF path", "data/raw/eeg.fif", key="eeg_in")
415
- eeg_out = st.text_input("Output Parquet path", "data/processed/eeg_features.parquet", key="eeg_out")
 
 
 
 
416
  if st.button("Run EEG pipeline", type="primary", key="eeg_run"):
417
  with st.spinner("Filtering and running ICA…"):
418
  try:
419
- result = _post("/pipeline/eeg", {
420
- "input_path": eeg_in, "output_path": eeg_out,
421
- })
 
422
  st.session_state["last_eeg_run"] = result
423
  _render_result(result)
424
- st.toast("EEG pipeline complete", icon="")
425
  except httpx.HTTPStatusError as e:
426
- st.error(f"Pipeline failed (HTTP {e.response.status_code}): {e.response.text}")
 
 
 
427
  except httpx.RequestError as e:
428
  st.error(f"Cannot reach FastAPI at {_API_URL}: {e!r}")
429
 
430
- # Day-8 T1C: AI Assistant inline for EEG
431
  last_eeg = st.session_state.get("last_eeg_run")
432
  if last_eeg is not None:
433
  with st.expander("Ask the AI Assistant about this EEG run", expanded=False):
@@ -463,7 +1070,10 @@ def _render_eeg_tab() -> None:
463
  f"Model: `{eeg_resp.get('model') or '—'}`"
464
  )
465
  except httpx.HTTPStatusError as e:
466
- st.error(f"Assistant failed (HTTP {e.response.status_code}): {e.response.text}")
 
 
 
467
  except httpx.RequestError as e:
468
  st.error(f"Cannot reach FastAPI: {e!r}")
469
 
@@ -473,16 +1083,21 @@ def _render_mri_tab() -> None:
473
  "IMAGE — MRI",
474
  "Multi-site harmonization via ComBat",
475
  "Loads NIfTI volumes, masks brain tissue, computes per-ROI summary "
476
- "statistics, then harmonizes across acquisition sites with neuroHarmonize "
477
- "to remove scanner-driven domain shift. The diagnostic plot below "
478
- "compares per-site feature distributions before and after harmonization."
 
479
  )
480
  mri_dir = st.text_input(
481
- "Input NIfTI directory", "tests/fixtures/mri_sample", key="mri_dir",
 
 
482
  help="Path to a directory of .nii(.gz) files + sites.csv",
483
  )
484
  sites_csv = st.text_input(
485
- "Sites CSV", "tests/fixtures/mri_sample/sites.csv", key="mri_sites",
 
 
486
  )
487
 
488
  if st.button("Run ComBat diagnostics", type="primary", key="mri_diag"):
@@ -493,7 +1108,7 @@ def _render_mri_tab() -> None:
493
  {"input_dir": mri_dir, "sites_csv": sites_csv},
494
  )
495
  _render_combat_diagnostics(result)
496
- st.toast("Diagnostics complete", icon="")
497
  except httpx.HTTPStatusError as e:
498
  st.error(
499
  f"Diagnostics failed (HTTP {e.response.status_code}): "
@@ -504,117 +1119,117 @@ def _render_mri_tab() -> None:
504
 
505
 
506
  def _render_prediction_card(result: dict) -> None:
507
- """Render a B2B-styled decision card: label badge + confidence + SHAP bars."""
508
  st.session_state["last_bbb_prediction"] = result
509
- provenance = result.get("provenance")
510
- if provenance is not None:
511
- run_id = provenance.get("mlflow_run_id")
512
- run_label = run_id[:8] if run_id else "—"
513
- train_date = provenance.get("train_date") or "—"
514
- n_examples = provenance.get("n_examples")
515
- n_label = f"n={n_examples}" if n_examples else "n=—"
516
- st.caption(
517
- f"🔎 MLflow run **{run_label}** · "
518
- f"Model **{provenance.get('model_version', 'v1')}** · "
519
- f"trained {train_date} · {n_label}"
520
- )
521
  label_text = _html.escape(str(result["label_text"]))
522
- badge_color = "#166534" if result["label"] == 1 else "#991B1B"
523
- badge_bg = "#DCFCE7" if result["label"] == 1 else "#FEE2E2"
524
- confidence_pct = result["confidence"] * 100
525
 
526
- st.markdown(
527
- f"""
528
- <div style='background:#FFFFFF;border:1px solid #E2E8F0;border-radius:10px;
529
- padding:1.5rem;margin:1rem 0;box-shadow:0 1px 2px rgba(15,23,42,0.04);'>
530
- <p style='font-size:0.72rem;font-weight:700;color:#64748B;
531
- letter-spacing:0.08em;text-transform:uppercase;margin:0;'>Prediction</p>
532
- <div style='display:flex;align-items:center;gap:0.75rem;margin-top:0.4rem;'>
533
- <span style='background:{badge_bg};color:{badge_color};
534
- padding:0.4rem 0.9rem;border-radius:999px;
535
- font-size:1rem;font-weight:700;letter-spacing:0.01em;'>
536
- {label_text.upper()}
537
- </span>
538
- <span style='color:#475569;font-size:0.95rem;'>
539
- Model confidence: <strong style='color:#0F172A;'>{confidence_pct:.1f}%</strong>
540
- </span>
541
- </div>
542
- </div>
543
- """,
544
- unsafe_allow_html=True,
545
- )
546
 
547
- # Confidence bar
548
- st.markdown(
549
- "<p style='font-size:0.72rem;font-weight:700;color:#64748B;"
550
- "letter-spacing:0.08em;text-transform:uppercase;margin:1rem 0 0.4rem 0;'>"
551
- "Confidence</p>",
552
- unsafe_allow_html=True,
553
- )
554
- st.progress(float(result["confidence"]))
555
 
556
- # Trust caption — precision-at-confidence from held-out 20% test split.
557
- # Silent skip when the API response has no calibration field (legacy models).
558
  calibration = result.get("calibration")
559
  if calibration is not None:
560
- threshold_pct = round(calibration["threshold"] * 100)
561
- precision_pct = round(calibration["precision"] * 100)
562
- support = calibration["support"]
563
  if support == 0:
564
- st.caption(
565
- "📊 Bu güven aralığında held-out test örneği yok — "
566
- "kalibrasyon bilgisi mevcut değil."
567
- )
568
  else:
569
- st.caption(
570
- f"📊 Test set'te ≥{threshold_pct}% güven üreten tahminlerin "
571
- f"precision'ı **{precision_pct}%** (n={support})."
572
  )
 
573
 
574
  drift_z = result.get("drift_z")
575
- rolling_n = result.get("rolling_n", 0)
576
  if drift_z is None and rolling_n < 10:
577
- st.caption(
578
- f"📈 Drift: warming up ({rolling_n}/10 predictions buffered)."
579
- )
580
  elif drift_z is None:
581
- st.caption(
582
- "📈 Drift: unavailable (model lacks train-time confidence stats)."
583
- )
584
  else:
585
- # Sign + magnitude: |z| < 1 in-band, 1–2 mild, >=2 significant.
586
  if abs(drift_z) < 1.0:
587
  tag = "within expected range"
588
  elif abs(drift_z) < 2.0:
589
  tag = "mild distribution shift"
590
  else:
591
  tag = "significant shift — retrain recommended"
592
- st.caption(
593
- f"📈 Drift: trailing-{rolling_n} confidence median is "
594
- f"**{drift_z:+.2f}σ** from train-time distribution ({tag})."
595
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
596
 
597
  # SHAP attributions chart
598
  n_features = len(result["top_features"])
599
  st.markdown(
600
- f"<p style='font-size:0.72rem;font-weight:700;color:#64748B;"
601
- f"letter-spacing:0.08em;text-transform:uppercase;margin:1.5rem 0 0.4rem 0;'>"
602
- f"Top {n_features} SHAP attributions</p>",
603
  unsafe_allow_html=True,
604
  )
605
  import pandas as pd
606
  shap_df = pd.DataFrame(result["top_features"]).set_index("feature")
607
- st.bar_chart(shap_df, height=240, color="#0369A1")
 
 
 
608
 
609
  st.caption(
610
  "Positive SHAP values pushed the model toward the predicted class; "
611
- "negative values pushed it away. Feature names are 2,048-bit Morgan "
612
  "fingerprint indices (`fp_<bit>`)."
613
  )
614
 
615
 
616
  def _render_combat_diagnostics(result: dict) -> None:
617
- """Render the Pre/Post-ComBat KDE comparison + site-gap KPI strip."""
618
  import altair as alt
619
  import pandas as pd
620
 
@@ -633,17 +1248,15 @@ def _render_combat_diagnostics(result: dict) -> None:
633
  "Reduction factor",
634
  f"{result['reduction_factor']:.0f}×",
635
  help=(
636
- "Pre-gap / Post-gap. A 100× reduction means ComBat "
637
- "removed two orders of magnitude of site-driven domain shift."
638
  ),
639
  )
640
 
641
  df = pd.DataFrame(rows)
642
- # Pin the chart to the first feature (most recognizable for the audience).
643
  feat = df["feature"].iloc[0]
644
  feat_df = df[df["feature"] == feat]
645
 
646
- # Layered KDE: x = feature_value, color = site, faceted by harmonization_state.
647
  chart = (
648
  alt.Chart(feat_df)
649
  .transform_density(
@@ -651,14 +1264,13 @@ def _render_combat_diagnostics(result: dict) -> None:
651
  groupby=["site", "harmonization_state"],
652
  as_=["feature_value", "density"],
653
  )
654
- .mark_area(opacity=0.55)
655
  .encode(
656
  x=alt.X("feature_value:Q", title=f"{feat} (intensity)"),
657
  y=alt.Y("density:Q", title="Density"),
658
  color=alt.Color(
659
  "site:N",
660
  title="Site",
661
- scale=alt.Scale(scheme="tableau10"),
662
  ),
663
  tooltip=[
664
  alt.Tooltip("site:N"),
@@ -672,7 +1284,6 @@ def _render_combat_diagnostics(result: dict) -> None:
672
  "harmonization_state:N",
673
  title=None,
674
  sort=["Pre-ComBat", "Post-ComBat"],
675
- header=alt.Header(labelFontSize=13, labelFontWeight="bold"),
676
  )
677
  )
678
  .resolve_scale(x="shared", y="shared")
@@ -680,14 +1291,13 @@ def _render_combat_diagnostics(result: dict) -> None:
680
  st.altair_chart(chart, use_container_width=True)
681
 
682
  st.caption(
683
- f"Per-site density of `{feat}` before and after ComBat. Each "
684
- f"colored region is one acquisition site. **Convergence of the "
685
- f"colored regions in the Post-ComBat panel is the visual proof "
686
- f"of harmonization** — the same property the {result['reduction_factor']:.0f}× "
687
- f"site-gap reduction quantifies."
688
  )
689
 
690
- # Day-8 T1C: AI Assistant inline for MRI
691
  n_subjects = len({r["subject_id"] for r in result.get("rows", [])})
692
  with st.expander("Ask the AI Assistant about this ComBat run", expanded=False):
693
  mri_q_presets = [
@@ -722,34 +1332,39 @@ def _render_combat_diagnostics(result: dict) -> None:
722
  f"Model: `{mri_resp.get('model') or '—'}`"
723
  )
724
  except httpx.HTTPStatusError as e:
725
- st.error(f"Assistant failed (HTTP {e.response.status_code}): {e.response.text}")
 
 
 
726
  except httpx.RequestError as e:
727
  st.error(f"Cannot reach FastAPI: {e!r}")
728
 
729
 
730
  def _render_ai_assistant_tab() -> None:
731
- """Day-7 T3C: chat-style explainer for the most recent BBB prediction."""
732
  _render_section(
733
  "AI Assistant",
734
  "Natural-language rationale (LLM or deterministic template)",
735
- "Pulls the most recent BBB prediction from this session and asks "
736
- "the explainer to justify it. Falls back to a deterministic, "
737
- "auditable template when no LLM is configured."
738
  )
739
 
740
  last = st.session_state.get("last_bbb_prediction")
741
  if last is None:
742
  st.info(
743
- "Run a BBB prediction first (BBB tab → Predict button), "
744
  "then come back here to ask the assistant about it."
745
  )
746
  return
747
 
748
- # Snapshot card so the user knows which prediction is being explained
 
 
749
  st.caption(
750
  f"Latest prediction: **{last['label_text']}** "
751
  f"({float(last['confidence']) * 100:.0f}% confident) · "
752
- f"Top SHAP: {', '.join(f['feature'] for f in last.get('top_features', [])[:3])}"
753
  )
754
 
755
  PRESETS = [
@@ -762,7 +1377,10 @@ def _render_ai_assistant_tab() -> None:
762
  "Or type your own question (optional)",
763
  value="",
764
  key="ai_custom",
765
- help="Custom questions only affect the LLM path; the template gives a generic SHAP-driven rationale either way.",
 
 
 
766
  )
767
  question = custom.strip() or preset
768
 
@@ -779,10 +1397,6 @@ def _render_ai_assistant_tab() -> None:
779
  "drift_z": last.get("drift_z"),
780
  "user_question": question,
781
  }
782
- # The /predict/bbb response payload doesn't include the
783
- # user-supplied SMILES (only label/confidence/etc.), so
784
- # pull it from the input widget for paper-trail accuracy.
785
- # Streamlit text inputs persist via st.session_state.
786
  if not body["smiles"]:
787
  body["smiles"] = st.session_state.get("bbb_smiles", "")
788
  resp = _post("/explain/bbb", body)
@@ -799,28 +1413,26 @@ def _render_ai_assistant_tab() -> None:
799
  history = st.session_state.setdefault("explain_history", [])
800
  history.insert(0, (question, resp))
801
 
802
- # Render history (most recent first)
803
  history = st.session_state.get("explain_history", [])
804
  if history:
805
  st.markdown("### Conversation")
806
- for q, r in history[:10]: # cap at 10 most recent
807
- with st.container():
808
- st.markdown(f"**Q:** {q}")
809
- st.markdown(f"**A:** {r['rationale']}")
810
- source = r.get("source", "?")
811
- model = r.get("model") or "—"
812
- st.caption(f"Source: `{source}` · Model: `{model}`")
813
- st.divider()
814
 
815
 
816
  def _render_experiments_tab() -> None:
817
- """Day-8 T2B: MLflow runs table + two-run diff (Track 5)."""
818
  _render_section(
819
  "Experiments — MLOps Audit",
820
  "MLflow runs across BBB / EEG / MRI experiments",
821
- "Lists every recorded training run; pick any two to see "
822
- "a side-by-side metric + parameter diff. Foundation for "
823
- "auditable, reproducible model lineage."
824
  )
825
 
826
  if st.button("Refresh runs", key="exp_refresh"):
@@ -833,7 +1445,10 @@ def _render_experiments_tab() -> None:
833
  runs = data.get("runs", [])
834
  st.session_state["experiments_runs_cache"] = runs
835
  except httpx.HTTPStatusError as e:
836
- st.error(f"Failed to load runs (HTTP {e.response.status_code}): {e.response.text}")
 
 
 
837
  return
838
  except httpx.RequestError as e:
839
  st.error(f"Cannot reach FastAPI at {_API_URL}: {e!r}")
@@ -841,26 +1456,25 @@ def _render_experiments_tab() -> None:
841
 
842
  if not runs:
843
  st.info(
844
- "No MLflow runs found. Trigger a pipeline (BBB / EEG / MRI) "
845
- "first, then refresh this tab. (If MLflow is disabled via "
846
- "NEUROBRIDGE_DISABLE_MLFLOW=1, this list will stay empty.)"
847
  )
848
  return
849
 
850
- # Render the runs table with a flat preview of metrics + params
851
- rows_preview = []
852
- for run in runs:
853
- rows_preview.append({
854
  "run_id": run["run_id"][:8],
855
  "experiment": run["experiment_name"],
856
- "start_time": run["start_time"][:19], # YYYY-MM-DDTHH:MM:SS
857
  "status": run["status"],
858
  "n_metrics": len(run["metrics"]),
859
  "n_params": len(run["params"]),
860
- })
 
 
861
  st.dataframe(rows_preview, use_container_width=True, hide_index=True)
862
 
863
- # Run-vs-run diff selector
864
  st.markdown("### Compare two runs")
865
  run_ids = [r["run_id"] for r in runs]
866
  if len(run_ids) < 2:
@@ -869,15 +1483,28 @@ def _render_experiments_tab() -> None:
869
 
870
  col_a, col_b = st.columns(2)
871
  with col_a:
872
- sel_a = st.selectbox("Run A", options=run_ids, format_func=lambda x: x[:8], key="diff_a")
 
 
 
873
  with col_b:
874
- sel_b = st.selectbox("Run B", options=run_ids, index=min(1, len(run_ids) - 1), format_func=lambda x: x[:8], key="diff_b")
 
 
 
 
875
 
876
  if st.button("Show diff", type="primary", key="exp_diff_go"):
877
  try:
878
- diff = _post("/experiments/diff", {"run_id_a": sel_a, "run_id_b": sel_b})
 
 
 
879
  except httpx.HTTPStatusError as e:
880
- st.error(f"Diff failed (HTTP {e.response.status_code}): {e.response.text}")
 
 
 
881
  return
882
  rows = diff.get("rows", [])
883
  if not rows:
@@ -896,6 +1523,10 @@ def _render_experiments_tab() -> None:
896
  st.dataframe(diff_table, use_container_width=True, hide_index=True)
897
 
898
 
 
 
 
 
899
  def main() -> None:
900
  """Streamlit entrypoint. Idempotent — Streamlit re-runs on every interaction."""
901
  st.set_page_config(
@@ -904,23 +1535,26 @@ def main() -> None:
904
  layout="wide",
905
  initial_sidebar_state="expanded",
906
  )
907
- st.markdown(_CUSTOM_CSS, unsafe_allow_html=True)
 
 
 
908
 
909
  api_ok, api_status = _check_api_health()
910
- _render_brand_header()
911
  _render_sidebar(api_ok, api_status)
912
 
913
  if not api_ok:
914
  st.warning(
915
- f"⚠️ FastAPI surface is not reachable at `{_API_URL}` ({api_status}). "
916
  "Pipeline runs will fail until the API service is up. "
917
  "Run `uvicorn src.api.main:app --port 8000` or `docker compose up`."
918
  )
919
 
920
  bbb_tab, eeg_tab, mri_tab, assistant_tab, experiments_tab = st.tabs([
921
- "Molecule (BBB)",
922
- "Signal (EEG)",
923
- "Image (MRI)",
924
  "AI Assistant",
925
  "Experiments",
926
  ])
 
1
+ """NeuroBridge Enterprise — Streamlit B2B dashboard (Editorial redesign).
2
 
3
+ Five tabs (Molecule / Signal / Image / AI Assistant / Experiments) sitting on
4
+ top of one FastAPI surface. Every interaction returns an auditable decision
5
+ artefact: label + confidence + calibration + drift + provenance + SHAP.
6
 
7
+ Visual language (post-redesign):
8
+ - Dark theme = editorial Netflix-style deep neutral grays + sand accent
9
+ - Light theme = warm paper + charcoal type — Apple HIG / NYT-Cooking energy
10
+ - Single sand brand-mark across both themes (#D2C4B1)
11
+ - Inter (display + body) + JetBrains Mono (data / code)
12
 
13
  Launch: `streamlit run src/frontend/app.py`
14
  """
 
27
  os.environ.get("MLFLOW_TRACKING_URI", "http://localhost:5000"),
28
  )
29
  _MLFLOW_DISABLED = os.environ.get("NEUROBRIDGE_DISABLE_MLFLOW") == "1"
30
+ _LLM_DISABLED = os.environ.get("NEUROBRIDGE_DISABLE_LLM") == "1"
31
+
32
+
33
+ # --------------------------------------------------------------------------- #
34
+ # Design tokens — single source of truth for both themes. #
35
+ # Tokens are exposed as CSS custom properties at the :root level; every #
36
+ # component reads from them so a theme swap is just a value swap. #
37
+ # --------------------------------------------------------------------------- #
38
+
39
+ _TOKENS_DARK = {
40
+ # Surfaces (deepest → most elevated)
41
+ "bg-base": "#0e0e10",
42
+ "bg-elevated": "#161618",
43
+ "bg-elevated-2": "#1e1e21",
44
+ "bg-elevated-3": "#2a2a2e",
45
+ # Brand accent
46
+ "accent": "#D2C4B1",
47
+ "accent-strong": "#E8DCC6",
48
+ "accent-soft": "rgba(210, 196, 177, 0.12)",
49
+ "accent-ring": "rgba(210, 196, 177, 0.35)",
50
+ # Text
51
+ "text-primary": "#F5F2ED",
52
+ "text-secondary": "#A8A29A",
53
+ "text-tertiary": "#6B6660",
54
+ "text-on-accent": "#161618",
55
+ # Lines
56
+ "border": "#2a2a2e",
57
+ "border-strong": "#3a3a3e",
58
+ # Semantic (keep cool — never red/green dominant in editorial)
59
+ "success": "#7FB069",
60
+ "warning": "#E0B469",
61
+ "danger": "#D97A6C",
62
+ # Effects
63
+ "shadow-sm": "0 1px 2px rgba(0, 0, 0, 0.4)",
64
+ "shadow-md": "0 8px 24px rgba(0, 0, 0, 0.45)",
65
+ "shadow-lg": "0 16px 48px rgba(0, 0, 0, 0.55)",
66
+ }
67
 
68
+ _TOKENS_LIGHT = {
69
+ "bg-base": "#FAF7F2",
70
+ "bg-elevated": "#FFFFFF",
71
+ "bg-elevated-2": "#F5F0E8",
72
+ "bg-elevated-3": "#EDE5D5",
73
+ "accent": "#1e1e21",
74
+ "accent-strong": "#0e0e10",
75
+ "accent-soft": "rgba(30, 30, 33, 0.06)",
76
+ "accent-ring": "rgba(30, 30, 33, 0.18)",
77
+ "text-primary": "#161618",
78
+ "text-secondary": "#4A4540",
79
+ "text-tertiary": "#8A857E",
80
+ "text-on-accent": "#FAF7F2",
81
+ "border": "#E5DDC9",
82
+ "border-strong": "#D2C4B1",
83
+ "success": "#3F7D45",
84
+ "warning": "#A06D1F",
85
+ "danger": "#A1483D",
86
+ "shadow-sm": "0 1px 2px rgba(40, 30, 20, 0.04)",
87
+ "shadow-md": "0 4px 16px rgba(40, 30, 20, 0.08)",
88
+ "shadow-lg": "0 12px 40px rgba(40, 30, 20, 0.12)",
89
+ }
90
 
 
 
 
 
 
91
 
92
+ def _build_css(theme: str) -> str:
93
+ """Return the full <style> block for the active theme.
 
94
 
95
+ All tokens are emitted as CSS variables so the rest of the stylesheet
96
+ is theme-agnostic. Re-runs cheaply since Streamlit caches markdown.
97
+ """
98
+ tokens = _TOKENS_DARK if theme == "dark" else _TOKENS_LIGHT
99
+ css_vars = "\n".join(f" --ng-{k}: {v};" for k, v in tokens.items())
100
 
101
+ return f"""
102
+ <style>
103
+ @import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500&display=swap');
104
+
105
+ :root {{
106
+ {css_vars}
107
+ --ng-radius-sm: 8px;
108
+ --ng-radius-md: 12px;
109
+ --ng-radius-lg: 16px;
110
+ --ng-radius-xl: 24px;
111
+ --ng-font-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
112
+ --ng-font-mono: 'JetBrains Mono', 'SF Mono', Menlo, monospace;
113
+ }}
114
+
115
+ /* --- Global typography + canvas ----------------------------------------- */
116
+
117
+ html, body, [class*="css"], .stApp, .stMarkdown, .stTabs, .stButton,
118
+ .stTextInput, .stSelectbox, .stSlider, .stDataFrame, .stMetric, .stExpander {{
119
+ font-family: var(--ng-font-sans) !important;
120
+ color: var(--ng-text-primary);
121
+ }}
122
+
123
+ .stApp {{
124
+ background: var(--ng-bg-base) !important;
125
+ color: var(--ng-text-primary);
126
+ }}
127
+
128
+ main .block-container {{
129
+ padding-top: 2rem;
130
+ padding-bottom: 4rem;
131
+ max-width: 1200px;
132
+ }}
133
+
134
+ /* --- Hero / brand strip ------------------------------------------------- */
135
+
136
+ .hero {{
137
+ position: relative;
138
+ padding: 3rem 2.25rem 2.5rem 2.25rem;
139
+ margin: -1rem 0 2rem 0;
140
+ border-radius: var(--ng-radius-lg);
141
+ background: linear-gradient(180deg,
142
+ var(--ng-bg-elevated) 0%,
143
+ var(--ng-bg-elevated-2) 100%);
144
+ border: 1px solid var(--ng-border);
145
+ box-shadow: var(--ng-shadow-md);
146
+ overflow: hidden;
147
+ }}
148
+
149
+ .hero::after {{
150
+ content: "";
151
+ position: absolute;
152
+ top: 0; right: 0; bottom: 0;
153
+ width: 1px;
154
+ background: linear-gradient(180deg,
155
+ transparent 0%,
156
+ var(--ng-accent) 50%,
157
+ transparent 100%);
158
+ }}
159
+
160
+ .hero-eyebrow {{
161
+ font-family: var(--ng-font-mono);
162
+ font-size: 0.72rem;
163
+ font-weight: 500;
164
+ color: var(--ng-accent);
165
+ letter-spacing: 0.18em;
166
+ text-transform: uppercase;
167
+ margin: 0 0 0.85rem 0;
168
+ }}
169
+
170
+ .hero-title {{
171
+ font-size: 2.6rem;
172
  font-weight: 700;
173
+ color: var(--ng-text-primary);
174
+ letter-spacing: -0.025em;
175
+ line-height: 1.05;
176
+ margin: 0 0 0.6rem 0;
177
+ }}
178
+
179
+ .hero-title .accent {{
180
+ color: var(--ng-accent);
181
+ font-weight: 800;
182
+ }}
183
+
184
+ .hero-tagline {{
185
+ color: var(--ng-text-secondary);
186
+ font-size: 1.05rem;
187
+ line-height: 1.55;
188
+ margin: 0 0 1.25rem 0;
189
+ max-width: 60ch;
190
+ }}
191
+
192
+ .hero-status-row {{
193
+ display: flex;
194
+ flex-wrap: wrap;
195
+ gap: 0.5rem;
196
+ align-items: center;
197
+ margin-top: 0.5rem;
198
+ }}
199
+
200
+ /* --- Status dots + pills ----------------------------------------------- */
201
+
202
+ .dot {{
203
+ display: inline-flex;
204
+ align-items: center;
205
+ gap: 0.45rem;
206
+ padding: 0.32rem 0.72rem;
207
+ border-radius: 999px;
208
+ font-family: var(--ng-font-mono);
209
+ font-size: 0.72rem;
210
+ font-weight: 500;
211
+ letter-spacing: 0.08em;
212
+ text-transform: uppercase;
213
+ background: var(--ng-bg-elevated-3);
214
+ color: var(--ng-text-secondary);
215
+ border: 1px solid var(--ng-border);
216
+ }}
217
+
218
+ .dot::before {{
219
+ content: "";
220
+ width: 6px; height: 6px;
221
+ border-radius: 50%;
222
+ background: var(--ng-text-tertiary);
223
+ }}
224
+
225
+ .dot.is-ok::before {{ background: var(--ng-success); box-shadow: 0 0 8px var(--ng-success); }}
226
+ .dot.is-warn::before {{ background: var(--ng-warning); }}
227
+ .dot.is-down::before {{ background: var(--ng-danger); }}
228
+ .dot.is-mute::before {{ background: var(--ng-text-tertiary); }}
229
+
230
+ /* --- Section header ----------------------------------------------------- */
231
+
232
+ .section {{
233
+ margin: 2rem 0 1.5rem 0;
234
+ padding-bottom: 1.25rem;
235
+ border-bottom: 1px solid var(--ng-border);
236
+ }}
237
+ .section-eyebrow {{
238
+ font-family: var(--ng-font-mono);
239
+ font-size: 0.7rem;
240
+ font-weight: 500;
241
+ color: var(--ng-accent);
242
+ letter-spacing: 0.18em;
243
+ text-transform: uppercase;
244
+ margin: 0 0 0.55rem 0;
245
+ }}
246
+ .section-title {{
247
+ font-size: 1.7rem;
248
+ font-weight: 700;
249
+ color: var(--ng-text-primary);
250
  letter-spacing: -0.02em;
251
+ margin: 0 0 0.65rem 0;
252
+ line-height: 1.2;
253
+ }}
254
+ .section-desc {{
255
+ color: var(--ng-text-secondary);
256
+ font-size: 0.97rem;
257
+ line-height: 1.65;
258
  margin: 0;
259
+ max-width: 70ch;
260
+ }}
261
+
262
+ /* --- Decision card (BBB) ----------------------------------------------- */
263
+
264
+ .card {{
265
+ background: var(--ng-bg-elevated);
266
+ border: 1px solid var(--ng-border);
267
+ border-radius: var(--ng-radius-md);
268
+ padding: 1.6rem 1.75rem;
269
+ margin: 1.25rem 0;
270
+ box-shadow: var(--ng-shadow-md);
271
+ }}
272
+
273
+ .provenance-strip {{
274
+ display: flex;
275
+ flex-wrap: wrap;
276
+ gap: 0.5rem 1rem;
277
+ font-family: var(--ng-font-mono);
278
+ font-size: 0.74rem;
279
+ color: var(--ng-text-tertiary);
280
+ letter-spacing: 0.04em;
281
+ margin-bottom: 1.25rem;
282
+ padding-bottom: 1.1rem;
283
+ border-bottom: 1px solid var(--ng-border);
284
+ }}
285
+ .provenance-strip strong {{
286
+ color: var(--ng-text-secondary);
287
+ font-weight: 500;
288
+ }}
289
+
290
+ .verdict {{
291
+ display: flex;
292
+ flex-direction: column;
293
+ gap: 0.5rem;
294
+ margin-bottom: 1.5rem;
295
+ }}
296
+ .verdict-label {{
297
+ font-family: var(--ng-font-mono);
298
+ font-size: 0.72rem;
299
+ font-weight: 500;
300
+ letter-spacing: 0.18em;
301
+ text-transform: uppercase;
302
+ color: var(--ng-text-tertiary);
303
+ margin: 0;
304
+ }}
305
+ .verdict-value {{
306
+ font-size: 3rem;
307
+ font-weight: 800;
308
+ color: var(--ng-accent);
309
+ letter-spacing: -0.03em;
310
+ line-height: 1;
311
+ margin: 0;
312
+ font-feature-settings: "tnum" on, "lnum" on;
313
+ }}
314
+ .verdict-confidence {{
315
+ font-size: 1.1rem;
316
+ color: var(--ng-text-secondary);
317
  margin: 0.25rem 0 0 0;
 
318
  font-weight: 400;
319
+ }}
320
+ .verdict-confidence strong {{
321
+ color: var(--ng-text-primary);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
322
  font-weight: 600;
323
+ font-feature-settings: "tnum" on;
324
+ }}
325
+
326
+ .signals {{
327
+ display: grid;
328
+ gap: 0.65rem;
329
+ padding: 1rem 0 1.25rem 0;
330
+ border-top: 1px solid var(--ng-border);
331
+ border-bottom: 1px solid var(--ng-border);
332
+ margin-bottom: 1.25rem;
333
+ }}
334
+ .signal-row {{
335
+ display: grid;
336
+ grid-template-columns: 100px 1fr;
337
+ gap: 0.85rem;
338
+ align-items: baseline;
339
+ font-size: 0.92rem;
340
+ line-height: 1.55;
341
+ }}
342
+ .signal-key {{
343
+ font-family: var(--ng-font-mono);
344
+ font-size: 0.72rem;
345
+ font-weight: 500;
346
+ color: var(--ng-text-tertiary);
347
+ letter-spacing: 0.12em;
348
  text-transform: uppercase;
349
+ }}
350
+ .signal-value {{
351
+ color: var(--ng-text-secondary);
352
+ font-feature-settings: "tnum" on;
353
+ }}
354
+ .signal-value strong {{
355
+ color: var(--ng-text-primary);
 
 
 
 
 
 
 
356
  font-weight: 600;
357
+ }}
358
+
359
+ /* --- Streamlit native overrides --------------------------------------- */
360
+
361
+ /* Buttons — primary CTA = sand block in dark, charcoal in light */
362
+ .stButton > button[kind="primary"],
363
+ .stButton > button[kind="primaryFormSubmit"] {{
364
+ background: var(--ng-accent) !important;
365
+ color: var(--ng-text-on-accent) !important;
366
+ border: 0 !important;
367
+ border-radius: var(--ng-radius-sm) !important;
368
+ font-weight: 600 !important;
369
+ padding: 0.6rem 1.4rem !important;
370
+ letter-spacing: 0.01em !important;
371
+ font-size: 0.92rem !important;
372
+ transition: background 180ms ease, transform 120ms ease, box-shadow 180ms ease !important;
373
+ box-shadow: 0 0 0 0 var(--ng-accent-ring);
374
+ }}
375
+ .stButton > button[kind="primary"]:hover {{
376
+ background: var(--ng-accent-strong) !important;
377
  transform: translateY(-1px);
378
+ }}
379
+ .stButton > button[kind="primary"]:focus {{
380
+ box-shadow: 0 0 0 3px var(--ng-accent-ring) !important;
381
+ outline: none !important;
382
+ }}
383
+
384
+ /* Buttons secondary = transparent border */
385
+ .stButton > button:not([kind="primary"]):not([kind="primaryFormSubmit"]) {{
386
+ background: transparent !important;
387
+ color: var(--ng-text-primary) !important;
388
+ border: 1px solid var(--ng-border-strong) !important;
389
+ border-radius: var(--ng-radius-sm) !important;
390
+ font-weight: 500 !important;
391
+ padding: 0.55rem 1.2rem !important;
392
+ transition: border-color 180ms ease, background 180ms ease !important;
393
+ }}
394
+ .stButton > button:not([kind="primary"]):not([kind="primaryFormSubmit"]):hover {{
395
+ background: var(--ng-bg-elevated-3) !important;
396
+ border-color: var(--ng-accent) !important;
397
+ }}
398
+
399
+ /* Tabs — left-aligned underline indicator (Apple/Netflix tab strip) */
400
+ .stTabs [data-baseweb="tab-list"] {{
401
  gap: 0.25rem;
402
+ border-bottom: 1px solid var(--ng-border);
403
+ background: transparent !important;
404
+ }}
405
+ .stTabs [data-baseweb="tab"] {{
406
+ color: var(--ng-text-tertiary) !important;
407
+ font-weight: 500 !important;
408
+ font-size: 0.95rem !important;
409
+ padding: 0.85rem 1.4rem !important;
410
+ border-bottom: 2px solid transparent !important;
411
+ background: transparent !important;
412
+ transition: color 180ms ease, border-color 180ms ease !important;
413
+ letter-spacing: -0.005em;
414
+ }}
415
+ .stTabs [data-baseweb="tab"]:hover {{
416
+ color: var(--ng-text-secondary) !important;
417
+ }}
418
+ .stTabs [aria-selected="true"] {{
419
+ color: var(--ng-accent) !important;
420
+ border-bottom-color: var(--ng-accent) !important;
421
+ font-weight: 600 !important;
422
+ }}
423
+
424
+ /* Inputs — flat with accent-on-focus border */
425
+ .stTextInput > div > div > input,
426
+ .stTextArea > div > div > textarea {{
427
+ background: var(--ng-bg-elevated-2) !important;
428
+ color: var(--ng-text-primary) !important;
429
+ border: 1px solid var(--ng-border) !important;
430
+ border-radius: var(--ng-radius-sm) !important;
431
+ padding: 0.7rem 0.85rem !important;
432
+ font-family: var(--ng-font-sans) !important;
433
+ font-size: 0.95rem !important;
434
+ transition: border-color 150ms ease, box-shadow 150ms ease !important;
435
+ }}
436
+ .stTextInput > div > div > input:focus,
437
+ .stTextArea > div > div > textarea:focus {{
438
+ border-color: var(--ng-accent) !important;
439
+ box-shadow: 0 0 0 3px var(--ng-accent-ring) !important;
440
+ outline: none !important;
441
+ }}
442
+
443
+ /* Selectbox */
444
+ [data-baseweb="select"] > div {{
445
+ background: var(--ng-bg-elevated-2) !important;
446
+ border: 1px solid var(--ng-border) !important;
447
+ border-radius: var(--ng-radius-sm) !important;
448
+ color: var(--ng-text-primary) !important;
449
+ }}
450
+
451
+ /* Sliders */
452
+ .stSlider [role="slider"] {{
453
+ background: var(--ng-accent) !important;
454
+ border: 2px solid var(--ng-bg-base) !important;
455
+ }}
456
+ .stSlider > div > div > div > div {{
457
+ background: var(--ng-accent) !important;
458
+ }}
459
+
460
+ /* Progress bar */
461
+ .stProgress > div > div > div > div {{
462
+ background: var(--ng-accent) !important;
463
+ border-radius: 999px !important;
464
+ }}
465
+ .stProgress > div > div > div {{
466
+ background: var(--ng-bg-elevated-3) !important;
467
+ border-radius: 999px !important;
468
+ }}
469
+
470
+ /* Metric cards (KPI strip) */
471
+ [data-testid="stMetric"] {{
472
+ background: var(--ng-bg-elevated) !important;
473
+ border: 1px solid var(--ng-border) !important;
474
+ border-radius: var(--ng-radius-md) !important;
475
+ padding: 1.4rem 1.5rem !important;
476
+ box-shadow: var(--ng-shadow-sm);
477
+ }}
478
+ [data-testid="stMetricLabel"] > div {{
479
+ color: var(--ng-text-tertiary) !important;
480
+ font-family: var(--ng-font-mono) !important;
481
+ font-size: 0.7rem !important;
482
+ font-weight: 500 !important;
483
+ text-transform: uppercase !important;
484
+ letter-spacing: 0.14em !important;
485
+ }}
486
+ [data-testid="stMetricValue"] > div {{
487
+ color: var(--ng-text-primary) !important;
488
+ font-weight: 700 !important;
489
+ font-size: 2.4rem !important;
490
+ letter-spacing: -0.02em !important;
491
+ font-feature-settings: "tnum" on, "lnum" on !important;
492
+ line-height: 1.1 !important;
493
+ }}
494
+ [data-testid="stMetricDelta"] {{
495
+ color: var(--ng-text-secondary) !important;
496
+ }}
497
+
498
+ /* Captions */
499
+ .stCaption, [data-testid="stCaptionContainer"] {{
500
+ color: var(--ng-text-tertiary) !important;
501
+ font-size: 0.85rem !important;
502
+ line-height: 1.55 !important;
503
+ }}
504
+
505
+ /* Expander */
506
+ .streamlit-expanderHeader, [data-testid="stExpander"] details summary {{
507
+ background: var(--ng-bg-elevated-2) !important;
508
+ color: var(--ng-text-primary) !important;
509
+ border: 1px solid var(--ng-border) !important;
510
+ border-radius: var(--ng-radius-sm) !important;
511
+ font-weight: 500 !important;
512
+ }}
513
+ [data-testid="stExpander"] {{
514
+ border: 1px solid var(--ng-border) !important;
515
+ border-radius: var(--ng-radius-sm) !important;
516
+ background: var(--ng-bg-elevated) !important;
517
+ }}
518
+
519
+ /* Code / inline code */
520
+ code, pre {{
521
+ background: var(--ng-bg-elevated-3) !important;
522
+ color: var(--ng-accent-strong) !important;
523
+ padding: 0.12rem 0.42rem !important;
524
+ border-radius: 4px !important;
525
+ font-family: var(--ng-font-mono) !important;
526
+ font-size: 0.86rem !important;
527
+ }}
528
+
529
+ /* Alerts (info / warning / error / success) — flat editorial banners */
530
+ [data-testid="stAlert"] {{
531
+ background: var(--ng-bg-elevated) !important;
532
+ border: 1px solid var(--ng-border) !important;
533
+ border-left: 3px solid var(--ng-accent) !important;
534
+ border-radius: var(--ng-radius-sm) !important;
535
+ color: var(--ng-text-primary) !important;
536
+ box-shadow: var(--ng-shadow-sm);
537
+ }}
538
+ [data-testid="stAlert"][data-baseweb="notification"][kind="info"] {{ border-left-color: var(--ng-accent); }}
539
+ [data-testid="stAlert"][data-baseweb="notification"][kind="warning"] {{ border-left-color: var(--ng-warning); }}
540
+ [data-testid="stAlert"][data-baseweb="notification"][kind="error"] {{ border-left-color: var(--ng-danger); }}
541
+ [data-testid="stAlert"][data-baseweb="notification"][kind="success"] {{ border-left-color: var(--ng-success); }}
542
 
543
  /* Sidebar */
544
+ section[data-testid="stSidebar"] {{
545
+ background: var(--ng-bg-elevated) !important;
546
+ border-right: 1px solid var(--ng-border) !important;
547
+ }}
548
+ section[data-testid="stSidebar"] .block-container {{
549
+ padding-top: 1.5rem;
550
+ }}
551
+ section[data-testid="stSidebar"] h1,
552
+ section[data-testid="stSidebar"] h2,
553
+ section[data-testid="stSidebar"] h3 {{
554
+ color: var(--ng-text-primary) !important;
555
+ }}
556
+ section[data-testid="stSidebar"] h3 {{
557
+ font-family: var(--ng-font-mono) !important;
558
+ font-size: 0.7rem !important;
559
+ font-weight: 500 !important;
560
+ color: var(--ng-text-tertiary) !important;
561
+ text-transform: uppercase !important;
562
+ letter-spacing: 0.18em !important;
563
+ margin-top: 1.5rem !important;
564
+ margin-bottom: 0.6rem !important;
565
+ }}
566
+
567
+ /* Sidebar brand mark */
568
+ .sidebar-brand {{
569
+ font-family: var(--ng-font-sans);
570
+ font-size: 1.1rem;
571
+ font-weight: 800;
572
+ color: var(--ng-text-primary);
573
+ letter-spacing: -0.02em;
574
+ margin: 0 0 0.15rem 0;
575
+ }}
576
+ .sidebar-brand .accent {{
577
+ color: var(--ng-accent);
578
+ }}
579
+ .sidebar-tagline {{
580
+ font-family: var(--ng-font-mono);
581
+ font-size: 0.7rem;
582
+ color: var(--ng-text-tertiary);
583
+ letter-spacing: 0.12em;
584
  text-transform: uppercase;
585
+ margin: 0 0 1.5rem 0;
586
+ }}
587
+
588
+ /* Toggle (theme switch) */
589
+ [data-baseweb="checkbox"] [aria-checked="true"] {{
590
+ background: var(--ng-accent) !important;
591
+ border-color: var(--ng-accent) !important;
592
+ }}
593
+
594
+ /* Dataframe */
595
+ [data-testid="stDataFrame"] {{
596
+ background: var(--ng-bg-elevated) !important;
597
+ border: 1px solid var(--ng-border) !important;
598
+ border-radius: var(--ng-radius-md) !important;
599
+ overflow: hidden;
600
+ }}
601
+
602
+ /* Markdown headings inside tabs */
603
+ .stMarkdown h1, .stMarkdown h2, .stMarkdown h3, .stMarkdown h4 {{
604
+ color: var(--ng-text-primary) !important;
605
+ letter-spacing: -0.015em !important;
606
+ }}
607
+ .stMarkdown h3 {{
608
+ font-size: 1.2rem !important;
609
+ font-weight: 600 !important;
610
+ margin-top: 1.5rem !important;
611
+ }}
612
+
613
+ /* Divider */
614
+ hr, [data-testid="stDivider"] {{
615
+ border-color: var(--ng-border) !important;
616
+ margin: 1.5rem 0 !important;
617
+ }}
618
+
619
+ /* Toast (st.toast) */
620
+ .stToast {{
621
+ background: var(--ng-bg-elevated) !important;
622
+ color: var(--ng-text-primary) !important;
623
+ border: 1px solid var(--ng-border) !important;
624
+ box-shadow: var(--ng-shadow-lg) !important;
625
+ }}
626
+
627
+ /* Chart container — quiet frame */
628
+ [data-testid="stArrowVegaLiteChart"], [data-testid="stVegaLiteChart"] {{
629
+ background: var(--ng-bg-elevated);
630
+ border: 1px solid var(--ng-border);
631
+ border-radius: var(--ng-radius-md);
632
+ padding: 1rem;
633
+ }}
634
+
635
+ /* Bar chart (st.bar_chart) inherits the same frame */
636
+ [data-testid="stBarChart"] {{
637
+ background: var(--ng-bg-elevated);
638
+ border: 1px solid var(--ng-border);
639
+ border-radius: var(--ng-radius-md);
640
+ padding: 1rem;
641
+ }}
642
+
643
+ /* Reduced motion */
644
+ @media (prefers-reduced-motion: reduce) {{
645
+ * {{
646
+ animation-duration: 0.01ms !important;
647
+ transition-duration: 0.01ms !important;
648
+ }}
649
+ }}
650
+
651
+ /* Scrollbar — subtle */
652
+ ::-webkit-scrollbar {{ width: 10px; height: 10px; }}
653
+ ::-webkit-scrollbar-track {{ background: var(--ng-bg-base); }}
654
+ ::-webkit-scrollbar-thumb {{
655
+ background: var(--ng-bg-elevated-3);
656
+ border-radius: 999px;
657
+ border: 2px solid var(--ng-bg-base);
658
+ }}
659
+ ::-webkit-scrollbar-thumb:hover {{ background: var(--ng-border-strong); }}
660
  </style>
661
  """
662
 
663
 
664
+ # --------------------------------------------------------------------------- #
665
+ # Theme management #
666
+ # --------------------------------------------------------------------------- #
667
+
668
+ def _init_theme() -> str:
669
+ """Initialize and return the active theme ('dark' default)."""
670
+ if "theme" not in st.session_state:
671
+ st.session_state["theme"] = "dark"
672
+ return st.session_state["theme"]
673
+
674
+
675
+ def _altair_theme(theme: str) -> dict:
676
+ """Return an altair theme matching the active palette.
677
+
678
+ Registered as 'neurobridge' on first call; subsequent calls just enable.
679
+ """
680
+ tokens = _TOKENS_DARK if theme == "dark" else _TOKENS_LIGHT
681
+ return {
682
+ "config": {
683
+ "background": tokens["bg-elevated"],
684
+ "view": {"stroke": "transparent"},
685
+ "axis": {
686
+ "labelColor": tokens["text-secondary"],
687
+ "titleColor": tokens["text-secondary"],
688
+ "labelFont": "Inter",
689
+ "titleFont": "Inter",
690
+ "labelFontSize": 11,
691
+ "titleFontSize": 12,
692
+ "gridColor": tokens["border"],
693
+ "domainColor": tokens["border"],
694
+ "tickColor": tokens["border"],
695
+ },
696
+ "header": {
697
+ "labelColor": tokens["text-primary"],
698
+ "labelFont": "Inter",
699
+ "labelFontSize": 13,
700
+ "labelFontWeight": 600,
701
+ "titleColor": tokens["text-secondary"],
702
+ },
703
+ "legend": {
704
+ "labelColor": tokens["text-secondary"],
705
+ "titleColor": tokens["text-secondary"],
706
+ "labelFont": "Inter",
707
+ "titleFont": "Inter",
708
+ },
709
+ "title": {
710
+ "color": tokens["text-primary"],
711
+ "font": "Inter",
712
+ "fontWeight": 600,
713
+ },
714
+ "range": {
715
+ # Editorial palette: sand-led, then warm secondaries.
716
+ "category": [
717
+ tokens["accent"], "#8FB3C9", "#C99B8F", "#9DAD86",
718
+ "#B8A4C9", "#D4B86A", "#7FB069", "#A6A2C2",
719
+ ],
720
+ },
721
+ }
722
+ }
723
+
724
+
725
+ def _register_altair_theme(theme: str) -> None:
726
+ """Register + enable the neurobridge altair theme for the current run."""
727
+ try:
728
+ import altair as alt
729
+ alt.themes.register("neurobridge", lambda: _altair_theme(theme))
730
+ alt.themes.enable("neurobridge")
731
+ except Exception:
732
+ # altair may not be importable in some environments; chart calls
733
+ # will simply use altair defaults — no functional impact.
734
+ pass
735
+
736
+
737
+ # --------------------------------------------------------------------------- #
738
+ # HTTP helpers #
739
+ # --------------------------------------------------------------------------- #
740
+
741
  def _check_api_health() -> tuple[bool, str]:
742
  """Ping FastAPI /health endpoint; return (ok, status_text)."""
743
  try:
744
  resp = httpx.get(f"{_API_URL}/health", timeout=2.0)
745
  if resp.status_code == 200:
746
+ return True, "operational"
747
  return False, f"http {resp.status_code}"
748
  except httpx.RequestError as e:
749
+ return False, type(e).__name__.lower()
750
 
751
 
752
  def _post(endpoint: str, payload: dict) -> dict:
 
763
  return resp.json()
764
 
765
 
766
+ # --------------------------------------------------------------------------- #
767
+ # Hero / sidebar / section primitives #
768
+ # --------------------------------------------------------------------------- #
769
+
770
+ def _render_brand_header(api_ok: bool, api_status: str) -> None:
771
+ """Editorial hero strip: word-mark + tagline + 3 status dots."""
772
+ api_class = "is-ok" if api_ok else "is-down"
773
+ mlflow_class = "is-mute" if _MLFLOW_DISABLED else "is-ok"
774
+ mlflow_label = "tracking off" if _MLFLOW_DISABLED else "tracking"
775
+ llm_class = "is-mute" if _LLM_DISABLED else "is-ok"
776
+ llm_label = "template only" if _LLM_DISABLED else "llm online"
777
+
778
  st.markdown(
779
+ f"""
780
+ <div class="hero">
781
+ <p class="hero-eyebrow">Living decision system · clinical ML</p>
782
+ <h1 class="hero-title">Neuro<span class="accent">Bridge</span> Enterprise</h1>
783
+ <p class="hero-tagline">
784
+ Three production pipelines — molecule, signal, image — behind one
785
+ auditable surface. Every prediction returns label, calibration,
786
+ drift, provenance and a natural-language rationale.
787
+ </p>
788
+ <div class="hero-status-row">
789
+ <span class="dot {api_class}">api · {_html.escape(api_status)}</span>
790
+ <span class="dot {mlflow_class}">mlflow · {mlflow_label}</span>
791
+ <span class="dot {llm_class}">explainer · {llm_label}</span>
792
+ </div>
793
  </div>
794
  """,
795
  unsafe_allow_html=True,
 
799
  def _render_section(eyebrow: str, title: str, desc: str) -> None:
800
  st.markdown(
801
  f"""
802
+ <div class="section">
803
+ <p class="section-eyebrow">{_html.escape(eyebrow)}</p>
804
+ <h2 class="section-title">{_html.escape(title)}</h2>
805
+ <p class="section-desc">{_html.escape(desc)}</p>
806
+ </div>
807
  """,
808
  unsafe_allow_html=True,
809
  )
810
 
811
 
812
  def _render_result(body: dict) -> None:
813
+ """Render a 3-metric result card + (optional) MLflow deep link."""
814
  cols = st.columns(3)
815
  cols[0].metric("Rows", f"{body['rows']:,}")
816
  cols[1].metric("Columns", f"{body['columns']:,}")
 
818
 
819
  safe_output_path = _html.escape(str(body["output_path"]))
820
  st.markdown(
821
+ f"<p style='color:var(--ng-text-tertiary);"
822
+ f"margin:1rem 0 0.5rem 0;font-size:0.85rem;'>"
823
+ f"output → <code>{safe_output_path}</code></p>",
824
  unsafe_allow_html=True,
825
  )
826
 
 
829
  safe_run_id = _html.escape(str(run_id))
830
  safe_url = _html.escape(_MLFLOW_URL, quote=True)
831
  st.markdown(
832
+ f"<p style='color:var(--ng-text-tertiary);font-size:0.85rem;'>"
833
+ f"mlflow run · <a href='{safe_url}/#/experiments/0/runs/{safe_run_id}' "
834
+ f"target='_blank' rel='noopener noreferrer' "
835
+ f"style='color:var(--ng-accent);text-decoration:none;"
836
+ f"border-bottom:1px solid var(--ng-accent-ring);'>"
837
+ f"{safe_run_id[:12]}…</a></p>",
838
  unsafe_allow_html=True,
839
  )
840
  elif _MLFLOW_DISABLED:
841
+ st.caption("mlflow tracking disabled (NEUROBRIDGE_DISABLE_MLFLOW=1)")
 
 
 
 
842
 
843
 
844
  def _render_sidebar(api_ok: bool, api_status: str) -> None:
845
  with st.sidebar:
846
+ st.markdown(
847
+ """
848
+ <p class="sidebar-brand">Neuro<span class="accent">Bridge</span></p>
849
+ <p class="sidebar-tagline">enterprise · v1</p>
850
+ """,
851
+ unsafe_allow_html=True,
852
  )
853
+
854
+ st.markdown("### Theme")
855
+ theme = st.session_state.get("theme", "dark")
856
+ is_dark = st.toggle(
857
+ "Dark mode",
858
+ value=(theme == "dark"),
859
+ key="theme_toggle",
860
+ help="Switch between editorial dark (Netflix-style) and warm paper (Apple HIG-style).",
861
+ )
862
+ new_theme = "dark" if is_dark else "light"
863
+ if new_theme != theme:
864
+ st.session_state["theme"] = new_theme
865
+ st.rerun()
866
+
867
+ st.markdown("### System")
868
+ api_class = "is-ok" if api_ok else "is-down"
869
+ mlflow_class = "is-mute" if _MLFLOW_DISABLED else "is-ok"
870
+ llm_class = "is-mute" if _LLM_DISABLED else "is-ok"
871
+ st.markdown(
872
+ f"""
873
+ <div style='display:flex;flex-direction:column;gap:0.4rem;'>
874
+ <span class='dot {api_class}'>api · {_html.escape(api_status)}</span>
875
+ <span class='dot {mlflow_class}'>mlflow · {"off" if _MLFLOW_DISABLED else "on"}</span>
876
+ <span class='dot {llm_class}'>llm · {"template" if _LLM_DISABLED else "online"}</span>
877
+ </div>
878
+ """,
879
+ unsafe_allow_html=True,
880
  )
 
881
 
882
  st.markdown("### Endpoints")
883
  st.markdown(
884
+ f"<p style='font-family:var(--ng-font-mono);font-size:0.78rem;"
885
+ f"color:var(--ng-text-tertiary);line-height:1.8;margin:0;'>"
886
+ f"fastapi · <code>{_API_URL}</code><br/>"
887
+ f"mlflow &nbsp;· <code>{_MLFLOW_URL}</code></p>",
888
  unsafe_allow_html=True,
889
  )
890
 
891
  st.markdown("### About")
892
  st.markdown(
893
+ "<p style='font-size:0.86rem;color:var(--ng-text-secondary);"
894
+ "line-height:1.65;margin:0;'>"
895
+ "Trust-engineered clinical-ML platform. Three modalities BBB drug "
896
+ "screening, EEG signal cleaning, MRI multi-site harmonization — "
897
+ "behind one FastAPI surface. Every inference is auditable.</p>",
898
  unsafe_allow_html=True,
899
  )
900
 
901
 
902
+ # --------------------------------------------------------------------------- #
903
+ # Tabs #
904
+ # --------------------------------------------------------------------------- #
905
+
906
  def _render_bbb_tab() -> None:
907
  _render_section(
908
  "MOLECULE — BBBP",
909
  "Blood-Brain-Barrier permeability decision",
910
  "Enter a SMILES string. The system computes a 2,048-bit Morgan "
911
+ "fingerprint, runs it through a Random Forest classifier, and returns "
912
+ "a label, calibration-grounded confidence, drift signal, and the top "
913
+ "SHAP attributions explaining the decision.",
 
914
  )
915
 
916
  EDGE_CASES = {
 
951
  }
952
 
953
  case_name = st.selectbox(
954
+ "Test edge cases",
955
  options=list(EDGE_CASES.keys()),
956
  index=0,
957
  key="bbb_case",
958
  help=(
959
+ "Pick a robustness probe. Each case demonstrates how the system "
960
+ "handles a real-world failure mode — invalid input, "
961
  "out-of-distribution molecules, or boundary conditions."
962
  ),
963
  )
 
975
  )
976
 
977
  if st.button("Predict BBB permeability", type="primary", key="bbb_predict"):
978
+ with st.spinner("Computing fingerprint, predicting, explaining…"):
979
  try:
980
  result = _post("/predict/bbb", {"smiles": smiles, "top_k": top_k})
981
  _render_prediction_card(result)
982
+ st.toast("Prediction complete", icon="")
983
  except httpx.HTTPStatusError as e:
984
  if e.response.status_code == 503:
985
  st.error(
 
988
  "then retry."
989
  )
990
  elif e.response.status_code == 400:
991
+ # Robustness story: WARNING (recoverable), not ERROR.
 
992
  st.warning(
993
  f"Robustness check passed: API rejected the input "
994
  f"with HTTP 400 (no crash). Detail: "
 
1012
  "across fixed-duration epochs.",
1013
  )
1014
  eeg_in = st.text_input("Input FIF/EDF path", "data/raw/eeg.fif", key="eeg_in")
1015
+ eeg_out = st.text_input(
1016
+ "Output Parquet path",
1017
+ "data/processed/eeg_features.parquet",
1018
+ key="eeg_out",
1019
+ )
1020
  if st.button("Run EEG pipeline", type="primary", key="eeg_run"):
1021
  with st.spinner("Filtering and running ICA…"):
1022
  try:
1023
+ result = _post(
1024
+ "/pipeline/eeg",
1025
+ {"input_path": eeg_in, "output_path": eeg_out},
1026
+ )
1027
  st.session_state["last_eeg_run"] = result
1028
  _render_result(result)
1029
+ st.toast("EEG pipeline complete", icon="")
1030
  except httpx.HTTPStatusError as e:
1031
+ st.error(
1032
+ f"Pipeline failed (HTTP {e.response.status_code}): "
1033
+ f"{e.response.text}"
1034
+ )
1035
  except httpx.RequestError as e:
1036
  st.error(f"Cannot reach FastAPI at {_API_URL}: {e!r}")
1037
 
 
1038
  last_eeg = st.session_state.get("last_eeg_run")
1039
  if last_eeg is not None:
1040
  with st.expander("Ask the AI Assistant about this EEG run", expanded=False):
 
1070
  f"Model: `{eeg_resp.get('model') or '—'}`"
1071
  )
1072
  except httpx.HTTPStatusError as e:
1073
+ st.error(
1074
+ f"Assistant failed (HTTP {e.response.status_code}): "
1075
+ f"{e.response.text}"
1076
+ )
1077
  except httpx.RequestError as e:
1078
  st.error(f"Cannot reach FastAPI: {e!r}")
1079
 
 
1083
  "IMAGE — MRI",
1084
  "Multi-site harmonization via ComBat",
1085
  "Loads NIfTI volumes, masks brain tissue, computes per-ROI summary "
1086
+ "statistics, then harmonizes across acquisition sites with "
1087
+ "neuroHarmonize to remove scanner-driven domain shift. The diagnostic "
1088
+ "plot below compares per-site feature distributions before and after "
1089
+ "harmonization.",
1090
  )
1091
  mri_dir = st.text_input(
1092
+ "Input NIfTI directory",
1093
+ "tests/fixtures/mri_sample",
1094
+ key="mri_dir",
1095
  help="Path to a directory of .nii(.gz) files + sites.csv",
1096
  )
1097
  sites_csv = st.text_input(
1098
+ "Sites CSV",
1099
+ "tests/fixtures/mri_sample/sites.csv",
1100
+ key="mri_sites",
1101
  )
1102
 
1103
  if st.button("Run ComBat diagnostics", type="primary", key="mri_diag"):
 
1108
  {"input_dir": mri_dir, "sites_csv": sites_csv},
1109
  )
1110
  _render_combat_diagnostics(result)
1111
+ st.toast("Diagnostics complete", icon="")
1112
  except httpx.HTTPStatusError as e:
1113
  st.error(
1114
  f"Diagnostics failed (HTTP {e.response.status_code}): "
 
1119
 
1120
 
1121
  def _render_prediction_card(result: dict) -> None:
1122
+ """Editorial decision card: provenance · verdict · signals · SHAP."""
1123
  st.session_state["last_bbb_prediction"] = result
 
 
 
 
 
 
 
 
 
 
 
 
1124
  label_text = _html.escape(str(result["label_text"]))
1125
+ confidence_pct = float(result["confidence"]) * 100
 
 
1126
 
1127
+ # 1) Provenance strip (auditable line)
1128
+ provenance = result.get("provenance") or {}
1129
+ run_id = provenance.get("mlflow_run_id")
1130
+ run_label = run_id[:8] if run_id else "—"
1131
+ train_date = provenance.get("train_date") or "—"
1132
+ model_version = provenance.get("model_version", "v1")
1133
+ n_examples = provenance.get("n_examples")
1134
+ n_label = f"n={n_examples}" if n_examples else "n=—"
 
 
 
 
 
 
 
 
 
 
 
 
1135
 
1136
+ # 2) Build signal rows: calibration, drift
1137
+ signal_rows: list[tuple[str, str]] = []
 
 
 
 
 
 
1138
 
 
 
1139
  calibration = result.get("calibration")
1140
  if calibration is not None:
1141
+ threshold_pct = round(float(calibration["threshold"]) * 100)
1142
+ precision_pct = round(float(calibration["precision"]) * 100)
1143
+ support = int(calibration["support"])
1144
  if support == 0:
1145
+ cal_str = "no held-out support in this band"
 
 
 
1146
  else:
1147
+ cal_str = (
1148
+ f"≥{threshold_pct}% confident "
1149
+ f"<strong>{precision_pct}%</strong> precision · n={support}"
1150
  )
1151
+ signal_rows.append(("calibration", cal_str))
1152
 
1153
  drift_z = result.get("drift_z")
1154
+ rolling_n = int(result.get("rolling_n", 0))
1155
  if drift_z is None and rolling_n < 10:
1156
+ drift_str = f"warming up · {rolling_n}/10 buffered"
 
 
1157
  elif drift_z is None:
1158
+ drift_str = "unavailable · model lacks train-time stats"
 
 
1159
  else:
 
1160
  if abs(drift_z) < 1.0:
1161
  tag = "within expected range"
1162
  elif abs(drift_z) < 2.0:
1163
  tag = "mild distribution shift"
1164
  else:
1165
  tag = "significant shift — retrain recommended"
1166
+ drift_str = (
1167
+ f"trailing-{rolling_n} median <strong>{drift_z:+.2f}σ</strong> · {tag}"
 
1168
  )
1169
+ signal_rows.append(("drift", drift_str))
1170
+
1171
+ signals_html = "".join(
1172
+ f'<div class="signal-row"><span class="signal-key">{k}</span>'
1173
+ f'<span class="signal-value">{v}</span></div>'
1174
+ for k, v in signal_rows
1175
+ )
1176
+
1177
+ st.markdown(
1178
+ f"""
1179
+ <div class="card">
1180
+ <div class="provenance-strip">
1181
+ <span>mlflow · <strong>{_html.escape(run_label)}</strong></span>
1182
+ <span>model · <strong>{_html.escape(model_version)}</strong></span>
1183
+ <span>trained · <strong>{_html.escape(train_date)}</strong></span>
1184
+ <span><strong>{_html.escape(n_label)}</strong></span>
1185
+ </div>
1186
+ <div class="verdict">
1187
+ <p class="verdict-label">verdict</p>
1188
+ <p class="verdict-value">{label_text.lower()}</p>
1189
+ <p class="verdict-confidence">
1190
+ Model confidence · <strong>{confidence_pct:.1f}%</strong>
1191
+ </p>
1192
+ </div>
1193
+ """,
1194
+ unsafe_allow_html=True,
1195
+ )
1196
+
1197
+ # Native progress bar — themed via CSS variables
1198
+ st.progress(float(result["confidence"]))
1199
+
1200
+ st.markdown(
1201
+ f"""
1202
+ <div class="signals">
1203
+ {signals_html}
1204
+ </div>
1205
+ </div>
1206
+ """,
1207
+ unsafe_allow_html=True,
1208
+ )
1209
 
1210
  # SHAP attributions chart
1211
  n_features = len(result["top_features"])
1212
  st.markdown(
1213
+ f'<p class="section-eyebrow" style="margin-top:1.5rem;">'
1214
+ f'top {n_features} shap attributions</p>',
 
1215
  unsafe_allow_html=True,
1216
  )
1217
  import pandas as pd
1218
  shap_df = pd.DataFrame(result["top_features"]).set_index("feature")
1219
+ # Keep st.bar_chart for simplicity; the wrapper now sits in a themed frame.
1220
+ st.bar_chart(shap_df, height=240, color=_TOKENS_DARK["accent"]
1221
+ if st.session_state.get("theme", "dark") == "dark"
1222
+ else _TOKENS_LIGHT["accent"])
1223
 
1224
  st.caption(
1225
  "Positive SHAP values pushed the model toward the predicted class; "
1226
+ "negative values pushed it away. Features are 2,048-bit Morgan "
1227
  "fingerprint indices (`fp_<bit>`)."
1228
  )
1229
 
1230
 
1231
  def _render_combat_diagnostics(result: dict) -> None:
1232
+ """Pre/Post-ComBat KDE comparison + 3-metric site-gap KPI strip."""
1233
  import altair as alt
1234
  import pandas as pd
1235
 
 
1248
  "Reduction factor",
1249
  f"{result['reduction_factor']:.0f}×",
1250
  help=(
1251
+ "Pre-gap / Post-gap. A 100× reduction means ComBat removed "
1252
+ "two orders of magnitude of site-driven domain shift."
1253
  ),
1254
  )
1255
 
1256
  df = pd.DataFrame(rows)
 
1257
  feat = df["feature"].iloc[0]
1258
  feat_df = df[df["feature"] == feat]
1259
 
 
1260
  chart = (
1261
  alt.Chart(feat_df)
1262
  .transform_density(
 
1264
  groupby=["site", "harmonization_state"],
1265
  as_=["feature_value", "density"],
1266
  )
1267
+ .mark_area(opacity=0.5)
1268
  .encode(
1269
  x=alt.X("feature_value:Q", title=f"{feat} (intensity)"),
1270
  y=alt.Y("density:Q", title="Density"),
1271
  color=alt.Color(
1272
  "site:N",
1273
  title="Site",
 
1274
  ),
1275
  tooltip=[
1276
  alt.Tooltip("site:N"),
 
1284
  "harmonization_state:N",
1285
  title=None,
1286
  sort=["Pre-ComBat", "Post-ComBat"],
 
1287
  )
1288
  )
1289
  .resolve_scale(x="shared", y="shared")
 
1291
  st.altair_chart(chart, use_container_width=True)
1292
 
1293
  st.caption(
1294
+ f"Per-site density of `{feat}` before and after ComBat. Each colored "
1295
+ f"region is one acquisition site. **Convergence of the colored "
1296
+ f"regions in the Post-ComBat panel is the visual proof of "
1297
+ f"harmonization** — the same property the "
1298
+ f"{result['reduction_factor']:.0f}× site-gap reduction quantifies."
1299
  )
1300
 
 
1301
  n_subjects = len({r["subject_id"] for r in result.get("rows", [])})
1302
  with st.expander("Ask the AI Assistant about this ComBat run", expanded=False):
1303
  mri_q_presets = [
 
1332
  f"Model: `{mri_resp.get('model') or '—'}`"
1333
  )
1334
  except httpx.HTTPStatusError as e:
1335
+ st.error(
1336
+ f"Assistant failed (HTTP {e.response.status_code}): "
1337
+ f"{e.response.text}"
1338
+ )
1339
  except httpx.RequestError as e:
1340
  st.error(f"Cannot reach FastAPI: {e!r}")
1341
 
1342
 
1343
  def _render_ai_assistant_tab() -> None:
1344
+ """Chat-style explainer for the most recent BBB prediction."""
1345
  _render_section(
1346
  "AI Assistant",
1347
  "Natural-language rationale (LLM or deterministic template)",
1348
+ "Pulls the most recent BBB prediction from this session and asks the "
1349
+ "explainer to justify it. Falls back to a deterministic, auditable "
1350
+ "template when no LLM is configured.",
1351
  )
1352
 
1353
  last = st.session_state.get("last_bbb_prediction")
1354
  if last is None:
1355
  st.info(
1356
+ "Run a BBB prediction first (Molecule tab → Predict button), "
1357
  "then come back here to ask the assistant about it."
1358
  )
1359
  return
1360
 
1361
+ top_features_preview = ", ".join(
1362
+ f["feature"] for f in last.get("top_features", [])[:3]
1363
+ )
1364
  st.caption(
1365
  f"Latest prediction: **{last['label_text']}** "
1366
  f"({float(last['confidence']) * 100:.0f}% confident) · "
1367
+ f"Top SHAP: {top_features_preview}"
1368
  )
1369
 
1370
  PRESETS = [
 
1377
  "Or type your own question (optional)",
1378
  value="",
1379
  key="ai_custom",
1380
+ help=(
1381
+ "Custom questions only affect the LLM path; the template gives a "
1382
+ "generic SHAP-driven rationale either way."
1383
+ ),
1384
  )
1385
  question = custom.strip() or preset
1386
 
 
1397
  "drift_z": last.get("drift_z"),
1398
  "user_question": question,
1399
  }
 
 
 
 
1400
  if not body["smiles"]:
1401
  body["smiles"] = st.session_state.get("bbb_smiles", "")
1402
  resp = _post("/explain/bbb", body)
 
1413
  history = st.session_state.setdefault("explain_history", [])
1414
  history.insert(0, (question, resp))
1415
 
 
1416
  history = st.session_state.get("explain_history", [])
1417
  if history:
1418
  st.markdown("### Conversation")
1419
+ for q, r in history[:10]:
1420
+ st.markdown(f"**Q:** {q}")
1421
+ st.markdown(f"**A:** {r['rationale']}")
1422
+ source = r.get("source", "?")
1423
+ model = r.get("model") or ""
1424
+ st.caption(f"Source: `{source}` · Model: `{model}`")
1425
+ st.divider()
 
1426
 
1427
 
1428
  def _render_experiments_tab() -> None:
1429
+ """MLflow runs table + two-run diff (Track 5)."""
1430
  _render_section(
1431
  "Experiments — MLOps Audit",
1432
  "MLflow runs across BBB / EEG / MRI experiments",
1433
+ "Lists every recorded training run; pick any two to see a side-by-side "
1434
+ "metric + parameter diff. Foundation for auditable, reproducible "
1435
+ "model lineage.",
1436
  )
1437
 
1438
  if st.button("Refresh runs", key="exp_refresh"):
 
1445
  runs = data.get("runs", [])
1446
  st.session_state["experiments_runs_cache"] = runs
1447
  except httpx.HTTPStatusError as e:
1448
+ st.error(
1449
+ f"Failed to load runs (HTTP {e.response.status_code}): "
1450
+ f"{e.response.text}"
1451
+ )
1452
  return
1453
  except httpx.RequestError as e:
1454
  st.error(f"Cannot reach FastAPI at {_API_URL}: {e!r}")
 
1456
 
1457
  if not runs:
1458
  st.info(
1459
+ "No MLflow runs found. Trigger a pipeline first (Molecule / "
1460
+ "Signal / Image), then refresh this tab. (Under "
1461
+ "NEUROBRIDGE_DISABLE_MLFLOW=1 the list will stay empty.)"
1462
  )
1463
  return
1464
 
1465
+ rows_preview = [
1466
+ {
 
 
1467
  "run_id": run["run_id"][:8],
1468
  "experiment": run["experiment_name"],
1469
+ "start_time": run["start_time"][:19],
1470
  "status": run["status"],
1471
  "n_metrics": len(run["metrics"]),
1472
  "n_params": len(run["params"]),
1473
+ }
1474
+ for run in runs
1475
+ ]
1476
  st.dataframe(rows_preview, use_container_width=True, hide_index=True)
1477
 
 
1478
  st.markdown("### Compare two runs")
1479
  run_ids = [r["run_id"] for r in runs]
1480
  if len(run_ids) < 2:
 
1483
 
1484
  col_a, col_b = st.columns(2)
1485
  with col_a:
1486
+ sel_a = st.selectbox(
1487
+ "Run A", options=run_ids,
1488
+ format_func=lambda x: x[:8], key="diff_a",
1489
+ )
1490
  with col_b:
1491
+ sel_b = st.selectbox(
1492
+ "Run B", options=run_ids,
1493
+ index=min(1, len(run_ids) - 1),
1494
+ format_func=lambda x: x[:8], key="diff_b",
1495
+ )
1496
 
1497
  if st.button("Show diff", type="primary", key="exp_diff_go"):
1498
  try:
1499
+ diff = _post(
1500
+ "/experiments/diff",
1501
+ {"run_id_a": sel_a, "run_id_b": sel_b},
1502
+ )
1503
  except httpx.HTTPStatusError as e:
1504
+ st.error(
1505
+ f"Diff failed (HTTP {e.response.status_code}): "
1506
+ f"{e.response.text}"
1507
+ )
1508
  return
1509
  rows = diff.get("rows", [])
1510
  if not rows:
 
1523
  st.dataframe(diff_table, use_container_width=True, hide_index=True)
1524
 
1525
 
1526
+ # --------------------------------------------------------------------------- #
1527
+ # Entrypoint #
1528
+ # --------------------------------------------------------------------------- #
1529
+
1530
  def main() -> None:
1531
  """Streamlit entrypoint. Idempotent — Streamlit re-runs on every interaction."""
1532
  st.set_page_config(
 
1535
  layout="wide",
1536
  initial_sidebar_state="expanded",
1537
  )
1538
+
1539
+ theme = _init_theme()
1540
+ st.markdown(_build_css(theme), unsafe_allow_html=True)
1541
+ _register_altair_theme(theme)
1542
 
1543
  api_ok, api_status = _check_api_health()
1544
+ _render_brand_header(api_ok, api_status)
1545
  _render_sidebar(api_ok, api_status)
1546
 
1547
  if not api_ok:
1548
  st.warning(
1549
+ f"FastAPI surface is not reachable at `{_API_URL}` ({api_status}). "
1550
  "Pipeline runs will fail until the API service is up. "
1551
  "Run `uvicorn src.api.main:app --port 8000` or `docker compose up`."
1552
  )
1553
 
1554
  bbb_tab, eeg_tab, mri_tab, assistant_tab, experiments_tab = st.tabs([
1555
+ "Molecule",
1556
+ "Signal",
1557
+ "Image",
1558
  "AI Assistant",
1559
  "Experiments",
1560
  ])