Spaces:
Sleeping
Sleeping
Update src/streamlit_app.py
Browse files- src/streamlit_app.py +516 -556
src/streamlit_app.py
CHANGED
|
@@ -1,256 +1,48 @@
|
|
| 1 |
"""
|
| 2 |
-
Health Matrix
|
| 3 |
-
-
|
| 4 |
-
- KPI: Animated gradient-border cards
|
| 5 |
-
- Employee Results:
|
| 6 |
-
* Board View (Kanban: Assigned / Notify / Skipped) — واضح ومناسب للأعداد الكبيرة
|
| 7 |
-
* Grid View (النسخة السابقة) — خيار إضافي
|
| 8 |
-
- Showcase Mode: يضمن (Assign / Notify / Skip) لعرض قدرات الوكيل + يضيف موظف ثالث تجريبي (اختياري)
|
| 9 |
-
- Lenient Eligibility (OR) خيار للعرض
|
| 10 |
-
- Full desktop width, responsive, WCAG-friendly
|
| 11 |
-
- لا تغييرات على المنطق/الـAPIs عند إيقاف وضع العرض
|
| 12 |
-
|
| 13 |
-
Usage: streamlit run streamlit_app.py
|
| 14 |
"""
|
| 15 |
|
| 16 |
-
import
|
| 17 |
-
import time
|
| 18 |
-
from io import StringIO
|
| 19 |
-
from typing import List, Optional, Iterable, Dict, Any
|
| 20 |
-
|
| 21 |
import pandas as pd
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
import requests
|
| 23 |
-
|
| 24 |
|
| 25 |
# Optional: OpenAI for GPT decisions (fails gracefully if missing)
|
| 26 |
try:
|
| 27 |
-
import openai
|
| 28 |
HAS_OPENAI = True
|
| 29 |
except Exception:
|
| 30 |
openai = None
|
| 31 |
HAS_OPENAI = False
|
| 32 |
|
| 33 |
-
|
| 34 |
# =============================================================================
|
| 35 |
-
#
|
| 36 |
# =============================================================================
|
| 37 |
-
|
| 38 |
-
:
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
/* Base */
|
| 51 |
-
html,body{
|
| 52 |
-
margin:0; padding:0; height:100%;
|
| 53 |
-
background:var(--bg); color:var(--t1);
|
| 54 |
-
font-family:'Segoe UI','Cairo','Tajawal','Open Sans',sans-serif;
|
| 55 |
-
}
|
| 56 |
-
h1,h2,h3{ margin:0 0 8px; color:var(--t1); text-align:center }
|
| 57 |
-
p{ color:var(--t2) }
|
| 58 |
-
|
| 59 |
-
/* Dataframes: sticky header + zebra */
|
| 60 |
-
[data-testid="stDataFrame"] table thead th{
|
| 61 |
-
position: sticky; top: 0; z-index: 2;
|
| 62 |
-
background:#003b70 !important; color:#fff !important;
|
| 63 |
-
}
|
| 64 |
-
[data-testid="stDataFrame"] table tbody tr:nth-child(odd){ background:#f9fafb }
|
| 65 |
-
[data-testid="stDataFrame"] table td{ padding:8px 10px }
|
| 66 |
-
.table-wrap{ overflow-x:auto; border:1px solid var(--line); border-radius:12px; margin-top:6px }
|
| 67 |
-
|
| 68 |
-
/* ======================= HERO ======================= */
|
| 69 |
-
.hero{ position:relative; text-align:center; padding:48px 8px 8px }
|
| 70 |
-
.hero img{ width:170px; height:auto; margin-bottom:8px }
|
| 71 |
-
.hero h1{
|
| 72 |
-
font-size:2.1rem; font-weight:900; line-height:1.25; margin:0 0 6px;
|
| 73 |
-
background:linear-gradient(90deg,var(--t1),var(--blue),var(--t1));
|
| 74 |
-
-webkit-background-clip:text; background-clip:text; -webkit-text-fill-color:transparent;
|
| 75 |
-
background-size:200% 100%; animation:aiTitle 8s ease-in-out infinite;
|
| 76 |
-
}
|
| 77 |
-
.hero p{ max-width:1150px; margin:0 auto; font-size:1.02rem }
|
| 78 |
-
|
| 79 |
-
/* CTA */
|
| 80 |
-
.stButton{ display:flex; justify-content:center; margin:10px 0 0 }
|
| 81 |
-
.stButton>button{
|
| 82 |
-
padding:12px 26px; font-size:1.05rem; font-weight:800; color:#fff !important;
|
| 83 |
-
border:0 !important; border-radius:14px !important; cursor:pointer;
|
| 84 |
-
background:linear-gradient(120deg,var(--green),var(--blue),var(--green)) !important;
|
| 85 |
-
background-size:320% 320% !important; animation:aiGradient 8s ease-in-out infinite;
|
| 86 |
-
box-shadow:0 10px 20px rgba(0,0,0,.12); transition:transform .18s ease, box-shadow .18s ease
|
| 87 |
-
}
|
| 88 |
-
.stButton>button:hover{ transform:translateY(-2px) scale(1.02); box-shadow:0 0 22px rgba(54,186,1,.26) }
|
| 89 |
-
|
| 90 |
-
/* Status Bar */
|
| 91 |
-
.status-wrap{ display:flex; justify-content:center; margin:8px 0 }
|
| 92 |
-
.status-bar{
|
| 93 |
-
display:flex; align-items:center; gap:10px; background:var(--soft); color:var(--t2);
|
| 94 |
-
border:1px solid var(--line); border-radius:12px; padding:8px 14px; font-size:.95rem
|
| 95 |
-
}
|
| 96 |
-
.pulse-dot{ width:10px; height:10px; border-radius:50%; background:var(--green);
|
| 97 |
-
box-shadow:0 0 0 0 rgba(54,186,1,.55); animation:pulse 1.4s infinite }
|
| 98 |
-
.status-done{ background:#E8F7ED; border-color:#B6E2C4 }
|
| 99 |
-
.status-done .pulse-dot{ background:var(--ok); box-shadow:none }
|
| 100 |
-
|
| 101 |
-
/* Card */
|
| 102 |
-
.card{
|
| 103 |
-
background:#fff; border:1px solid var(--line); border-radius:16px;
|
| 104 |
-
box-shadow:0 6px 16px rgba(0,0,0,.06); padding:14px; margin-bottom:12px
|
| 105 |
-
}
|
| 106 |
-
.card h4{ margin:0 0 6px }
|
| 107 |
-
.card ul{ margin:8px 0 0 18px; color:#334155; font-size:.95rem; line-height:1.6 }
|
| 108 |
-
|
| 109 |
-
/* Overview chips */
|
| 110 |
-
.overview{ display:flex; gap:8px; flex-wrap:wrap; justify-content:center; margin:4px 0 10px }
|
| 111 |
-
.overview .chip{ font-size:.9rem; padding:6px 10px; border:1px solid var(--line); border-radius:999px; background:#fff }
|
| 112 |
-
.overview .chip b{ color:#0f172a }
|
| 113 |
-
|
| 114 |
-
/* ================= KPI: animated gradient-border cards ================= */
|
| 115 |
-
.kpi-grid{ display:grid; grid-template-columns:repeat(3, 1fr); gap:12px; margin:12px 0 }
|
| 116 |
-
.kpi-card{
|
| 117 |
-
position:relative; border-radius:16px; padding:16px; text-align:center;
|
| 118 |
-
border:1px solid transparent;
|
| 119 |
-
background:
|
| 120 |
-
linear-gradient(#ffffff,#ffffff) padding-box,
|
| 121 |
-
linear-gradient(120deg, var(--green), var(--blue), var(--green)) border-box;
|
| 122 |
-
background-size: 300% 300%;
|
| 123 |
-
animation: borderShift 8s ease infinite;
|
| 124 |
-
box-shadow:0 10px 24px rgba(0,0,0,.06);
|
| 125 |
-
}
|
| 126 |
-
.kpi-card .ico{ font-size:22px; margin-bottom:6px }
|
| 127 |
-
.kpi-card .lbl{ color:#334155 }
|
| 128 |
-
.kpi-card .val{ font-size:1.8rem; font-weight:900; line-height:1 }
|
| 129 |
-
|
| 130 |
-
/* ================= Employee Cards – base ================= */
|
| 131 |
-
.emp-card{
|
| 132 |
-
position:relative; overflow:hidden;
|
| 133 |
-
background: rgba(255,255,255,.9);
|
| 134 |
-
border:1px solid #E5E7EB; border-radius:16px; backdrop-filter: blur(10px);
|
| 135 |
-
box-shadow:0 10px 26px rgba(0,0,0,.08);
|
| 136 |
-
padding:12px; transform: translateY(6px); opacity:0;
|
| 137 |
-
animation: cardIn .35s ease-out forwards;
|
| 138 |
-
}
|
| 139 |
-
.emp-card::before{ /* Aurora glow */
|
| 140 |
-
content:""; position:absolute; inset:-30% -10% auto -10%; height:60%;
|
| 141 |
-
background:
|
| 142 |
-
radial-gradient(600px 280px at 20% 30%, rgba(54,186,1,.18), transparent 60%),
|
| 143 |
-
radial-gradient(520px 220px at 80% 20%, rgba(0,76,151,.16), transparent 60%);
|
| 144 |
-
filter: blur(28px); opacity:.7; z-index:0; pointer-events:none;
|
| 145 |
-
animation: aurora 18s linear infinite;
|
| 146 |
-
}
|
| 147 |
-
.emp-head, .emp-body, .emp-foot{ position:relative; z-index:1 }
|
| 148 |
-
.emp-head{ display:flex; align-items:center; gap:10px; padding:6px 8px; border-radius:12px }
|
| 149 |
-
.emp-avatar{
|
| 150 |
-
width:40px; height:40px; border-radius:999px; background:#F1F5F9;
|
| 151 |
-
display:flex; align-items:center; justify-content:center; font-weight:800; color:#0f172a
|
| 152 |
-
}
|
| 153 |
-
.emp-name{ font-weight:900 }
|
| 154 |
-
.emp-chip{ font-size:.8rem; padding:4px 8px; border-radius:999px; border:1px solid #E5E7EB; background:#fff; margin-left:auto }
|
| 155 |
-
.emp-chip.ok{ background:#E8F7ED; border-color:#B6E2C4 }
|
| 156 |
-
.emp-chip.warn{ background:#FFF7ED; border-color:#FED7AA }
|
| 157 |
-
.emp-chip.info{ background:#EFF6FF; border-color:#BFDBFE }
|
| 158 |
-
.emp-chip.fail{ background:#FEF2F2; border-color:#FECACA }
|
| 159 |
-
|
| 160 |
-
.emp-line{ height:1px; background:#E5E7EB; margin:8px 0 }
|
| 161 |
-
|
| 162 |
-
.emp-body{ display:grid; grid-template-columns: 1fr 1fr; gap:8px }
|
| 163 |
-
.emp-badge{
|
| 164 |
-
display:flex; gap:6px; align-items:center; font-size:.92rem; color:#334155;
|
| 165 |
-
background:#fff; border:1px solid #E5E7EB; border-radius:12px; padding:8px 10px
|
| 166 |
-
}
|
| 167 |
-
.emp-badge.ok{ border-color:#BBF7D0; background:#F0FDF4 }
|
| 168 |
-
.emp-badge.fail{ border-color:#FECACA; background:#FEF2F2 }
|
| 169 |
-
.emp-badge .dot{ width:8px; height:8px; border-radius:50% }
|
| 170 |
-
.emp-badge.ok .dot{ background:var(--ok) }
|
| 171 |
-
.emp-badge.fail .dot{ background:var(--fail) }
|
| 172 |
-
|
| 173 |
-
.emp-foot{ display:flex; flex-wrap:wrap; gap:6px; margin-top:8px }
|
| 174 |
-
.emp-tag{ font-size:.8rem; padding:6px 10px; border-radius:999px; border:1px solid #E5E7EB; background:#fff }
|
| 175 |
-
.emp-meta{ margin-left:auto; color:#64748B; font-size:.85rem }
|
| 176 |
-
|
| 177 |
-
/* ====== GRID mode (old view, tidy) ====== */
|
| 178 |
-
.emp-grid{
|
| 179 |
-
display:grid; gap:14px; margin-top:12px;
|
| 180 |
-
grid-template-columns: repeat(12, 1fr);
|
| 181 |
-
}
|
| 182 |
-
.emp-grid .emp-card{ grid-column: span 4 } /* 3 per row desktop */
|
| 183 |
-
@media (max-width: 1320px){ .emp-grid .emp-card{ grid-column: span 6 } } /* 2 per row */
|
| 184 |
-
@media (max-width: 820px){ .emp-grid .emp-card{ grid-column: span 12 } .emp-body{ grid-template-columns: 1fr } }
|
| 185 |
-
|
| 186 |
-
/* ====== BOARD (Kanban) ====== */
|
| 187 |
-
.board{ display:grid; grid-template-columns:repeat(3, 1fr); gap:14px; margin-top:10px }
|
| 188 |
-
.lane{
|
| 189 |
-
background:#fff; border:1px solid var(--line); border-radius:16px;
|
| 190 |
-
box-shadow:0 6px 16px rgba(0,0,0,.06); padding:12px
|
| 191 |
-
}
|
| 192 |
-
.lane-header{
|
| 193 |
-
display:flex; align-items:center; gap:8px; margin-bottom:10px;
|
| 194 |
-
font-weight:900; font-size:1.05rem;
|
| 195 |
-
}
|
| 196 |
-
.lane-header .count{ margin-left:auto; font-size:.9rem; color:#64748B; background:var(--soft); padding:4px 10px; border-radius:999px; border:1px solid var(--line) }
|
| 197 |
-
|
| 198 |
-
.lane-assign{ background:var(--tint-assign) }
|
| 199 |
-
.lane-notify{ background:var(--tint-notify) }
|
| 200 |
-
.lane-skip{ background:var(--tint-skip) }
|
| 201 |
-
|
| 202 |
-
.emp-card.status-assign .emp-head{ background:var(--tint-assign); border:1px solid var(--tint-assign-b) }
|
| 203 |
-
.emp-card.status-notify .emp-head{ background:var(--tint-notify); border:1px solid var(--tint-notify-b) }
|
| 204 |
-
.emp-card.status-skip .emp-head{ background:var(--tint-skip); border:1px solid var(--tint-skip-b) }
|
| 205 |
-
|
| 206 |
-
/* ======================= Chris Wright Timeline (adapted) ======================= */
|
| 207 |
-
.cw-rail{ position:sticky; top:84px } /* pinned under Streamlit header */
|
| 208 |
-
@media (max-width: 1100px){ .cw-rail{ position:static } } /* not sticky on small screens */
|
| 209 |
-
|
| 210 |
-
.cw-timeline{ position:relative; margin:0; padding:0 0 0 28px; list-style:none; }
|
| 211 |
-
.cw-timeline::before{ content:""; position:absolute; left:11px; top:0; bottom:0; width:2px; background:#E2E8F0; }
|
| 212 |
-
.cw-item{
|
| 213 |
-
position:relative; margin:0 0 14px; background:#fff; border:1px solid var(--line);
|
| 214 |
-
border-radius:14px; box-shadow:0 6px 14px rgba(0,0,0,.06);
|
| 215 |
-
padding:10px 12px; opacity:0; transform:translateX(-10px); animation:cwFadeIn .45s ease-out forwards;
|
| 216 |
-
}
|
| 217 |
-
.cw-item:nth-child(2){ animation-delay:.05s }
|
| 218 |
-
.cw-item:nth-child(3){ animation-delay:.1s }
|
| 219 |
-
.cw-item:nth-child(4){ animation-delay:.15s }
|
| 220 |
-
.cw-item:nth-child(5){ animation-delay:.2s }
|
| 221 |
-
.cw-item::before{
|
| 222 |
-
content:""; position:absolute; left:-17px; top:14px; width:12px; height:12px;
|
| 223 |
-
border-radius:50%; border:2px solid #fff; background:#94A3B8; box-shadow:0 0 0 2px #E2E8F0;
|
| 224 |
-
}
|
| 225 |
-
.cw-item.done::before{ background:var(--green) }
|
| 226 |
-
.cw-item.current::before{ background:var(--blue) }
|
| 227 |
-
.cw-title{ margin:0 0 4px; font-weight:800 }
|
| 228 |
-
.cw-meta{ color:#64748B; font-size:.85rem; margin-bottom:4px }
|
| 229 |
-
.cw-desc{ color:#334155; font-size:.95rem }
|
| 230 |
-
@media (max-width: 1100px){
|
| 231 |
-
.cw-timeline{ padding-left:0; }
|
| 232 |
-
.cw-timeline::before{ display:none }
|
| 233 |
-
.cw-row{ display:flex; flex-wrap:wrap; gap:12px; align-items:stretch }
|
| 234 |
-
.cw-item{ flex:1 1 calc(50% - 12px); transform:translateY(8px) }
|
| 235 |
-
.cw-item::before{ left:12px }
|
| 236 |
-
}
|
| 237 |
-
@media (max-width: 700px){ .cw-item{ flex:1 1 100% } }
|
| 238 |
-
|
| 239 |
-
/* ===== Animations ===== */
|
| 240 |
-
@keyframes aiTitle{0%{background-position:0 0}50%{background-position:100% 0}100%{background-position:0 0}}
|
| 241 |
-
@keyframes pulse{0%{transform:scale(.95);box-shadow:0 0 0 0 rgba(54,186,1,.55)}
|
| 242 |
-
70%{transform:scale(1);box-shadow:0 0 0 10px rgba(54,186,1,0)}
|
| 243 |
-
100%{transform:scale(.95);box-shadow:0 0 0 0 rgba(54,186,1,0)}}
|
| 244 |
-
@keyframes borderShift{0%{background-position:0% 50%}50%{background-position:100% 50%}100%{background-position:0% 50%}}
|
| 245 |
-
@keyframes cardIn{ to{ opacity:1; transform:none } }
|
| 246 |
-
@keyframes aurora{ 0%{transform:translateX(-10%)} 50%{transform:translateX(10%)} 100%{transform:translateX(-10%)} }
|
| 247 |
-
@keyframes cwFadeIn{ to{ opacity:1; transform:none } }
|
| 248 |
-
"""
|
| 249 |
|
| 250 |
# =============================================================================
|
| 251 |
-
# UKG API helpers (unchanged)
|
| 252 |
# =============================================================================
|
|
|
|
| 253 |
def _get_auth_header() -> Dict[str, str]:
|
|
|
|
| 254 |
app_key = os.environ.get("UKG_APP_KEY")
|
| 255 |
token = os.environ.get("UKG_AUTH_TOKEN")
|
| 256 |
if not app_key or not token:
|
|
@@ -266,8 +58,10 @@ def fetch_open_shifts(
|
|
| 266 |
end_date: str = "3000-01-01",
|
| 267 |
location_ids: Optional[Iterable[str]] = None,
|
| 268 |
) -> pd.DataFrame:
|
|
|
|
| 269 |
if location_ids is None:
|
| 270 |
location_ids = ["2401", "2402", "2953", "2955", "2927", "2928", "2401", "2955"]
|
|
|
|
| 271 |
url = "https://partnerdemo-019.cfn.mykronos.com/api/v1/scheduling/schedule/multi_read"
|
| 272 |
headers = _get_auth_header()
|
| 273 |
payload = {
|
|
@@ -280,11 +74,13 @@ def fetch_open_shifts(
|
|
| 280 |
}
|
| 281 |
},
|
| 282 |
}
|
|
|
|
| 283 |
try:
|
| 284 |
r = requests.post(url, headers=headers, json=payload)
|
| 285 |
r.raise_for_status()
|
| 286 |
data = r.json()
|
| 287 |
rows: List[Dict[str, Any]] = []
|
|
|
|
| 288 |
for shift in data.get("openShifts", []):
|
| 289 |
rows.append({
|
| 290 |
"ID": shift.get("id"),
|
|
@@ -303,14 +99,20 @@ def fetch_open_shifts(
|
|
| 303 |
return pd.DataFrame()
|
| 304 |
|
| 305 |
def fetch_location_data(date: str = "2025-07-13", query: str = "Medsurg") -> pd.DataFrame:
|
|
|
|
| 306 |
url = "https://partnerdemo-019.cfn.mykronos.com/api/v1/commons/locations/multi_read"
|
| 307 |
headers = _get_auth_header()
|
| 308 |
-
payload = {
|
|
|
|
|
|
|
|
|
|
|
|
|
| 309 |
try:
|
| 310 |
r = requests.post(url, headers=headers, json=payload)
|
| 311 |
r.raise_for_status()
|
| 312 |
data = r.json()
|
| 313 |
rows = []
|
|
|
|
| 314 |
for item in data if isinstance(data, list) else data.get("locations", []):
|
| 315 |
rows.append({
|
| 316 |
"Node ID": item.get("nodeId", ""),
|
|
@@ -326,6 +128,7 @@ def fetch_location_data(date: str = "2025-07-13", query: str = "Medsurg") -> pd.
|
|
| 326 |
return pd.DataFrame()
|
| 327 |
|
| 328 |
def fetch_employees(employee_ids: Iterable[int]) -> pd.DataFrame:
|
|
|
|
| 329 |
base_url = "https://partnerdemo-019.cfn.mykronos.com/api/v1/commons/persons/"
|
| 330 |
headers = _get_auth_header()
|
| 331 |
|
|
@@ -335,19 +138,23 @@ def fetch_employees(employee_ids: Iterable[int]) -> pd.DataFrame:
|
|
| 335 |
if resp.status_code != 200:
|
| 336 |
st.warning(f"⚠️ Could not fetch employee {emp_id}: {resp.status_code}")
|
| 337 |
return None
|
|
|
|
| 338 |
data = resp.json()
|
| 339 |
person_info = data.get("personInformation", {}).get("person", {})
|
| 340 |
person_number = person_info.get("personNumber")
|
| 341 |
full_name = person_info.get("fullName", "")
|
|
|
|
| 342 |
org_path = ""
|
| 343 |
primary_accounts = data.get("jobAssignment", {}).get("primaryLaborAccounts", [])
|
| 344 |
if primary_accounts:
|
| 345 |
org_path = primary_accounts[0].get("organizationPath", "")
|
|
|
|
| 346 |
phone = ""
|
| 347 |
phones = data.get("personInformation", {}).get("telephoneNumbers", [])
|
| 348 |
if phones:
|
| 349 |
phone = phones[0].get("phoneNumber", "")
|
| 350 |
|
|
|
|
| 351 |
cert_url = f"{base_url}certifications/{emp_id}"
|
| 352 |
cert_resp = requests.get(cert_url, headers=headers)
|
| 353 |
certs = []
|
|
@@ -361,12 +168,7 @@ def fetch_employees(employee_ids: Iterable[int]) -> pd.DataFrame:
|
|
| 361 |
for a in cert_data.get("assignments", []):
|
| 362 |
qual = a.get("certification", {}).get("qualifier")
|
| 363 |
if qual: certs.append(qual)
|
| 364 |
-
else:
|
| 365 |
-
st.warning(f"Unrecognized cert format for {emp_id}: {cert_data}")
|
| 366 |
-
else:
|
| 367 |
-
st.warning(f"⚠️ Could not fetch certifications for {emp_id}")
|
| 368 |
|
| 369 |
-
# sanitize
|
| 370 |
certs = [str(x).strip() for x in certs if x]
|
| 371 |
|
| 372 |
return {
|
|
@@ -383,30 +185,38 @@ def fetch_employees(employee_ids: Iterable[int]) -> pd.DataFrame:
|
|
| 383 |
records = []
|
| 384 |
for emp_id in employee_ids:
|
| 385 |
row = fetch_employee_data(emp_id)
|
| 386 |
-
if row:
|
|
|
|
| 387 |
|
| 388 |
df = pd.DataFrame(records)
|
| 389 |
for col in ["personNumber", "organizationPath", "phoneNumber", "fullName", "Certifications"]:
|
| 390 |
-
if col not in df.columns:
|
|
|
|
|
|
|
| 391 |
if "Languages" not in df.columns:
|
| 392 |
df["Languages"] = [[] for _ in range(len(df))]
|
| 393 |
-
|
|
|
|
|
|
|
|
|
|
| 394 |
return df
|
| 395 |
|
| 396 |
-
|
| 397 |
# =============================================================================
|
| 398 |
-
# Decision
|
| 399 |
# =============================================================================
|
|
|
|
| 400 |
def _has_any(tokens: List[str], haystack: str) -> bool:
|
|
|
|
| 401 |
hay = (haystack or "").lower()
|
| 402 |
return any((t or "").strip().lower() in hay for t in tokens if t and t.strip())
|
| 403 |
|
| 404 |
def _has_all_in_list(need: List[str], have: List[str]) -> bool:
|
|
|
|
| 405 |
have_l = [h.lower() for h in (have or [])]
|
| 406 |
return all(n.strip().lower() in have_l for n in need if n and n.strip())
|
| 407 |
|
| 408 |
def is_eligible_strict(row: pd.Series, shift: pd.Series) -> bool:
|
| 409 |
-
"""
|
| 410 |
role_req = str(shift.get("RoleRequired","")).strip().lower()
|
| 411 |
specialty = str(shift.get("Specialty","")).replace("-", " ").strip().lower()
|
| 412 |
mandatory = [t.strip() for t in str(shift.get("MandatoryTraining","")).replace(",", "/").split("/") if t.strip()]
|
|
@@ -420,10 +230,11 @@ def is_eligible_strict(row: pd.Series, shift: pd.Series) -> bool:
|
|
| 420 |
training_ok = (not mandatory) or _has_all_in_list(mandatory, row.get("Certifications", []))
|
| 421 |
ward_ok = (not ward) or _has_any([ward], row.get("organizationPath",""))
|
| 422 |
lang_ok = (not lang_req) or (lang_req in [l.lower() for l in (row.get("Languages") or [])])
|
|
|
|
| 423 |
return role_ok and spec_ok and training_ok and ward_ok and lang_ok
|
| 424 |
|
| 425 |
def is_eligible_lenient(row: pd.Series, shift: pd.Series) -> bool:
|
| 426 |
-
"""
|
| 427 |
role_req = str(shift.get("RoleRequired","")).strip().lower()
|
| 428 |
specialty = str(shift.get("Specialty","")).replace("-", " ").strip().lower()
|
| 429 |
mandatory_raw = str(shift.get("MandatoryTraining",""))
|
|
@@ -441,12 +252,15 @@ def is_eligible_lenient(row: pd.Series, shift: pd.Series) -> bool:
|
|
| 441 |
training_ok = (not mandatory) or all(any(mtok in text_all for mtok in [m.lower(), m.lower().replace(" certified","")]) for m in mandatory)
|
| 442 |
ward_ok = (not ward) or (ward in text_all)
|
| 443 |
lang_ok = (not lang_req) or (lang_req in [l.lower() for l in (row.get("Languages") or [])])
|
|
|
|
| 444 |
core_ok = role_ok or spec_ok or training_ok
|
| 445 |
return core_ok and (not ward or ward_ok) and (not lang_req or lang_ok)
|
| 446 |
|
| 447 |
def gpt_decide(shift: pd.Series, eligible_df: pd.DataFrame) -> Dict[str, str]:
|
|
|
|
| 448 |
if not HAS_OPENAI or openai is None or not os.getenv("OPENAI_API_KEY"):
|
| 449 |
return {"action": "skip"}
|
|
|
|
| 450 |
emp_list = []
|
| 451 |
for _, r in eligible_df.iterrows():
|
| 452 |
role_match = str(shift.get('RoleRequired','')) in (r.get("organizationPath") or "") or \
|
|
@@ -454,6 +268,7 @@ def gpt_decide(shift: pd.Series, eligible_df: pd.DataFrame) -> Dict[str, str]:
|
|
| 454 |
mand_tokens = [t.strip() for t in str(shift.get("MandatoryTraining","")).replace(",", "/").split("/") if t.strip()]
|
| 455 |
cert_ok = _has_all_in_list(mand_tokens, r.get("Certifications") or [])
|
| 456 |
score = int(bool(role_match)) + int(bool(cert_ok))
|
|
|
|
| 457 |
emp_list.append({
|
| 458 |
"fullName": r.get("fullName",""),
|
| 459 |
"phoneNumber": r.get("phoneNumber",""),
|
|
@@ -461,29 +276,34 @@ def gpt_decide(shift: pd.Series, eligible_df: pd.DataFrame) -> Dict[str, str]:
|
|
| 461 |
"Certifications": r.get("Certifications",[]),
|
| 462 |
"matchScore": score
|
| 463 |
})
|
|
|
|
| 464 |
prompt = f"""
|
| 465 |
-
|
| 466 |
-
|
| 467 |
-
-
|
| 468 |
-
-
|
| 469 |
-
-
|
| 470 |
-
-
|
| 471 |
-
-
|
| 472 |
-
-
|
| 473 |
-
-
|
| 474 |
-
-
|
| 475 |
-
|
| 476 |
-
|
| 477 |
-
|
| 478 |
-
|
| 479 |
-
{{"action":"assign","employee":"
|
| 480 |
""".strip()
|
|
|
|
| 481 |
try:
|
| 482 |
resp = openai.ChatCompletion.create(
|
| 483 |
model="gpt-4",
|
| 484 |
-
messages=[
|
| 485 |
-
|
| 486 |
-
|
|
|
|
|
|
|
|
|
|
| 487 |
)
|
| 488 |
import ast
|
| 489 |
return ast.literal_eval(resp["choices"][0]["message"]["content"])
|
|
@@ -491,34 +311,12 @@ def gpt_decide(shift: pd.Series, eligible_df: pd.DataFrame) -> Dict[str, str]:
|
|
| 491 |
st.error(f"❌ GPT Error: {e}")
|
| 492 |
return {"action": "skip"}
|
| 493 |
|
| 494 |
-
|
| 495 |
# =============================================================================
|
| 496 |
-
#
|
| 497 |
# =============================================================================
|
| 498 |
-
def render_cw_timeline(events: List[Dict[str, Any]]) -> str:
|
| 499 |
-
items = []
|
| 500 |
-
for ev in events:
|
| 501 |
-
cls = ev.get("status", "done")
|
| 502 |
-
dur = ev.get("dur_ms")
|
| 503 |
-
dur_txt = f"{int(dur)} ms" if isinstance(dur, (int, float)) else "—"
|
| 504 |
-
title = str(ev.get("title", ""))
|
| 505 |
-
desc = str(ev.get("desc", ""))
|
| 506 |
-
items.append(
|
| 507 |
-
f'''
|
| 508 |
-
<li class="cw-item {cls}">
|
| 509 |
-
<div class="cw-title">{title}</div>
|
| 510 |
-
<div class="cw-meta">Duration: {dur_txt}</div>
|
| 511 |
-
<div class="cw-desc">{desc}</div>
|
| 512 |
-
</li>'''
|
| 513 |
-
)
|
| 514 |
-
return '<ul class="cw-timeline cw-row">' + "".join(items) + '</ul>'
|
| 515 |
-
|
| 516 |
|
| 517 |
-
# =============================================================================
|
| 518 |
-
# Showcase helpers
|
| 519 |
-
# =============================================================================
|
| 520 |
def _append_demo_employee(df_employees: pd.DataFrame) -> pd.DataFrame:
|
| 521 |
-
"""Adds a
|
| 522 |
demo = {
|
| 523 |
"personNumber": "D-1001",
|
| 524 |
"organizationPath": "Hospital/A&E/ICU/Registered Nurse",
|
|
@@ -535,33 +333,37 @@ def _append_demo_employee(df_employees: pd.DataFrame) -> pd.DataFrame:
|
|
| 535 |
return pd.concat([df_employees, extra], ignore_index=True)
|
| 536 |
|
| 537 |
def _score_match(row: pd.Series, shift: pd.Series) -> int:
|
| 538 |
-
"""Simple score to rank candidates for showcase distribution
|
| 539 |
text_all = " ".join([
|
| 540 |
row.get("JobRole",""), row.get("organizationPath",""),
|
| 541 |
" ".join((row.get("Certifications") or [])), " ".join((row.get("Languages") or []))
|
| 542 |
]).lower()
|
|
|
|
| 543 |
role_req = str(shift.get("RoleRequired","")).strip().lower()
|
| 544 |
specialty = str(shift.get("Specialty","")).replace("-", " ").strip().lower()
|
| 545 |
ward = str(shift.get("WardDepartment","")).strip().lower()
|
| 546 |
lang = str(shift.get("LanguageRequirement","")).strip().lower()
|
|
|
|
| 547 |
pts = 0
|
| 548 |
if role_req and role_req in text_all: pts += 3
|
| 549 |
if specialty and ("icu" in text_all or specialty.replace(" ", "") in text_all): pts += 2
|
| 550 |
if ward and ward in text_all: pts += 1
|
| 551 |
if lang and lang in text_all: pts += 1
|
| 552 |
if any(tok in text_all for tok in ["bls", "als"]): pts += 2
|
|
|
|
| 553 |
return pts
|
| 554 |
|
| 555 |
-
|
| 556 |
# =============================================================================
|
| 557 |
-
# Agent runner
|
| 558 |
# =============================================================================
|
|
|
|
| 559 |
def run_agent(
|
| 560 |
employee_ids: Iterable[int],
|
| 561 |
df_shifts: pd.DataFrame,
|
| 562 |
showcase: bool = False,
|
| 563 |
lenient: bool = False,
|
| 564 |
):
|
|
|
|
| 565 |
events: List[Dict[str, Any]] = []
|
| 566 |
shift_assignment_results: List[tuple] = []
|
| 567 |
reasoning_rows: List[Dict[str, Any]] = []
|
|
@@ -572,12 +374,22 @@ def run_agent(
|
|
| 572 |
if showcase and len(df_employees) < 3:
|
| 573 |
df_employees = _append_demo_employee(df_employees)
|
| 574 |
dur = (time.perf_counter() - t0) * 1000
|
|
|
|
| 575 |
loaded_txt = f"{len(df_employees)} employee(s) loaded successfully." if not df_employees.empty else "No employees returned or credentials invalid."
|
| 576 |
-
events.append({
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 577 |
|
| 578 |
# 2) Evaluate shifts
|
| 579 |
t_eval = time.perf_counter()
|
| 580 |
-
events.append({
|
|
|
|
|
|
|
|
|
|
|
|
|
| 581 |
|
| 582 |
elig_fn = is_eligible_lenient if lenient else is_eligible_strict
|
| 583 |
|
|
@@ -590,54 +402,90 @@ def run_agent(
|
|
| 590 |
ranked = df_employees.copy()
|
| 591 |
ranked["__score"] = ranked.apply(lambda r: _score_match(r, shift), axis=1)
|
| 592 |
ranked = ranked.sort_values("__score", ascending=False).reset_index(drop=True)
|
|
|
|
| 593 |
cand_assign = ranked.iloc[0] if len(ranked) > 0 else None
|
| 594 |
cand_notify = ranked.iloc[1] if len(ranked) > 1 else None
|
| 595 |
-
cand_skip
|
| 596 |
|
| 597 |
if cand_assign is not None:
|
| 598 |
-
name = cand_assign.get("fullName","Candidate A")
|
| 599 |
-
events.append({
|
|
|
|
|
|
|
|
|
|
|
|
|
| 600 |
shift_assignment_results.append((name, shift["ShiftID"], "✅ Auto-Filled"))
|
| 601 |
|
| 602 |
if cand_notify is not None:
|
| 603 |
-
name = cand_notify.get("fullName","Candidate B")
|
| 604 |
-
events.append({
|
|
|
|
|
|
|
|
|
|
|
|
|
| 605 |
shift_assignment_results.append((name, shift["ShiftID"], "📨 Notify"))
|
| 606 |
|
| 607 |
if cand_skip is not None:
|
| 608 |
-
name = cand_skip.get("fullName","Candidate C")
|
| 609 |
-
events.append({
|
|
|
|
|
|
|
|
|
|
|
|
|
| 610 |
shift_assignment_results.append((name, shift["ShiftID"], "⚠️ Skipped"))
|
| 611 |
|
| 612 |
ai_dur = (time.perf_counter() - t_ai) * 1000
|
| 613 |
-
events.insert(-3, {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 614 |
else:
|
| 615 |
-
events.append({
|
|
|
|
|
|
|
|
|
|
|
|
|
| 616 |
decision = gpt_decide(shift, eligible)
|
| 617 |
ai_dur = (time.perf_counter() - t_ai) * 1000
|
| 618 |
-
events[-1]["dur_ms"] = max(int(ai_dur),1)
|
| 619 |
|
| 620 |
if decision.get("action") == "assign":
|
| 621 |
emp = decision.get("employee")
|
| 622 |
-
events.append({
|
|
|
|
|
|
|
|
|
|
|
|
|
| 623 |
shift_assignment_results.append((emp, shift["ShiftID"], "✅ Auto-Filled"))
|
| 624 |
elif decision.get("action") == "notify":
|
| 625 |
emp = decision.get("employee")
|
| 626 |
-
events.append({
|
|
|
|
|
|
|
|
|
|
|
|
|
| 627 |
shift_assignment_results.append((emp, shift["ShiftID"], "📨 Notify"))
|
| 628 |
else:
|
| 629 |
-
events.append({
|
|
|
|
|
|
|
|
|
|
|
|
|
| 630 |
shift_assignment_results.append(("❌ No eligible", shift["ShiftID"], "⚠️ Skipped"))
|
| 631 |
|
| 632 |
-
#
|
| 633 |
for _, emp_row in df_employees.iterrows():
|
| 634 |
role_match = str(shift.get("RoleRequired","")).strip().lower() == emp_row.get("JobRole","").strip().lower()
|
| 635 |
spec = str(shift.get("Specialty","")).replace("-", " ").strip().lower()
|
| 636 |
text_all = (emp_row.get("JobRole","")+" "+emp_row.get("organizationPath","")+" "+" ".join(emp_row.get("Certifications",[]))).lower()
|
| 637 |
spec_ok = (spec and ("icu" in text_all or spec.replace(" ", "") in text_all))
|
|
|
|
| 638 |
training_need = [t.strip().lower() for t in str(shift.get("MandatoryTraining","")).replace(",", "/").split("/") if t.strip()]
|
| 639 |
training_ok_strict = all(t in [c.lower() for c in (emp_row.get("Certifications",[]) or [])] for t in training_need)
|
| 640 |
training_ok_len = all(any(mtok in text_all for mtok in [t, t.replace(" certified","")]) for t in training_need) if training_need else True
|
|
|
|
| 641 |
ward_ok = not str(shift.get("WardDepartment","")).strip() or str(shift.get("WardDepartment","")).lower() in (emp_row.get("organizationPath","").lower())
|
| 642 |
lang_req = str(shift.get("LanguageRequirement","")).strip().lower()
|
| 643 |
lang_ok = (not lang_req) or (lang_req in [l.lower() for l in (emp_row.get("Languages") or [])])
|
|
@@ -645,7 +493,7 @@ def run_agent(
|
|
| 645 |
if lenient:
|
| 646 |
status = "✅ Eligible" if (role_match or spec_ok or training_ok_len) and ward_ok and lang_ok else "❌ Not Eligible"
|
| 647 |
else:
|
| 648 |
-
status = "✅ Eligible" if (
|
| 649 |
|
| 650 |
reasoning_rows.append({
|
| 651 |
"Employee": emp_row.get("fullName",""),
|
|
@@ -664,295 +512,407 @@ def run_agent(
|
|
| 664 |
eval_dur = (time.perf_counter() - t_eval) * 1000
|
| 665 |
for e in events:
|
| 666 |
if e["title"] == "Evaluate open shifts":
|
| 667 |
-
e["dur_ms"] = max(int(eval_dur),1)
|
| 668 |
break
|
| 669 |
|
| 670 |
-
events.append({
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 671 |
return events, shift_assignment_results, reasoning_rows
|
| 672 |
|
| 673 |
-
|
| 674 |
# =============================================================================
|
| 675 |
-
#
|
| 676 |
# =============================================================================
|
| 677 |
-
def render_kpis(assigned:int, notified:int, skipped:int) -> str:
|
| 678 |
-
return f"""
|
| 679 |
-
<div class="kpi-grid" role="group" aria-label="KPI">
|
| 680 |
-
<div class="kpi-card"><div class="ico">✅</div><div class="lbl">Assigned</div><div class="val">{assigned}</div></div>
|
| 681 |
-
<div class="kpi-card"><div class="ico">📬</div><div class="lbl">Notify</div><div class="val">{notified}</div></div>
|
| 682 |
-
<div class="kpi-card"><div class="ico">⚠️</div><div class="lbl">Skipped</div><div class="val">{skipped}</div></div>
|
| 683 |
-
</div>
|
| 684 |
-
"""
|
| 685 |
|
| 686 |
-
def
|
| 687 |
-
|
| 688 |
-
|
| 689 |
-
|
| 690 |
-
|
| 691 |
-
|
| 692 |
-
|
| 693 |
-
|
| 694 |
-
|
| 695 |
-
|
| 696 |
-
|
| 697 |
-
|
| 698 |
-
|
| 699 |
-
|
| 700 |
-
|
| 701 |
-
|
| 702 |
-
|
| 703 |
-
|
| 704 |
-
|
| 705 |
-
|
| 706 |
-
|
| 707 |
-
|
| 708 |
-
|
| 709 |
-
|
| 710 |
-
|
| 711 |
-
|
| 712 |
-
|
| 713 |
-
|
| 714 |
-
|
| 715 |
-
|
| 716 |
-
|
| 717 |
-
|
| 718 |
-
chip_class = _chip_class(chip)
|
| 719 |
-
return f"""
|
| 720 |
-
<div class="emp-card status-{lane}" role="group" aria-label="{name}">
|
| 721 |
-
<div class="emp-head">
|
| 722 |
-
<div class="emp-avatar">{_name_initials(name)}</div>
|
| 723 |
-
<div>
|
| 724 |
-
<div class="emp-name">{name}</div>
|
| 725 |
-
<div style="color:#64748B;font-size:.85rem">Shift: {shift or "—"}</div>
|
| 726 |
</div>
|
| 727 |
-
|
| 728 |
-
|
| 729 |
-
|
| 730 |
-
|
| 731 |
-
|
| 732 |
-
|
| 733 |
-
|
| 734 |
-
|
| 735 |
-
|
| 736 |
-
|
| 737 |
-
</div>
|
| 738 |
-
|
| 739 |
-
|
| 740 |
-
|
| 741 |
-
|
| 742 |
-
|
| 743 |
-
|
| 744 |
-
|
| 745 |
-
|
| 746 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 747 |
for emp, sid, status in shift_results:
|
| 748 |
if emp and not str(emp).startswith("❌"):
|
| 749 |
action_by_emp[str(emp)] = status
|
| 750 |
shift_by_emp[str(emp)] = str(sid)
|
| 751 |
|
| 752 |
-
#
|
| 753 |
seen = set()
|
| 754 |
-
employees
|
| 755 |
for r in reasoning_rows:
|
| 756 |
emp = (r.get("Employee") or "").strip()
|
| 757 |
-
if not emp or emp in seen:
|
|
|
|
| 758 |
seen.add(emp)
|
| 759 |
employees.append({
|
| 760 |
"name": emp,
|
| 761 |
-
"shift": r.get("ShiftID",""),
|
| 762 |
-
"eligible": r.get("Eligible",""),
|
| 763 |
-
"reasoning": r.get("Reasoning",""),
|
| 764 |
-
"certs": r.get("Certifications",""),
|
| 765 |
-
"action": action_by_emp.get(emp,"")
|
| 766 |
})
|
| 767 |
|
| 768 |
if not employees:
|
| 769 |
-
|
| 770 |
-
|
| 771 |
-
if mode == "grid":
|
| 772 |
-
cards = []
|
| 773 |
-
for emp in employees:
|
| 774 |
-
lane = _status_lane_for(emp["action"], emp["eligible"])
|
| 775 |
-
chip = emp["action"] or ("✅ Eligible" if str(emp["eligible"]).startswith("✅") else "❌ Not Eligible")
|
| 776 |
-
cards.append(_build_emp_card(emp["name"], emp["shift"], chip, emp["reasoning"], emp["certs"], lane))
|
| 777 |
-
return '<div class="emp-grid">' + "".join(cards) + '</div>'
|
| 778 |
-
|
| 779 |
-
# Board (Kanban)
|
| 780 |
-
lanes = {"assign": [], "notify": [], "skip": []}
|
| 781 |
-
for emp in employees:
|
| 782 |
-
lane = _status_lane_for(emp["action"], emp["eligible"])
|
| 783 |
-
chip = emp["action"] or ("✅ Eligible" if str(emp["eligible"]).startswith("✅") else "❌ Not Eligible")
|
| 784 |
-
lanes[lane].append(_build_emp_card(emp["name"], emp["shift"], chip, emp["reasoning"], emp["certs"], lane))
|
| 785 |
-
|
| 786 |
-
def lane_html(title:str, icon:str, klass:str, items:List[str]) -> str:
|
| 787 |
-
return f"""
|
| 788 |
-
<div class="lane lane-{klass}">
|
| 789 |
-
<div class="lane-header">{icon} {title} <span class="count">{len(items)}</span></div>
|
| 790 |
-
{''.join(items) if items else '<div style="color:#64748B;padding:10px">No employees in this lane.</div>'}
|
| 791 |
-
</div>
|
| 792 |
-
"""
|
| 793 |
|
| 794 |
-
|
| 795 |
-
|
| 796 |
-
|
| 797 |
-
|
| 798 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 799 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 800 |
|
| 801 |
# =============================================================================
|
| 802 |
-
#
|
| 803 |
# =============================================================================
|
| 804 |
-
def main() -> None:
|
| 805 |
-
st.set_page_config(page_title="Health Matrix AI Command Center", layout="wide")
|
| 806 |
-
st.markdown(f"<style>{_EMBEDDED_CSS}</style>", unsafe_allow_html=True)
|
| 807 |
-
|
| 808 |
-
# HERO
|
| 809 |
-
st.markdown(
|
| 810 |
-
"""
|
| 811 |
-
<div class="hero" dir="auto">
|
| 812 |
-
<img src="https://www.healthmatrixcorp.com/web/image/website/1/logo/Health%20Matrix?unique=956ad7b" alt="Health Matrix" />
|
| 813 |
-
<h1>AI Command Center – Smart Staffing & Actions</h1>
|
| 814 |
-
<p>Desktop-first view. Sticky left runline + KPI + <b>Kanban board</b> لنتائج الموظفين — واضح للأعداد الكبيرة. Showcase اختياري.</p>
|
| 815 |
-
</div>
|
| 816 |
-
""", unsafe_allow_html=True
|
| 817 |
-
)
|
| 818 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 819 |
# Business Case
|
| 820 |
st.markdown("""
|
| 821 |
-
<div class="
|
| 822 |
-
|
| 823 |
-
|
| 824 |
-
|
| 825 |
-
|
| 826 |
-
|
| 827 |
-
|
| 828 |
-
</div>
|
| 829 |
-
""", unsafe_allow_html=True)
|
| 830 |
-
|
| 831 |
-
# Demo
|
| 832 |
with st.expander("🔧 Demo / Showcase Controls", expanded=False):
|
| 833 |
-
|
| 834 |
-
|
| 835 |
-
|
| 836 |
-
|
| 837 |
-
|
| 838 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 839 |
status_placeholder = st.empty()
|
| 840 |
-
status_placeholder.
|
| 841 |
-
"""
|
| 842 |
-
|
| 843 |
-
|
| 844 |
-
<span class="pulse-dot"></span>
|
| 845 |
-
<span>Ready – Click “Start AI Agent” to evaluate shifts</span>
|
| 846 |
-
</div>
|
| 847 |
-
</div>
|
| 848 |
-
""", unsafe_allow_html=True
|
| 849 |
-
)
|
| 850 |
-
|
| 851 |
-
# Demo input (unchanged behavior)
|
| 852 |
employee_ids_default = [850, 825]
|
| 853 |
shift_data = """ShiftID,Department,RoleRequired,Specialty,MandatoryTraining,ShiftType,ShiftDuration,WardDepartment,LanguageRequirement,ShiftTime
|
| 854 |
S101,ICU,Registered Nurse,ICU-trained,BLS/ALS certified,Night,12h,A&E,Arabic,2025-06-04 07:00"""
|
| 855 |
df_shifts_default = pd.read_csv(StringIO(shift_data))
|
| 856 |
-
|
| 857 |
-
# CTA
|
| 858 |
-
|
| 859 |
-
|
| 860 |
-
|
| 861 |
-
|
| 862 |
-
|
| 863 |
-
|
| 864 |
-
|
| 865 |
-
|
| 866 |
-
<div class="status-bar">
|
| 867 |
-
<span class="pulse-dot"></span>
|
| 868 |
-
<span>Processing – contacting UKG APIs and evaluating shifts...</span>
|
| 869 |
-
</div>
|
| 870 |
-
</div>
|
| 871 |
-
""", unsafe_allow_html=True
|
| 872 |
-
)
|
| 873 |
-
|
| 874 |
-
# Run agent
|
| 875 |
try:
|
| 876 |
events, shift_assignment_results, reasoning_rows = run_agent(
|
| 877 |
-
employee_ids_default,
|
| 878 |
-
|
|
|
|
|
|
|
| 879 |
)
|
| 880 |
-
|
| 881 |
-
|
| 882 |
-
|
| 883 |
-
|
| 884 |
-
|
| 885 |
-
|
| 886 |
-
<div class="status-wrap" role="status" aria-live="polite">
|
| 887 |
-
<div class="status-bar status-done">
|
| 888 |
-
<span class="pulse-dot"></span>
|
| 889 |
-
<span>Completed – results below</span>
|
| 890 |
-
</div>
|
| 891 |
-
</div>
|
| 892 |
-
""", unsafe_allow_html=True
|
| 893 |
-
)
|
| 894 |
-
|
| 895 |
-
# ----- DESKTOP LAYOUT: Left sticky timeline + right content -----
|
| 896 |
-
left, right = st.columns([3, 9], gap="large")
|
| 897 |
-
|
| 898 |
-
# LEFT: Timeline (sticky rail on desktop)
|
| 899 |
-
with left:
|
| 900 |
-
st.markdown("### 🕒 Agent Runline")
|
| 901 |
-
st.markdown('<div class="cw-rail">', unsafe_allow_html=True)
|
| 902 |
-
st.markdown(render_cw_timeline(events), unsafe_allow_html=True)
|
| 903 |
-
st.markdown('</div>', unsafe_allow_html=True)
|
| 904 |
-
|
| 905 |
-
# RIGHT: Overview + KPIs + Employee View
|
| 906 |
-
with right:
|
| 907 |
-
total_steps = len(events)
|
| 908 |
-
total_ms = sum(int(e.get("dur_ms", 0) or 0) for e in events)
|
| 909 |
assigned = sum(1 for e in shift_assignment_results if "Auto-Filled" in e[2])
|
| 910 |
notified = sum(1 for e in shift_assignment_results if "Notify" in e[2])
|
| 911 |
skipped = sum(1 for e in shift_assignment_results if "Skip" in e[2] or "Skipped" in e[2])
|
| 912 |
-
|
| 913 |
-
|
| 914 |
-
st.
|
| 915 |
-
|
| 916 |
-
|
| 917 |
-
|
| 918 |
-
|
| 919 |
-
|
| 920 |
-
|
| 921 |
-
|
| 922 |
-
|
| 923 |
-
|
| 924 |
-
|
| 925 |
-
|
| 926 |
-
|
| 927 |
-
|
| 928 |
-
|
| 929 |
-
|
| 930 |
-
|
| 931 |
-
|
| 932 |
-
|
| 933 |
-
|
| 934 |
-
|
| 935 |
-
|
| 936 |
-
|
| 937 |
-
|
| 938 |
-
|
| 939 |
-
|
| 940 |
-
|
| 941 |
-
|
| 942 |
-
|
| 943 |
-
|
| 944 |
-
|
| 945 |
-
|
| 946 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 947 |
# Footer
|
| 948 |
-
st.markdown(
|
| 949 |
-
|
| 950 |
-
<hr/>
|
| 951 |
-
<div
|
| 952 |
-
© 2025 Health Matrix Corp – Empowering Digital Health Transformation ·
|
| 953 |
-
</
|
| 954 |
-
|
| 955 |
-
|
|
|
|
| 956 |
|
| 957 |
if __name__ == "__main__":
|
| 958 |
-
main()
|
|
|
|
| 1 |
"""
|
| 2 |
+
Health Matrix AI Command Center - Streamlit Application
|
| 3 |
+
Modern AI-inspired healthcare workforce management platform
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
"""
|
| 5 |
|
| 6 |
+
import streamlit as st
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
import pandas as pd
|
| 8 |
+
import time
|
| 9 |
+
from datetime import datetime
|
| 10 |
+
import os
|
| 11 |
+
from typing import Dict, List, Any, Optional, Iterable
|
| 12 |
import requests
|
| 13 |
+
from io import StringIO
|
| 14 |
|
| 15 |
# Optional: OpenAI for GPT decisions (fails gracefully if missing)
|
| 16 |
try:
|
| 17 |
+
import openai
|
| 18 |
HAS_OPENAI = True
|
| 19 |
except Exception:
|
| 20 |
openai = None
|
| 21 |
HAS_OPENAI = False
|
| 22 |
|
|
|
|
| 23 |
# =============================================================================
|
| 24 |
+
# Configuration & Setup
|
| 25 |
# =============================================================================
|
| 26 |
+
|
| 27 |
+
def setup_page():
|
| 28 |
+
"""Configure Streamlit page settings and load custom CSS"""
|
| 29 |
+
st.set_page_config(
|
| 30 |
+
page_title="Health Matrix AI Command Center",
|
| 31 |
+
page_icon="🏥",
|
| 32 |
+
layout="wide",
|
| 33 |
+
initial_sidebar_state="collapsed"
|
| 34 |
+
)
|
| 35 |
+
|
| 36 |
+
# Load custom CSS
|
| 37 |
+
with open("src/style.css", "r", encoding="utf-8") as f:
|
| 38 |
+
st.markdown(f"<style>{f.read()}</style>", unsafe_allow_html=True)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 39 |
|
| 40 |
# =============================================================================
|
| 41 |
+
# UKG API helpers (unchanged from original)
|
| 42 |
# =============================================================================
|
| 43 |
+
|
| 44 |
def _get_auth_header() -> Dict[str, str]:
|
| 45 |
+
"""Get UKG API authentication headers"""
|
| 46 |
app_key = os.environ.get("UKG_APP_KEY")
|
| 47 |
token = os.environ.get("UKG_AUTH_TOKEN")
|
| 48 |
if not app_key or not token:
|
|
|
|
| 58 |
end_date: str = "3000-01-01",
|
| 59 |
location_ids: Optional[Iterable[str]] = None,
|
| 60 |
) -> pd.DataFrame:
|
| 61 |
+
"""Fetch open shifts from UKG API"""
|
| 62 |
if location_ids is None:
|
| 63 |
location_ids = ["2401", "2402", "2953", "2955", "2927", "2928", "2401", "2955"]
|
| 64 |
+
|
| 65 |
url = "https://partnerdemo-019.cfn.mykronos.com/api/v1/scheduling/schedule/multi_read"
|
| 66 |
headers = _get_auth_header()
|
| 67 |
payload = {
|
|
|
|
| 74 |
}
|
| 75 |
},
|
| 76 |
}
|
| 77 |
+
|
| 78 |
try:
|
| 79 |
r = requests.post(url, headers=headers, json=payload)
|
| 80 |
r.raise_for_status()
|
| 81 |
data = r.json()
|
| 82 |
rows: List[Dict[str, Any]] = []
|
| 83 |
+
|
| 84 |
for shift in data.get("openShifts", []):
|
| 85 |
rows.append({
|
| 86 |
"ID": shift.get("id"),
|
|
|
|
| 99 |
return pd.DataFrame()
|
| 100 |
|
| 101 |
def fetch_location_data(date: str = "2025-07-13", query: str = "Medsurg") -> pd.DataFrame:
|
| 102 |
+
"""Fetch location data from UKG API"""
|
| 103 |
url = "https://partnerdemo-019.cfn.mykronos.com/api/v1/commons/locations/multi_read"
|
| 104 |
headers = _get_auth_header()
|
| 105 |
+
payload = {
|
| 106 |
+
"multiReadOptions": {"includeOrgPathDetails": True},
|
| 107 |
+
"where": {"query": {"context": "ORG", "date": date, "q": query}}
|
| 108 |
+
}
|
| 109 |
+
|
| 110 |
try:
|
| 111 |
r = requests.post(url, headers=headers, json=payload)
|
| 112 |
r.raise_for_status()
|
| 113 |
data = r.json()
|
| 114 |
rows = []
|
| 115 |
+
|
| 116 |
for item in data if isinstance(data, list) else data.get("locations", []):
|
| 117 |
rows.append({
|
| 118 |
"Node ID": item.get("nodeId", ""),
|
|
|
|
| 128 |
return pd.DataFrame()
|
| 129 |
|
| 130 |
def fetch_employees(employee_ids: Iterable[int]) -> pd.DataFrame:
|
| 131 |
+
"""Fetch employee data from UKG API"""
|
| 132 |
base_url = "https://partnerdemo-019.cfn.mykronos.com/api/v1/commons/persons/"
|
| 133 |
headers = _get_auth_header()
|
| 134 |
|
|
|
|
| 138 |
if resp.status_code != 200:
|
| 139 |
st.warning(f"⚠️ Could not fetch employee {emp_id}: {resp.status_code}")
|
| 140 |
return None
|
| 141 |
+
|
| 142 |
data = resp.json()
|
| 143 |
person_info = data.get("personInformation", {}).get("person", {})
|
| 144 |
person_number = person_info.get("personNumber")
|
| 145 |
full_name = person_info.get("fullName", "")
|
| 146 |
+
|
| 147 |
org_path = ""
|
| 148 |
primary_accounts = data.get("jobAssignment", {}).get("primaryLaborAccounts", [])
|
| 149 |
if primary_accounts:
|
| 150 |
org_path = primary_accounts[0].get("organizationPath", "")
|
| 151 |
+
|
| 152 |
phone = ""
|
| 153 |
phones = data.get("personInformation", {}).get("telephoneNumbers", [])
|
| 154 |
if phones:
|
| 155 |
phone = phones[0].get("phoneNumber", "")
|
| 156 |
|
| 157 |
+
# Fetch certifications
|
| 158 |
cert_url = f"{base_url}certifications/{emp_id}"
|
| 159 |
cert_resp = requests.get(cert_url, headers=headers)
|
| 160 |
certs = []
|
|
|
|
| 168 |
for a in cert_data.get("assignments", []):
|
| 169 |
qual = a.get("certification", {}).get("qualifier")
|
| 170 |
if qual: certs.append(qual)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 171 |
|
|
|
|
| 172 |
certs = [str(x).strip() for x in certs if x]
|
| 173 |
|
| 174 |
return {
|
|
|
|
| 185 |
records = []
|
| 186 |
for emp_id in employee_ids:
|
| 187 |
row = fetch_employee_data(emp_id)
|
| 188 |
+
if row:
|
| 189 |
+
records.append(row)
|
| 190 |
|
| 191 |
df = pd.DataFrame(records)
|
| 192 |
for col in ["personNumber", "organizationPath", "phoneNumber", "fullName", "Certifications"]:
|
| 193 |
+
if col not in df.columns:
|
| 194 |
+
df[col] = []
|
| 195 |
+
|
| 196 |
if "Languages" not in df.columns:
|
| 197 |
df["Languages"] = [[] for _ in range(len(df))]
|
| 198 |
+
|
| 199 |
+
df["JobRole"] = df["organizationPath"].apply(
|
| 200 |
+
lambda p: p.split("/")[-1] if isinstance(p, str) and p else ""
|
| 201 |
+
)
|
| 202 |
return df
|
| 203 |
|
|
|
|
| 204 |
# =============================================================================
|
| 205 |
+
# Decision Logic (unchanged from original)
|
| 206 |
# =============================================================================
|
| 207 |
+
|
| 208 |
def _has_any(tokens: List[str], haystack: str) -> bool:
|
| 209 |
+
"""Check if any token exists in haystack"""
|
| 210 |
hay = (haystack or "").lower()
|
| 211 |
return any((t or "").strip().lower() in hay for t in tokens if t and t.strip())
|
| 212 |
|
| 213 |
def _has_all_in_list(need: List[str], have: List[str]) -> bool:
|
| 214 |
+
"""Check if all needed items exist in have list"""
|
| 215 |
have_l = [h.lower() for h in (have or [])]
|
| 216 |
return all(n.strip().lower() in have_l for n in need if n and n.strip())
|
| 217 |
|
| 218 |
def is_eligible_strict(row: pd.Series, shift: pd.Series) -> bool:
|
| 219 |
+
"""Original strict eligibility logic: AND on all criteria"""
|
| 220 |
role_req = str(shift.get("RoleRequired","")).strip().lower()
|
| 221 |
specialty = str(shift.get("Specialty","")).replace("-", " ").strip().lower()
|
| 222 |
mandatory = [t.strip() for t in str(shift.get("MandatoryTraining","")).replace(",", "/").split("/") if t.strip()]
|
|
|
|
| 230 |
training_ok = (not mandatory) or _has_all_in_list(mandatory, row.get("Certifications", []))
|
| 231 |
ward_ok = (not ward) or _has_any([ward], row.get("organizationPath",""))
|
| 232 |
lang_ok = (not lang_req) or (lang_req in [l.lower() for l in (row.get("Languages") or [])])
|
| 233 |
+
|
| 234 |
return role_ok and spec_ok and training_ok and ward_ok and lang_ok
|
| 235 |
|
| 236 |
def is_eligible_lenient(row: pd.Series, shift: pd.Series) -> bool:
|
| 237 |
+
"""Lenient eligibility: OR between role/specialty/training + respect ward/language if mentioned"""
|
| 238 |
role_req = str(shift.get("RoleRequired","")).strip().lower()
|
| 239 |
specialty = str(shift.get("Specialty","")).replace("-", " ").strip().lower()
|
| 240 |
mandatory_raw = str(shift.get("MandatoryTraining",""))
|
|
|
|
| 252 |
training_ok = (not mandatory) or all(any(mtok in text_all for mtok in [m.lower(), m.lower().replace(" certified","")]) for m in mandatory)
|
| 253 |
ward_ok = (not ward) or (ward in text_all)
|
| 254 |
lang_ok = (not lang_req) or (lang_req in [l.lower() for l in (row.get("Languages") or [])])
|
| 255 |
+
|
| 256 |
core_ok = role_ok or spec_ok or training_ok
|
| 257 |
return core_ok and (not ward or ward_ok) and (not lang_req or lang_ok)
|
| 258 |
|
| 259 |
def gpt_decide(shift: pd.Series, eligible_df: pd.DataFrame) -> Dict[str, str]:
|
| 260 |
+
"""GPT-based decision making"""
|
| 261 |
if not HAS_OPENAI or openai is None or not os.getenv("OPENAI_API_KEY"):
|
| 262 |
return {"action": "skip"}
|
| 263 |
+
|
| 264 |
emp_list = []
|
| 265 |
for _, r in eligible_df.iterrows():
|
| 266 |
role_match = str(shift.get('RoleRequired','')) in (r.get("organizationPath") or "") or \
|
|
|
|
| 268 |
mand_tokens = [t.strip() for t in str(shift.get("MandatoryTraining","")).replace(",", "/").split("/") if t.strip()]
|
| 269 |
cert_ok = _has_all_in_list(mand_tokens, r.get("Certifications") or [])
|
| 270 |
score = int(bool(role_match)) + int(bool(cert_ok))
|
| 271 |
+
|
| 272 |
emp_list.append({
|
| 273 |
"fullName": r.get("fullName",""),
|
| 274 |
"phoneNumber": r.get("phoneNumber",""),
|
|
|
|
| 276 |
"Certifications": r.get("Certifications",[]),
|
| 277 |
"matchScore": score
|
| 278 |
})
|
| 279 |
+
|
| 280 |
prompt = f"""
|
| 281 |
+
You are an intelligent shift assignment assistant:
|
| 282 |
+
Shift Details:
|
| 283 |
+
- Department: {shift.get('Department','')}
|
| 284 |
+
- Required Role: {shift.get('RoleRequired','')}
|
| 285 |
+
- Specialty/Experience: {shift.get('Specialty','')}
|
| 286 |
+
- Mandatory Training: {shift.get('MandatoryTraining','')}
|
| 287 |
+
- Shift Type/Duration: {shift.get('ShiftType','')} / {shift.get('ShiftDuration','')}
|
| 288 |
+
- Ward/Department: {shift.get('WardDepartment','')}
|
| 289 |
+
- Language Required: {shift.get('LanguageRequirement','')}
|
| 290 |
+
- Time: {shift.get('ShiftTime','')}
|
| 291 |
+
|
| 292 |
+
Eligible Employees (matchScore=0..2): {emp_list if emp_list else 'None'}
|
| 293 |
+
|
| 294 |
+
Choose JSON only:
|
| 295 |
+
{{"action":"assign","employee":"Name"}} or {{"action":"notify","employee":"Name"}} or {{"action":"skip"}}
|
| 296 |
""".strip()
|
| 297 |
+
|
| 298 |
try:
|
| 299 |
resp = openai.ChatCompletion.create(
|
| 300 |
model="gpt-4",
|
| 301 |
+
messages=[
|
| 302 |
+
{"role":"system","content":"You are an intelligent healthcare shift management assistant"},
|
| 303 |
+
{"role":"user","content":prompt}
|
| 304 |
+
],
|
| 305 |
+
temperature=0.4,
|
| 306 |
+
max_tokens=200,
|
| 307 |
)
|
| 308 |
import ast
|
| 309 |
return ast.literal_eval(resp["choices"][0]["message"]["content"])
|
|
|
|
| 311 |
st.error(f"❌ GPT Error: {e}")
|
| 312 |
return {"action": "skip"}
|
| 313 |
|
|
|
|
| 314 |
# =============================================================================
|
| 315 |
+
# Showcase helpers (unchanged from original)
|
| 316 |
# =============================================================================
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 317 |
|
|
|
|
|
|
|
|
|
|
| 318 |
def _append_demo_employee(df_employees: pd.DataFrame) -> pd.DataFrame:
|
| 319 |
+
"""Adds a demo RN with Arabic + BLS/ALS + A&E to guarantee an Assign in demo"""
|
| 320 |
demo = {
|
| 321 |
"personNumber": "D-1001",
|
| 322 |
"organizationPath": "Hospital/A&E/ICU/Registered Nurse",
|
|
|
|
| 333 |
return pd.concat([df_employees, extra], ignore_index=True)
|
| 334 |
|
| 335 |
def _score_match(row: pd.Series, shift: pd.Series) -> int:
|
| 336 |
+
"""Simple score to rank candidates for showcase distribution"""
|
| 337 |
text_all = " ".join([
|
| 338 |
row.get("JobRole",""), row.get("organizationPath",""),
|
| 339 |
" ".join((row.get("Certifications") or [])), " ".join((row.get("Languages") or []))
|
| 340 |
]).lower()
|
| 341 |
+
|
| 342 |
role_req = str(shift.get("RoleRequired","")).strip().lower()
|
| 343 |
specialty = str(shift.get("Specialty","")).replace("-", " ").strip().lower()
|
| 344 |
ward = str(shift.get("WardDepartment","")).strip().lower()
|
| 345 |
lang = str(shift.get("LanguageRequirement","")).strip().lower()
|
| 346 |
+
|
| 347 |
pts = 0
|
| 348 |
if role_req and role_req in text_all: pts += 3
|
| 349 |
if specialty and ("icu" in text_all or specialty.replace(" ", "") in text_all): pts += 2
|
| 350 |
if ward and ward in text_all: pts += 1
|
| 351 |
if lang and lang in text_all: pts += 1
|
| 352 |
if any(tok in text_all for tok in ["bls", "als"]): pts += 2
|
| 353 |
+
|
| 354 |
return pts
|
| 355 |
|
|
|
|
| 356 |
# =============================================================================
|
| 357 |
+
# Agent runner (unchanged logic, updated for Streamlit UI)
|
| 358 |
# =============================================================================
|
| 359 |
+
|
| 360 |
def run_agent(
|
| 361 |
employee_ids: Iterable[int],
|
| 362 |
df_shifts: pd.DataFrame,
|
| 363 |
showcase: bool = False,
|
| 364 |
lenient: bool = False,
|
| 365 |
):
|
| 366 |
+
"""Main agent execution logic"""
|
| 367 |
events: List[Dict[str, Any]] = []
|
| 368 |
shift_assignment_results: List[tuple] = []
|
| 369 |
reasoning_rows: List[Dict[str, Any]] = []
|
|
|
|
| 374 |
if showcase and len(df_employees) < 3:
|
| 375 |
df_employees = _append_demo_employee(df_employees)
|
| 376 |
dur = (time.perf_counter() - t0) * 1000
|
| 377 |
+
|
| 378 |
loaded_txt = f"{len(df_employees)} employee(s) loaded successfully." if not df_employees.empty else "No employees returned or credentials invalid."
|
| 379 |
+
events.append({
|
| 380 |
+
"title": "Fetch employees",
|
| 381 |
+
"desc": loaded_txt,
|
| 382 |
+
"status": "done",
|
| 383 |
+
"dur_ms": max(int(dur), 1)
|
| 384 |
+
})
|
| 385 |
|
| 386 |
# 2) Evaluate shifts
|
| 387 |
t_eval = time.perf_counter()
|
| 388 |
+
events.append({
|
| 389 |
+
"title": "Evaluate open shifts",
|
| 390 |
+
"desc": "Matching employees vs. role/specialty/training/ward…",
|
| 391 |
+
"status": "done"
|
| 392 |
+
})
|
| 393 |
|
| 394 |
elig_fn = is_eligible_lenient if lenient else is_eligible_strict
|
| 395 |
|
|
|
|
| 402 |
ranked = df_employees.copy()
|
| 403 |
ranked["__score"] = ranked.apply(lambda r: _score_match(r, shift), axis=1)
|
| 404 |
ranked = ranked.sort_values("__score", ascending=False).reset_index(drop=True)
|
| 405 |
+
|
| 406 |
cand_assign = ranked.iloc[0] if len(ranked) > 0 else None
|
| 407 |
cand_notify = ranked.iloc[1] if len(ranked) > 1 else None
|
| 408 |
+
cand_skip = ranked.iloc[-1] if len(ranked) > 2 else None
|
| 409 |
|
| 410 |
if cand_assign is not None:
|
| 411 |
+
name = cand_assign.get("fullName", "Candidate A")
|
| 412 |
+
events.append({
|
| 413 |
+
"title": f"Assigned {name}",
|
| 414 |
+
"desc": f"{name} → {shift.get('Department','')} ({shift.get('ShiftType','')}, {shift.get('ShiftDuration','')})",
|
| 415 |
+
"status": "done"
|
| 416 |
+
})
|
| 417 |
shift_assignment_results.append((name, shift["ShiftID"], "✅ Auto-Filled"))
|
| 418 |
|
| 419 |
if cand_notify is not None:
|
| 420 |
+
name = cand_notify.get("fullName", "Candidate B")
|
| 421 |
+
events.append({
|
| 422 |
+
"title": f"Notify {name}",
|
| 423 |
+
"desc": f"Send notification for {shift['ShiftID']}",
|
| 424 |
+
"status": "done"
|
| 425 |
+
})
|
| 426 |
shift_assignment_results.append((name, shift["ShiftID"], "📨 Notify"))
|
| 427 |
|
| 428 |
if cand_skip is not None:
|
| 429 |
+
name = cand_skip.get("fullName", "Candidate C")
|
| 430 |
+
events.append({
|
| 431 |
+
"title": "Skipped",
|
| 432 |
+
"desc": f"No eligible employees or decision skipped (e.g., low match for {name})",
|
| 433 |
+
"status": "done"
|
| 434 |
+
})
|
| 435 |
shift_assignment_results.append((name, shift["ShiftID"], "⚠️ Skipped"))
|
| 436 |
|
| 437 |
ai_dur = (time.perf_counter() - t_ai) * 1000
|
| 438 |
+
events.insert(-3, {
|
| 439 |
+
"title": "AI decision",
|
| 440 |
+
"desc": "Showcase distribution Assign/Notify/Skip",
|
| 441 |
+
"status": "done",
|
| 442 |
+
"dur_ms": max(int(ai_dur), 1)
|
| 443 |
+
})
|
| 444 |
else:
|
| 445 |
+
events.append({
|
| 446 |
+
"title": "AI decision",
|
| 447 |
+
"desc": "Select assign / notify / skip",
|
| 448 |
+
"status": "done"
|
| 449 |
+
})
|
| 450 |
decision = gpt_decide(shift, eligible)
|
| 451 |
ai_dur = (time.perf_counter() - t_ai) * 1000
|
| 452 |
+
events[-1]["dur_ms"] = max(int(ai_dur), 1)
|
| 453 |
|
| 454 |
if decision.get("action") == "assign":
|
| 455 |
emp = decision.get("employee")
|
| 456 |
+
events.append({
|
| 457 |
+
"title": f"Assigned {emp}",
|
| 458 |
+
"desc": f"{emp} → {shift.get('Department','')} ({shift.get('ShiftType','')}, {shift.get('ShiftDuration','')})",
|
| 459 |
+
"status": "done"
|
| 460 |
+
})
|
| 461 |
shift_assignment_results.append((emp, shift["ShiftID"], "✅ Auto-Filled"))
|
| 462 |
elif decision.get("action") == "notify":
|
| 463 |
emp = decision.get("employee")
|
| 464 |
+
events.append({
|
| 465 |
+
"title": f"Notify {emp}",
|
| 466 |
+
"desc": f"Send notification for {shift['ShiftID']}",
|
| 467 |
+
"status": "done"
|
| 468 |
+
})
|
| 469 |
shift_assignment_results.append((emp, shift["ShiftID"], "📨 Notify"))
|
| 470 |
else:
|
| 471 |
+
events.append({
|
| 472 |
+
"title": "Skipped",
|
| 473 |
+
"desc": "No eligible employees or decision skipped",
|
| 474 |
+
"status": "done"
|
| 475 |
+
})
|
| 476 |
shift_assignment_results.append(("❌ No eligible", shift["ShiftID"], "⚠️ Skipped"))
|
| 477 |
|
| 478 |
+
# Generate reasoning rows for each employee
|
| 479 |
for _, emp_row in df_employees.iterrows():
|
| 480 |
role_match = str(shift.get("RoleRequired","")).strip().lower() == emp_row.get("JobRole","").strip().lower()
|
| 481 |
spec = str(shift.get("Specialty","")).replace("-", " ").strip().lower()
|
| 482 |
text_all = (emp_row.get("JobRole","")+" "+emp_row.get("organizationPath","")+" "+" ".join(emp_row.get("Certifications",[]))).lower()
|
| 483 |
spec_ok = (spec and ("icu" in text_all or spec.replace(" ", "") in text_all))
|
| 484 |
+
|
| 485 |
training_need = [t.strip().lower() for t in str(shift.get("MandatoryTraining","")).replace(",", "/").split("/") if t.strip()]
|
| 486 |
training_ok_strict = all(t in [c.lower() for c in (emp_row.get("Certifications",[]) or [])] for t in training_need)
|
| 487 |
training_ok_len = all(any(mtok in text_all for mtok in [t, t.replace(" certified","")]) for t in training_need) if training_need else True
|
| 488 |
+
|
| 489 |
ward_ok = not str(shift.get("WardDepartment","")).strip() or str(shift.get("WardDepartment","")).lower() in (emp_row.get("organizationPath","").lower())
|
| 490 |
lang_req = str(shift.get("LanguageRequirement","")).strip().lower()
|
| 491 |
lang_ok = (not lang_req) or (lang_req in [l.lower() for l in (emp_row.get("Languages") or [])])
|
|
|
|
| 493 |
if lenient:
|
| 494 |
status = "✅ Eligible" if (role_match or spec_ok or training_ok_len) and ward_ok and lang_ok else "❌ Not Eligible"
|
| 495 |
else:
|
| 496 |
+
status = "✅ Eligible" if ((role_match or spec_ok) and training_ok_strict and ward_ok and lang_ok) else "❌ Not Eligible"
|
| 497 |
|
| 498 |
reasoning_rows.append({
|
| 499 |
"Employee": emp_row.get("fullName",""),
|
|
|
|
| 512 |
eval_dur = (time.perf_counter() - t_eval) * 1000
|
| 513 |
for e in events:
|
| 514 |
if e["title"] == "Evaluate open shifts":
|
| 515 |
+
e["dur_ms"] = max(int(eval_dur), 1)
|
| 516 |
break
|
| 517 |
|
| 518 |
+
events.append({
|
| 519 |
+
"title": "Summary ready",
|
| 520 |
+
"desc": "AI finished processing shifts",
|
| 521 |
+
"status": "current"
|
| 522 |
+
})
|
| 523 |
+
|
| 524 |
return events, shift_assignment_results, reasoning_rows
|
| 525 |
|
|
|
|
| 526 |
# =============================================================================
|
| 527 |
+
# UI Components
|
| 528 |
# =============================================================================
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 529 |
|
| 530 |
+
def render_hero():
|
| 531 |
+
"""Render the hero section with AI-inspired design"""
|
| 532 |
+
st.markdown("""
|
| 533 |
+
<div class="hero-section">
|
| 534 |
+
<div class="hero-content">
|
| 535 |
+
<div class="hero-logo">
|
| 536 |
+
<img src="https://www.healthmatrixcorp.com/web/image/website/1/logo/Health%20Matrix?unique=956ad7b" alt="Health Matrix" />
|
| 537 |
+
</div>
|
| 538 |
+
<h1 class="hero-title">
|
| 539 |
+
<span class="gradient-text">AI Command Center</span>
|
| 540 |
+
<br>
|
| 541 |
+
<span class="subtitle">Smart Staffing & Actions</span>
|
| 542 |
+
</h1>
|
| 543 |
+
<p class="hero-description">
|
| 544 |
+
Transform healthcare delivery with AI-driven workforce management, predictive analytics,
|
| 545 |
+
and intelligent automation that ensures compliance while optimizing patient care.
|
| 546 |
+
</p>
|
| 547 |
+
<div class="hero-stats">
|
| 548 |
+
<div class="stat-item">
|
| 549 |
+
<div class="stat-value">99.9%</div>
|
| 550 |
+
<div class="stat-label">Compliance Rate</div>
|
| 551 |
+
</div>
|
| 552 |
+
<div class="stat-item">
|
| 553 |
+
<div class="stat-value">45%</div>
|
| 554 |
+
<div class="stat-label">Efficiency Gain</div>
|
| 555 |
+
</div>
|
| 556 |
+
<div class="stat-item">
|
| 557 |
+
<div class="stat-value"><2min</div>
|
| 558 |
+
<div class="stat-label">Response Time</div>
|
| 559 |
+
</div>
|
| 560 |
+
</div>
|
| 561 |
+
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 562 |
</div>
|
| 563 |
+
""", unsafe_allow_html=True)
|
| 564 |
+
|
| 565 |
+
def render_kpi_cards(assigned: int, notified: int, skipped: int):
|
| 566 |
+
"""Render KPI cards with animations"""
|
| 567 |
+
st.markdown(f"""
|
| 568 |
+
<div class="kpi-grid">
|
| 569 |
+
<div class="kpi-card kpi-assigned">
|
| 570 |
+
<div class="kpi-icon">✅</div>
|
| 571 |
+
<div class="kpi-value">{assigned}</div>
|
| 572 |
+
<div class="kpi-label">Auto-Assigned</div>
|
| 573 |
+
<div class="kpi-change">+15%</div>
|
| 574 |
+
</div>
|
| 575 |
+
<div class="kpi-card kpi-notify">
|
| 576 |
+
<div class="kpi-icon">📨</div>
|
| 577 |
+
<div class="kpi-value">{notified}</div>
|
| 578 |
+
<div class="kpi-label">Notifications Sent</div>
|
| 579 |
+
<div class="kpi-change">-8%</div>
|
| 580 |
+
</div>
|
| 581 |
+
<div class="kpi-card kpi-skipped">
|
| 582 |
+
<div class="kpi-icon">⚠️</div>
|
| 583 |
+
<div class="kpi-value">{skipped}</div>
|
| 584 |
+
<div class="kpi-label">Skipped</div>
|
| 585 |
+
<div class="kpi-change">-23%</div>
|
| 586 |
+
</div>
|
| 587 |
+
</div>
|
| 588 |
+
""", unsafe_allow_html=True)
|
| 589 |
+
|
| 590 |
+
def render_timeline(events: List[Dict[str, Any]]):
|
| 591 |
+
"""Render the AI agent timeline"""
|
| 592 |
+
st.markdown('<div class="timeline-container">', unsafe_allow_html=True)
|
| 593 |
+
st.markdown("### 🕒 Agent Timeline")
|
| 594 |
+
|
| 595 |
+
timeline_html = '<div class="timeline">'
|
| 596 |
+
for i, event in enumerate(events):
|
| 597 |
+
status_class = event.get("status", "done")
|
| 598 |
+
duration = event.get("dur_ms", 0)
|
| 599 |
+
duration_text = f"{int(duration)} ms" if duration else "—"
|
| 600 |
+
|
| 601 |
+
timeline_html += f"""
|
| 602 |
+
<div class="timeline-item {status_class}">
|
| 603 |
+
<div class="timeline-marker"></div>
|
| 604 |
+
<div class="timeline-content">
|
| 605 |
+
<div class="timeline-title">{event.get('title', '')}</div>
|
| 606 |
+
<div class="timeline-meta">Duration: {duration_text}</div>
|
| 607 |
+
<div class="timeline-desc">{event.get('desc', '')}</div>
|
| 608 |
+
</div>
|
| 609 |
+
</div>
|
| 610 |
+
"""
|
| 611 |
+
|
| 612 |
+
timeline_html += '</div>'
|
| 613 |
+
st.markdown(timeline_html, unsafe_allow_html=True)
|
| 614 |
+
st.markdown('</div>', unsafe_allow_html=True)
|
| 615 |
+
|
| 616 |
+
def render_employee_board(shift_results: List[tuple], reasoning_rows: List[Dict[str, Any]]):
|
| 617 |
+
"""Render employee results in Kanban board style"""
|
| 618 |
+
# Map actions to employees
|
| 619 |
+
action_by_emp = {}
|
| 620 |
+
shift_by_emp = {}
|
| 621 |
for emp, sid, status in shift_results:
|
| 622 |
if emp and not str(emp).startswith("❌"):
|
| 623 |
action_by_emp[str(emp)] = status
|
| 624 |
shift_by_emp[str(emp)] = str(sid)
|
| 625 |
|
| 626 |
+
# Aggregate employees
|
| 627 |
seen = set()
|
| 628 |
+
employees = []
|
| 629 |
for r in reasoning_rows:
|
| 630 |
emp = (r.get("Employee") or "").strip()
|
| 631 |
+
if not emp or emp in seen:
|
| 632 |
+
continue
|
| 633 |
seen.add(emp)
|
| 634 |
employees.append({
|
| 635 |
"name": emp,
|
| 636 |
+
"shift": r.get("ShiftID", ""),
|
| 637 |
+
"eligible": r.get("Eligible", ""),
|
| 638 |
+
"reasoning": r.get("Reasoning", ""),
|
| 639 |
+
"certs": r.get("Certifications", ""),
|
| 640 |
+
"action": action_by_emp.get(emp, "")
|
| 641 |
})
|
| 642 |
|
| 643 |
if not employees:
|
| 644 |
+
st.info("No employee details available.")
|
| 645 |
+
return
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 646 |
|
| 647 |
+
# Categorize employees into lanes
|
| 648 |
+
lanes = {"assigned": [], "notify": [], "skipped": []}
|
| 649 |
+
|
| 650 |
+
for emp in employees:
|
| 651 |
+
action = emp["action"].lower()
|
| 652 |
+
if "auto-filled" in action or "assign" in action:
|
| 653 |
+
lane = "assigned"
|
| 654 |
+
elif "notify" in action:
|
| 655 |
+
lane = "notify"
|
| 656 |
+
else:
|
| 657 |
+
# No explicit decision: move eligible to notify for review, rest to skip
|
| 658 |
+
lane = "notify" if emp["eligible"].startswith("✅") else "skipped"
|
| 659 |
+
|
| 660 |
+
lanes[lane].append(emp)
|
| 661 |
|
| 662 |
+
# Render Kanban board
|
| 663 |
+
st.markdown("""
|
| 664 |
+
<div class="kanban-board">
|
| 665 |
+
<div class="kanban-lane">
|
| 666 |
+
<div class="lane-header assigned-header">
|
| 667 |
+
<span class="lane-icon">✅</span>
|
| 668 |
+
<span class="lane-title">Assigned</span>
|
| 669 |
+
<span class="lane-count">{}</span>
|
| 670 |
+
</div>
|
| 671 |
+
<div class="lane-content">
|
| 672 |
+
""".format(len(lanes["assigned"])), unsafe_allow_html=True)
|
| 673 |
+
|
| 674 |
+
for emp in lanes["assigned"]:
|
| 675 |
+
render_employee_card(emp, "assigned")
|
| 676 |
+
|
| 677 |
+
st.markdown("""
|
| 678 |
+
</div>
|
| 679 |
+
</div>
|
| 680 |
+
<div class="kanban-lane">
|
| 681 |
+
<div class="lane-header notify-header">
|
| 682 |
+
<span class="lane-icon">📨</span>
|
| 683 |
+
<span class="lane-title">Notify</span>
|
| 684 |
+
<span class="lane-count">{}</span>
|
| 685 |
+
</div>
|
| 686 |
+
<div class="lane-content">
|
| 687 |
+
""".format(len(lanes["notify"])), unsafe_allow_html=True)
|
| 688 |
+
|
| 689 |
+
for emp in lanes["notify"]:
|
| 690 |
+
render_employee_card(emp, "notify")
|
| 691 |
+
|
| 692 |
+
st.markdown("""
|
| 693 |
+
</div>
|
| 694 |
+
</div>
|
| 695 |
+
<div class="kanban-lane">
|
| 696 |
+
<div class="lane-header skipped-header">
|
| 697 |
+
<span class="lane-icon">⚠️</span>
|
| 698 |
+
<span class="lane-title">Skipped</span>
|
| 699 |
+
<span class="lane-count">{}</span>
|
| 700 |
+
</div>
|
| 701 |
+
<div class="lane-content">
|
| 702 |
+
""".format(len(lanes["skipped"])), unsafe_allow_html=True)
|
| 703 |
+
|
| 704 |
+
for emp in lanes["skipped"]:
|
| 705 |
+
render_employee_card(emp, "skipped")
|
| 706 |
+
|
| 707 |
+
st.markdown("""
|
| 708 |
+
</div>
|
| 709 |
+
</div>
|
| 710 |
+
</div>
|
| 711 |
+
""", unsafe_allow_html=True)
|
| 712 |
+
|
| 713 |
+
def render_employee_card(emp: Dict[str, Any], lane: str):
|
| 714 |
+
"""Render individual employee card"""
|
| 715 |
+
name = emp["name"]
|
| 716 |
+
shift = emp["shift"]
|
| 717 |
+
reasoning = emp["reasoning"]
|
| 718 |
+
certs = emp["certs"]
|
| 719 |
+
action = emp["action"]
|
| 720 |
+
|
| 721 |
+
# Get initials for avatar
|
| 722 |
+
initials = "".join([word[0] for word in name.split()[:2]]).upper() if name else "?"
|
| 723 |
+
|
| 724 |
+
# Parse reasoning for badges
|
| 725 |
+
checks = [x.strip() for x in str(reasoning or "").split("|") if x and x.strip()]
|
| 726 |
+
badges_html = ""
|
| 727 |
+
|
| 728 |
+
for check in checks:
|
| 729 |
+
is_ok = "✅" in check
|
| 730 |
+
label = check.replace("✅", "").replace("❌", "").strip()
|
| 731 |
+
badge_class = "badge-ok" if is_ok else "badge-fail"
|
| 732 |
+
badges_html += f'<div class="emp-badge {badge_class}"><span class="badge-dot"></span><span>{label}</span></div>'
|
| 733 |
+
|
| 734 |
+
# Determine chip status
|
| 735 |
+
chip_text = action or ("✅ Eligible" if emp["eligible"].startswith("✅") else "❌ Not Eligible")
|
| 736 |
+
chip_class = "chip-ok" if "✅" in chip_text or "Auto-Filled" in chip_text else "chip-warn" if "Notify" in chip_text else "chip-fail"
|
| 737 |
+
|
| 738 |
+
st.markdown(f"""
|
| 739 |
+
<div class="employee-card {lane}-card">
|
| 740 |
+
<div class="emp-header">
|
| 741 |
+
<div class="emp-avatar">{initials}</div>
|
| 742 |
+
<div class="emp-info">
|
| 743 |
+
<div class="emp-name">{name}</div>
|
| 744 |
+
<div class="emp-shift">Shift: {shift}</div>
|
| 745 |
+
</div>
|
| 746 |
+
<div class="emp-chip {chip_class}">{chip_text}</div>
|
| 747 |
+
</div>
|
| 748 |
+
<div class="emp-divider"></div>
|
| 749 |
+
<div class="emp-badges">
|
| 750 |
+
{badges_html if badges_html else '<div class="emp-badge badge-info"><span class="badge-dot"></span><span>No reasoning available</span></div>'}
|
| 751 |
+
</div>
|
| 752 |
+
<div class="emp-footer">
|
| 753 |
+
<div class="emp-certs">🪪 Certifications: {certs or "—"}</div>
|
| 754 |
+
<div class="emp-meta">Updated just now</div>
|
| 755 |
+
</div>
|
| 756 |
+
</div>
|
| 757 |
+
""", unsafe_allow_html=True)
|
| 758 |
+
|
| 759 |
+
def render_status_indicator(status: str, message: str):
|
| 760 |
+
"""Render status indicator with pulsing animation"""
|
| 761 |
+
if status == "processing":
|
| 762 |
+
st.markdown(f"""
|
| 763 |
+
<div class="status-indicator processing">
|
| 764 |
+
<div class="status-dot pulsing"></div>
|
| 765 |
+
<span>{message}</span>
|
| 766 |
+
</div>
|
| 767 |
+
""", unsafe_allow_html=True)
|
| 768 |
+
elif status == "completed":
|
| 769 |
+
st.markdown(f"""
|
| 770 |
+
<div class="status-indicator completed">
|
| 771 |
+
<div class="status-dot completed"></div>
|
| 772 |
+
<span>{message}</span>
|
| 773 |
+
</div>
|
| 774 |
+
""", unsafe_allow_html=True)
|
| 775 |
+
else:
|
| 776 |
+
st.markdown(f"""
|
| 777 |
+
<div class="status-indicator ready">
|
| 778 |
+
<div class="status-dot ready"></div>
|
| 779 |
+
<span>{message}</span>
|
| 780 |
+
</div>
|
| 781 |
+
""", unsafe_allow_html=True)
|
| 782 |
|
| 783 |
# =============================================================================
|
| 784 |
+
# Main Application
|
| 785 |
# =============================================================================
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 786 |
|
| 787 |
+
def main():
|
| 788 |
+
"""Main Streamlit application"""
|
| 789 |
+
setup_page()
|
| 790 |
+
|
| 791 |
+
# Hero Section
|
| 792 |
+
render_hero()
|
| 793 |
+
|
| 794 |
# Business Case
|
| 795 |
st.markdown("""
|
| 796 |
+
<div class="business-case">
|
| 797 |
+
<h4>Business Case — Open Shifts Auto-Fulfillment</h4>
|
| 798 |
+
<ul>
|
| 799 |
+
<li>✅ Reduce time to fill critical shifts</li>
|
| 800 |
+
<li>📜 Enforce mandatory certifications & policies</li>
|
| 801 |
+
<li>📊 Transparent Agent Timeline (steps + durations)</li>
|
| 802 |
+
</ul>
|
| 803 |
+
</div>
|
| 804 |
+
""", unsafe_allow_html=True)
|
| 805 |
+
|
| 806 |
+
# Demo Controls
|
| 807 |
with st.expander("🔧 Demo / Showcase Controls", expanded=False):
|
| 808 |
+
col1, col2 = st.columns(2)
|
| 809 |
+
with col1:
|
| 810 |
+
show_showcase = st.checkbox(
|
| 811 |
+
"Ensure Assign + Notify + Skip (adds a demo nurse if needed)",
|
| 812 |
+
value=True
|
| 813 |
+
)
|
| 814 |
+
lenient_mode = st.checkbox(
|
| 815 |
+
"Lenient eligibility (OR) for demo",
|
| 816 |
+
value=True
|
| 817 |
+
)
|
| 818 |
+
with col2:
|
| 819 |
+
st.caption("When these options are disabled, behavior returns to original strict logic (AND).")
|
| 820 |
+
|
| 821 |
+
# Status Indicator
|
| 822 |
status_placeholder = st.empty()
|
| 823 |
+
with status_placeholder.container():
|
| 824 |
+
render_status_indicator("ready", "Ready – Click 'Start AI Agent' to evaluate shifts")
|
| 825 |
+
|
| 826 |
+
# Demo Data (unchanged from original)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 827 |
employee_ids_default = [850, 825]
|
| 828 |
shift_data = """ShiftID,Department,RoleRequired,Specialty,MandatoryTraining,ShiftType,ShiftDuration,WardDepartment,LanguageRequirement,ShiftTime
|
| 829 |
S101,ICU,Registered Nurse,ICU-trained,BLS/ALS certified,Night,12h,A&E,Arabic,2025-06-04 07:00"""
|
| 830 |
df_shifts_default = pd.read_csv(StringIO(shift_data))
|
| 831 |
+
|
| 832 |
+
# Main CTA Button
|
| 833 |
+
st.markdown('<div class="cta-container">', unsafe_allow_html=True)
|
| 834 |
+
if st.button("▶️ Start AI Agent", key="start_agent", help="Let the AI handle the work"):
|
| 835 |
+
|
| 836 |
+
# Update status to processing
|
| 837 |
+
with status_placeholder.container():
|
| 838 |
+
render_status_indicator("processing", "Processing – contacting UKG APIs and evaluating shifts...")
|
| 839 |
+
|
| 840 |
+
# Run the agent
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 841 |
try:
|
| 842 |
events, shift_assignment_results, reasoning_rows = run_agent(
|
| 843 |
+
employee_ids_default,
|
| 844 |
+
df_shifts_default,
|
| 845 |
+
showcase=show_showcase,
|
| 846 |
+
lenient=lenient_mode
|
| 847 |
)
|
| 848 |
+
|
| 849 |
+
# Update status to completed
|
| 850 |
+
with status_placeholder.container():
|
| 851 |
+
render_status_indicator("completed", "Completed – results below")
|
| 852 |
+
|
| 853 |
+
# Calculate metrics
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 854 |
assigned = sum(1 for e in shift_assignment_results if "Auto-Filled" in e[2])
|
| 855 |
notified = sum(1 for e in shift_assignment_results if "Notify" in e[2])
|
| 856 |
skipped = sum(1 for e in shift_assignment_results if "Skip" in e[2] or "Skipped" in e[2])
|
| 857 |
+
|
| 858 |
+
# Layout: Timeline on left, Results on right
|
| 859 |
+
col1, col2 = st.columns([1, 2], gap="large")
|
| 860 |
+
|
| 861 |
+
with col1:
|
| 862 |
+
render_timeline(events)
|
| 863 |
+
|
| 864 |
+
with col2:
|
| 865 |
+
# Overview metrics
|
| 866 |
+
total_steps = len(events)
|
| 867 |
+
total_ms = sum(int(e.get("dur_ms", 0) or 0) for e in events)
|
| 868 |
+
|
| 869 |
+
st.markdown(f"""
|
| 870 |
+
<div class="overview-metrics">
|
| 871 |
+
<div class="metric-chip">🧠 <strong>Agent Steps:</strong> {total_steps}</div>
|
| 872 |
+
<div class="metric-chip">⏱️ <strong>Total Duration:</strong> {total_ms} ms</div>
|
| 873 |
+
<div class="metric-chip">✅ <strong>Assigned:</strong> {assigned}</div>
|
| 874 |
+
<div class="metric-chip">📬 <strong>Notify:</strong> {notified}</div>
|
| 875 |
+
<div class="metric-chip">⚠️ <strong>Skipped:</strong> {skipped}</div>
|
| 876 |
+
</div>
|
| 877 |
+
""", unsafe_allow_html=True)
|
| 878 |
+
|
| 879 |
+
# KPI Cards
|
| 880 |
+
render_kpi_cards(assigned, notified, skipped)
|
| 881 |
+
|
| 882 |
+
# Employee Results
|
| 883 |
+
st.markdown("### 👥 Employee Results")
|
| 884 |
+
render_employee_board(shift_assignment_results, reasoning_rows)
|
| 885 |
+
|
| 886 |
+
# Optional: Raw data tables
|
| 887 |
+
with st.expander("Raw Summary (optional)", expanded=False):
|
| 888 |
+
if shift_assignment_results:
|
| 889 |
+
st.markdown("**Assignment Summary:**")
|
| 890 |
+
st.dataframe(
|
| 891 |
+
pd.DataFrame(shift_assignment_results, columns=["Employee", "ShiftID", "Status"]),
|
| 892 |
+
use_container_width=True
|
| 893 |
+
)
|
| 894 |
+
|
| 895 |
+
if reasoning_rows:
|
| 896 |
+
st.markdown("**Detailed Reasoning:**")
|
| 897 |
+
st.dataframe(pd.DataFrame(reasoning_rows), use_container_width=True)
|
| 898 |
+
|
| 899 |
+
except Exception as e:
|
| 900 |
+
st.error(f"Unexpected error: {e}")
|
| 901 |
+
with status_placeholder.container():
|
| 902 |
+
render_status_indicator("ready", "Error occurred – please try again")
|
| 903 |
+
|
| 904 |
+
st.markdown('</div>', unsafe_allow_html=True)
|
| 905 |
+
|
| 906 |
# Footer
|
| 907 |
+
st.markdown("""
|
| 908 |
+
<div class="footer">
|
| 909 |
+
<hr/>
|
| 910 |
+
<div class="footer-content">
|
| 911 |
+
© 2025 Health Matrix Corp – Empowering Digital Health Transformation ·
|
| 912 |
+
<a href="mailto:info@healthmatrixcorp.com">info@healthmatrixcorp.com</a>
|
| 913 |
+
</div>
|
| 914 |
+
</div>
|
| 915 |
+
""", unsafe_allow_html=True)
|
| 916 |
|
| 917 |
if __name__ == "__main__":
|
| 918 |
+
main()
|