Dhom1 commited on
Commit
b56044f
·
verified ·
1 Parent(s): 98bb40e

Update src/streamlit_app.py

Browse files
Files changed (1) hide show
  1. src/streamlit_app.py +258 -269
src/streamlit_app.py CHANGED
@@ -1,25 +1,18 @@
1
  """
2
- Health Matrix – AI Command Center (Enhanced UI/UX • Wide Timeline)
3
- Single-file Streamlit app with embedded CSS + UKG helpers.
4
-
5
- Front-end upgrades (layout/styling ONLY):
6
- - Page uses full width on web
7
- - Horizontal/GRID Agent Runline (wide cards) + legend
8
- - Agent Overview strip (steps, total duration, decision mix)
9
- - KPI cards with micro-interactions
10
- - Readable tables: sticky headers, zebra rows; horizontal scroll support
11
- - Loading skeletons, friendly empty/error states
12
- - RTL/LTR friendly styles
13
-
14
- All logic, API calls, inputs/outputs are unchanged.
15
- Usage: streamlit run streamlit_app.py
16
  """
17
 
18
  import os
19
- import json
20
  import time
21
- from typing import List, Optional, Iterable, Dict, Any
22
  from io import StringIO
 
23
 
24
  import pandas as pd
25
  import requests
@@ -29,141 +22,151 @@ import streamlit as st
29
  try:
30
  import openai # type: ignore
31
  HAS_OPENAI = True
32
- except ImportError:
33
- openai = None # type: ignore
34
  HAS_OPENAI = False
35
 
36
  # =============================================================================
37
- # Embedded Enhanced Light UI CSS (brand-aligned, responsive, accessible)
38
  # =============================================================================
39
  _EMBEDDED_CSS = """
40
  :root{
41
  --bg:#FFFFFF; --soft:#F8FAFC; --line:#E5E7EB;
42
- --t1:#0F172A; --t2:#1F2937; --t3:#334155; --muted:#64748B;
43
- --blue:#004C97; --green:#36BA01; --ok:#16a34a; --warn:#D97706; --fail:#DC2626;
44
  }
45
 
46
- /* ====== Make the main page wider on web ====== */
47
- .main .block-container{max-width:1520px !important; padding-left:1rem; padding-right:1rem}
 
 
 
48
 
49
- html,body{margin:0;padding:0;height:100%;background:var(--bg);color:var(--t1);
50
- font-family:'Segoe UI','Cairo','Tajawal','Open Sans',sans-serif;}
51
- .container{max-width:1200px;margin:0 auto;padding:24px}
52
- h1,h2,h3{color:var(--t1);text-align:center;margin:0 0 8px}
53
- p,li{color:var(--t2);font-size:1.05em}
54
- [dir="rtl"] h1,[dir="rtl"] h2,[dir="rtl"] h3{text-align:center}
 
 
55
 
56
- /* Streamlit DataFrame: sticky header + zebra + compact density */
57
  [data-testid="stDataFrame"] table thead th{
58
  position: sticky; top: 0; z-index: 2;
59
  background:#003b70 !important; color:#fff !important;
60
  }
61
- [data-testid="stDataFrame"] table tbody tr:nth-child(odd){background-color:#f9fafb}
62
- [data-testid="stDataFrame"] table td{padding:8px 10px}
63
 
64
- /* Horizontal scroll helper on small screens */
65
- .table-wrap{overflow-x:auto; margin:6px 0 0; border:1px solid var(--line); border-radius:12px}
 
 
66
 
67
- /* ================= Hero ================= */
68
- .hero{position:relative;text-align:center;padding:64px 12px 16px;overflow:hidden}
69
- .hero img{width:180px;height:auto;margin-bottom:12px}
70
- .hero h1{font-size:2.2rem;line-height:1.3;font-weight:900;margin:0 0 8px}
71
- .ai-headline{
72
  background:linear-gradient(90deg,var(--t1),var(--blue),var(--t1));
73
- -webkit-background-clip:text;background-clip:text;-webkit-text-fill-color:transparent;
74
- background-size:200% 100%;animation:aiTitle 8s ease-in-out infinite;
75
- }
76
- .hero p{font-size:1rem;line-height:1.6;color:var(--t2);max-width:900px;margin:0 auto}
77
- .hero::before,.hero::after{content:"";position:absolute;inset:-20%;pointer-events:none;filter:blur(42px);opacity:.18}
78
- .hero::before{
79
- background:
80
- radial-gradient(600px 280px at 20% 30%, rgba(54,186,1,.45), transparent 60%),
81
- radial-gradient(500px 240px at 80% 70%, rgba(0,76,151,.35), transparent 60%);
82
  }
83
- .hero::after{background:conic-gradient(from 0deg at 50% 50%, rgba(54,186,1,.18), rgba(0,76,151,.18), transparent);
84
- animation:slowSpin 26s linear infinite}
85
 
86
- /* ================= CTA button ================= */
87
- .stButton{display:flex;justify-content:center;margin:16px 0}
88
  .stButton>button{
89
- padding:14px 28px;font-size:1.1rem;font-weight:800;letter-spacing:.2px;
90
- color:#fff !important;border:0 !important;border-radius:16px !important;cursor:pointer;
91
  background:linear-gradient(120deg,var(--green),var(--blue),var(--green)) !important;
92
- background-size:320% 320% !important;animation:aiGradient 8s ease-in-out infinite;
93
- box-shadow:0 12px 24px rgba(0,0,0,.12);transition:transform .18s ease, box-shadow .18s ease}
94
- .stButton>button:hover{transform:translateY(-2px) scale(1.02);box-shadow:0 0 22px rgba(54,186,1,.26)}
95
- .stButton>button:disabled{cursor:progress;filter:saturate(.95)}
96
-
97
- /* ================= Status bar ================= */
98
- .status-wrap{display:flex;justify-content:center;margin:8px 0}
99
- .status-bar{display:flex;align-items:center;gap:10px;background:var(--soft);border:1px solid var(--line);
100
- border-radius:12px;padding:8px 14px;color:var(--t2);font-size:0.95rem}
101
- .pulse-dot{width:10px;height:10px;border-radius:50%;background:var(--green);
102
- box-shadow:0 0 0 0 rgba(54,186,1,.6);animation:pulse 1.4s infinite}
103
- .status-done{background:#E8F7ED;border-color:#B6E2C4}.status-done .pulse-dot{background:var(--ok);box-shadow:none}
104
-
105
- /* ================= Cards ================= */
106
- .card{background:#fff;border:1px solid var(--line);border-radius:16px;box-shadow:0 6px 16px rgba(0,0,0,.06);padding:16px;margin-bottom:14px}
107
- .card h4{margin:0 0 6px}
108
- .card ul{margin:8px 0 0 18px;color:#334155;font-size:0.95rem;line-height:1.6}
109
- .card ul li{margin-bottom:4px}
110
-
111
- /* ================= KPI ================= */
112
- .stat-row{display:flex;gap:12px;margin-top:8px;flex-wrap:wrap}
113
- .stat-card{flex:1;background:#fff;padding:1rem;border-radius:16px;border:1px solid var(--line);
114
- box-shadow:0 6px 18px rgba(0,0,0,.06);text-align:center;transition:transform .2s ease}
115
- .stat-card:hover{transform:scale(1.02)}
116
- .stat-icon{font-size:24px;margin-bottom:.2rem}
117
- .stat-label{color:#334155;font-size:0.95rem}
118
- .stat-value{font-size:1.7rem;font-weight:900;color:var(--t1)}
119
-
120
- /* === Agent Overview strip (chips) === */
121
- .overview{display:flex;gap:8px;flex-wrap:wrap;margin:4px 0 10px}
122
- .overview .chip{font-size:12.5px;padding:6px 10px;border:1px solid var(--line);border-radius:999px;background:#fff}
123
- .overview .chip b{color:var(--t1)}
124
-
125
- /* === Agent Runline - WIDE GRID (cards) === */
126
- .runH{display:grid;grid-template-columns:repeat(12, 1fr);gap:12px;margin:8px 0}
127
- .run-card{grid-column:span 4;background:#fff;border:1px solid var(--line);border-radius:14px;
128
- box-shadow:0 6px 14px rgba(0,0,0,.06);padding:12px;position:relative;min-height:96px;opacity:0;animation:fadeIn .45s forwards}
129
- .run-card .dot{position:absolute;left:10px;top:12px;width:10px;height:10px;border-radius:50%}
130
- .run-card .title{margin:0 0 6px 0;padding-left:22px;font-weight:800}
131
- .run-card .meta{color:#64748B;font-size:12.5px;margin-bottom:4px;padding-left:22px}
132
- .run-card .desc{color:#334155;font-size:13px;padding-left:22px}
133
- .run-card.done .dot{background:var(--green)}
134
- .run-card.current .dot{background:var(--blue)}
135
- @media (max-width:1200px){.run-card{grid-column:span 6}}
136
- @media (max-width:640px){.run-card{grid-column:span 12}}
137
-
138
- /* ================= Empty & Error States ================= */
139
- .empty{padding:12px;border:1px dashed var(--line);border-radius:12px;text-align:center;color:#64748B}
140
- .error{padding:12px;border:1px solid #fecaca;background:#fff1f2;color:#7f1d1d;border-radius:12px}
141
-
142
- /* ================= Loading Skeleton ================= */
143
- .skel{position:relative;overflow:hidden;background:#E5E7EB;border-radius:12px}
144
- .skel::after{content:"";position:absolute;inset:0;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
145
  background:linear-gradient(90deg, rgba(255,255,255,0), rgba(255,255,255,.45), rgba(255,255,255,0));
146
- transform:translateX(-100%);animation:shimmer 1.2s infinite}
147
- .skel.h40{height:40px}.skel.h80{height:80px}.skel.h120{height:120px}
148
- @keyframes shimmer{100%{transform:translateX(100%)}}
149
 
150
- /* ================= Animations ================= */
151
  @keyframes aiGradient{0%{background-position:0% 50%}50%{background-position:100% 50%}100%{background-position:0% 50%}}
152
  @keyframes aiTitle{0%{background-position:0 0}50%{background-position:100% 0}100%{background-position:0 0}}
153
- @keyframes slowSpin{to{transform:rotate(360deg)}}
154
- @keyframes pulse{0%{transform:scale(.95);box-shadow:0 0 0 0 rgba(54,186,1,.6)}
155
- 70%{transform:scale(1);box-shadow:0 0 0 10px rgba(54,186,1,0)}100%{transform:scale(.95);box-shadow:0 0 0 0 rgba(54,186,1,0)}}
156
  @keyframes fadeIn{to{opacity:1}}
157
-
158
- /* ================= Responsive ================= */
159
- @media (max-width:768px){
160
- .hero h1{font-size:1.6rem}
161
- .stat-row{flex-direction:column}
162
- }
163
  """
164
 
165
  # =============================================================================
166
- # UKG API helpers (unchanged logic)
167
  # =============================================================================
168
  def _get_auth_header() -> Dict[str, str]:
169
  app_key = os.environ.get("UKG_APP_KEY")
@@ -302,14 +305,13 @@ def fetch_employees(employee_ids: Iterable[int]) -> pd.DataFrame:
302
  df = pd.DataFrame(records)
303
  for col in ["personNumber", "organizationPath", "phoneNumber", "fullName", "Certifications"]:
304
  if col not in df.columns: df[col] = []
305
- # Optionals for matching
306
  if "Languages" not in df.columns:
307
  df["Languages"] = [[] for _ in range(len(df))]
308
  df["JobRole"] = df["organizationPath"].apply(lambda p: p.split("/")[-1] if isinstance(p, str) and p else "")
309
  return df
310
 
311
  # =============================================================================
312
- # Decision logic + timelines (unchanged)
313
  # =============================================================================
314
  def _has_any(tokens: List[str], haystack: str) -> bool:
315
  hay = (haystack or "").lower()
@@ -320,7 +322,6 @@ def _has_all_in_list(need: List[str], have: List[str]) -> bool:
320
  return all(n.strip().lower() in have_l for n in need if n and n.strip())
321
 
322
  def is_eligible(row: pd.Series, shift: pd.Series) -> bool:
323
- """Eligibility using expanded criteria."""
324
  role_req = str(shift.get("RoleRequired","")).strip().lower()
325
  specialty = str(shift.get("Specialty","")).replace("-", " ").strip().lower()
326
  mandatory = [t.strip() for t in str(shift.get("MandatoryTraining","")).replace(",", "/").split("/") if t.strip()]
@@ -334,7 +335,6 @@ def is_eligible(row: pd.Series, shift: pd.Series) -> bool:
334
  training_ok = (not mandatory) or _has_all_in_list(mandatory, row.get("Certifications", []))
335
  ward_ok = (not ward) or _has_any([ward], row.get("organizationPath",""))
336
  lang_ok = (not lang_req) or (lang_req in [l.lower() for l in (row.get("Languages") or [])])
337
-
338
  return role_ok and spec_ok and training_ok and ward_ok and lang_ok
339
 
340
  def gpt_decide(shift: pd.Series, eligible_df: pd.DataFrame) -> Dict[str, str]:
@@ -374,8 +374,9 @@ def gpt_decide(shift: pd.Series, eligible_df: pd.DataFrame) -> Dict[str, str]:
374
  try:
375
  resp = openai.ChatCompletion.create(
376
  model="gpt-4",
377
- messages=[{"role":"system","content":"أنت مساعد ذكي لإدارة المناوبات الصحية"},
378
- {"role":"user","content":prompt}],
 
379
  temperature=0.4, max_tokens=200,
380
  )
381
  import ast
@@ -384,44 +385,37 @@ def gpt_decide(shift: pd.Series, eligible_df: pd.DataFrame) -> Dict[str, str]:
384
  st.error(f"❌ GPT Error: {e}")
385
  return {"action": "skip"}
386
 
387
- # -------- EXISTING (vertical) kept if you want to switch back later ----------
388
- def render_runline(events: List[Dict[str, Any]]) -> str:
389
- def li(ev):
390
- cls = ev.get("status","done")
391
- dur = ev.get("dur_ms")
392
- dur_txt = f"{int(dur)} ms" if isinstance(dur,(int,float)) else "—"
393
- return f"""
394
- <li class="{cls}">
395
- <h5>{ev.get('title','')}</h5>
396
- <div class="meta"><span>Duration: {dur_txt}</span></div>
397
- <div class="desc">{ev.get('desc','')}</div>
398
- </li>"""
399
- html = '<div class="run-wrap" dir="auto"><div class="run-legend">' \
400
- '<span class="chip">● Current</span><span class="chip">● Done</span></div>' \
401
- '<ul class="run">{items}</ul></div>'
402
- return html.replace("{items}", "".join(li(e) for e in events))
403
-
404
- # -------- NEW: WIDE GRID timeline (uses the full horizontal space) ----------
405
  def render_runline_wide(events: List[Dict[str, Any]]) -> str:
406
- def card(ev):
407
- cls = ev.get("status","done")
 
408
  dur = ev.get("dur_ms")
409
- dur_txt = f"{int(dur)} ms" if isinstance(dur,(int,float)) else "—"
410
- title = ev.get("title","")
411
- desc = ev.get("desc","")
412
- return f"""
413
- <div class="run-card {cls}">
414
- <span class="dot"></span>
415
- <div class="title">{title}</div>
416
- <div class="meta">Duration: {dur_txt}</div>
417
- <div class="desc">{desc}</div>
418
- </div>
419
- """
420
- legend = '<div class="overview" dir="auto"><span class="chip">● <b>Current</b></span><span class="chip">● <b>Done</b></span></div>'
421
- return legend + '<div class="runH" dir="auto">' + "".join(card(e) for e in events) + "</div>"
 
 
 
 
 
 
 
422
 
423
  # =============================================================================
424
- # Main agent runner (unchanged)
425
  # =============================================================================
426
  def run_agent(employee_ids: Iterable[int], df_shifts: pd.DataFrame):
427
  events: List[Dict[str, Any]] = []
@@ -444,7 +438,7 @@ def run_agent(employee_ids: Iterable[int], df_shifts: pd.DataFrame):
444
 
445
  # 3) AI decision
446
  t_ai = time.perf_counter()
447
- events.append({"title":"AI decision", "desc":"Select assign / notify / skip", "status":"done"}) # duration after call
448
  decision = gpt_decide(shift, eligible)
449
  ai_dur = (time.perf_counter() - t_ai) * 1000
450
  events[-1]["dur_ms"] = max(int(ai_dur),1)
@@ -498,42 +492,36 @@ def run_agent(employee_ids: Iterable[int], df_shifts: pd.DataFrame):
498
  return events, shift_assignment_results, reasoning_rows
499
 
500
  # =============================================================================
501
- # Streamlit UI (enhanced front-end only)
502
  # =============================================================================
503
  def main() -> None:
504
  st.set_page_config(page_title="Health Matrix AI Command Center", layout="wide")
505
- os.environ.setdefault("XDG_CONFIG_HOME","/tmp"); os.environ.setdefault("HF_HOME","/tmp"); os.environ.setdefault("TRANSFORMERS_CACHE","/tmp")
506
  st.markdown(f"<style>{_EMBEDDED_CSS}</style>", unsafe_allow_html=True)
507
 
508
- # --- HERO ---
509
  st.markdown(
510
  """
511
  <div class="hero" dir="auto">
512
  <img src="https://www.healthmatrixcorp.com/web/image/website/1/logo/Health%20Matrix?unique=956ad7b" alt="Health Matrix" />
513
- <h1 class="ai-headline">AI Command Center – Smart Staffing & Actions</h1>
514
- <p>Automate decisions, ensure compliance, and track results with a real-time execution log tailored for critical operations.</p>
515
  </div>
516
  """, unsafe_allow_html=True
517
  )
518
 
519
- # --- Business Case (top) ---
520
  st.markdown("""
521
- <div class="card" style="max-width:1200px;margin:0 auto 10px;" dir="auto">
522
- <h4 style="margin:0 0 6px;">Business Case — Open Shifts Auto-Fulfillment</h4>
523
- <p style="margin:0;">
524
- Health Matrix AI Agent يراقب الشفتات المفتوحة ويطابقها تلقائيًا مع الموارد المؤهّلة وفق المعايير:
525
- <b>Role</b>، <b>Specialty</b>، <b>Mandatory Training</b>، <b>Shift Type/Duration</b>، <b>Ward/Department</b>، <b>Language</b>.
526
- ثم يقرّر <b>Assign</b> أو <b>Notify</b> تلقائيًا ويعرض سجل التنفيذ خطوة-بخطوة.
527
- </p>
528
  <ul>
529
- <li>✅ Reduce time to fill critical shifts (ICU/ED)</li>
530
- <li>📜 Improve compliance with mandatory training</li>
531
  <li>📊 Transparent Agent Runline (steps + durations)</li>
532
  </ul>
533
  </div>
534
  """, unsafe_allow_html=True)
535
 
536
- # --- Status bar (initial) ---
537
  status_placeholder = st.empty()
538
  status_placeholder.markdown(
539
  """
@@ -546,19 +534,21 @@ def main() -> None:
546
  """, unsafe_allow_html=True
547
  )
548
 
549
- # Demo data (kept as in your original file)
550
  employee_ids_default = [850, 825]
551
  shift_data = """ShiftID,Department,RoleRequired,Specialty,MandatoryTraining,ShiftType,ShiftDuration,WardDepartment,LanguageRequirement,ShiftTime
552
  S101,ICU,Registered Nurse,ICU-trained,BLS/ALS certified,Night,12h,A&E,Arabic,2025-06-04 07:00"""
553
  df_shifts_default = pd.read_csv(StringIO(shift_data))
554
 
555
- # --- CTA ---
556
- col_left, col_center, col_right = st.columns([1,2,1])
557
- with col_center:
558
- if st.button("▶️ Start AI Agent", key="start_agent", help="Let the AI handle the work"):
559
- # Loading state
560
- status_placeholder.markdown(
561
- """
 
 
562
  <div class="status-wrap" role="status" aria-live="polite">
563
  <div class="status-bar">
564
  <span class="pulse-dot"></span>
@@ -566,32 +556,28 @@ S101,ICU,Registered Nurse,ICU-trained,BLS/ALS certified,Night,12h,A&E,Arabic,202
566
  </div>
567
  </div>
568
  """, unsafe_allow_html=True
569
- )
570
-
571
- # --- Lightweight Skeletons while processing (visual only) ---
572
- skel_tl = st.empty()
573
- skel_kpi = st.empty()
574
- skel_tbl = st.empty()
575
- skel_tl.markdown('<div class="skel h120"></div>', unsafe_allow_html=True)
576
- skel_kpi.markdown('<div class="skel h80" style="margin-top:8px"></div>', unsafe_allow_html=True)
577
- skel_tbl.markdown('<div class="skel h120" style="margin-top:8px"></div>', unsafe_allow_html=True)
578
-
579
- # Run (unchanged logic)
580
- try:
581
- events, shift_assignment_results, reasoning_rows = run_agent(employee_ids_default, df_shifts_default)
582
- except Exception as e:
583
- skel_tl.empty(); skel_kpi.empty(); skel_tbl.empty()
584
- st.markdown(f'<div class="error">Unexpected error while processing. {e}</div>', unsafe_allow_html=True)
585
- if st.button("Retry"):
586
- st.rerun()
587
- return
588
-
589
- # Clear skeletons
590
- skel_tl.empty(); skel_kpi.empty(); skel_tbl.empty()
591
-
592
- # Done state
593
- status_placeholder.markdown(
594
- """
595
  <div class="status-wrap" role="status" aria-live="polite">
596
  <div class="status-bar status-done">
597
  <span class="pulse-dot"></span>
@@ -599,62 +585,65 @@ S101,ICU,Registered Nurse,ICU-trained,BLS/ALS certified,Night,12h,A&E,Arabic,202
599
  </div>
600
  </div>
601
  """, unsafe_allow_html=True
602
- )
603
-
604
- # ---------------- Agent Overview (clarify agent function) ----------------
605
- total_steps = len(events)
606
- total_ms = sum(int(e.get("dur_ms", 0) or 0) for e in events)
607
- assigned = sum(1 for e in events if e["title"].startswith("Assigned"))
608
- notified = sum(1 for e in events if e["title"].startswith("Notify"))
609
- skipped = sum(1 for e in events if e["title"].startswith("Skipped"))
610
- st.markdown('<div class="overview">', unsafe_allow_html=True)
611
- st.markdown(
612
- f'<span class="chip">🧠 <b>Agent Steps:</b> {total_steps}</span>'
613
- f'<span class="chip">⏱️ <b>Total Duration:</b> {total_ms} ms</span>'
614
- f'<span class="chip">✅ <b>Assigned:</b> {assigned}</span>'
615
- f'<span class="chip">📬 <b>Notify:</b> {notified}</span>'
616
- f'<span class="chip">⚠️ <b>Skipped:</b> {skipped}</span>',
617
- unsafe_allow_html=True
618
- )
619
- st.markdown('</div>', unsafe_allow_html=True)
620
-
621
- # --- Agent Runline (WIDE GRID) ---
622
- st.markdown("### 🕒 Agent Runline")
623
- st.markdown(render_runline_wide(events), unsafe_allow_html=True)
624
-
625
- # --- Stat row (quick KPIs) ---
626
- st.markdown('<div class="stat-row">', unsafe_allow_html=True)
627
- st.markdown(f'''
628
- <div class="stat-card" role="group" aria-label="Assigned"><div class="stat-icon">✅</div><div class="stat-label">Assigned</div><div class="stat-value">{assigned}</div></div>
629
- <div class="stat-card" role="group" aria-label="Notify"><div class="stat-icon">📬</div><div class="stat-label">Notify</div><div class="stat-value">{notified}</div></div>
630
- <div class="stat-card" role="group" aria-label="Skipped"><div class="stat-icon">⚠️</div><div class="stat-label">Skipped</div><div class="stat-value">{skipped}</div></div>
631
- ''', unsafe_allow_html=True)
632
- st.markdown('</div>', unsafe_allow_html=True)
633
-
634
- # --- Results ---
635
- st.markdown("### 📊 Results")
636
- with st.expander("Shift Fulfillment Summary", expanded=True):
637
- if shift_assignment_results:
638
- st.markdown('<div class="table-wrap">', unsafe_allow_html=True)
639
- st.dataframe(pd.DataFrame(shift_assignment_results, columns=["Employee", "ShiftID", "Status"]),
640
- use_container_width=True)
641
- st.markdown('</div>', unsafe_allow_html=True)
642
- else:
643
- st.markdown('<div class="empty">No assignments to display.</div>', unsafe_allow_html=True)
644
 
645
- with st.expander("Reasoning Behind Selections", expanded=False):
646
- if reasoning_rows:
647
- st.markdown('<div class="table-wrap">', unsafe_allow_html=True)
648
- st.dataframe(pd.DataFrame(reasoning_rows), use_container_width=True)
649
- st.markdown('</div>', unsafe_allow_html=True)
650
- else:
651
- st.markdown('<div class="empty">No reasoning data available.</div>', unsafe_allow_html=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
652
 
653
- else:
654
- # Idle extra info (unchanged content, refined visuals)
655
- st.markdown(
656
- """
657
- <div class="card" style="margin-top:8px" dir="auto">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
658
  <h4>How it works</h4>
659
  <ol style="margin:6px 0 0 18px;color:#334155">
660
  <li>Fetch employees & certifications from UKG demo API</li>
@@ -664,13 +653,13 @@ S101,ICU,Registered Nurse,ICU-trained,BLS/ALS certified,Night,12h,A&E,Arabic,202
664
  </ol>
665
  </div>
666
  """, unsafe_allow_html=True
667
- )
668
 
669
- # --- Footer ---
670
  st.markdown(
671
  """
672
  <hr/>
673
- <div class="container" style="text-align:center;color:#64748B;font-size:13px">
674
  © 2025 Health Matrix Corp – Empowering Digital Health Transformation · <a href="mailto:info@healthmatrixcorp.com">info@healthmatrixcorp.com</a>
675
  </div>
676
  """, unsafe_allow_html=True
 
1
  """
2
+ Health Matrix – AI Command Center (Wide Web UI)
3
+ - Full-width desktop layout
4
+ - Horizontal (Grid) Agent Runline that uses screen width
5
+ - Agent Overview chips (steps / total duration / decisions)
6
+ - Readable data tables (sticky header, zebra, full width, horizontal scroll on narrow)
7
+ - No logic/API/decision changes
8
+
9
+ This is a front-end only enhancement.
 
 
 
 
 
 
10
  """
11
 
12
  import os
 
13
  import time
 
14
  from io import StringIO
15
+ from typing import List, Optional, Iterable, Dict, Any
16
 
17
  import pandas as pd
18
  import requests
 
22
  try:
23
  import openai # type: ignore
24
  HAS_OPENAI = True
25
+ except Exception:
26
+ openai = None
27
  HAS_OPENAI = False
28
 
29
  # =============================================================================
30
+ # Embedded CSS (Desktop-first, wide layout)
31
  # =============================================================================
32
  _EMBEDDED_CSS = """
33
  :root{
34
  --bg:#FFFFFF; --soft:#F8FAFC; --line:#E5E7EB;
35
+ --t1:#0F172A; --t2:#1F2937; --muted:#64748B;
36
+ --blue:#004C97; --green:#36BA01; --ok:#16a34a; --warn:#D97706;
37
  }
38
 
39
+ /* ==== Use full width on desktop ==== */
40
+ .main .block-container{
41
+ max-width: 1600px !important;
42
+ padding-left: 16px; padding-right: 16px;
43
+ }
44
 
45
+ /* Base */
46
+ html,body{
47
+ margin:0; padding:0; height:100%;
48
+ background:var(--bg); color:var(--t1);
49
+ font-family:'Segoe UI','Cairo','Tajawal','Open Sans',sans-serif;
50
+ }
51
+ h1,h2,h3{ margin:0 0 8px; color:var(--t1); text-align:center }
52
+ p{ color:var(--t2) }
53
 
54
+ /* Dataframes: sticky header + zebra + compact */
55
  [data-testid="stDataFrame"] table thead th{
56
  position: sticky; top: 0; z-index: 2;
57
  background:#003b70 !important; color:#fff !important;
58
  }
59
+ [data-testid="stDataFrame"] table tbody tr:nth-child(odd){ background:#f9fafb }
60
+ [data-testid="stDataFrame"] table td{ padding:8px 10px }
61
 
62
+ /* Scroll wrapper (when needed) */
63
+ .table-wrap{
64
+ overflow-x:auto; border:1px solid var(--line); border-radius:12px; margin-top:6px;
65
+ }
66
 
67
+ /* ===== Hero ===== */
68
+ .hero{ position:relative; text-align:center; padding:48px 8px 8px }
69
+ .hero img{ width:170px; height:auto; margin-bottom:8px }
70
+ .hero h1{
71
+ font-size:2.1rem; font-weight:900; line-height:1.25; margin:0 0 6px;
72
  background:linear-gradient(90deg,var(--t1),var(--blue),var(--t1));
73
+ -webkit-background-clip:text; background-clip:text; -webkit-text-fill-color:transparent;
74
+ background-size:200% 100%; animation:aiTitle 8s ease-in-out infinite;
 
 
 
 
 
 
 
75
  }
76
+ .hero p{ max-width:1050px; margin:0 auto; font-size:1.02rem }
 
77
 
78
+ /* CTA */
79
+ .stButton{ display:flex; justify-content:center; margin:10px 0 0 }
80
  .stButton>button{
81
+ padding:12px 26px; font-size:1.05rem; font-weight:800; color:#fff !important;
82
+ border:0 !important; border-radius:14px !important; cursor:pointer;
83
  background:linear-gradient(120deg,var(--green),var(--blue),var(--green)) !important;
84
+ background-size:320% 320% !important; animation:aiGradient 8s ease-in-out infinite;
85
+ box-shadow:0 10px 20px rgba(0,0,0,.12); transition:transform .18s ease, box-shadow .18s ease
86
+ }
87
+ .stButton>button:hover{ transform:translateY(-2px) scale(1.02); box-shadow:0 0 22px rgba(54,186,1,.26) }
88
+
89
+ /* Status Bar */
90
+ .status-wrap{ display:flex; justify-content:center; margin:8px 0 }
91
+ .status-bar{
92
+ display:flex; align-items:center; gap:10px; background:var(--soft); color:var(--t2);
93
+ border:1px solid var(--line); border-radius:12px; padding:8px 14px; font-size:.95rem
94
+ }
95
+ .pulse-dot{ width:10px; height:10px; border-radius:50%; background:var(--green);
96
+ box-shadow:0 0 0 0 rgba(54,186,1,.55); animation:pulse 1.4s infinite }
97
+ .status-done{ background:#E8F7ED; border-color:#B6E2C4 }
98
+ .status-done .pulse-dot{ background:var(--ok); box-shadow:none }
99
+
100
+ /* Card */
101
+ .card{
102
+ background:#fff; border:1px solid var(--line); border-radius:16px;
103
+ box-shadow:0 6px 16px rgba(0,0,0,.06); padding:14px; margin-bottom:12px
104
+ }
105
+ .card h4{ margin:0 0 6px }
106
+ .card ul{ margin:8px 0 0 18px; color:#334155; font-size:.95rem; line-height:1.6 }
107
+
108
+ /* Overview chips */
109
+ .overview{ display:flex; gap:8px; flex-wrap:wrap; justify-content:center; margin:4px 0 10px }
110
+ .overview .chip{ font-size:.9rem; padding:6px 10px; border:1px solid var(--line); border-radius:999px; background:#fff }
111
+ .overview .chip b{ color:#0f172a }
112
+
113
+ /* KPI */
114
+ .stat-row{ display:grid; grid-template-columns:repeat(3, 1fr); gap:12px; margin-top:10px }
115
+ .stat-card{
116
+ background:#fff; padding:14px; border-radius:14px; border:1px solid var(--line);
117
+ box-shadow:0 6px 16px rgba(0,0,0,.06); text-align:center; transition:transform .2s ease
118
+ }
119
+ .stat-card:hover{ transform:scale(1.02) }
120
+ .stat-icon{ font-size:22px; margin-bottom:.2rem }
121
+ .stat-label{ color:#334155 }
122
+ .stat-value{ font-size:1.7rem; font-weight:900 }
123
+
124
+ /* ======= WIDE Runline (Grid) ======= */
125
+ .run-legend{ display:flex; gap:8px; flex-wrap:wrap; justify-content:center; margin:6px 0 6px }
126
+ .run-legend .chip{ font-size:.85rem; padding:6px 10px; border:1px solid var(--line); border-radius:999px; background:#fff }
127
+ .runH{
128
+ display:grid; gap:12px; margin:6px auto 12px; align-items:stretch;
129
+ grid-template-columns: repeat(12, 1fr);
130
+ max-width: 1520px;
131
+ }
132
+ .run-card{
133
+ grid-column: span 3; /* 4 cards per row on desktop */
134
+ background:#fff; border:1px solid var(--line); border-radius:14px; padding:12px;
135
+ box-shadow:0 6px 14px rgba(0,0,0,.06); position:relative; min-height:110px;
136
+ opacity:0; animation:fadeIn .45s forwards
137
+ }
138
+ .run-card .dot{ position:absolute; left:10px; top:12px; width:10px; height:10px; border-radius:50% }
139
+ .run-card .title{ margin:0 0 6px; padding-left:22px; font-weight:800 }
140
+ .run-card .meta{ color:#64748B; font-size:.85rem; margin-bottom:4px; padding-left:22px }
141
+ .run-card .desc{ color:#334155; font-size:.95rem; padding-left:22px }
142
+ .run-card.done .dot{ background:var(--green) }
143
+ .run-card.current .dot{ background:var(--blue) }
144
+
145
+ @media (max-width:1450px){ .run-card{ grid-column: span 4 } } /* 3 per row */
146
+ @media (max-width:1150px){ .run-card{ grid-column: span 6 } } /* 2 per row */
147
+ @media (max-width:760px){ .run-card{ grid-column: span 12 } } /* 1 per row */
148
+
149
+ /* Loading skeleton */
150
+ .skel{ position:relative; overflow:hidden; background:#E5E7EB; border-radius:12px }
151
+ .skel::after{
152
+ content:""; position:absolute; inset:0;
153
  background:linear-gradient(90deg, rgba(255,255,255,0), rgba(255,255,255,.45), rgba(255,255,255,0));
154
+ transform:translateX(-100%); animation:shimmer 1.2s infinite
155
+ }
156
+ .skel.h80{height:80px}.skel.h120{height:120px}
157
 
158
+ /* Animations */
159
  @keyframes aiGradient{0%{background-position:0% 50%}50%{background-position:100% 50%}100%{background-position:0% 50%}}
160
  @keyframes aiTitle{0%{background-position:0 0}50%{background-position:100% 0}100%{background-position:0 0}}
161
+ @keyframes pulse{0%{transform:scale(.95);box-shadow:0 0 0 0 rgba(54,186,1,.55)}
162
+ 70%{transform:scale(1);box-shadow:0 0 0 10px rgba(54,186,1,0)}
163
+ 100%{transform:scale(.95);box-shadow:0 0 0 0 rgba(54,186,1,0)}}
164
  @keyframes fadeIn{to{opacity:1}}
165
+ @keyframes shimmer{100%{transform:translateX(100%)}}
 
 
 
 
 
166
  """
167
 
168
  # =============================================================================
169
+ # UKG API helpers (unchanged)
170
  # =============================================================================
171
  def _get_auth_header() -> Dict[str, str]:
172
  app_key = os.environ.get("UKG_APP_KEY")
 
305
  df = pd.DataFrame(records)
306
  for col in ["personNumber", "organizationPath", "phoneNumber", "fullName", "Certifications"]:
307
  if col not in df.columns: df[col] = []
 
308
  if "Languages" not in df.columns:
309
  df["Languages"] = [[] for _ in range(len(df))]
310
  df["JobRole"] = df["organizationPath"].apply(lambda p: p.split("/")[-1] if isinstance(p, str) and p else "")
311
  return df
312
 
313
  # =============================================================================
314
+ # Decision logic (unchanged)
315
  # =============================================================================
316
  def _has_any(tokens: List[str], haystack: str) -> bool:
317
  hay = (haystack or "").lower()
 
322
  return all(n.strip().lower() in have_l for n in need if n and n.strip())
323
 
324
  def is_eligible(row: pd.Series, shift: pd.Series) -> bool:
 
325
  role_req = str(shift.get("RoleRequired","")).strip().lower()
326
  specialty = str(shift.get("Specialty","")).replace("-", " ").strip().lower()
327
  mandatory = [t.strip() for t in str(shift.get("MandatoryTraining","")).replace(",", "/").split("/") if t.strip()]
 
335
  training_ok = (not mandatory) or _has_all_in_list(mandatory, row.get("Certifications", []))
336
  ward_ok = (not ward) or _has_any([ward], row.get("organizationPath",""))
337
  lang_ok = (not lang_req) or (lang_req in [l.lower() for l in (row.get("Languages") or [])])
 
338
  return role_ok and spec_ok and training_ok and ward_ok and lang_ok
339
 
340
  def gpt_decide(shift: pd.Series, eligible_df: pd.DataFrame) -> Dict[str, str]:
 
374
  try:
375
  resp = openai.ChatCompletion.create(
376
  model="gpt-4",
377
+ messages=[
378
+ {"role":"system","content":"أنت مساعد ذكي لإدارة المناوبات الصحية"},
379
+ {"role":"user","content":prompt}],
380
  temperature=0.4, max_tokens=200,
381
  )
382
  import ast
 
385
  st.error(f"❌ GPT Error: {e}")
386
  return {"action": "skip"}
387
 
388
+ # =============================================================================
389
+ # Timeline renderers
390
+ # =============================================================================
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
391
  def render_runline_wide(events: List[Dict[str, Any]]) -> str:
392
+ """Return HTML string for the wide Grid timeline (no backticks, safe for st.markdown)."""
393
+ def card(ev: Dict[str, Any]) -> str:
394
+ cls = ev.get("status", "done")
395
  dur = ev.get("dur_ms")
396
+ dur_txt = f"{int(dur)} ms" if isinstance(dur, (int, float)) else "—"
397
+ title = str(ev.get("title", ""))
398
+ desc = str(ev.get("desc", ""))
399
+ return (
400
+ '<div class="run-card ' + cls + '">'
401
+ '<span class="dot"></span>'
402
+ '<div class="title">' + title + '</div>'
403
+ '<div class="meta">Duration: ' + dur_txt + '</div>'
404
+ '<div class="desc">' + desc + '</div>'
405
+ '</div>'
406
+ )
407
+
408
+ legend = (
409
+ '<div class="run-legend" dir="auto">'
410
+ '<span class="chip">● <b>Current</b></span>'
411
+ '<span class="chip">● <b>Done</b></span>'
412
+ '</div>'
413
+ )
414
+ grid = '<div class="runH" dir="auto">' + "".join(card(e) for e in events) + '</div>'
415
+ return legend + grid
416
 
417
  # =============================================================================
418
+ # Agent main (unchanged logic)
419
  # =============================================================================
420
  def run_agent(employee_ids: Iterable[int], df_shifts: pd.DataFrame):
421
  events: List[Dict[str, Any]] = []
 
438
 
439
  # 3) AI decision
440
  t_ai = time.perf_counter()
441
+ events.append({"title":"AI decision", "desc":"Select assign / notify / skip", "status":"done"})
442
  decision = gpt_decide(shift, eligible)
443
  ai_dur = (time.perf_counter() - t_ai) * 1000
444
  events[-1]["dur_ms"] = max(int(ai_dur),1)
 
492
  return events, shift_assignment_results, reasoning_rows
493
 
494
  # =============================================================================
495
+ # UI
496
  # =============================================================================
497
  def main() -> None:
498
  st.set_page_config(page_title="Health Matrix AI Command Center", layout="wide")
 
499
  st.markdown(f"<style>{_EMBEDDED_CSS}</style>", unsafe_allow_html=True)
500
 
501
+ # ========== HERO ==========
502
  st.markdown(
503
  """
504
  <div class="hero" dir="auto">
505
  <img src="https://www.healthmatrixcorp.com/web/image/website/1/logo/Health%20Matrix?unique=956ad7b" alt="Health Matrix" />
506
+ <h1>AI Command Center – Smart Staffing & Actions</h1>
507
+ <p>Desktop-first view. Uses full browser width to present the agent’s steps, decisions, and results clearly for operations teams.</p>
508
  </div>
509
  """, unsafe_allow_html=True
510
  )
511
 
512
+ # ========== Business Case ==========
513
  st.markdown("""
514
+ <div class="card" dir="auto" style="max-width:1400px;margin:0 auto 8px;">
515
+ <h4>Business Case — Open Shifts Auto-Fulfillment</h4>
 
 
 
 
 
516
  <ul>
517
+ <li>✅ Reduce time to fill critical shifts</li>
518
+ <li>📜 Enforce mandatory certifications & policies</li>
519
  <li>📊 Transparent Agent Runline (steps + durations)</li>
520
  </ul>
521
  </div>
522
  """, unsafe_allow_html=True)
523
 
524
+ # ========== Status ==========
525
  status_placeholder = st.empty()
526
  status_placeholder.markdown(
527
  """
 
534
  """, unsafe_allow_html=True
535
  )
536
 
537
+ # Demo input (unchanged behavior)
538
  employee_ids_default = [850, 825]
539
  shift_data = """ShiftID,Department,RoleRequired,Specialty,MandatoryTraining,ShiftType,ShiftDuration,WardDepartment,LanguageRequirement,ShiftTime
540
  S101,ICU,Registered Nurse,ICU-trained,BLS/ALS certified,Night,12h,A&E,Arabic,2025-06-04 07:00"""
541
  df_shifts_default = pd.read_csv(StringIO(shift_data))
542
 
543
+ # CTA centered
544
+ _, cta, _ = st.columns([1,2,1])
545
+ with cta:
546
+ run_clicked = st.button("▶️ Start AI Agent", key="start_agent", help="Let the AI handle the work")
547
+
548
+ if run_clicked:
549
+ # Processing state
550
+ status_placeholder.markdown(
551
+ """
552
  <div class="status-wrap" role="status" aria-live="polite">
553
  <div class="status-bar">
554
  <span class="pulse-dot"></span>
 
556
  </div>
557
  </div>
558
  """, unsafe_allow_html=True
559
+ )
560
+
561
+ # Lightweight skeletons (visual only)
562
+ skel_top = st.empty()
563
+ skel_mid = st.empty()
564
+ skel_top.markdown('<div class="skel h120"></div>', unsafe_allow_html=True)
565
+ skel_mid.markdown('<div class="skel h80" style="margin-top:8px"></div>', unsafe_allow_html=True)
566
+
567
+ # Run agent (logic unchanged)
568
+ try:
569
+ events, shift_assignment_results, reasoning_rows = run_agent(employee_ids_default, df_shifts_default)
570
+ except Exception as e:
571
+ skel_top.empty(); skel_mid.empty()
572
+ st.error(f"Unexpected error: {e}")
573
+ return
574
+
575
+ # Clear skeletons
576
+ skel_top.empty(); skel_mid.empty()
577
+
578
+ # Completed status
579
+ status_placeholder.markdown(
580
+ """
 
 
 
 
581
  <div class="status-wrap" role="status" aria-live="polite">
582
  <div class="status-bar status-done">
583
  <span class="pulse-dot"></span>
 
585
  </div>
586
  </div>
587
  """, unsafe_allow_html=True
588
+ )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
589
 
590
+ # Agent Overview
591
+ total_steps = len(events)
592
+ total_ms = sum(int(e.get("dur_ms", 0) or 0) for e in events)
593
+ assigned = sum(1 for e in events if e["title"].startswith("Assigned"))
594
+ notified = sum(1 for e in events if e["title"].startswith("Notify"))
595
+ skipped = sum(1 for e in events if e["title"].startswith("Skipped"))
596
+ st.markdown('<div class="overview">', unsafe_allow_html=True)
597
+ st.markdown(
598
+ f'<span class="chip">🧠 <b>Agent Steps:</b> {total_steps}</span>'
599
+ f'<span class="chip">⏱️ <b>Total Duration:</b> {total_ms} ms</span>'
600
+ f'<span class="chip">✅ <b>Assigned:</b> {assigned}</span>'
601
+ f'<span class="chip">📬 <b>Notify:</b> {notified}</span>'
602
+ f'<span class="chip">⚠️ <b>Skipped:</b> {skipped}</span>',
603
+ unsafe_allow_html=True
604
+ )
605
+ st.markdown('</div>', unsafe_allow_html=True)
606
+
607
+ # --------- WIDE TIMELINE (Grid) -----------
608
+ st.markdown("### 🕒 Agent Runline")
609
+ # Important: use st.markdown with unsafe_allow_html=True (NOT st.write), to avoid escaped tags
610
+ st.markdown(render_runline_wide(events), unsafe_allow_html=True)
611
+
612
+ # KPIs
613
+ st.markdown('<div class="stat-row">', unsafe_allow_html=True)
614
+ st.markdown(
615
+ f'<div class="stat-card"><div class="stat-icon">✅</div><div class="stat-label">Assigned</div><div class="stat-value">{assigned}</div></div>'
616
+ f'<div class="stat-card"><div class="stat-icon">📬</div><div class="stat-label">Notify</div><div class="stat-value">{notified}</div></div>'
617
+ f'<div class="stat-card"><div class="stat-icon">⚠️</div><div class="stat-label">Skipped</div><div class="stat-value">{skipped}</div></div>',
618
+ unsafe_allow_html=True
619
+ )
620
+ st.markdown('</div>', unsafe_allow_html=True)
621
 
622
+ # Results (tables use full container width; horizontal scroll wrapper present on narrow)
623
+ st.markdown("### 📊 Results")
624
+
625
+ with st.expander("Shift Fulfillment Summary", expanded=True):
626
+ if shift_assignment_results:
627
+ st.markdown('<div class="table-wrap">', unsafe_allow_html=True)
628
+ st.dataframe(pd.DataFrame(shift_assignment_results, columns=["Employee", "ShiftID", "Status"]),
629
+ use_container_width=True)
630
+ st.markdown('</div>', unsafe_allow_html=True)
631
+ else:
632
+ st.info("No assignments to display.")
633
+
634
+ with st.expander("Reasoning Behind Selections", expanded=False):
635
+ if reasoning_rows:
636
+ st.markdown('<div class="table-wrap">', unsafe_allow_html=True)
637
+ st.dataframe(pd.DataFrame(reasoning_rows), use_container_width=True)
638
+ st.markdown('</div>', unsafe_allow_html=True)
639
+ else:
640
+ st.info("No reasoning data available.")
641
+
642
+ else:
643
+ # Idle info
644
+ st.markdown(
645
+ """
646
+ <div class="card" dir="auto" style="max-width:1200px;margin:8px auto 0;">
647
  <h4>How it works</h4>
648
  <ol style="margin:6px 0 0 18px;color:#334155">
649
  <li>Fetch employees & certifications from UKG demo API</li>
 
653
  </ol>
654
  </div>
655
  """, unsafe_allow_html=True
656
+ )
657
 
658
+ # Footer
659
  st.markdown(
660
  """
661
  <hr/>
662
+ <div style="text-align:center;color:#64748B;font-size:13px">
663
  © 2025 Health Matrix Corp – Empowering Digital Health Transformation · <a href="mailto:info@healthmatrixcorp.com">info@healthmatrixcorp.com</a>
664
  </div>
665
  """, unsafe_allow_html=True