Spaces:
Sleeping
Sleeping
enhancements, added view for scehduled cases as tickets
Browse files- app.py +28 -2
- eda/exploration.py +21 -10
- pages/5_Scheduled_Cases_Explorer.py +17 -0
- src/config/paths.py +40 -0
- src/dashboard/pages/3_Simulation_Workflow.py +3 -2
- src/dashboard/pages/4_Cause_Lists_And_Overrides.py +64 -25
- src/dashboard/pages/5_Scheduled_Cases_Explorer.py +209 -0
- src/dashboard/pages/6_Analytics_And_Reports.py +16 -9
- src/dashboard/utils/ticket_views.py +148 -0
- src/simulation/engine.py +4 -3
app.py
CHANGED
|
@@ -136,6 +136,31 @@ if not eda_ready:
|
|
| 136 |
st.code("uv run court-scheduler eda", language="bash")
|
| 137 |
else:
|
| 138 |
st.success("System ready - all data processed")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 139 |
|
| 140 |
st.markdown("---")
|
| 141 |
|
|
@@ -164,8 +189,8 @@ with col2:
|
|
| 164 |
#### 4. Cause Lists & Overrides
|
| 165 |
View generated cause lists, make judge overrides, and track modification history.
|
| 166 |
|
| 167 |
-
#### 5.
|
| 168 |
-
|
| 169 |
|
| 170 |
#### 6. Analytics & Reports
|
| 171 |
Compare simulation runs, analyze performance metrics, and export comprehensive reports.
|
|
@@ -210,3 +235,4 @@ with st.expander("Typical Usage Workflow"):
|
|
| 210 |
# Footer
|
| 211 |
st.markdown("---")
|
| 212 |
st.caption("Court Scheduling System - Code4Change Hackathon - Karnataka High Court")
|
|
|
|
|
|
| 136 |
st.code("uv run court-scheduler eda", language="bash")
|
| 137 |
else:
|
| 138 |
st.success("System ready - all data processed")
|
| 139 |
+
# Allow user to override and re-run EDA even if it's already completed
|
| 140 |
+
st.markdown("\n")
|
| 141 |
+
if st.button("Re-run EDA Pipeline (override)", use_container_width=False):
|
| 142 |
+
from eda.load_clean import run_load_and_clean
|
| 143 |
+
from eda.exploration import run_exploration
|
| 144 |
+
from eda.parameters import run_parameter_export
|
| 145 |
+
|
| 146 |
+
with st.spinner("Re-running EDA pipeline... This may take a few minutes."):
|
| 147 |
+
try:
|
| 148 |
+
# Step 1: Load & clean data
|
| 149 |
+
run_load_and_clean()
|
| 150 |
+
|
| 151 |
+
# Step 2: Generate visualizations
|
| 152 |
+
run_exploration()
|
| 153 |
+
|
| 154 |
+
# Step 3: Extract parameters
|
| 155 |
+
run_parameter_export()
|
| 156 |
+
|
| 157 |
+
st.success("EDA pipeline re-run completed")
|
| 158 |
+
st.rerun()
|
| 159 |
+
|
| 160 |
+
except Exception as e:
|
| 161 |
+
st.error("Pipeline failed while re-running inside the dashboard.")
|
| 162 |
+
with st.expander("Show error details"):
|
| 163 |
+
st.exception(e)
|
| 164 |
|
| 165 |
st.markdown("---")
|
| 166 |
|
|
|
|
| 189 |
#### 4. Cause Lists & Overrides
|
| 190 |
View generated cause lists, make judge overrides, and track modification history.
|
| 191 |
|
| 192 |
+
#### 5. Scheduled Cases Explorer
|
| 193 |
+
Browse individual case, view status timelines, and understand scheduling decisions.
|
| 194 |
|
| 195 |
#### 6. Analytics & Reports
|
| 196 |
Compare simulation runs, analyze performance metrics, and export comprehensive reports.
|
|
|
|
| 235 |
# Footer
|
| 236 |
st.markdown("---")
|
| 237 |
st.caption("Court Scheduling System - Code4Change Hackathon - Karnataka High Court")
|
| 238 |
+
st.caption("Developed by Aalekh Roy")
|
eda/exploration.py
CHANGED
|
@@ -21,6 +21,7 @@ from datetime import timedelta
|
|
| 21 |
|
| 22 |
import plotly.express as px
|
| 23 |
import plotly.graph_objects as go
|
|
|
|
| 24 |
import polars as pl
|
| 25 |
|
| 26 |
from eda.config import (
|
|
@@ -31,6 +32,11 @@ from eda.config import (
|
|
| 31 |
)
|
| 32 |
|
| 33 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
def load_cleaned():
|
| 35 |
cases = pl.read_parquet(_get_cases_parquet())
|
| 36 |
hearings = pl.read_parquet(_get_hearings_parquet())
|
|
@@ -41,10 +47,7 @@ def load_cleaned():
|
|
| 41 |
|
| 42 |
def run_exploration() -> None:
|
| 43 |
cases, hearings = load_cleaned()
|
| 44 |
-
#
|
| 45 |
-
|
| 46 |
-
# --------------------------------------------------
|
| 47 |
-
# 1. Case Type Distribution (aggregated to reduce plot data size)
|
| 48 |
# --------------------------------------------------
|
| 49 |
try:
|
| 50 |
ct_counts = (
|
|
@@ -70,18 +73,26 @@ def run_exploration() -> None:
|
|
| 70 |
print("Case type distribution error:", e)
|
| 71 |
|
| 72 |
# --------------------------------------------------
|
| 73 |
-
# 2. Filing Trends by Year
|
| 74 |
# --------------------------------------------------
|
|
|
|
| 75 |
if "YEAR_FILED" in cases.columns:
|
| 76 |
-
year_counts =
|
| 77 |
-
|
| 78 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 79 |
x="YEAR_FILED",
|
| 80 |
y="Count",
|
|
|
|
| 81 |
title="Cases Filed by Year",
|
| 82 |
)
|
| 83 |
-
fig2.
|
| 84 |
-
|
|
|
|
| 85 |
f2 = "2_cases_filed_by_year.html"
|
| 86 |
safe_write_figure(fig2, f2)
|
| 87 |
|
|
|
|
| 21 |
|
| 22 |
import plotly.express as px
|
| 23 |
import plotly.graph_objects as go
|
| 24 |
+
import plotly.io as pio
|
| 25 |
import polars as pl
|
| 26 |
|
| 27 |
from eda.config import (
|
|
|
|
| 32 |
)
|
| 33 |
|
| 34 |
|
| 35 |
+
px.defaults.template = "plotly_white"
|
| 36 |
+
px.defaults.color_discrete_sequence = px.colors.qualitative.Set2
|
| 37 |
+
pio.templates.default = "plotly_white"
|
| 38 |
+
|
| 39 |
+
|
| 40 |
def load_cleaned():
|
| 41 |
cases = pl.read_parquet(_get_cases_parquet())
|
| 42 |
hearings = pl.read_parquet(_get_hearings_parquet())
|
|
|
|
| 47 |
|
| 48 |
def run_exploration() -> None:
|
| 49 |
cases, hearings = load_cleaned()
|
| 50 |
+
# 1. Case Type Distribution
|
|
|
|
|
|
|
|
|
|
| 51 |
# --------------------------------------------------
|
| 52 |
try:
|
| 53 |
ct_counts = (
|
|
|
|
| 73 |
print("Case type distribution error:", e)
|
| 74 |
|
| 75 |
# --------------------------------------------------
|
| 76 |
+
# 2. Filing Trends by Year (single line, no slider)
|
| 77 |
# --------------------------------------------------
|
| 78 |
+
|
| 79 |
if "YEAR_FILED" in cases.columns:
|
| 80 |
+
year_counts = (
|
| 81 |
+
cases.group_by("YEAR_FILED")
|
| 82 |
+
.agg(pl.len().alias("Count"))
|
| 83 |
+
.sort("YEAR_FILED", descending=False)
|
| 84 |
+
)
|
| 85 |
+
df_year = year_counts.to_pandas()
|
| 86 |
+
fig2 = px.line(
|
| 87 |
+
df_year,
|
| 88 |
x="YEAR_FILED",
|
| 89 |
y="Count",
|
| 90 |
+
markers=True,
|
| 91 |
title="Cases Filed by Year",
|
| 92 |
)
|
| 93 |
+
fig2.update_layout(xaxis_title="Year", yaxis_title="Cases")
|
| 94 |
+
# Fix y-axis max to 10k (counts are known to be < 10k)
|
| 95 |
+
fig2.update_yaxes(range=[0, 10000])
|
| 96 |
f2 = "2_cases_filed_by_year.html"
|
| 97 |
safe_write_figure(fig2, f2)
|
| 98 |
|
pages/5_Scheduled_Cases_Explorer.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Wrapper to expose the cause lists & overrides page to Streamlit's pages system."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
import runpy
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
ORIG = (
|
| 10 |
+
Path(__file__).resolve().parents[1]
|
| 11 |
+
/ "src"
|
| 12 |
+
/ "dashboard"
|
| 13 |
+
/ "pages"
|
| 14 |
+
/ "5_Scheduled_Cases_Explorer.py"
|
| 15 |
+
)
|
| 16 |
+
|
| 17 |
+
runpy.run_path(str(ORIG), run_name="__main__")
|
src/config/paths.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import os
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
# Centralized paths used across the dashboard and simulation
|
| 8 |
+
# One source of truth for simulation run directories.
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def get_runs_base() -> Path:
|
| 12 |
+
"""Return the base directory where simulation runs are stored.
|
| 13 |
+
|
| 14 |
+
Priority order:
|
| 15 |
+
1) Env var DASHBOARD_RUNS_BASE
|
| 16 |
+
2) Default: outputs/simulation_runs
|
| 17 |
+
"""
|
| 18 |
+
env = os.getenv("DASHBOARD_RUNS_BASE")
|
| 19 |
+
if env:
|
| 20 |
+
return Path(env)
|
| 21 |
+
return Path("outputs") / "simulation_runs"
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def list_run_dirs(base: Path | None = None) -> list[Path]:
|
| 25 |
+
"""List immediate child directories representing simulation runs."""
|
| 26 |
+
base = base or get_runs_base()
|
| 27 |
+
if not base.exists():
|
| 28 |
+
return []
|
| 29 |
+
return sorted([p for p in base.iterdir() if p.is_dir()], reverse=True)
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def make_new_run_dir(run_id: str) -> Path:
|
| 33 |
+
"""Create and return a new run directory at the configured base.
|
| 34 |
+
|
| 35 |
+
Does not overwrite existing; ensures parent exists.
|
| 36 |
+
"""
|
| 37 |
+
base = get_runs_base()
|
| 38 |
+
path = base / run_id
|
| 39 |
+
path.mkdir(parents=True, exist_ok=True)
|
| 40 |
+
return path
|
src/dashboard/pages/3_Simulation_Workflow.py
CHANGED
|
@@ -17,6 +17,7 @@ import plotly.express as px
|
|
| 17 |
import streamlit as st
|
| 18 |
|
| 19 |
from src.output.cause_list import CauseListGenerator
|
|
|
|
| 20 |
|
| 21 |
CLI_VERSION = "1.0.0"
|
| 22 |
# Page configuration
|
|
@@ -366,8 +367,8 @@ elif st.session_state.workflow_step == 2:
|
|
| 366 |
|
| 367 |
log_dir = st.text_input(
|
| 368 |
"Output directory",
|
| 369 |
-
value=
|
| 370 |
-
help="Directory to save simulation outputs",
|
| 371 |
)
|
| 372 |
|
| 373 |
with col2:
|
|
|
|
| 17 |
import streamlit as st
|
| 18 |
|
| 19 |
from src.output.cause_list import CauseListGenerator
|
| 20 |
+
from src.config.paths import get_runs_base
|
| 21 |
|
| 22 |
CLI_VERSION = "1.0.0"
|
| 23 |
# Page configuration
|
|
|
|
| 367 |
|
| 368 |
log_dir = st.text_input(
|
| 369 |
"Output directory",
|
| 370 |
+
value=str(get_runs_base()),
|
| 371 |
+
help="Directory to save simulation outputs (override with DASHBOARD_RUNS_BASE env var)",
|
| 372 |
)
|
| 373 |
|
| 374 |
with col2:
|
src/dashboard/pages/4_Cause_Lists_And_Overrides.py
CHANGED
|
@@ -45,7 +45,9 @@ if "draft_modifications" not in st.session_state:
|
|
| 45 |
st.session_state.draft_modifications = []
|
| 46 |
|
| 47 |
# Main tabs
|
| 48 |
-
tab1, tab2, tab3 = st.tabs(
|
|
|
|
|
|
|
| 49 |
|
| 50 |
# TAB 1: View Cause Lists
|
| 51 |
with tab1:
|
|
@@ -55,18 +57,24 @@ with tab1:
|
|
| 55 |
)
|
| 56 |
|
| 57 |
# Check for available cause lists
|
| 58 |
-
#
|
| 59 |
-
|
|
|
|
|
|
|
| 60 |
|
| 61 |
if not outputs_dir.exists():
|
| 62 |
-
st.warning(
|
|
|
|
|
|
|
| 63 |
st.markdown("Go to **Simulation Workflow** to run a simulation.")
|
| 64 |
else:
|
| 65 |
# Look for simulation runs (each is a subdirectory in outputs/simulation_runs)
|
| 66 |
sim_runs = [d for d in outputs_dir.iterdir() if d.is_dir()]
|
| 67 |
|
| 68 |
if not sim_runs:
|
| 69 |
-
st.info(
|
|
|
|
|
|
|
| 70 |
else:
|
| 71 |
st.markdown(f"**{len(sim_runs)} simulation run(s) found**")
|
| 72 |
|
|
@@ -75,7 +83,9 @@ with tab1:
|
|
| 75 |
|
| 76 |
with col1:
|
| 77 |
selected_run = st.selectbox(
|
| 78 |
-
"Select simulation run",
|
|
|
|
|
|
|
| 79 |
)
|
| 80 |
|
| 81 |
with col2:
|
|
@@ -138,18 +148,25 @@ with tab1:
|
|
| 138 |
st.metric("Unique Cases", unique_cases)
|
| 139 |
|
| 140 |
with col2:
|
| 141 |
-
st.metric(
|
|
|
|
|
|
|
|
|
|
| 142 |
|
| 143 |
with col3:
|
| 144 |
st.metric(
|
| 145 |
"Courtrooms",
|
| 146 |
-
df["courtroom"].nunique()
|
|
|
|
|
|
|
| 147 |
)
|
| 148 |
|
| 149 |
with col4:
|
| 150 |
st.metric(
|
| 151 |
"Case Types",
|
| 152 |
-
df["case_type"].nunique()
|
|
|
|
|
|
|
| 153 |
)
|
| 154 |
|
| 155 |
# Filters
|
|
@@ -206,7 +223,9 @@ with tab1:
|
|
| 206 |
]
|
| 207 |
|
| 208 |
st.markdown("---")
|
| 209 |
-
st.markdown(
|
|
|
|
|
|
|
| 210 |
|
| 211 |
# Display table
|
| 212 |
st.dataframe(
|
|
@@ -226,7 +245,9 @@ with tab1:
|
|
| 226 |
|
| 227 |
# Load into override interface
|
| 228 |
if st.button(
|
| 229 |
-
"Load into Override Interface",
|
|
|
|
|
|
|
| 230 |
):
|
| 231 |
st.session_state.current_cause_list = {
|
| 232 |
"source": str(cause_list_path),
|
|
@@ -235,7 +256,9 @@ with tab1:
|
|
| 235 |
"loaded_at": datetime.now().isoformat(),
|
| 236 |
}
|
| 237 |
st.success("Cause list loaded into Override Interface")
|
| 238 |
-
st.info(
|
|
|
|
|
|
|
| 239 |
|
| 240 |
except Exception as e:
|
| 241 |
st.error(f"Error loading cause list: {e}")
|
|
@@ -248,7 +271,9 @@ with tab2:
|
|
| 248 |
)
|
| 249 |
|
| 250 |
if not st.session_state.current_cause_list:
|
| 251 |
-
st.info(
|
|
|
|
|
|
|
| 252 |
else:
|
| 253 |
cause_list_info = st.session_state.current_cause_list
|
| 254 |
|
|
@@ -295,7 +320,9 @@ with tab2:
|
|
| 295 |
|
| 296 |
# Remove from draft
|
| 297 |
draft_df = draft_df[draft_df["case_id"] != case_to_remove]
|
| 298 |
-
st.session_state.current_cause_list["data"] = draft_df.to_dict(
|
|
|
|
|
|
|
| 299 |
|
| 300 |
st.success(f"Removed case {case_to_remove}")
|
| 301 |
st.rerun()
|
|
@@ -312,7 +339,9 @@ with tab2:
|
|
| 312 |
)
|
| 313 |
|
| 314 |
new_priority = st.selectbox(
|
| 315 |
-
"New priority",
|
|
|
|
|
|
|
| 316 |
)
|
| 317 |
|
| 318 |
if case_to_prioritize != "(None)" and st.button("Update Priority"):
|
|
@@ -328,11 +357,11 @@ with tab2:
|
|
| 328 |
|
| 329 |
# Update priority in draft
|
| 330 |
if "priority" in draft_df.columns:
|
| 331 |
-
draft_df.loc[
|
| 332 |
-
|
| 333 |
-
|
| 334 |
-
st.session_state.current_cause_list["data"] =
|
| 335 |
-
"records"
|
| 336 |
)
|
| 337 |
|
| 338 |
st.success(f"Updated priority for case {case_to_prioritize}")
|
|
@@ -342,7 +371,9 @@ with tab2:
|
|
| 342 |
|
| 343 |
# Display draft with modifications
|
| 344 |
st.markdown("### Current Draft")
|
| 345 |
-
st.caption(
|
|
|
|
|
|
|
| 346 |
|
| 347 |
st.dataframe(
|
| 348 |
draft_df,
|
|
@@ -396,14 +427,18 @@ with tab2:
|
|
| 396 |
st.success(f"Draft saved to {draft_file}")
|
| 397 |
|
| 398 |
with approval_col3:
|
| 399 |
-
if st.button(
|
|
|
|
|
|
|
| 400 |
# Record approval
|
| 401 |
approval = {
|
| 402 |
"timestamp": datetime.now().isoformat(),
|
| 403 |
"action": "APPROVE",
|
| 404 |
"source": cause_list_info["source"],
|
| 405 |
"final_count": len(draft_df),
|
| 406 |
-
"modifications_count": len(
|
|
|
|
|
|
|
| 407 |
"modifications": st.session_state.draft_modifications.copy(),
|
| 408 |
}
|
| 409 |
st.session_state.override_history.append(approval)
|
|
@@ -413,7 +448,9 @@ with tab2:
|
|
| 413 |
approved_path.mkdir(parents=True, exist_ok=True)
|
| 414 |
|
| 415 |
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
| 416 |
-
approved_file =
|
|
|
|
|
|
|
| 417 |
|
| 418 |
draft_df.to_csv(approved_file, index=False)
|
| 419 |
|
|
@@ -439,7 +476,9 @@ with tab3:
|
|
| 439 |
if not st.session_state.override_history:
|
| 440 |
st.info("No approval history yet. Approve cause lists to build audit trail.")
|
| 441 |
else:
|
| 442 |
-
st.markdown(
|
|
|
|
|
|
|
| 443 |
|
| 444 |
# Summary statistics
|
| 445 |
st.markdown("#### Summary Statistics")
|
|
|
|
| 45 |
st.session_state.draft_modifications = []
|
| 46 |
|
| 47 |
# Main tabs
|
| 48 |
+
tab1, tab2, tab3 = st.tabs(
|
| 49 |
+
["View Cause Lists", "Judge Override Interface", "Audit Trail"]
|
| 50 |
+
)
|
| 51 |
|
| 52 |
# TAB 1: View Cause Lists
|
| 53 |
with tab1:
|
|
|
|
| 57 |
)
|
| 58 |
|
| 59 |
# Check for available cause lists
|
| 60 |
+
# Use centralized runs base directory
|
| 61 |
+
from src.config.paths import get_runs_base
|
| 62 |
+
|
| 63 |
+
outputs_dir = get_runs_base()
|
| 64 |
|
| 65 |
if not outputs_dir.exists():
|
| 66 |
+
st.warning(
|
| 67 |
+
"No simulation outputs found. Run a simulation first to generate cause lists."
|
| 68 |
+
)
|
| 69 |
st.markdown("Go to **Simulation Workflow** to run a simulation.")
|
| 70 |
else:
|
| 71 |
# Look for simulation runs (each is a subdirectory in outputs/simulation_runs)
|
| 72 |
sim_runs = [d for d in outputs_dir.iterdir() if d.is_dir()]
|
| 73 |
|
| 74 |
if not sim_runs:
|
| 75 |
+
st.info(
|
| 76 |
+
"No simulation runs found. Generate cause lists by running a simulation."
|
| 77 |
+
)
|
| 78 |
else:
|
| 79 |
st.markdown(f"**{len(sim_runs)} simulation run(s) found**")
|
| 80 |
|
|
|
|
| 83 |
|
| 84 |
with col1:
|
| 85 |
selected_run = st.selectbox(
|
| 86 |
+
"Select simulation run",
|
| 87 |
+
options=[d.name for d in sim_runs],
|
| 88 |
+
key="view_sim_run",
|
| 89 |
)
|
| 90 |
|
| 91 |
with col2:
|
|
|
|
| 148 |
st.metric("Unique Cases", unique_cases)
|
| 149 |
|
| 150 |
with col2:
|
| 151 |
+
st.metric(
|
| 152 |
+
"Dates",
|
| 153 |
+
df["date"].nunique() if "date" in df.columns else "N/A",
|
| 154 |
+
)
|
| 155 |
|
| 156 |
with col3:
|
| 157 |
st.metric(
|
| 158 |
"Courtrooms",
|
| 159 |
+
df["courtroom"].nunique()
|
| 160 |
+
if "courtroom" in df.columns
|
| 161 |
+
else "N/A",
|
| 162 |
)
|
| 163 |
|
| 164 |
with col4:
|
| 165 |
st.metric(
|
| 166 |
"Case Types",
|
| 167 |
+
df["case_type"].nunique()
|
| 168 |
+
if "case_type" in df.columns
|
| 169 |
+
else "N/A",
|
| 170 |
)
|
| 171 |
|
| 172 |
# Filters
|
|
|
|
| 223 |
]
|
| 224 |
|
| 225 |
st.markdown("---")
|
| 226 |
+
st.markdown(
|
| 227 |
+
f"**Showing {len(filtered_df):,} of {len(df):,} hearings**"
|
| 228 |
+
)
|
| 229 |
|
| 230 |
# Display table
|
| 231 |
st.dataframe(
|
|
|
|
| 245 |
|
| 246 |
# Load into override interface
|
| 247 |
if st.button(
|
| 248 |
+
"Load into Override Interface",
|
| 249 |
+
type="primary",
|
| 250 |
+
use_container_width=True,
|
| 251 |
):
|
| 252 |
st.session_state.current_cause_list = {
|
| 253 |
"source": str(cause_list_path),
|
|
|
|
| 256 |
"loaded_at": datetime.now().isoformat(),
|
| 257 |
}
|
| 258 |
st.success("Cause list loaded into Override Interface")
|
| 259 |
+
st.info(
|
| 260 |
+
"Navigate to 'Judge Override Interface' tab to review and modify."
|
| 261 |
+
)
|
| 262 |
|
| 263 |
except Exception as e:
|
| 264 |
st.error(f"Error loading cause list: {e}")
|
|
|
|
| 271 |
)
|
| 272 |
|
| 273 |
if not st.session_state.current_cause_list:
|
| 274 |
+
st.info(
|
| 275 |
+
"No cause list loaded. Go to 'View Cause Lists' tab and load a cause list first."
|
| 276 |
+
)
|
| 277 |
else:
|
| 278 |
cause_list_info = st.session_state.current_cause_list
|
| 279 |
|
|
|
|
| 320 |
|
| 321 |
# Remove from draft
|
| 322 |
draft_df = draft_df[draft_df["case_id"] != case_to_remove]
|
| 323 |
+
st.session_state.current_cause_list["data"] = draft_df.to_dict(
|
| 324 |
+
"records"
|
| 325 |
+
)
|
| 326 |
|
| 327 |
st.success(f"Removed case {case_to_remove}")
|
| 328 |
st.rerun()
|
|
|
|
| 339 |
)
|
| 340 |
|
| 341 |
new_priority = st.selectbox(
|
| 342 |
+
"New priority",
|
| 343 |
+
options=["HIGH", "MEDIUM", "LOW"],
|
| 344 |
+
key="new_priority",
|
| 345 |
)
|
| 346 |
|
| 347 |
if case_to_prioritize != "(None)" and st.button("Update Priority"):
|
|
|
|
| 357 |
|
| 358 |
# Update priority in draft
|
| 359 |
if "priority" in draft_df.columns:
|
| 360 |
+
draft_df.loc[
|
| 361 |
+
draft_df["case_id"] == case_to_prioritize, "priority"
|
| 362 |
+
] = new_priority
|
| 363 |
+
st.session_state.current_cause_list["data"] = (
|
| 364 |
+
draft_df.to_dict("records")
|
| 365 |
)
|
| 366 |
|
| 367 |
st.success(f"Updated priority for case {case_to_prioritize}")
|
|
|
|
| 371 |
|
| 372 |
# Display draft with modifications
|
| 373 |
st.markdown("### Current Draft")
|
| 374 |
+
st.caption(
|
| 375 |
+
f"{len(st.session_state.draft_modifications)} modification(s) made"
|
| 376 |
+
)
|
| 377 |
|
| 378 |
st.dataframe(
|
| 379 |
draft_df,
|
|
|
|
| 427 |
st.success(f"Draft saved to {draft_file}")
|
| 428 |
|
| 429 |
with approval_col3:
|
| 430 |
+
if st.button(
|
| 431 |
+
"Approve & Finalize", type="primary", use_container_width=True
|
| 432 |
+
):
|
| 433 |
# Record approval
|
| 434 |
approval = {
|
| 435 |
"timestamp": datetime.now().isoformat(),
|
| 436 |
"action": "APPROVE",
|
| 437 |
"source": cause_list_info["source"],
|
| 438 |
"final_count": len(draft_df),
|
| 439 |
+
"modifications_count": len(
|
| 440 |
+
st.session_state.draft_modifications
|
| 441 |
+
),
|
| 442 |
"modifications": st.session_state.draft_modifications.copy(),
|
| 443 |
}
|
| 444 |
st.session_state.override_history.append(approval)
|
|
|
|
| 448 |
approved_path.mkdir(parents=True, exist_ok=True)
|
| 449 |
|
| 450 |
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
| 451 |
+
approved_file = (
|
| 452 |
+
approved_path / f"approved_cause_list_{timestamp}.csv"
|
| 453 |
+
)
|
| 454 |
|
| 455 |
draft_df.to_csv(approved_file, index=False)
|
| 456 |
|
|
|
|
| 476 |
if not st.session_state.override_history:
|
| 477 |
st.info("No approval history yet. Approve cause lists to build audit trail.")
|
| 478 |
else:
|
| 479 |
+
st.markdown(
|
| 480 |
+
f"**{len(st.session_state.override_history)} approval(s) recorded**"
|
| 481 |
+
)
|
| 482 |
|
| 483 |
# Summary statistics
|
| 484 |
st.markdown("#### Summary Statistics")
|
src/dashboard/pages/5_Scheduled_Cases_Explorer.py
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Ticket Explorer (Post-Run)
|
| 2 |
+
|
| 3 |
+
Browse simulation runs as a CMS of tickets (cases). After a run finishes,
|
| 4 |
+
we build compact Parquet artifacts from events.csv and render case timelines.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
from pathlib import Path
|
| 10 |
+
|
| 11 |
+
import streamlit as st
|
| 12 |
+
import polars as pl
|
| 13 |
+
|
| 14 |
+
import pandas as pd
|
| 15 |
+
import plotly.express as px
|
| 16 |
+
|
| 17 |
+
from src.dashboard.utils.ticket_views import build_ticket_views, load_ticket_views
|
| 18 |
+
from src.config.paths import get_runs_base, list_run_dirs
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
st.set_page_config(page_title="Ticket Explorer", page_icon="tickets", layout="wide")
|
| 22 |
+
st.title("Scheduled Cases Explorer (Post-Run)")
|
| 23 |
+
st.caption(
|
| 24 |
+
"Inspect each case as a ticket with a full audit trail after the simulation run."
|
| 25 |
+
)
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def _list_runs(base: Path) -> list[Path]:
|
| 29 |
+
if not base.exists():
|
| 30 |
+
return []
|
| 31 |
+
# run dirs are expected to be leaf directories under base
|
| 32 |
+
return sorted([p for p in base.iterdir() if p.is_dir()], reverse=True)
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
runs_base = get_runs_base()
|
| 36 |
+
run_dirs = list_run_dirs(runs_base)
|
| 37 |
+
|
| 38 |
+
if not run_dirs:
|
| 39 |
+
st.warning(f"No simulation runs found in {runs_base}. Run a simulation first.")
|
| 40 |
+
st.stop()
|
| 41 |
+
|
| 42 |
+
labels = [d.name for d in run_dirs]
|
| 43 |
+
idx = st.selectbox(
|
| 44 |
+
"Select a run", options=list(range(len(labels))), format_func=lambda i: labels[i]
|
| 45 |
+
)
|
| 46 |
+
run_dir = run_dirs[idx]
|
| 47 |
+
|
| 48 |
+
st.markdown(f"Run directory: `{run_dir}`")
|
| 49 |
+
|
| 50 |
+
col_a, col_b, col_c = st.columns([1, 1, 2])
|
| 51 |
+
with col_a:
|
| 52 |
+
if st.button(
|
| 53 |
+
"Rebuild ticket views", help="Recompute Parquet artifacts from events.csv"
|
| 54 |
+
):
|
| 55 |
+
build_ticket_views(run_dir)
|
| 56 |
+
st.success("Ticket views rebuilt")
|
| 57 |
+
st.rerun()
|
| 58 |
+
with col_b:
|
| 59 |
+
events_path = run_dir / "events.csv"
|
| 60 |
+
st.download_button(
|
| 61 |
+
"Download events.csv",
|
| 62 |
+
data=events_path.read_bytes() if events_path.exists() else b"",
|
| 63 |
+
file_name="events.csv",
|
| 64 |
+
mime="text/csv",
|
| 65 |
+
disabled=not events_path.exists(),
|
| 66 |
+
)
|
| 67 |
+
|
| 68 |
+
# Load views (build if missing)
|
| 69 |
+
journal_df, summary_df, spans_df = load_ticket_views(run_dir)
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
# Normalize to pandas for Streamlit controls
|
| 73 |
+
def _to_pandas(df):
|
| 74 |
+
if pl is not None and isinstance(df, pl.DataFrame):
|
| 75 |
+
return df.to_pandas()
|
| 76 |
+
return df
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
journal_pd: pd.DataFrame = _to_pandas(journal_df)
|
| 80 |
+
summary_pd: pd.DataFrame = _to_pandas(summary_df)
|
| 81 |
+
spans_pd: pd.DataFrame = _to_pandas(spans_df)
|
| 82 |
+
|
| 83 |
+
with st.sidebar:
|
| 84 |
+
st.header("Filters")
|
| 85 |
+
case_q = st.text_input("Search case_id contains")
|
| 86 |
+
types = sorted([x for x in summary_pd["case_type"].dropna().unique().tolist()])
|
| 87 |
+
sel_types = st.multiselect("Case types", options=types, default=[])
|
| 88 |
+
statuses = ["ACTIVE", "DISPOSED"]
|
| 89 |
+
sel_status = st.multiselect("Final status", options=statuses, default=[])
|
| 90 |
+
|
| 91 |
+
hearings_min, hearings_max = st.slider(
|
| 92 |
+
"Total hearings",
|
| 93 |
+
min_value=int(summary_pd.get("total_hearings", pd.Series([0])).min() or 0),
|
| 94 |
+
max_value=int(summary_pd.get("total_hearings", pd.Series([0])).max() or 0),
|
| 95 |
+
value=(0, int(summary_pd.get("total_hearings", pd.Series([0])).max() or 0)),
|
| 96 |
+
)
|
| 97 |
+
|
| 98 |
+
# Apply filters
|
| 99 |
+
filtered = summary_pd.copy()
|
| 100 |
+
if case_q:
|
| 101 |
+
filtered = filtered[
|
| 102 |
+
filtered["case_id"].astype(str).str.contains(case_q, case=False, na=False)
|
| 103 |
+
]
|
| 104 |
+
if sel_types:
|
| 105 |
+
filtered = filtered[filtered["case_type"].isin(sel_types)]
|
| 106 |
+
if sel_status:
|
| 107 |
+
filtered = filtered[filtered["final_status"].isin(sel_status)]
|
| 108 |
+
filtered = filtered[
|
| 109 |
+
(filtered.get("total_hearings", 0) >= hearings_min)
|
| 110 |
+
& (filtered.get("total_hearings", 0) <= hearings_max)
|
| 111 |
+
]
|
| 112 |
+
|
| 113 |
+
st.markdown("### Filtered Cases")
|
| 114 |
+
|
| 115 |
+
# Pagination
|
| 116 |
+
page_size = st.selectbox("Rows per page", [25, 50, 100], index=0)
|
| 117 |
+
total_rows = len(filtered)
|
| 118 |
+
page = st.number_input(
|
| 119 |
+
"Page",
|
| 120 |
+
min_value=1,
|
| 121 |
+
max_value=max(1, (total_rows - 1) // page_size + 1),
|
| 122 |
+
value=1,
|
| 123 |
+
step=1,
|
| 124 |
+
)
|
| 125 |
+
start, end = (page - 1) * page_size, min(page * page_size, total_rows)
|
| 126 |
+
st.caption(f"Showing {start + 1}–{end} of {total_rows}")
|
| 127 |
+
|
| 128 |
+
cols_to_show = [
|
| 129 |
+
"case_id",
|
| 130 |
+
"case_type",
|
| 131 |
+
"final_status",
|
| 132 |
+
"current_stage",
|
| 133 |
+
"total_hearings",
|
| 134 |
+
"heard_count",
|
| 135 |
+
"adjourned_count",
|
| 136 |
+
"last_seen_date",
|
| 137 |
+
]
|
| 138 |
+
cols_to_show = [c for c in cols_to_show if c in filtered.columns]
|
| 139 |
+
st.dataframe(
|
| 140 |
+
filtered.iloc[start:end][cols_to_show], use_container_width=True, hide_index=True
|
| 141 |
+
)
|
| 142 |
+
|
| 143 |
+
st.markdown("### Scheduled Case event details")
|
| 144 |
+
sel_case = st.selectbox(
|
| 145 |
+
"Choose a case_id",
|
| 146 |
+
options=filtered["case_id"].tolist() if not filtered.empty else [],
|
| 147 |
+
)
|
| 148 |
+
|
| 149 |
+
if sel_case:
|
| 150 |
+
row = summary_pd[summary_pd["case_id"] == sel_case].iloc[0]
|
| 151 |
+
kpi1, kpi2, kpi3, kpi4 = st.columns(4)
|
| 152 |
+
with kpi1:
|
| 153 |
+
st.metric("Total hearings", int(row.get("total_hearings", 0)))
|
| 154 |
+
with kpi2:
|
| 155 |
+
st.metric("Heard", int(row.get("heard_count", 0)))
|
| 156 |
+
with kpi3:
|
| 157 |
+
st.metric("Adjourned", int(row.get("adjourned_count", 0)))
|
| 158 |
+
with kpi4:
|
| 159 |
+
st.metric("Status", str(row.get("final_status", "")))
|
| 160 |
+
|
| 161 |
+
# Journal slice
|
| 162 |
+
j = journal_pd[journal_pd["case_id"] == sel_case].copy()
|
| 163 |
+
j.sort_values(["date", "seq_no"], inplace=True)
|
| 164 |
+
|
| 165 |
+
# Export button
|
| 166 |
+
csv_bytes = j.to_csv(index=False).encode("utf-8")
|
| 167 |
+
st.download_button(
|
| 168 |
+
"Download this ticket's journal (CSV)",
|
| 169 |
+
data=csv_bytes,
|
| 170 |
+
file_name=f"{sel_case}_journal.csv",
|
| 171 |
+
mime="text/csv",
|
| 172 |
+
)
|
| 173 |
+
|
| 174 |
+
# Timeline table
|
| 175 |
+
st.subheader("Event journal")
|
| 176 |
+
show_cols = [
|
| 177 |
+
c
|
| 178 |
+
for c in [
|
| 179 |
+
"date",
|
| 180 |
+
"type",
|
| 181 |
+
"detail",
|
| 182 |
+
"stage",
|
| 183 |
+
"courtroom_id",
|
| 184 |
+
"priority_score",
|
| 185 |
+
"readiness_score",
|
| 186 |
+
"ripeness_status",
|
| 187 |
+
"days_since_hearing",
|
| 188 |
+
]
|
| 189 |
+
if c in j.columns
|
| 190 |
+
]
|
| 191 |
+
st.dataframe(j[show_cols].tail(100), use_container_width=True, hide_index=True)
|
| 192 |
+
|
| 193 |
+
# Stage spans chart (if available)
|
| 194 |
+
s = spans_pd[spans_pd["case_id"] == sel_case].copy()
|
| 195 |
+
if not s.empty:
|
| 196 |
+
s["start_date"] = pd.to_datetime(s["start_date"])
|
| 197 |
+
s["end_date"] = pd.to_datetime(s["end_date"])
|
| 198 |
+
fig = px.timeline(
|
| 199 |
+
s,
|
| 200 |
+
x_start="start_date",
|
| 201 |
+
x_end="end_date",
|
| 202 |
+
y="stage",
|
| 203 |
+
color="stage",
|
| 204 |
+
title="Stage spans",
|
| 205 |
+
)
|
| 206 |
+
fig.update_yaxes(autorange="reversed")
|
| 207 |
+
st.plotly_chart(fig, use_container_width=True)
|
| 208 |
+
else:
|
| 209 |
+
st.info("No stage change spans available for this ticket.")
|
src/dashboard/pages/6_Analytics_And_Reports.py
CHANGED
|
@@ -47,9 +47,10 @@ with tab1:
|
|
| 47 |
"Compare multiple simulation runs to evaluate different policies and parameters."
|
| 48 |
)
|
| 49 |
|
| 50 |
-
# Check for available simulation runs
|
| 51 |
-
|
| 52 |
-
|
|
|
|
| 53 |
|
| 54 |
if not runs_dir.exists():
|
| 55 |
st.warning(
|
|
@@ -252,9 +253,10 @@ with tab2:
|
|
| 252 |
st.markdown("### Performance Trends")
|
| 253 |
st.markdown("Analyze performance metrics across all simulation runs.")
|
| 254 |
|
| 255 |
-
# Use
|
| 256 |
-
|
| 257 |
-
|
|
|
|
| 258 |
|
| 259 |
if not runs_dir.exists():
|
| 260 |
st.warning("No simulation outputs found.")
|
|
@@ -273,7 +275,11 @@ with tab2:
|
|
| 273 |
try:
|
| 274 |
df = pd.read_csv(metrics_path)
|
| 275 |
# Use relative label for clarity across nested structures
|
| 276 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 277 |
all_metrics.append(df)
|
| 278 |
except Exception:
|
| 279 |
pass # Skip invalid metrics files
|
|
@@ -345,8 +351,9 @@ with tab3:
|
|
| 345 |
- **Case Type Balance**: Ensures no case type is systematically disadvantaged
|
| 346 |
""")
|
| 347 |
|
| 348 |
-
|
| 349 |
-
|
|
|
|
| 350 |
|
| 351 |
if not runs_dir.exists():
|
| 352 |
st.warning("No simulation outputs found.")
|
|
|
|
| 47 |
"Compare multiple simulation runs to evaluate different policies and parameters."
|
| 48 |
)
|
| 49 |
|
| 50 |
+
# Check for available simulation runs (centralized base)
|
| 51 |
+
from src.config.paths import get_runs_base
|
| 52 |
+
|
| 53 |
+
runs_dir = get_runs_base()
|
| 54 |
|
| 55 |
if not runs_dir.exists():
|
| 56 |
st.warning(
|
|
|
|
| 253 |
st.markdown("### Performance Trends")
|
| 254 |
st.markdown("Analyze performance metrics across all simulation runs.")
|
| 255 |
|
| 256 |
+
# Use centralized runs directory recursively
|
| 257 |
+
from src.config.paths import get_runs_base
|
| 258 |
+
|
| 259 |
+
runs_dir = get_runs_base()
|
| 260 |
|
| 261 |
if not runs_dir.exists():
|
| 262 |
st.warning("No simulation outputs found.")
|
|
|
|
| 275 |
try:
|
| 276 |
df = pd.read_csv(metrics_path)
|
| 277 |
# Use relative label for clarity across nested structures
|
| 278 |
+
try:
|
| 279 |
+
df["run"] = str(run_dir.relative_to(runs_dir))
|
| 280 |
+
except ValueError:
|
| 281 |
+
# Fallback to folder name if not under base (shouldn't happen)
|
| 282 |
+
df["run"] = run_dir.name
|
| 283 |
all_metrics.append(df)
|
| 284 |
except Exception:
|
| 285 |
pass # Skip invalid metrics files
|
|
|
|
| 351 |
- **Case Type Balance**: Ensures no case type is systematically disadvantaged
|
| 352 |
""")
|
| 353 |
|
| 354 |
+
from src.config.paths import get_runs_base
|
| 355 |
+
|
| 356 |
+
runs_dir = get_runs_base()
|
| 357 |
|
| 358 |
if not runs_dir.exists():
|
| 359 |
st.warning("No simulation outputs found.")
|
src/dashboard/utils/ticket_views.py
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
from typing import Tuple
|
| 5 |
+
import polars as pl
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def build_ticket_views(run_dir: Path) -> Tuple[Path, Path, Path]:
|
| 9 |
+
"""Materialize post-run ticket views from events.csv into Parquet files.
|
| 10 |
+
|
| 11 |
+
Creates three artifacts in the run directory:
|
| 12 |
+
- ticket_journal.parquet
|
| 13 |
+
- ticket_summary.parquet
|
| 14 |
+
- ticket_state_spans.parquet
|
| 15 |
+
|
| 16 |
+
Returns paths to the three files in the order above.
|
| 17 |
+
"""
|
| 18 |
+
run_dir = Path(run_dir)
|
| 19 |
+
events_csv = run_dir / "events.csv"
|
| 20 |
+
if not events_csv.exists():
|
| 21 |
+
raise FileNotFoundError(f"events.csv not found in run dir: {events_csv}")
|
| 22 |
+
|
| 23 |
+
journal_pq = run_dir / "ticket_journal.parquet"
|
| 24 |
+
summary_pq = run_dir / "ticket_summary.parquet"
|
| 25 |
+
spans_pq = run_dir / "ticket_state_spans.parquet"
|
| 26 |
+
|
| 27 |
+
events = pl.scan_csv(str(events_csv))
|
| 28 |
+
# Normalize and order
|
| 29 |
+
journal = (
|
| 30 |
+
events.with_columns(
|
| 31 |
+
[
|
| 32 |
+
pl.col("date").str.to_date().alias("date"),
|
| 33 |
+
]
|
| 34 |
+
)
|
| 35 |
+
.sort(["case_id", "date"]) # lazy
|
| 36 |
+
.with_columns(
|
| 37 |
+
[
|
| 38 |
+
pl.arange(0, pl.len()).over("case_id").alias("seq_no"),
|
| 39 |
+
]
|
| 40 |
+
)
|
| 41 |
+
.collect(streaming=True)
|
| 42 |
+
)
|
| 43 |
+
journal.write_parquet(str(journal_pq))
|
| 44 |
+
|
| 45 |
+
# Outcomes for counts
|
| 46 |
+
heard = journal.filter(pl.col("type") == "outcome").with_columns(
|
| 47 |
+
(pl.col("detail") == "heard").alias("is_heard")
|
| 48 |
+
)
|
| 49 |
+
|
| 50 |
+
base_summary = journal.group_by("case_id").agg(
|
| 51 |
+
[
|
| 52 |
+
pl.first("case_type").alias("case_type"),
|
| 53 |
+
pl.first("date").alias("first_seen_date"),
|
| 54 |
+
pl.last("date").alias("last_seen_date"),
|
| 55 |
+
pl.col("stage").sort_by("seq_no").last().alias("current_stage"),
|
| 56 |
+
(pl.col("type") == "stage_change")
|
| 57 |
+
.cast(pl.Int64)
|
| 58 |
+
.sum()
|
| 59 |
+
.alias("stage_changes"),
|
| 60 |
+
(pl.col("type") == "ripeness_change")
|
| 61 |
+
.cast(pl.Int64)
|
| 62 |
+
.sum()
|
| 63 |
+
.alias("ripeness_transitions"),
|
| 64 |
+
]
|
| 65 |
+
)
|
| 66 |
+
outcome_summary = (
|
| 67 |
+
heard.group_by("case_id")
|
| 68 |
+
.agg(
|
| 69 |
+
[
|
| 70 |
+
pl.len().alias("total_hearings"),
|
| 71 |
+
pl.col("is_heard").cast(pl.Int64).sum().alias("heard_count"),
|
| 72 |
+
]
|
| 73 |
+
)
|
| 74 |
+
.with_columns(
|
| 75 |
+
(pl.col("total_hearings") - pl.col("heard_count")).alias("adjourned_count")
|
| 76 |
+
)
|
| 77 |
+
)
|
| 78 |
+
disposed = (
|
| 79 |
+
journal.filter(pl.col("type") == "disposed")
|
| 80 |
+
.group_by("case_id")
|
| 81 |
+
.agg([pl.min("date").alias("disposal_date")])
|
| 82 |
+
)
|
| 83 |
+
|
| 84 |
+
summary = (
|
| 85 |
+
base_summary.join(outcome_summary, on="case_id", how="left")
|
| 86 |
+
.with_columns(
|
| 87 |
+
[
|
| 88 |
+
pl.col("total_hearings").fill_null(0),
|
| 89 |
+
pl.col("heard_count").fill_null(0),
|
| 90 |
+
pl.col("adjourned_count").fill_null(0),
|
| 91 |
+
]
|
| 92 |
+
)
|
| 93 |
+
.join(disposed, on="case_id", how="left")
|
| 94 |
+
.with_columns(
|
| 95 |
+
[
|
| 96 |
+
# Compute age in full days from first to last seen.
|
| 97 |
+
# Use total_days() on duration to be compatible across Polars versions.
|
| 98 |
+
(pl.col("last_seen_date") - pl.col("first_seen_date"))
|
| 99 |
+
.dt.total_days()
|
| 100 |
+
.alias("age_days_end"),
|
| 101 |
+
pl.when(pl.col("disposal_date").is_not_null())
|
| 102 |
+
.then(pl.lit("DISPOSED"))
|
| 103 |
+
.otherwise(pl.lit("ACTIVE"))
|
| 104 |
+
.alias("final_status"),
|
| 105 |
+
]
|
| 106 |
+
)
|
| 107 |
+
)
|
| 108 |
+
summary.write_parquet(str(summary_pq))
|
| 109 |
+
|
| 110 |
+
# Spans from stage changes
|
| 111 |
+
sc = (
|
| 112 |
+
journal.filter(pl.col("type") == "stage_change")
|
| 113 |
+
.select(["case_id", "date", "stage"])
|
| 114 |
+
.rename({"date": "start_date"})
|
| 115 |
+
)
|
| 116 |
+
spans = sc.with_columns(
|
| 117 |
+
[
|
| 118 |
+
pl.col("start_date").shift(-1).over("case_id").alias("end_date"),
|
| 119 |
+
]
|
| 120 |
+
).with_columns(
|
| 121 |
+
[
|
| 122 |
+
pl.when(pl.col("end_date").is_null())
|
| 123 |
+
.then(pl.col("start_date"))
|
| 124 |
+
.otherwise(pl.col("end_date"))
|
| 125 |
+
.alias("end_date")
|
| 126 |
+
]
|
| 127 |
+
)
|
| 128 |
+
spans.write_parquet(str(spans_pq))
|
| 129 |
+
return journal_pq, summary_pq, spans_pq
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
def load_ticket_views(run_dir: Path):
|
| 133 |
+
"""Load ticket views; build them if missing. Returns (journal, summary, spans).
|
| 134 |
+
|
| 135 |
+
Uses Polars DataFrames if Polars is available; otherwise returns pandas DataFrames.
|
| 136 |
+
"""
|
| 137 |
+
run_dir = Path(run_dir)
|
| 138 |
+
journal_pq = run_dir / "ticket_journal.parquet"
|
| 139 |
+
summary_pq = run_dir / "ticket_summary.parquet"
|
| 140 |
+
spans_pq = run_dir / "ticket_state_spans.parquet"
|
| 141 |
+
|
| 142 |
+
if not (journal_pq.exists() and summary_pq.exists() and spans_pq.exists()):
|
| 143 |
+
build_ticket_views(run_dir)
|
| 144 |
+
|
| 145 |
+
journal = pl.read_parquet(str(journal_pq))
|
| 146 |
+
summary = pl.read_parquet(str(summary_pq))
|
| 147 |
+
spans = pl.read_parquet(str(spans_pq))
|
| 148 |
+
return journal, summary, spans
|
src/simulation/engine.py
CHANGED
|
@@ -36,6 +36,7 @@ from src.simulation.allocator import AllocationStrategy, CourtroomAllocator
|
|
| 36 |
from src.simulation.events import EventWriter
|
| 37 |
from src.simulation.policies import get_policy
|
| 38 |
from src.utils.calendar import CourtCalendar
|
|
|
|
| 39 |
|
| 40 |
|
| 41 |
@dataclass
|
|
@@ -86,11 +87,11 @@ class CourtSim:
|
|
| 86 |
self._log_dir: Path | None = None
|
| 87 |
if self.cfg.log_dir:
|
| 88 |
self._log_dir = Path(self.cfg.log_dir)
|
|
|
|
| 89 |
else:
|
| 90 |
-
# default run folder
|
| 91 |
run_id = time.strftime("%Y%m%d_%H%M%S")
|
| 92 |
-
self._log_dir =
|
| 93 |
-
self._log_dir.mkdir(parents=True, exist_ok=True)
|
| 94 |
self._metrics_path = self._log_dir / "metrics.csv"
|
| 95 |
with self._metrics_path.open("w", newline="", encoding="utf-8") as f:
|
| 96 |
w = csv.writer(f)
|
|
|
|
| 36 |
from src.simulation.events import EventWriter
|
| 37 |
from src.simulation.policies import get_policy
|
| 38 |
from src.utils.calendar import CourtCalendar
|
| 39 |
+
from src.config.paths import make_new_run_dir
|
| 40 |
|
| 41 |
|
| 42 |
@dataclass
|
|
|
|
| 87 |
self._log_dir: Path | None = None
|
| 88 |
if self.cfg.log_dir:
|
| 89 |
self._log_dir = Path(self.cfg.log_dir)
|
| 90 |
+
self._log_dir.mkdir(parents=True, exist_ok=True)
|
| 91 |
else:
|
| 92 |
+
# default run folder (centralized base path)
|
| 93 |
run_id = time.strftime("%Y%m%d_%H%M%S")
|
| 94 |
+
self._log_dir = make_new_run_dir(run_id)
|
|
|
|
| 95 |
self._metrics_path = self._log_dir / "metrics.csv"
|
| 96 |
with self._metrics_path.open("w", newline="", encoding="utf-8") as f:
|
| 97 |
w = csv.writer(f)
|