Spaces:
Running
Running
| """Ticket Explorer (Post-Run) | |
| Browse simulation runs as a CMS of tickets (cases). After a run finishes, | |
| we build compact Parquet artifacts from events.csv and render case timelines. | |
| """ | |
| from __future__ import annotations | |
| from pathlib import Path | |
| import streamlit as st | |
| import polars as pl | |
| import pandas as pd | |
| import plotly.express as px | |
| from src.dashboard.utils.ticket_views import build_ticket_views, load_ticket_views | |
| from src.config.paths import get_runs_base, list_run_dirs | |
| st.set_page_config(page_title="Ticket Explorer", page_icon="tickets", layout="wide") | |
| st.title("Scheduled Cases Explorer (Post-Run)") | |
| st.caption( | |
| "Inspect each case as a ticket with a full audit trail after the simulation run." | |
| ) | |
| def _list_runs(base: Path) -> list[Path]: | |
| if not base.exists(): | |
| return [] | |
| # run dirs are expected to be leaf directories under base | |
| return sorted([p for p in base.iterdir() if p.is_dir()], reverse=True) | |
| runs_base = get_runs_base() | |
| run_dirs = list_run_dirs(runs_base) | |
| if not run_dirs: | |
| st.warning(f"No simulation runs found in {runs_base}. Run a simulation first.") | |
| st.stop() | |
| labels = [d.name for d in run_dirs] | |
| idx = st.selectbox( | |
| "Select a run", options=list(range(len(labels))), format_func=lambda i: labels[i] | |
| ) | |
| run_dir = run_dirs[idx] | |
| st.markdown(f"Run directory: `{run_dir}`") | |
| col_a, col_b, col_c = st.columns([1, 1, 2]) | |
| with col_a: | |
| if st.button( | |
| "Rebuild ticket views", help="Recompute Parquet artifacts from events.csv" | |
| ): | |
| build_ticket_views(run_dir) | |
| st.success("Ticket views rebuilt") | |
| st.rerun() | |
| with col_b: | |
| events_path = run_dir / "events.csv" | |
| st.download_button( | |
| "Download events.csv", | |
| data=events_path.read_bytes() if events_path.exists() else b"", | |
| file_name="events.csv", | |
| mime="text/csv", | |
| disabled=not events_path.exists(), | |
| ) | |
| # Load views (build if missing) | |
| journal_df, summary_df, spans_df = load_ticket_views(run_dir) | |
| # Normalize to pandas for Streamlit controls | |
| def _to_pandas(df): | |
| if pl is not None and isinstance(df, pl.DataFrame): | |
| return df.to_pandas() | |
| return df | |
| journal_pd: pd.DataFrame = _to_pandas(journal_df) | |
| summary_pd: pd.DataFrame = _to_pandas(summary_df) | |
| spans_pd: pd.DataFrame = _to_pandas(spans_df) | |
| with st.sidebar: | |
| st.header("Filters") | |
| case_q = st.text_input("Search case_id contains") | |
| types = sorted([x for x in summary_pd["case_type"].dropna().unique().tolist()]) | |
| sel_types = st.multiselect("Case types", options=types, default=[]) | |
| statuses = ["ACTIVE", "DISPOSED"] | |
| sel_status = st.multiselect("Final status", options=statuses, default=[]) | |
| hearings_min, hearings_max = st.slider( | |
| "Total hearings", | |
| min_value=int(summary_pd.get("total_hearings", pd.Series([0])).min() or 0), | |
| max_value=int(summary_pd.get("total_hearings", pd.Series([0])).max() or 0), | |
| value=(0, int(summary_pd.get("total_hearings", pd.Series([0])).max() or 0)), | |
| ) | |
| # Apply filters | |
| filtered = summary_pd.copy() | |
| if case_q: | |
| filtered = filtered[ | |
| filtered["case_id"].astype(str).str.contains(case_q, case=False, na=False) | |
| ] | |
| if sel_types: | |
| filtered = filtered[filtered["case_type"].isin(sel_types)] | |
| if sel_status: | |
| filtered = filtered[filtered["final_status"].isin(sel_status)] | |
| filtered = filtered[ | |
| (filtered.get("total_hearings", 0) >= hearings_min) | |
| & (filtered.get("total_hearings", 0) <= hearings_max) | |
| ] | |
| st.markdown("### Filtered Cases") | |
| # Pagination | |
| page_size = st.selectbox("Rows per page", [25, 50, 100], index=0) | |
| total_rows = len(filtered) | |
| page = st.number_input( | |
| "Page", | |
| min_value=1, | |
| max_value=max(1, (total_rows - 1) // page_size + 1), | |
| value=1, | |
| step=1, | |
| ) | |
| start, end = (page - 1) * page_size, min(page * page_size, total_rows) | |
| st.caption(f"Showing {start + 1}–{end} of {total_rows}") | |
| cols_to_show = [ | |
| "case_id", | |
| "case_type", | |
| "final_status", | |
| "current_stage", | |
| "total_hearings", | |
| "heard_count", | |
| "adjourned_count", | |
| "last_seen_date", | |
| ] | |
| cols_to_show = [c for c in cols_to_show if c in filtered.columns] | |
| st.dataframe( | |
| filtered.iloc[start:end][cols_to_show], use_container_width=True, hide_index=True | |
| ) | |
| st.markdown("### Scheduled Case event details") | |
| sel_case = st.selectbox( | |
| "Choose a case_id", | |
| options=filtered["case_id"].tolist() if not filtered.empty else [], | |
| ) | |
| if sel_case: | |
| row = summary_pd[summary_pd["case_id"] == sel_case].iloc[0] | |
| kpi1, kpi2, kpi3, kpi4 = st.columns(4) | |
| with kpi1: | |
| st.metric("Total hearings", int(row.get("total_hearings", 0))) | |
| with kpi2: | |
| st.metric("Heard", int(row.get("heard_count", 0))) | |
| with kpi3: | |
| st.metric("Adjourned", int(row.get("adjourned_count", 0))) | |
| with kpi4: | |
| st.metric("Status", str(row.get("final_status", ""))) | |
| # Journal slice | |
| j = journal_pd[journal_pd["case_id"] == sel_case].copy() | |
| j.sort_values(["date", "seq_no"], inplace=True) | |
| # Export button | |
| csv_bytes = j.to_csv(index=False).encode("utf-8") | |
| st.download_button( | |
| "Download this ticket's journal (CSV)", | |
| data=csv_bytes, | |
| file_name=f"{sel_case}_journal.csv", | |
| mime="text/csv", | |
| ) | |
| # Timeline table | |
| st.subheader("Event journal") | |
| show_cols = [ | |
| c | |
| for c in [ | |
| "date", | |
| "type", | |
| "detail", | |
| "stage", | |
| "courtroom_id", | |
| "priority_score", | |
| "readiness_score", | |
| "ripeness_status", | |
| "days_since_hearing", | |
| ] | |
| if c in j.columns | |
| ] | |
| st.dataframe(j[show_cols].tail(100), use_container_width=True, hide_index=True) | |
| # Stage spans chart (if available) | |
| s = spans_pd[spans_pd["case_id"] == sel_case].copy() | |
| if not s.empty: | |
| s["start_date"] = pd.to_datetime(s["start_date"]) | |
| s["end_date"] = pd.to_datetime(s["end_date"]) | |
| fig = px.timeline( | |
| s, | |
| x_start="start_date", | |
| x_end="end_date", | |
| y="stage", | |
| color="stage", | |
| title="Stage spans", | |
| ) | |
| fig.update_yaxes(autorange="reversed") | |
| st.plotly_chart(fig, use_container_width=True) | |
| else: | |
| st.info("No stage change spans available for this ticket.") | |