Spaces:
Sleeping
Sleeping
Update src/streamlit_app.py
Browse files- 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 |
-
-
|
|
|
|
| 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
|
| 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 |
-
|
| 264 |
-
|
| 265 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 273 |
-
|
|
|
|
|
|
|
|
|
|
| 274 |
emp_list.append({
|
| 275 |
-
"fullName": r
|
| 276 |
-
"
|
| 277 |
-
"
|
|
|
|
|
|
|
| 278 |
})
|
| 279 |
prompt = f"""
|
| 280 |
أنت مساعد ذكي للمناوبات:
|
| 281 |
-
تفاصيل الشفت:
|
| 282 |
-
|
| 283 |
-
ا
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 284 |
{{"action":"assign","employee":"الاسم"}} أو {{"action":"notify","employee":"الاسم"}} أو {{"action":"skip"}}
|
| 285 |
-
|
| 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 |
-
#
|
| 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,
|
| 317 |
shift_assignment_results: List[tuple] = []
|
| 318 |
reasoning_rows: List[Dict[str, Any]] = []
|
| 319 |
|
| 320 |
-
# 1)
|
| 321 |
-
|
| 322 |
df_employees = fetch_employees(employee_ids)
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
|
| 326 |
-
events.append({"icon":"✅","title":"Employee Data Loaded","desc":f"{len(df_employees)} employee(s) loaded successfully."})
|
| 327 |
|
| 328 |
# 2) Evaluate shifts
|
| 329 |
-
|
|
|
|
|
|
|
| 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 |
-
|
| 333 |
-
|
|
|
|
|
|
|
| 334 |
decision = gpt_decide(shift, eligible)
|
|
|
|
|
|
|
|
|
|
| 335 |
if decision.get("action") == "assign":
|
| 336 |
emp = decision.get("employee")
|
| 337 |
-
events.append({"
|
| 338 |
shift_assignment_results.append((emp, shift["ShiftID"], "✅ Auto-Filled"))
|
| 339 |
elif decision.get("action") == "notify":
|
| 340 |
emp = decision.get("employee")
|
| 341 |
-
events.append({"
|
| 342 |
shift_assignment_results.append((emp, shift["ShiftID"], "📨 Notify"))
|
| 343 |
else:
|
| 344 |
-
events.append({"
|
| 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
|
| 350 |
-
|
| 351 |
-
|
| 352 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 353 |
reasoning_rows.append({
|
| 354 |
"Employee": emp_row.get("fullName",""),
|
| 355 |
"ShiftID": shift["ShiftID"],
|
| 356 |
"Eligible": status,
|
| 357 |
"Reasoning": " | ".join([
|
| 358 |
-
"✅ Role
|
| 359 |
-
"✅
|
| 360 |
-
"✅
|
| 361 |
-
"✅
|
|
|
|
| 362 |
]),
|
| 363 |
"Certifications": ", ".join(emp_row.get("Certifications", []))
|
| 364 |
})
|
| 365 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 (
|
| 401 |
employee_ids_default = [850, 825]
|
| 402 |
-
shift_data = "ShiftID,Department,
|
|
|
|
| 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 |
-
# ---
|
| 437 |
-
st.markdown("### 🕒
|
| 438 |
-
|
| 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
|
| 474 |
<li>Use GPT (if key set) to assign/notify or skip</li>
|
| 475 |
-
<li>Show
|
| 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
|