Dhom1 commited on
Commit
872370b
·
verified ·
1 Parent(s): c02638d

Update src/streamlit_app.py

Browse files
Files changed (1) hide show
  1. src/streamlit_app.py +516 -556
src/streamlit_app.py CHANGED
@@ -1,256 +1,48 @@
1
  """
2
- Health Matrix AI Command Center (Desktop Wide + AI Cards + Showcase Mode + Kanban Board)
3
- - Sticky Left Timeline (Chris Wright–style, animated)
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 os
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
- import streamlit as st
24
 
25
  # Optional: OpenAI for GPT decisions (fails gracefully if missing)
26
  try:
27
- import openai # type: ignore
28
  HAS_OPENAI = True
29
  except Exception:
30
  openai = None
31
  HAS_OPENAI = False
32
 
33
-
34
  # =============================================================================
35
- # Embedded CSS (Desktop-first, AI look)
36
  # =============================================================================
37
- _EMBEDDED_CSS = """
38
- :root{
39
- --bg:#FFFFFF; --soft:#F8FAFC; --line:#E5E7EB;
40
- --t1:#0F172A; --t2:#1F2937; --muted:#64748B;
41
- --blue:#004C97; --green:#36BA01; --ok:#16a34a; --warn:#D97706; --fail:#DC2626;
42
- --tint-assign:#E8F7ED; --tint-assign-b:#B6E2C4;
43
- --tint-notify:#EFF6FF; --tint-notify-b:#BFDBFE;
44
- --tint-skip:#FFF7ED; --tint-skip-b:#FED7AA;
45
- }
46
-
47
- /* ==== Full-width page on desktop ==== */
48
- .main .block-container{ max-width: 1600px !important; padding-left: 16px; padding-right: 16px }
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 = {"multiReadOptions": {"includeOrgPathDetails": True}, "where": {"query": {"context": "ORG", "date": date, "q": query}}}
 
 
 
 
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: records.append(row)
 
387
 
388
  df = pd.DataFrame(records)
389
  for col in ["personNumber", "organizationPath", "phoneNumber", "fullName", "Certifications"]:
390
- if col not in df.columns: df[col] = []
 
 
391
  if "Languages" not in df.columns:
392
  df["Languages"] = [[] for _ in range(len(df))]
393
- df["JobRole"] = df["organizationPath"].apply(lambda p: p.split("/")[-1] if isinstance(p, str) and p else "")
 
 
 
394
  return df
395
 
396
-
397
  # =============================================================================
398
- # Decision helpers
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
- """السلوك الأصلي: AND على كل المعايير."""
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
- """وضع العرض: OR بين (الدور/الخبرة/التدريب) + احترام القسم/اللغة إن ذكرت."""
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
- - القسم: {shift.get('Department','')}
468
- - الدور المطلوب: {shift.get('RoleRequired','')}
469
- - التخصص/الخبرة: {shift.get('Specialty','')}
470
- - التدريب الإلزامي: {shift.get('MandatoryTraining','')}
471
- - نوع الشفت/المدة: {shift.get('ShiftType','')} / {shift.get('ShiftDuration','')}
472
- - القسم/الورد: {shift.get('WardDepartment','')}
473
- - اللغة المطلوبة: {shift.get('LanguageRequirement','')}
474
- - الوقت: {shift.get('ShiftTime','')}
475
-
476
- الموظفون المؤهلون (matchScore=0..2): {emp_list if emp_list else 'لا يوجد'}
477
-
478
- اختر JSON فقط:
479
- {{"action":"assign","employee":"الاسم"}} أو {{"action":"notify","employee":"الاسم"}} أو {{"action":"skip"}}
480
  """.strip()
 
481
  try:
482
  resp = openai.ChatCompletion.create(
483
  model="gpt-4",
484
- messages=[{"role":"system","content":"أنت مساعد ذكي لإدارة المناوبات الصحية"},
485
- {"role":"user","content":prompt}],
486
- temperature=0.4, max_tokens=200,
 
 
 
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
- # Timeline: Chris Wright (HTML builder)
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 single demo RN with Arabic + BLS/ALS + A&E to guarantee an Assign in demo."""
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({"title":"Fetch employees", "desc":loaded_txt, "status":"done", "dur_ms":max(int(dur),1)})
 
 
 
 
 
577
 
578
  # 2) Evaluate shifts
579
  t_eval = time.perf_counter()
580
- events.append({"title":"Evaluate open shifts", "desc":"Matching employees vs. role/specialty/training/ward…", "status":"done"})
 
 
 
 
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 = ranked.iloc[-1] if len(ranked) > 2 else None
596
 
597
  if cand_assign is not None:
598
- name = cand_assign.get("fullName","Candidate A")
599
- events.append({"title":f"Assigned {name}", "desc":f"{name} → {shift.get('Department','')} ({shift.get('ShiftType','')}, {shift.get('ShiftDuration','')})", "status":"done"})
 
 
 
 
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({"title":f"Notify {name}", "desc":f"Send notification for {shift['ShiftID']}", "status":"done"})
 
 
 
 
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({"title":"Skipped", "desc":f"No eligible employees or decision skipped (e.g., low match for {name})", "status":"done"})
 
 
 
 
610
  shift_assignment_results.append((name, shift["ShiftID"], "⚠️ Skipped"))
611
 
612
  ai_dur = (time.perf_counter() - t_ai) * 1000
613
- events.insert(-3, {"title":"AI decision", "desc":"Showcase distribution Assign/Notify/Skip", "status":"done", "dur_ms":max(int(ai_dur),1)})
 
 
 
 
 
614
  else:
615
- events.append({"title":"AI decision", "desc":"Select assign / notify / skip", "status":"done"})
 
 
 
 
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({"title":f"Assigned {emp}", "desc":f"{emp} → {shift.get('Department','')} ({shift.get('ShiftType','')}, {shift.get('ShiftDuration','')})", "status":"done"})
 
 
 
 
623
  shift_assignment_results.append((emp, shift["ShiftID"], "✅ Auto-Filled"))
624
  elif decision.get("action") == "notify":
625
  emp = decision.get("employee")
626
- events.append({"title":f"Notify {emp}", "desc":f"Send notification for {shift['ShiftID']}", "status":"done"})
 
 
 
 
627
  shift_assignment_results.append((emp, shift["ShiftID"], "📨 Notify"))
628
  else:
629
- events.append({"title":"Skipped", "desc":"No eligible employees or decision skipped", "status":"done"})
 
 
 
 
630
  shift_assignment_results.append(("❌ No eligible", shift["ShiftID"], "⚠️ Skipped"))
631
 
632
- # Reasoning rows
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 ( (role_match or spec_ok) and training_ok_strict and ward_ok and lang_ok ) else "❌ Not Eligible"
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({"title":"Summary ready", "desc":"AI finished processing shifts", "status":"current"})
 
 
 
 
 
671
  return events, shift_assignment_results, reasoning_rows
672
 
673
-
674
  # =============================================================================
675
- # Helpers: KPI & Employee Cards (HTML)
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 _name_initials(n:str)->str:
687
- n = (n or "").strip()
688
- if not n: return "?"
689
- parts = [p for p in n.split(" ") if p]
690
- if len(parts)==1: return parts[0][:2].upper()
691
- return (parts[0][0]+parts[-1][0]).upper()
692
-
693
- def _chip_class(txt:str)->str:
694
- t = (txt or "").lower()
695
- if "auto-filled" in t or "assigned" in t: return "ok"
696
- if "notify" in t: return "info"
697
- if "skip" in t: return "warn"
698
- if "eligible" in t and "✅" in (txt or ""): return "ok"
699
- if "eligible" in t and "❌" in (txt or ""): return "fail"
700
- return "info"
701
-
702
- def _status_lane_for(emp_action:str, eligible_txt:str)->str:
703
- """assign|notify|skip"""
704
- a = (emp_action or "").lower()
705
- if "auto-filled" in a or "assign" in a: return "assign"
706
- if "notify" in a: return "notify"
707
- if a: return "skip"
708
- # لا يوجد قرار صريح: انقل المؤهل إلى notify للتدقيق، والباقي skip
709
- return "notify" if str(eligible_txt).startswith("✅") else "skip"
710
-
711
- def _build_emp_card(name:str, shift:str, chip:str, reasoning:str, certs:str, lane:str)->str:
712
- checks = [x.strip() for x in str(reasoning or "").split("|") if x and x.strip()]
713
- check_html = []
714
- for ch in checks:
715
- ok = "✅" in ch or ch.endswith("✅")
716
- label = ch.replace("✅","").replace("❌","").strip()
717
- check_html.append(f'<div class="emp-badge {"ok" if ok else "fail"}"><span class="dot"></span><span>{label}</span></div>')
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
- <div class="emp-chip {chip_class}" title="Decision/Status">{chip}</div>
728
- </div>
729
- <div class="emp-line"></div>
730
- <div class="emp-body">
731
- {''.join(check_html) if check_html else '<div class="emp-badge info"><span class="dot" style="background:#60a5fa"></span><span>No reasoning available</span></div>'}
732
- </div>
733
- <div class="emp-foot">
734
- <div class="emp-tag">🪪 Certifications: {certs or "—"}</div>
735
- <div class="emp-meta">Updated just now</div>
736
- </div>
737
- </div>
738
- """
739
-
740
- def render_employee_view(shift_results: List[tuple], reasoning_rows: List[Dict[str,Any]], mode:str="board") -> str:
741
- """
742
- mode = 'board' (Kanban) or 'grid'
743
- """
744
- # map actions
745
- action_by_emp: Dict[str, str] = {}
746
- shift_by_emp: Dict[str, str] = {}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- # aggregate employees (first reasoning row per employee)
753
  seen = set()
754
- employees: List[Dict[str,Any]] = []
755
  for r in reasoning_rows:
756
  emp = (r.get("Employee") or "").strip()
757
- if not emp or emp in seen: continue
 
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
- return '<div class="table-wrap"><div style="padding:10px;color:#64748B">No employee details available.</div></div>'
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}&nbsp;{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
- return '<div class="board">' + \
795
- lane_html("Assigned", "", "assign", lanes["assign"]) + \
796
- lane_html("Notify", "📬", "notify", lanes["notify"]) + \
797
- lane_html("Skipped", "⚠️", "skip", lanes["skip"]) + \
798
- '</div>'
 
 
 
 
 
 
 
 
 
799
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
800
 
801
  # =============================================================================
802
- # UI
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="card" dir="auto" style="max-width:1400px;margin:0 auto 8px;">
822
- <h4>Business Case — Open Shifts Auto-Fulfillment</h4>
823
- <ul>
824
- <li>✅ Reduce time to fill critical shifts</li>
825
- <li>📜 Enforce mandatory certifications & policies</li>
826
- <li>📊 Transparent Agent Runline (steps + durations)</li>
827
- </ul>
828
- </div>
829
- """, unsafe_allow_html=True)
830
-
831
- # Demo controls
832
  with st.expander("🔧 Demo / Showcase Controls", expanded=False):
833
- show_showcase = st.checkbox("Ensure Assign + Notify + Skip (adds a demo nurse if needed)", value=True)
834
- lenient_mode = st.checkbox("Lenient eligibility (OR) for demo", value=True)
835
- view_mode = st.radio("Employee results view", options=["Board (Kanban)", "Grid"], index=0, horizontal=True)
836
- st.caption("عند إيقاف هذه الخيارات يعود السلوك إلى المنطق الأصلي الصارم (AND).")
837
-
838
- # Status
 
 
 
 
 
 
 
 
839
  status_placeholder = st.empty()
840
- status_placeholder.markdown(
841
- """
842
- <div class="status-wrap" role="status" aria-live="polite">
843
- <div class="status-bar">
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
- _, cta, _ = st.columns([1,2,1])
859
- with cta:
860
- run_clicked = st.button("▶️ Start AI Agent", key="start_agent", help="Let the AI handle the work")
861
-
862
- if run_clicked:
863
- status_placeholder.markdown(
864
- """
865
- <div class="status-wrap" role="status" aria-live="polite">
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, df_shifts_default,
878
- showcase=show_showcase, lenient=lenient_mode
 
 
879
  )
880
- except Exception as e:
881
- st.error(f"Unexpected error: {e}")
882
- return
883
-
884
- status_placeholder.markdown(
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
- st.markdown('<div class="overview">', unsafe_allow_html=True)
914
- st.markdown(
915
- f'<span class="chip">🧠 <b>Agent Steps:</b> {total_steps}</span>'
916
- f'<span class="chip">⏱️ <b>Total Duration:</b> {total_ms} ms</span>'
917
- f'<span class="chip">✅ <b>Assigned:</b> {assigned}</span>'
918
- f'<span class="chip">📬 <b>Notify:</b> {notified}</span>'
919
- f'<span class="chip">⚠️ <b>Skipped:</b> {skipped}</span>',
920
- unsafe_allow_html=True
921
- )
922
- st.markdown('</div>', unsafe_allow_html=True)
923
-
924
- st.markdown(render_kpis(assigned, notified, skipped), unsafe_allow_html=True)
925
-
926
- # ====== Employee board/grid ======
927
- st.markdown("### 👥 Employee Results")
928
- mode = "board" if view_mode.startswith("Board") else "grid"
929
- emp_html = render_employee_view(shift_assignment_results, reasoning_rows, mode=mode)
930
- st.markdown(emp_html, unsafe_allow_html=True)
931
-
932
- # Optional: raw tables (hidden by default) for auditing/export
933
- with st.expander("Raw Summary (optional)", expanded=False):
934
- if shift_assignment_results:
935
- st.markdown('<div class="table-wrap">', unsafe_allow_html=True)
936
- st.dataframe(pd.DataFrame(shift_assignment_results, columns=["Employee", "ShiftID", "Status"]),
937
- use_container_width=True)
938
- st.markdown('</div>', unsafe_allow_html=True)
939
- if reasoning_rows:
940
- st.markdown('<div class="table-wrap">', unsafe_allow_html=True)
941
- st.dataframe(pd.DataFrame(reasoning_rows), use_container_width=True)
942
- st.markdown('</div>', unsafe_allow_html=True)
943
-
944
- else:
945
- st.info("Click “▶️ Start AI Agent” to see the sticky timeline and AI-styled results.")
946
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
947
  # Footer
948
- st.markdown(
949
- """
950
- <hr/>
951
- <div style="text-align:center;color:#64748B;font-size:13px">
952
- © 2025 Health Matrix Corp – Empowering Digital Health Transformation · <a href="mailto:info@healthmatrixcorp.com">info@healthmatrixcorp.com</a>
953
- </div>
954
- """, unsafe_allow_html=True
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">&lt;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()