Dhom1 commited on
Commit
d749d67
·
verified ·
1 Parent(s): 9a74305

Update src/streamlit_app.py

Browse files
Files changed (1) hide show
  1. src/streamlit_app.py +157 -49
src/streamlit_app.py CHANGED
@@ -2,13 +2,15 @@
2
  Health Matrix – AI Command Center (Light UI)
3
  Single-file Streamlit app with embedded CSS + UKG helpers.
4
  - Keeps all original functionality
5
- - New Light UI cards/timeline inspired by your style (21).css
 
6
  Usage: streamlit run merged_app.py
7
  """
8
 
9
  import os
10
  import json
11
  import pathlib
 
12
  from typing import List, Optional, Iterable, Dict, Any
13
  from io import StringIO
14
 
@@ -44,7 +46,7 @@ p,li{color:var(--t2);font-size:1.05em}
44
  .stDataFrame th{background:#003b70 !important;color:#fff !important}
45
 
46
  /* ================= Hero ================= */
47
- .hero{position:relative;text-align:center;padding:72px 0 32px;overflow:hidden}
48
  .hero img{width:200px;height:auto;margin-bottom:14px}
49
  .hero h1{font-size:34px;line-height:42px;font-weight:900;margin:0 0 10px}
50
  .ai-headline{
@@ -52,7 +54,7 @@ p,li{color:var(--t2);font-size:1.05em}
52
  -webkit-background-clip:text;background-clip:text;-webkit-text-fill-color:transparent;
53
  background-size:200% 100%;animation:aiTitle 8s ease-in-out infinite;
54
  }
55
- .hero p{font-size:16px;line-height:26px;color:var(--t2);max-width:760px;margin:0 auto}
56
  .hero::before,.hero::after{content:"";position:absolute;inset:-20%;pointer-events:none;filter:blur(42px);opacity:.18}
57
  .hero::before{
58
  background:
@@ -82,7 +84,7 @@ p,li{color:var(--t2);font-size:1.05em}
82
  box-shadow:0 0 0 0 rgba(54,186,1,.6);animation:pulse 1.4s infinite}
83
  .status-done{background:#E8F7ED;border-color:#B6E2C4}.status-done .pulse-dot{background:var(--ok);box-shadow:none}
84
 
85
- /* ================= Mini Timeline ================= */
86
  .ai-timeline-light{display:flex;justify-content:space-between;gap:10px;margin:18px 0 8px}
87
  .ai-timeline-light .step{flex:1;position:relative;text-align:center;color:var(--t1);font-size:12px;padding-top:18px}
88
  .ai-timeline-light .step::before{content:"";position:absolute;top:0;left:50%;transform:translateX(-50%);
@@ -102,6 +104,20 @@ p,li{color:var(--t2);font-size:1.05em}
102
  .stat-icon{font-size:24px;margin-bottom:.2rem;color:var(--blue)}
103
  .stat-label{color:#334155;font-size:.95rem}.stat-value{font-size:1.6rem;font-weight:800;color:var(--t1)}
104
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
105
  /* ================= Animations ================= */
106
  @keyframes aiGradient{0%{background-position:0% 50%}50%{background-position:100% 50%}100%{background-position:0% 50%}}
107
  @keyframes aiTitle{0%{background-position:0 0}50%{background-position:100% 0}100%{background-position:0 0}}
@@ -253,36 +269,75 @@ def fetch_employees(employee_ids: Iterable[int]) -> pd.DataFrame:
253
  df = pd.DataFrame(records)
254
  for col in ["personNumber", "organizationPath", "phoneNumber", "fullName", "Certifications"]:
255
  if col not in df.columns: df[col] = []
 
 
 
256
  df["JobRole"] = df["organizationPath"].apply(lambda p: p.split("/")[-1] if isinstance(p, str) and p else "")
257
  return df
258
 
259
  # =============================================================================
260
  # Decision logic + timelines
261
  # =============================================================================
 
 
 
 
 
 
 
 
262
  def is_eligible(row: pd.Series, shift: pd.Series) -> bool:
263
- role_match = row.get("JobRole", "").strip().lower() == shift["RequiredSkill"].strip().lower()
264
- cert_ok = shift["RequiredCert"].strip().lower() in [c.lower() for c in row.get("Certifications", [])]
265
- return role_match or cert_ok # keep original relaxed rule
 
 
 
 
 
 
 
 
 
 
 
 
 
266
 
267
  def gpt_decide(shift: pd.Series, eligible_df: pd.DataFrame) -> Dict[str, str]:
268
  if not HAS_OPENAI or openai is None or not os.getenv("OPENAI_API_KEY"):
269
  return {"action": "skip"}
270
  emp_list = []
271
  for _, r in eligible_df.iterrows():
272
- role_match = shift['RequiredSkill'] in (r.get("organizationPath") or "")
273
- cert_ok = shift['RequiredCert'] in (r.get("Certifications") or [])
 
 
 
274
  emp_list.append({
275
- "fullName": r["fullName"], "phoneNumber": r["phoneNumber"],
276
- "organizationPath": r["organizationPath"], "Certifications": r["Certifications"],
277
- "matchScore": int(role_match) + int(cert_ok)
 
 
278
  })
279
  prompt = f"""
280
  أنت مساعد ذكي للمناوبات:
281
- تفاصيل الشفت: القسم: {shift['Department']} | المهارة: {shift['RequiredSkill']} | الشهادة: {shift['RequiredCert']} | الوقت: {shift['ShiftTime']}
282
- الموظفون المؤهلون جزئياً/كلياً: {emp_list if emp_list else 'لا يوجد'}
283
- اختر بتنسيق JSON فقط:
 
 
 
 
 
 
 
 
 
 
284
  {{"action":"assign","employee":"الاسم"}} أو {{"action":"notify","employee":"الاسم"}} أو {{"action":"skip"}}
285
- """.strip()
286
  try:
287
  resp = openai.ChatCompletion.create(
288
  model="gpt-4",
@@ -297,12 +352,11 @@ def gpt_decide(shift: pd.Series, eligible_df: pd.DataFrame) -> Dict[str, str]:
297
  return {"action": "skip"}
298
 
299
  def render_mini_timeline(events: List[Dict[str, str]]) -> str:
300
- # Convert events to a compact “done/next” mini timeline
301
  steps = []
302
  for i, ev in enumerate(events):
303
  title = ev.get("title","")
304
  done = "done" if i < len(events)-1 else "" # last is current
305
- # short label
306
  label = (title.replace("🔍","").replace("✅","").replace("📋","")
307
  .replace("🤖","").replace("📬","").strip())
308
  steps.append(f'<div class="step {done}">{label}</div>')
@@ -312,57 +366,94 @@ def render_mini_timeline(events: List[Dict[str, str]]) -> str:
312
  '''
313
  return html
314
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
315
  def run_agent(employee_ids: Iterable[int], df_shifts: pd.DataFrame):
316
- events: List[Dict[str, str]] = []
317
  shift_assignment_results: List[tuple] = []
318
  reasoning_rows: List[Dict[str, Any]] = []
319
 
320
- # 1) Employees
321
- events.append({"icon":"🔍","title":"Fetching employee data","desc":"Loading employee information from UKG API..."})
322
  df_employees = fetch_employees(employee_ids)
323
- if df_employees.empty:
324
- events.append({"icon":"⚠️","title":"No Employee Data","desc":"No employees returned or credentials invalid."})
325
- else:
326
- events.append({"icon":"✅","title":"Employee Data Loaded","desc":f"{len(df_employees)} employee(s) loaded successfully."})
327
 
328
  # 2) Evaluate shifts
329
- events.append({"icon":"📋","title":"Evaluating Shifts","desc":"Matching employees to shift requirements..."})
 
 
330
  for _, shift in df_shifts.iterrows():
331
  eligible = df_employees[df_employees.apply(lambda r: is_eligible(r, shift), axis=1)] if not df_employees.empty else pd.DataFrame()
332
- # 3) Decision
333
- events.append({"icon":"🤖","title":"Running AI Decision","desc":"Determining best action for the shift..."})
 
 
334
  decision = gpt_decide(shift, eligible)
 
 
 
335
  if decision.get("action") == "assign":
336
  emp = decision.get("employee")
337
- events.append({"icon":"✅","title":f"Assigned {emp}","desc":f"{emp} -> {shift['Department']} at {shift['ShiftTime']}"})
338
  shift_assignment_results.append((emp, shift["ShiftID"], "✅ Auto-Filled"))
339
  elif decision.get("action") == "notify":
340
  emp = decision.get("employee")
341
- events.append({"icon":"📬","title":f"Notify {emp}","desc":f"Send notification to {emp} for the shift"})
342
  shift_assignment_results.append((emp, shift["ShiftID"], "📨 Notify"))
343
  else:
344
- events.append({"icon":"⚠️","title":"Skipped","desc":"No eligible employees available or decision skipped."})
345
  shift_assignment_results.append(("❌ No eligible", shift["ShiftID"], "⚠️ Skipped"))
346
 
347
- # Reasoning rows
348
  for _, emp_row in df_employees.iterrows():
349
- role_match = shift["RequiredSkill"].strip().lower() == emp_row.get("JobRole", "").strip().lower()
350
- cert_ok = shift["RequiredCert"].strip().lower() in [c.lower() for c in emp_row.get("Certifications", [])]
351
- avail_ok = True; ot_ok = True
352
- status = "✅ Eligible" if all([role_match, cert_ok, avail_ok, ot_ok]) else " Not Eligible"
 
 
 
 
 
353
  reasoning_rows.append({
354
  "Employee": emp_row.get("fullName",""),
355
  "ShiftID": shift["ShiftID"],
356
  "Eligible": status,
357
  "Reasoning": " | ".join([
358
- "✅ Role Match" if role_match else "❌ Role",
359
- "✅ Cert" if cert_ok else "❌ Cert",
360
- "✅ Avail" if avail_ok else "❌ Avail",
361
- "✅ OT" if ot_ok else "❌ OT",
 
362
  ]),
363
  "Certifications": ", ".join(emp_row.get("Certifications", []))
364
  })
365
- events.append({"icon":"📊","title":"Summary Ready","desc":"AI finished processing shifts."})
 
 
 
 
 
 
 
 
 
366
  return events, shift_assignment_results, reasoning_rows
367
 
368
  # =============================================================================
@@ -384,6 +475,23 @@ def main() -> None:
384
  """, unsafe_allow_html=True
385
  )
386
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
387
  # --- Button + status bar (initial) ---
388
  status_placeholder = st.empty()
389
  status_placeholder.markdown(
@@ -397,9 +505,10 @@ def main() -> None:
397
  """, unsafe_allow_html=True
398
  )
399
 
400
- # Demo data (unchanged)
401
  employee_ids_default = [850, 825]
402
- shift_data = "ShiftID,Department,RequiredSkill,RequiredCert,ShiftTime\nS101,ICU,MedSurg RN, Registered Nurse,2025-06-04 07:00"
 
403
  df_shifts_default = pd.read_csv(StringIO(shift_data))
404
 
405
  # --- CTA ---
@@ -433,10 +542,9 @@ def main() -> None:
433
  """, unsafe_allow_html=True
434
  )
435
 
436
- # --- Mini Timeline (horizontal) ---
437
- st.markdown("### 🕒 Timeline")
438
- tl_html = render_mini_timeline(events)
439
- components.html(tl_html, height=96, scrolling=False)
440
 
441
  # --- Stat row (quick KPIs) ---
442
  assigned = sum(1 for e in events if e["title"].startswith("Assigned"))
@@ -470,9 +578,9 @@ def main() -> None:
470
  <h4>How it works</h4>
471
  <ol style="margin:6px 0 0 18px;color:#334155">
472
  <li>Fetch employees & certifications from UKG demo API</li>
473
- <li>Match against shift skill & required cert</li>
474
  <li>Use GPT (if key set) to assign/notify or skip</li>
475
- <li>Show a clean timeline + KPIs + detailed reasoning</li>
476
  </ol>
477
  </div>
478
  """, unsafe_allow_html=True
 
2
  Health Matrix – AI Command Center (Light UI)
3
  Single-file Streamlit app with embedded CSS + UKG helpers.
4
  - Keeps all original functionality
5
+ - Business Case section + Agent Runline (vertical execution log)
6
+ - Extended shift criteria (role/specialty/mandatory training/ward/language)
7
  Usage: streamlit run merged_app.py
8
  """
9
 
10
  import os
11
  import json
12
  import pathlib
13
+ import time
14
  from typing import List, Optional, Iterable, Dict, Any
15
  from io import StringIO
16
 
 
46
  .stDataFrame th{background:#003b70 !important;color:#fff !important}
47
 
48
  /* ================= Hero ================= */
49
+ .hero{position:relative;text-align:center;padding:72px 0 18px;overflow:hidden}
50
  .hero img{width:200px;height:auto;margin-bottom:14px}
51
  .hero h1{font-size:34px;line-height:42px;font-weight:900;margin:0 0 10px}
52
  .ai-headline{
 
54
  -webkit-background-clip:text;background-clip:text;-webkit-text-fill-color:transparent;
55
  background-size:200% 100%;animation:aiTitle 8s ease-in-out infinite;
56
  }
57
+ .hero p{font-size:16px;line-height:26px;color:var(--t2);max-width:760px;margin:0 auto 8px}
58
  .hero::before,.hero::after{content:"";position:absolute;inset:-20%;pointer-events:none;filter:blur(42px);opacity:.18}
59
  .hero::before{
60
  background:
 
84
  box-shadow:0 0 0 0 rgba(54,186,1,.6);animation:pulse 1.4s infinite}
85
  .status-done{background:#E8F7ED;border-color:#B6E2C4}.status-done .pulse-dot{background:var(--ok);box-shadow:none}
86
 
87
+ /* ================= Mini Timeline (kept for reuse) ================= */
88
  .ai-timeline-light{display:flex;justify-content:space-between;gap:10px;margin:18px 0 8px}
89
  .ai-timeline-light .step{flex:1;position:relative;text-align:center;color:var(--t1);font-size:12px;padding-top:18px}
90
  .ai-timeline-light .step::before{content:"";position:absolute;top:0;left:50%;transform:translateX(-50%);
 
104
  .stat-icon{font-size:24px;margin-bottom:.2rem;color:var(--blue)}
105
  .stat-label{color:#334155;font-size:.95rem}.stat-value{font-size:1.6rem;font-weight:800;color:var(--t1)}
106
 
107
+ /* === Agent Runline (vertical execution log) === */
108
+ .run-wrap{margin:10px 0 20px}
109
+ .run-legend{display:flex;gap:8px;flex-wrap:wrap;margin:6px 0 10px}
110
+ .run-legend .chip{font-size:12px;padding:6px 10px;border:1px solid var(--line);border-radius:999px;background:#fff}
111
+ .run{position:relative;margin:0;padding:0 0 0 18px;list-style:none}
112
+ .run::before{content:"";position:absolute;left:6px;top:0;bottom:0;width:2px;background:#E2E8F0}
113
+ .run li{position:relative;margin:0 0 12px;padding:8px 12px 8px 12px;background:#fff;border:1px solid var(--line);border-radius:12px;box-shadow:0 6px 14px rgba(0,0,0,.05)}
114
+ .run li::before{content:"";position:absolute;left:-13px;top:12px;width:12px;height:12px;border-radius:50%;background:#94A3B8;border:2px solid #fff;box-shadow:0 0 0 2px #E2E8F0}
115
+ .run li.done::before{background:var(--green)}
116
+ .run li.current::before{background:var(--blue)}
117
+ .run h5{margin:0 0 4px;font-size:14px}
118
+ .run .meta{display:flex;gap:10px;font-size:12px;color:#64748B}
119
+ .run .desc{margin-top:6px;color:#334155;font-size:13px}
120
+
121
  /* ================= Animations ================= */
122
  @keyframes aiGradient{0%{background-position:0% 50%}50%{background-position:100% 50%}100%{background-position:0% 50%}}
123
  @keyframes aiTitle{0%{background-position:0 0}50%{background-position:100% 0}100%{background-position:0 0}}
 
269
  df = pd.DataFrame(records)
270
  for col in ["personNumber", "organizationPath", "phoneNumber", "fullName", "Certifications"]:
271
  if col not in df.columns: df[col] = []
272
+ # Optionals for matching
273
+ if "Languages" not in df.columns:
274
+ df["Languages"] = [[] for _ in range(len(df))]
275
  df["JobRole"] = df["organizationPath"].apply(lambda p: p.split("/")[-1] if isinstance(p, str) and p else "")
276
  return df
277
 
278
  # =============================================================================
279
  # Decision logic + timelines
280
  # =============================================================================
281
+ def _has_any(tokens: List[str], haystack: str) -> bool:
282
+ hay = (haystack or "").lower()
283
+ return any(t.strip().lower() in hay for t in tokens if t and t.strip())
284
+
285
+ def _has_all_in_list(need: List[str], have: List[str]) -> bool:
286
+ have_l = [h.lower() for h in (have or [])]
287
+ return all(n.strip().lower() in have_l for n in need if n and n.strip())
288
+
289
  def is_eligible(row: pd.Series, shift: pd.Series) -> bool:
290
+ """Eligibility using expanded criteria."""
291
+ role_req = str(shift.get("RoleRequired","")).strip().lower()
292
+ specialty = str(shift.get("Specialty","")).replace("-", " ").strip().lower()
293
+ mandatory = [t.strip() for t in str(shift.get("MandatoryTraining","")).replace(",", "/").split("/") if t.strip()]
294
+ ward = str(shift.get("WardDepartment","")).strip().lower()
295
+ lang_req = str(shift.get("LanguageRequirement","")).strip().lower()
296
+
297
+ role_ok = (row.get("JobRole","").strip().lower() == role_req) or _has_any([role_req], row.get("organizationPath",""))
298
+ spec_ok = (not specialty) or _has_any([specialty], row.get("JobRole","")) \
299
+ or _has_any([specialty], row.get("organizationPath","")) \
300
+ or _has_any([specialty], " ".join(row.get("Certifications", [])))
301
+ training_ok = (not mandatory) or _has_all_in_list(mandatory, row.get("Certifications", []))
302
+ ward_ok = (not ward) or _has_any([ward], row.get("organizationPath",""))
303
+ lang_ok = (not lang_req) or (lang_req in [l.lower() for l in (row.get("Languages") or [])])
304
+
305
+ return role_ok and spec_ok and training_ok and ward_ok and lang_ok
306
 
307
  def gpt_decide(shift: pd.Series, eligible_df: pd.DataFrame) -> Dict[str, str]:
308
  if not HAS_OPENAI or openai is None or not os.getenv("OPENAI_API_KEY"):
309
  return {"action": "skip"}
310
  emp_list = []
311
  for _, r in eligible_df.iterrows():
312
+ role_match = str(shift.get('RoleRequired','')) in (r.get("organizationPath") or "") or \
313
+ (r.get("JobRole","") == str(shift.get('RoleRequired','')))
314
+ mand_tokens = [t.strip() for t in str(shift.get("MandatoryTraining","")).replace(",", "/").split("/") if t.strip()]
315
+ cert_ok = _has_all_in_list(mand_tokens, r.get("Certifications") or [])
316
+ score = int(bool(role_match)) + int(bool(cert_ok))
317
  emp_list.append({
318
+ "fullName": r.get("fullName",""),
319
+ "phoneNumber": r.get("phoneNumber",""),
320
+ "organizationPath": r.get("organizationPath",""),
321
+ "Certifications": r.get("Certifications",[]),
322
+ "matchScore": score
323
  })
324
  prompt = f"""
325
  أنت مساعد ذكي للمناوبات:
326
+ تفاصيل الشفت:
327
+ - القسم: {shift.get('Department','')}
328
+ - الدور المطلوب: {shift.get('RoleRequired','')}
329
+ - التخصص/الخبرة: {shift.get('Specialty','')}
330
+ - التدريب الإلزامي: {shift.get('MandatoryTraining','')}
331
+ - نوع الشفت/المدة: {shift.get('ShiftType','')} / {shift.get('ShiftDuration','')}
332
+ - القسم/الورد: {shift.get('WardDepartment','')}
333
+ - اللغة المطلوبة: {shift.get('LanguageRequirement','')}
334
+ - الوقت: {shift.get('ShiftTime','')}
335
+
336
+ الموظفون المؤهلون (matchScore=0..2): {emp_list if emp_list else 'لا يوجد'}
337
+
338
+ اختر JSON فقط:
339
  {{"action":"assign","employee":"الاسم"}} أو {{"action":"notify","employee":"الاسم"}} أو {{"action":"skip"}}
340
+ """.strip()
341
  try:
342
  resp = openai.ChatCompletion.create(
343
  model="gpt-4",
 
352
  return {"action": "skip"}
353
 
354
  def render_mini_timeline(events: List[Dict[str, str]]) -> str:
355
+ # kept in case you want the compact view
356
  steps = []
357
  for i, ev in enumerate(events):
358
  title = ev.get("title","")
359
  done = "done" if i < len(events)-1 else "" # last is current
 
360
  label = (title.replace("🔍","").replace("✅","").replace("📋","")
361
  .replace("🤖","").replace("📬","").strip())
362
  steps.append(f'<div class="step {done}">{label}</div>')
 
366
  '''
367
  return html
368
 
369
+ def render_runline(events: List[Dict[str, Any]]) -> str:
370
+ def li(ev):
371
+ cls = ev.get("status","done")
372
+ dur = ev.get("dur_ms")
373
+ dur_txt = f"{int(dur)} ms" if isinstance(dur,(int,float)) else "—"
374
+ return f"""
375
+ <li class="{cls}">
376
+ <h5>{ev.get('title','')}</h5>
377
+ <div class="meta"><span>Duration: {dur_txt}</span></div>
378
+ <div class="desc">{ev.get('desc','')}</div>
379
+ </li>"""
380
+ html = '<div class="run-wrap"><div class="run-legend">' \
381
+ '<span class="chip">● Current</span><span class="chip">● Done</span></div>' \
382
+ '<ul class="run">{items}</ul></div>'
383
+ return html.replace("{items}", "".join(li(e) for e in events))
384
+
385
  def run_agent(employee_ids: Iterable[int], df_shifts: pd.DataFrame):
386
+ events: List[Dict[str, Any]] = []
387
  shift_assignment_results: List[tuple] = []
388
  reasoning_rows: List[Dict[str, Any]] = []
389
 
390
+ # 1) Fetch employees
391
+ t0 = time.perf_counter()
392
  df_employees = fetch_employees(employee_ids)
393
+ dur = (time.perf_counter() - t0) * 1000
394
+ loaded_txt = f"{len(df_employees)} employee(s) loaded successfully." if not df_employees.empty else "No employees returned or credentials invalid."
395
+ events.append({"title":"Fetch employees", "desc":loaded_txt, "status":"done", "dur_ms":max(int(dur),1)})
 
396
 
397
  # 2) Evaluate shifts
398
+ t_eval = time.perf_counter()
399
+ events.append({"title":"Evaluate open shifts", "desc":"Matching employees vs. role/specialty/training/ward…", "status":"done"})
400
+
401
  for _, shift in df_shifts.iterrows():
402
  eligible = df_employees[df_employees.apply(lambda r: is_eligible(r, shift), axis=1)] if not df_employees.empty else pd.DataFrame()
403
+
404
+ # 3) AI decision
405
+ t_ai = time.perf_counter()
406
+ events.append({"title":"AI decision", "desc":"Select assign / notify / skip", "status":"done"}) # duration after call
407
  decision = gpt_decide(shift, eligible)
408
+ ai_dur = (time.perf_counter() - t_ai) * 1000
409
+ events[-1]["dur_ms"] = max(int(ai_dur),1)
410
+
411
  if decision.get("action") == "assign":
412
  emp = decision.get("employee")
413
+ events.append({"title":f"Assigned {emp}", "desc":f"{emp} {shift.get('Department','')} ({shift.get('ShiftType','')}, {shift.get('ShiftDuration','')})", "status":"done"})
414
  shift_assignment_results.append((emp, shift["ShiftID"], "✅ Auto-Filled"))
415
  elif decision.get("action") == "notify":
416
  emp = decision.get("employee")
417
+ events.append({"title":f"Notify {emp}", "desc":f"Send notification for {shift['ShiftID']}", "status":"done"})
418
  shift_assignment_results.append((emp, shift["ShiftID"], "📨 Notify"))
419
  else:
420
+ events.append({"title":"Skipped", "desc":"No eligible employees or decision skipped", "status":"done"})
421
  shift_assignment_results.append(("❌ No eligible", shift["ShiftID"], "⚠️ Skipped"))
422
 
423
+ # Reasoning rows (explanatory)
424
  for _, emp_row in df_employees.iterrows():
425
+ role_match = str(shift.get("RoleRequired","")).strip().lower() == emp_row.get("JobRole","").strip().lower()
426
+ spec = str(shift.get("Specialty","")).replace("-", " ").strip().lower()
427
+ spec_ok = any(s for s in [spec] if s and (
428
+ s in (emp_row.get("JobRole","")+emp_row.get("organizationPath","")+" ".join(emp_row.get("Certifications",[]))).lower()
429
+ ))
430
+ training_need = [t.strip().lower() for t in str(shift.get("MandatoryTraining","")).replace(",", "/").split("/") if t.strip()]
431
+ training_ok = all(t in [c.lower() for c in emp_row.get("Certifications",[])] for t in training_need)
432
+ ward_ok = not str(shift.get("WardDepartment","")).strip() or str(shift.get("WardDepartment","")).lower() in (emp_row.get("organizationPath","").lower())
433
+ status = "✅ Eligible" if all([role_match or spec_ok, training_ok, ward_ok]) else "❌ Not Eligible"
434
  reasoning_rows.append({
435
  "Employee": emp_row.get("fullName",""),
436
  "ShiftID": shift["ShiftID"],
437
  "Eligible": status,
438
  "Reasoning": " | ".join([
439
+ "✅ Role" if role_match else "❌ Role",
440
+ "✅ Specialty" if spec_ok else "❌ Specialty",
441
+ "✅ Training" if training_ok else "❌ Training",
442
+ "✅ Ward" if ward_ok else "❌ Ward",
443
+ f"Lang: {shift.get('LanguageRequirement','') or '—'}"
444
  ]),
445
  "Certifications": ", ".join(emp_row.get("Certifications", []))
446
  })
447
+
448
+ # set evaluation duration
449
+ eval_dur = (time.perf_counter() - t_eval) * 1000
450
+ for e in events:
451
+ if e["title"] == "Evaluate open shifts":
452
+ e["dur_ms"] = max(int(eval_dur),1)
453
+ break
454
+
455
+ # 4) Summary
456
+ events.append({"title":"Summary ready", "desc":"AI finished processing shifts", "status":"current"})
457
  return events, shift_assignment_results, reasoning_rows
458
 
459
  # =============================================================================
 
475
  """, unsafe_allow_html=True
476
  )
477
 
478
+ # --- Business Case (top) ---
479
+ st.markdown("""
480
+ <div class="card" style="max-width:980px;margin:0 auto 10px;">
481
+ <h4 style="margin:0 0 6px;">Business Case — Open Shifts Auto‑Fulfillment</h4>
482
+ <p style="margin:0;">
483
+ Health Matrix AI Agent يراقب الشفتات المفتوحة ويطابقها تلقائيًا مع الموارد المؤهّلة وفق المعايير:
484
+ <b>Role</b>، <b>Specialty</b>، <b>Mandatory Training</b>، <b>Shift Type/Duration</b>، <b>Ward/Department</b>، <b>Language</b>.
485
+ ثم يقرّر <b>Assign</b> أو <b>Notify</b> تلقائيًا ويعرض سجل التنفيذ خطوة‑بخطوة.
486
+ </p>
487
+ <ul style="margin:8px 0 0 18px;color:#334155;font-size:14px;line-height:22px">
488
+ <li>تقليل وقت شَغل الشفتات الحرجة (ICU/ED)</li>
489
+ <li>تحسين الالتزام باللوائح والتدريب الإلزامي</li>
490
+ <li>شفافية كاملة عبر Agent Runline (الخطوات + المدد)</li>
491
+ </ul>
492
+ </div>
493
+ """, unsafe_allow_html=True)
494
+
495
  # --- Button + status bar (initial) ---
496
  status_placeholder = st.empty()
497
  status_placeholder.markdown(
 
505
  """, unsafe_allow_html=True
506
  )
507
 
508
+ # Demo data (updated with new fields)
509
  employee_ids_default = [850, 825]
510
+ shift_data = """ShiftID,Department,RoleRequired,Specialty,MandatoryTraining,ShiftType,ShiftDuration,WardDepartment,LanguageRequirement,ShiftTime
511
+ S101,ICU,Registered Nurse,ICU-trained,BLS/ALS certified,Night,12h,A&E,Arabic,2025-06-04 07:00"""
512
  df_shifts_default = pd.read_csv(StringIO(shift_data))
513
 
514
  # --- CTA ---
 
542
  """, unsafe_allow_html=True
543
  )
544
 
545
+ # --- Agent Runline (vertical) ---
546
+ st.markdown("### 🕒 Agent Runline")
547
+ components.html(render_runline(events), height=360, scrolling=True)
 
548
 
549
  # --- Stat row (quick KPIs) ---
550
  assigned = sum(1 for e in events if e["title"].startswith("Assigned"))
 
578
  <h4>How it works</h4>
579
  <ol style="margin:6px 0 0 18px;color:#334155">
580
  <li>Fetch employees & certifications from UKG demo API</li>
581
+ <li>Match against role/specialty/mandatory training/ward/language</li>
582
  <li>Use GPT (if key set) to assign/notify or skip</li>
583
+ <li>Show an execution Runline + KPIs + detailed reasoning</li>
584
  </ol>
585
  </div>
586
  """, unsafe_allow_html=True