import streamlit as st import requests import pandas as pd import plotly.express as px import plotly.graph_objects as go import time import os API_DEFAULT = "https://live-fraud-detection-agent.onrender.com/api/v1" API_BASE_URL = os.getenv("API_BASE_URL", API_DEFAULT) st.set_page_config(page_title="Fraud Console", layout="wide") _custom = st.session_state.get("_custom_api_url") if _custom: API_BASE_URL = _custom API_ROOT_URL = API_BASE_URL.split("/api")[0] # --- Helpers --- def get_data(endpoint): url = f"{API_BASE_URL}/{endpoint}" if endpoint else f"{API_BASE_URL}/" try: r = requests.get(url, timeout=15) if r.status_code == 200: return r.json() if r.status_code == 404: return "NOT_FOUND" return None except requests.RequestException: return None def post_data(endpoint, payload): try: r = requests.post(f"{API_BASE_URL}/{endpoint}", json=payload, timeout=15) return r.status_code == 200 except requests.RequestException: return False def post_predict(payload: dict) -> tuple[bool, object, float]: try: t0 = time.perf_counter() r = requests.post(f"{API_BASE_URL}/predict", json=payload, timeout=30) latency_ms = (time.perf_counter() - t0) * 1000 if r.status_code == 200: return True, r.json(), latency_ms return False, r.text, latency_ms except requests.RequestException as exc: return False, str(exc), 0.0 def generate_simulation_transactions(count: int = 50, fraud_ratio: float = 0.5) -> list[dict]: """Generate fresh random transactions for simulation. Each call produces unique transactions, ensuring no duplicate fingerprints and always creating new predictions and review cases. """ from transaction_generator import generate_transactions return generate_transactions(count=count, fraud_ratio=fraud_ratio) # --- Cached data fetchers --- @st.cache_data(ttl=15) def get_pending(): return get_data("cases/pending") @st.cache_data(ttl=15) def get_predictions(limit: int = 100, offset: int = 0): data = get_data(f"predictions?limit={limit}&offset={offset}") if isinstance(data, dict) and "items" in data: return data return {"items": data if isinstance(data, list) else [], "total": 0, "offset": offset, "limit": limit} @st.cache_data(ttl=60) def get_health(): try: r = requests.get(f"{API_ROOT_URL}/", timeout=30) return r.json() if r.status_code == 200 else None except requests.RequestException: return None # --- Connection check (retry for cold starts) --- health = get_health() if not health: status_placeholder = st.empty() status_placeholder.info("Waking up the API (cold start can take up to 2 min)...") for attempt in range(12): time.sleep(10) health = get_health() if health: status_placeholder.success("API is online!") st.rerun() status_placeholder.info( f"Waiting for API... attempt {attempt + 1}/12" ) status_placeholder.empty() st.error( f"Could not reach API at {API_BASE_URL}\n\n" "Start the API locally:\n" "```\nuvicorn app.main:app --host 0.0.0.0 --port 8000\n```\n\n" "Or enter a custom URL in the sidebar." ) st.stop() # --- Sidebar --- with st.sidebar: st.markdown("## Fraud Console") st.divider() st.markdown("### Live") st.checkbox( "Live auto-refresh (10s)", value=st.session_state.get("live_mode", False), key="live_mode", help="SSE live feed + periodic chart refresh.", ) st.divider() st.markdown("### Connection") api_status = "Connected" if health else "Disconnected" st.markdown(f"**Status:** {api_status}") custom_url = st.text_input( "API URL", value=st.session_state.get("_custom_api_url", ""), placeholder="https://live-fraud-detection-agent.onrender.com/api/v1", label_visibility="collapsed", ) if custom_url and custom_url != st.session_state.get("_custom_api_url"): st.session_state["_custom_api_url"] = custom_url st.rerun() st.divider() if st.button("Clear Cache", use_container_width=True): st.session_state.pop("sim_results", None) get_pending.clear() get_predictions.clear() st.rerun() st.markdown(""" """, unsafe_allow_html=True) # --- Header / Toolbar --- col_tool = st.columns([1, 1]) with col_tool[0]: st.markdown("## Fraud Detection Console") st.caption("XGBoost · LangGraph HITL · FastAPI") with col_tool[1]: st.markdown( f'
' f'{"● Live" if health else "● Offline"}' f'
', unsafe_allow_html=True, ) if st.button("Refresh", key="header_refresh", use_container_width=True): st.session_state.pop("sim_results", None) get_pending.clear() get_predictions.clear() st.rerun() st.divider() # --- Simulation --- with st.expander("Run Simulation (50 transactions)", expanded=False): st.markdown( '

Posts 50 transactions to /api/v1/predict — same model, same policy as production.

', unsafe_allow_html=True, ) if st.button("▶ Start", key="run_simulation"): payloads = generate_simulation_transactions(count=50, fraud_ratio=0.5) if not payloads: st.error("Failed to generate transactions.") else: results = [] latencies = [] progress = st.progress(0, text="Scoring transactions…") for i, payload in enumerate(payloads): ok, result, latency_ms = post_predict(payload) latencies.append(latency_ms) if ok and isinstance(result, dict): results.append({ "merchant": payload.get("merchant", "—"), "amount": payload.get("amt", 0), "decision": result.get("decision", "—"), "risk_band": result.get("risk_band", "—"), "requires_review": result.get("requires_review", False), "probability": result.get("probability", 0), }) else: results.append({ "merchant": payload.get("merchant", "—"), "amount": payload.get("amt", 0), "decision": "ERROR", "risk_band": "—", "requires_review": False, "probability": 0, }) progress.progress((i + 1) / len(payloads), text=f"Scored {i + 1} / {len(payloads)}") progress.empty() df = pd.DataFrame(results) total = len(df) auto_ok = len(df[df["decision"] == "APPROVE"]) flagged = len(df[df["requires_review"]]) blocked = len(df[df["decision"] == "BLOCK"]) avg_lat = sum(latencies) / len(latencies) if latencies else 0 st.session_state["sim_results"] = { "df": df, "latencies": latencies, "total": total, "auto_ok": auto_ok, "flagged": flagged, "blocked": blocked, "avg_lat": avg_lat, "total_ms": sum(latencies), } get_pending.clear() get_predictions.clear() st.rerun() # Render simulation results if "sim_results" in st.session_state: r = st.session_state["sim_results"] df = r["df"] m1, m2, m3, m4, m5 = st.columns(5) m1.metric("Transactions scored", r["total"]) m2.metric("Auto-approved", r["auto_ok"]) m3.metric("Sent to review", r["flagged"]) m4.metric("Auto-blocked", r["blocked"]) m5.metric("Avg latency", f"{r['avg_lat']:.0f} ms") auto_df = df[df["decision"] == "APPROVE"][["merchant", "amount", "risk_band", "probability"]].copy() auto_df["amount"] = auto_df["amount"].map(lambda x: f"${x:,.2f}") auto_df["probability"] = auto_df["probability"].map(lambda x: f"{x*100:.1f}%") auto_df.columns = ["Merchant", "Amount", "Risk band", "Fraud prob."] if auto_df.empty: st.info("No auto-approved transactions.") else: st.dataframe(auto_df, width='stretch', hide_index=True) flag_df = df[df["requires_review"] | (df["decision"] == "BLOCK")][ ["merchant", "amount", "decision", "risk_band", "probability"] ].copy() flag_df["amount"] = flag_df["amount"].map(lambda x: f"${x:,.2f}") flag_df["probability"] = flag_df["probability"].map(lambda x: f"{x*100:.1f}%") flag_df.columns = ["Merchant", "Amount", "Decision", "Risk band", "Fraud prob."] if flag_df.empty: st.info("No flagged or blocked transactions.") else: st.dataframe(flag_df, width='stretch', hide_index=True) st.caption(f"Completed in {r['total_ms']:.0f} ms total.") # --- Data (cached) --- pending = get_pending() predictions_page = get_predictions() all_predictions = predictions_page.get("items", []) if isinstance(predictions_page, dict) else (predictions_page or []) total_preds = predictions_page.get("total", len(all_predictions)) if isinstance(predictions_page, dict) else len(all_predictions) # Pagination: version counter resets accumulated items on cache refresh if "prediction_version" not in st.session_state: st.session_state["prediction_version"] = 0 st.session_state["prediction_items"] = list(all_predictions) st.session_state["prediction_offset"] = 100 else: prev_items = st.session_state["prediction_items"] stale = len(prev_items) > len(all_predictions) or ( len(prev_items) > 0 and len(all_predictions) > 0 and prev_items[0].get("id") != all_predictions[0].get("id") ) if stale: st.session_state["prediction_version"] += 1 st.session_state["prediction_items"] = list(all_predictions) st.session_state["prediction_offset"] = 100 if pending == "NOT_FOUND" or predictions_page == "NOT_FOUND": st.error("Endpoint Error: Required API routes not found.") st.warning("Your Docker image is outdated. Run: docker compose up --build") st.stop() if pending is None: st.error("Could not load pending cases from API.") st.stop() if all_predictions is None: all_predictions = [] # --- Persistent KPI bar --- pending_count = len(pending) if isinstance(pending, list) else 0 total_txns = total_preds fraud_volume = 0 fraud_count = 0 if all_predictions and len(all_predictions) > 0: flagged_or_blocked = [p for p in all_predictions if p.get("requires_review") or p.get("decision") == "BLOCK"] fraud_volume = sum(p.get("amt", 0) for p in flagged_or_blocked) fraud_count = len(flagged_or_blocked) avg_latency = 0 sim_lat = st.session_state.get("sim_results", {}).get("latencies") if sim_lat: avg_latency = sum(sim_lat) / len(sim_lat) kpi_cols = st.columns(4) kpi_cols[0].metric("Transactions", f"{total_txns:,}" if total_txns else "—", help="Total predictions scored") kpi_cols[1].metric("Fraud Volume", f"${fraud_volume:,.0f}" if fraud_volume else "—", delta=f"{fraud_count} flagged" if fraud_count else None, delta_color="inverse", help="Total $ amounts flagged or blocked") kpi_cols[2].metric("Pending Review", f"{pending_count}", delta_color="inverse" if pending_count > 0 else "normal", help="Cases awaiting human decision") kpi_cols[3].metric("Avg Latency", f"{avg_latency:.0f} ms" if avg_latency else "—", help="API response time") # --- Pending banner --- if pending_count > 0: st.markdown( f'
' f'' f'{pending_count} case(s) pending review' f'
', unsafe_allow_html=True, ) # --- Live Feed (SSE-powered, always visible when active) --- if st.session_state.get("live_mode"): from streamlit.components.v1 import html as st_html sse_url = f"{API_BASE_URL}/stream" st_html( f"""
Live Feed connecting…
""", height=420, scrolling=False, ) st.caption("New predictions appear instantly via SSE. Charts refresh every 10s.") # --- Navigation --- view_mode = st.radio( "View", ["Analytics", "Review Queue", "Activity History"], horizontal=True, label_visibility="collapsed", ) # ── Analytics ───────────────────────────────────────────────────────────────── if view_mode == "Analytics": sim_df = st.session_state.get("sim_results", {}).get("df") sim_latencies = st.session_state.get("sim_results", {}).get("latencies") has_sim = sim_df is not None and len(sim_df) > 0 if not all_predictions and not has_sim: st.info("No data yet. Run a simulation above to generate charts.") st.stop() chart_data = pd.DataFrame(all_predictions) if all_predictions else pd.DataFrame() if not chart_data.empty: chart_data["trans_date_trans_time"] = pd.to_datetime(chart_data["trans_date_trans_time"], errors="coerce") tabs = st.tabs(["Risk Distribution", "Decisions", "Categories", "Amounts", "Latency"]) # ── Tab 1: Probability Distribution ── with tabs[0]: col1, col2 = st.columns(2) with col1: if not chart_data.empty and "probability" in chart_data.columns: fig = px.histogram( chart_data, x="probability", color="risk_band", nbins=40, title="Fraud Probability Distribution", labels={"probability": "Fraud Probability", "count": "Transactions", "risk_band": "Risk Band"}, color_discrete_map={ "LOW": "#22c55e", "MEDIUM": "#eab308", "HIGH": "#f97316", "CRITICAL": "#ef4444", }, template="plotly_dark", ) fig.update_layout( bargap=0.05, xaxis=dict(tickformat=".0%"), legend=dict(orientation="h", y=1.12), height=350, ) st.plotly_chart(fig, width='stretch') with col2: if not chart_data.empty and "probability" in chart_data.columns: fig = px.box( chart_data, x="risk_band", y="probability", color="risk_band", title="Probability by Risk Band", labels={"probability": "Fraud Probability", "risk_band": "Risk Band"}, color_discrete_map={ "LOW": "#22c55e", "MEDIUM": "#eab308", "HIGH": "#f97316", "CRITICAL": "#ef4444", }, template="plotly_dark", category_orders={"risk_band": ["LOW", "MEDIUM", "HIGH", "CRITICAL"]}, ) fig.update_layout( showlegend=False, yaxis=dict(tickformat=".0%"), height=350, ) st.plotly_chart(fig, width='stretch') # ── Tab 2: Decision Breakdown ── with tabs[1]: col1, col2 = st.columns(2) with col1: if not chart_data.empty and "decision" in chart_data.columns: decision_counts = chart_data["decision"].value_counts().reset_index() decision_counts.columns = ["decision", "count"] fig = go.Figure( go.Pie( labels=decision_counts["decision"], values=decision_counts["count"], hole=0.5, marker=dict(colors=["#22c55e", "#eab308", "#ef4444"]), ) ) fig.update_layout( title="Decision Breakdown", template="plotly_dark", height=350, annotations=[dict(text=f"{len(chart_data)}", x=0.5, y=0.5, font_size=24, showarrow=False)], ) st.plotly_chart(fig, width='stretch') with col2: if not chart_data.empty and "risk_band" in chart_data.columns: risk_counts = chart_data["risk_band"].value_counts().reset_index() risk_counts.columns = ["risk_band", "count"] risk_order = ["LOW", "MEDIUM", "HIGH", "CRITICAL"] risk_counts["risk_band"] = pd.Categorical(risk_counts["risk_band"], categories=risk_order, ordered=True) risk_counts = risk_counts.sort_values("risk_band") fig = px.bar( risk_counts, x="risk_band", y="count", color="risk_band", title="Risk Band Distribution", labels={"risk_band": "Risk Band", "count": "Transactions"}, color_discrete_map={ "LOW": "#22c55e", "MEDIUM": "#eab308", "HIGH": "#f97316", "CRITICAL": "#ef4444", }, template="plotly_dark", category_orders={"risk_band": risk_order}, ) fig.update_layout(showlegend=False, height=350) st.plotly_chart(fig, width='stretch') # ── Tab 3: Categories ── with tabs[2]: if not chart_data.empty and "category" in chart_data.columns: cat_counts = chart_data.groupby(["category", "risk_band"]).size().reset_index(name="count") fig = px.bar( cat_counts, x="category", y="count", color="risk_band", title="Transactions by Category and Risk Band", labels={"category": "Category", "count": "Transactions", "risk_band": "Risk Band"}, color_discrete_map={ "LOW": "#22c55e", "MEDIUM": "#eab308", "HIGH": "#f97316", "CRITICAL": "#ef4444", }, template="plotly_dark", barmode="stack", ) fig.update_layout( xaxis=dict(tickangle=-45), height=400, legend=dict(orientation="h", y=1.12), ) st.plotly_chart(fig, width='stretch') flagged = chart_data[chart_data["decision"] == "BLOCK"].groupby("category").size().reset_index(name="count") flagged = flagged.sort_values("count", ascending=True) if not flagged.empty: fig = px.bar( flagged.tail(10), x="count", y="category", orientation="h", title="Top Categories Blocked", labels={"category": "", "count": "Blocked"}, color="count", color_continuous_scale="Reds", template="plotly_dark", ) fig.update_layout(height=350, showlegend=False, yaxis=dict(autorange="reversed")) st.plotly_chart(fig, width='stretch') # ── Tab 4: Amount Analysis ── with tabs[3]: col1, col2 = st.columns(2) with col1: if not chart_data.empty and "amt" in chart_data.columns: fig = px.scatter( chart_data, x="amt", y="probability", color="decision", title="Amount vs Fraud Probability", labels={"amt": "Transaction Amount ($)", "probability": "Fraud Probability", "decision": "Decision"}, color_discrete_map={ "APPROVE": "#22c55e", "REVIEW": "#eab308", "BLOCK": "#ef4444", "ERROR": "#6b7280", }, template="plotly_dark", opacity=0.6, ) fig.update_layout( height=400, yaxis=dict(tickformat=".0%"), xaxis=dict(tickprefix="$"), ) st.plotly_chart(fig, width='stretch') with col2: if not chart_data.empty and "amt" in chart_data.columns: fig = px.box( chart_data, x="decision", y="amt", color="decision", title="Amount Distribution by Decision", labels={"amt": "Amount ($)", "decision": "Decision"}, color_discrete_map={ "APPROVE": "#22c55e", "REVIEW": "#eab308", "BLOCK": "#ef4444", "ERROR": "#6b7280", }, template="plotly_dark", ) fig.update_layout(showlegend=False, height=400, yaxis=dict(tickprefix="$")) st.plotly_chart(fig, width='stretch') # ── Tab 5: Latency ── with tabs[4]: if has_sim and sim_latencies: lat_df = pd.DataFrame({"latency_ms": sim_latencies}) col1, col2 = st.columns(2) with col1: fig = px.histogram( lat_df, x="latency_ms", nbins=30, title="Prediction Latency Distribution", labels={"latency_ms": "Latency (ms)", "count": "Requests"}, template="plotly_dark", ) fig.update_layout(bargap=0.05, height=350) st.plotly_chart(fig, width='stretch') with col2: fig = px.box( lat_df, y="latency_ms", title="Latency Summary", labels={"latency_ms": "Latency (ms)"}, template="plotly_dark", ) fig.update_layout(showlegend=False, height=350) st.plotly_chart(fig, width='stretch') avg_lat = pd.Series(sim_latencies).mean() max_lat = pd.Series(sim_latencies).max() p99_lat = pd.Series(sim_latencies).quantile(0.99) m1, m2, m3 = st.columns(3) m1.metric("Avg latency", f"{avg_lat:.0f} ms") m2.metric("P99 latency", f"{p99_lat:.0f} ms") m3.metric("Max latency", f"{max_lat:.0f} ms") else: st.info("Run a simulation above to see latency metrics.") # ── Review Queue ────────────────────────────────────────────────────────────── elif view_mode == "Review Queue": if len(pending) == 0: st.success("All clear — no cases require manual review.") c1, c2 = st.columns(2) c1.metric("Pending cases", 0) c2.metric("Transactions scored", len(all_predictions)) st.caption("Run the simulation above to generate review cases.") if st.button("Refresh queue"): get_pending.clear() get_predictions.clear() st.rerun() else: st.caption(f"{len(pending)} case(s) awaiting reviewer action.") selected_id = st.selectbox( "Queue", [c["case_id"] for c in pending], format_func=lambda x: f"CASE {x[:8]}".upper(), label_visibility="collapsed", ) current_case = next(c for c in pending if c["case_id"] == selected_id) tx = get_data(f"predictions/{current_case['prediction_id']}") if tx and tx != "NOT_FOUND": st.title(f"CASE {selected_id[:8]}".upper()) m1, m2, m3, m4 = st.columns(4) m1.metric("FRAUD PROB", f"{tx['probability']*100:.0f}%") m2.metric("RISK BAND", tx["risk_band"].upper()) m3.metric("AMOUNT", f"${tx['amt']:,.2f}") m4.metric("CATEGORY", tx["category"].replace("_", " ").upper()) st.divider() col_left, col_right = st.columns(2, gap="large") with col_left: st.markdown("### CONTEXT") st.json({ "Merchant": tx["merchant"], "Location": f"{tx['city']}, {tx['state']}", "Time": tx["trans_date_trans_time"], }) st.markdown("
", unsafe_allow_html=True) st.markdown("### AGENT ANALYSIS") reasoning = current_case.get("reasoning") if reasoning: st.write(reasoning) else: st.warning( "Agent reasoning not saved — check that LangGraph enrichment " "is wiring `reasoning` back to `FraudCase.reasoning` in routes.py." ) if current_case.get("reason_codes"): st.markdown(" ".join([f"`{c}`" for c in current_case["reason_codes"]])) if current_case.get("agent_confidence") is not None: st.caption(f"Agent confidence: {current_case['agent_confidence']*100:.0f}%") with col_right: st.markdown("### CHECKLIST") reviewer_questions = current_case.get("reviewer_questions") if reviewer_questions: for q in reviewer_questions: st.write(f"· {q}") else: st.caption("No checklist items.") st.markdown("
", unsafe_allow_html=True) st.markdown("### RESOLUTION") note = st.text_area("NOTES", placeholder="Decision rationale…", label_visibility="collapsed") b1, b2 = st.columns(2) if b1.button("APPROVE", type="primary", width='stretch'): if post_data(f"cases/{selected_id}/decision", {"decision": "APPROVE", "note": note}): get_pending.clear() get_predictions.clear() st.toast("APPROVED") time.sleep(1) st.rerun() else: st.error("Failed. Please retry.") if b2.button("BLOCK", width='stretch'): if post_data(f"cases/{selected_id}/decision", {"decision": "BLOCK", "note": note}): get_pending.clear() get_predictions.clear() st.toast("BLOCKED") time.sleep(1) st.rerun() else: st.error("Failed. Please retry.") # ── Activity History ────────────────────────────────────────────────────────── elif view_mode == "Activity History": all_items = st.session_state.get("prediction_items", all_predictions) if not all_items: st.info("No transaction history yet. Run the simulation above.") else: df_full = pd.DataFrame(all_items) if "trans_date_trans_time" in df_full.columns: df_full["trans_date_trans_time"] = pd.to_datetime(df_full["trans_date_trans_time"], errors="coerce") df_full = df_full.sort_values("trans_date_trans_time", ascending=False) with st.expander("Filters", expanded=False): f1, f2, f3, f4, f5 = st.columns(5) with f1: if "trans_date_trans_time" in df_full.columns and not df_full["trans_date_trans_time"].isna().all(): min_date = df_full["trans_date_trans_time"].min().date() max_date = df_full["trans_date_trans_time"].max().date() date_start = st.date_input("From", min_date, min_value=min_date, max_value=max_date, key="f_date_start") date_end = st.date_input("To", max_date, min_value=min_date, max_value=max_date, key="f_date_end") else: date_start = date_end = None st.write("No date data") with f2: categories = sorted(df_full["category"].dropna().unique()) if "category" in df_full.columns else [] selected_cats = st.multiselect("Category", categories, default=[], key="f_cats") with f3: bands = [b for b in ["LOW", "MEDIUM", "HIGH", "CRITICAL"] if b in df_full["risk_band"].values] selected_bands = st.multiselect("Risk Band", bands, default=[], key="f_bands") with f4: if "amt" in df_full.columns and not df_full["amt"].isna().all(): min_amt = float(df_full["amt"].min()) max_amt = float(df_full["amt"].max()) amt_range = st.slider("Amount ($)", min_value=min_amt, max_value=max_amt, value=(min_amt, max_amt), key="f_amt") else: amt_range = None with f5: search = st.text_input("Merchant", placeholder="Search…", key="f_merchant") filtered = df_full.copy() if date_start and date_end: filtered = filtered[ (filtered["trans_date_trans_time"].dt.date >= date_start) & (filtered["trans_date_trans_time"].dt.date <= date_end) ] if selected_cats: filtered = filtered[filtered["category"].isin(selected_cats)] if selected_bands: filtered = filtered[filtered["risk_band"].isin(selected_bands)] if amt_range: filtered = filtered[(filtered["amt"] >= amt_range[0]) & (filtered["amt"] <= amt_range[1])] if search: filtered = filtered[filtered["merchant"].str.contains(search, case=False, na=False)] total_count = len(filtered) auto_approved_count = len(filtered[~filtered["requires_review"]]) flagged_count = len(filtered[filtered["requires_review"]]) st.markdown(f"#### Summary — {total_count} transactions ({auto_approved_count} auto-approved, {flagged_count} flagged)") auto_df = filtered[~filtered["requires_review"]].copy() if not auto_df.empty: display = auto_df[["trans_date_trans_time", "amt", "category", "merchant", "risk_band", "decision"]].copy() display.columns = ["Time", "Amount", "Category", "Merchant", "Risk", "Outcome"] display["Amount"] = display["Amount"].map(lambda x: f"${x:,.2f}") st.dataframe(display, width='stretch', hide_index=True) else: st.info("No matching auto-approved transactions.") st.divider() st.markdown(f"#### Full audit log ({total_count})") log_df = filtered[["trans_date_trans_time", "amt", "merchant", "risk_band", "decision", "requires_review"]].copy() log_df.columns = ["Time", "Amount", "Merchant", "Risk", "Action", "Review Req."] log_df["Amount"] = log_df["Amount"].map(lambda x: f"${x:,.2f}") st.dataframe(log_df.head(50), width='stretch', hide_index=True) loaded = len(st.session_state["prediction_items"]) if loaded < total_preds: if st.button(f"Load More ({loaded} / {total_preds})", use_container_width=True): next_page = get_data(f"predictions?limit=100&offset={st.session_state['prediction_offset']}") if isinstance(next_page, dict) and "items" in next_page: existing_ids = {p["id"] for p in st.session_state["prediction_items"]} new_items = [p for p in next_page["items"] if p["id"] not in existing_ids] st.session_state["prediction_items"].extend(new_items) st.session_state["prediction_offset"] += 100 st.rerun() # --- Auto-refresh loop --- if st.session_state.get("live_mode"): get_pending.clear() get_predictions.clear() time.sleep(10) st.rerun()