feat(frontend): Experiments tab — MLflow runs table + two-run diff
Browse files- New 5th tab in main(): BBB / EEG / MRI / AI Assistant / Experiments.
- _render_experiments_tab loads /experiments/runs (cached in session
state, refresh button to invalidate), shows a runs table with run_id
prefix / experiment / start_time / status / metric+param counts.
- Two selectboxes pick run ids; 'Show diff' POSTs /experiments/diff
and renders a key/kind/A/B/differs table.
- Empty-state messaging when MLflow is disabled or no runs exist;
helpful hint to trigger a pipeline first.
- New _get() helper for symmetric GET calls.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- src/frontend/app.py +94 -1
src/frontend/app.py
CHANGED
|
@@ -204,6 +204,13 @@ def _post(endpoint: str, payload: dict) -> dict:
|
|
| 204 |
return resp.json()
|
| 205 |
|
| 206 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 207 |
def _render_brand_header() -> None:
|
| 208 |
st.markdown(
|
| 209 |
"""
|
|
@@ -806,6 +813,89 @@ def _render_ai_assistant_tab() -> None:
|
|
| 806 |
st.divider()
|
| 807 |
|
| 808 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 809 |
def main() -> None:
|
| 810 |
"""Streamlit entrypoint. Idempotent — Streamlit re-runs on every interaction."""
|
| 811 |
st.set_page_config(
|
|
@@ -827,11 +917,12 @@ def main() -> None:
|
|
| 827 |
"Run `uvicorn src.api.main:app --port 8000` or `docker compose up`."
|
| 828 |
)
|
| 829 |
|
| 830 |
-
bbb_tab, eeg_tab, mri_tab, assistant_tab = st.tabs([
|
| 831 |
"Molecule (BBB)",
|
| 832 |
"Signal (EEG)",
|
| 833 |
"Image (MRI)",
|
| 834 |
"AI Assistant",
|
|
|
|
| 835 |
])
|
| 836 |
|
| 837 |
with bbb_tab:
|
|
@@ -842,6 +933,8 @@ def main() -> None:
|
|
| 842 |
_render_mri_tab()
|
| 843 |
with assistant_tab:
|
| 844 |
_render_ai_assistant_tab()
|
|
|
|
|
|
|
| 845 |
|
| 846 |
|
| 847 |
if __name__ == "__main__":
|
|
|
|
| 204 |
return resp.json()
|
| 205 |
|
| 206 |
|
| 207 |
+
def _get(path: str) -> dict:
|
| 208 |
+
"""GET helper symmetric with _post."""
|
| 209 |
+
resp = httpx.get(f"{_API_URL}{path}", timeout=10.0)
|
| 210 |
+
resp.raise_for_status()
|
| 211 |
+
return resp.json()
|
| 212 |
+
|
| 213 |
+
|
| 214 |
def _render_brand_header() -> None:
|
| 215 |
st.markdown(
|
| 216 |
"""
|
|
|
|
| 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"):
|
| 827 |
+
st.session_state.pop("experiments_runs_cache", None)
|
| 828 |
+
|
| 829 |
+
runs = st.session_state.get("experiments_runs_cache")
|
| 830 |
+
if runs is None:
|
| 831 |
+
try:
|
| 832 |
+
data = _get("/experiments/runs")
|
| 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}")
|
| 840 |
+
return
|
| 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:
|
| 867 |
+
st.caption("Need at least 2 runs to compare. Trigger another pipeline.")
|
| 868 |
+
return
|
| 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:
|
| 884 |
+
st.info("Both runs have identical metrics and params (or are empty).")
|
| 885 |
+
return
|
| 886 |
+
diff_table = [
|
| 887 |
+
{
|
| 888 |
+
"key": r["key"],
|
| 889 |
+
"kind": r["kind"],
|
| 890 |
+
"A": r["value_a"] or "—",
|
| 891 |
+
"B": r["value_b"] or "—",
|
| 892 |
+
"differs": "✓" if r["differs"] else "",
|
| 893 |
+
}
|
| 894 |
+
for r in rows
|
| 895 |
+
]
|
| 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(
|
|
|
|
| 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 |
])
|
| 927 |
|
| 928 |
with bbb_tab:
|
|
|
|
| 933 |
_render_mri_tab()
|
| 934 |
with assistant_tab:
|
| 935 |
_render_ai_assistant_tab()
|
| 936 |
+
with experiments_tab:
|
| 937 |
+
_render_experiments_tab()
|
| 938 |
|
| 939 |
|
| 940 |
if __name__ == "__main__":
|