Dhom1 commited on
Commit
229dcd9
·
verified ·
1 Parent(s): d8534da

Update src/streamlit_app.py

Browse files
Files changed (1) hide show
  1. src/streamlit_app.py +468 -133
src/streamlit_app.py CHANGED
@@ -1,104 +1,365 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  import os
 
2
  import pathlib
3
- import streamlit as st
4
- import pandas as pd
5
  from io import StringIO
6
- import openai
7
 
8
- # استيراد دالة جلب الموظفين من ملف schedule ضمن حزمة ukg
9
- from ukg.schedule import fetch_employees
 
10
 
11
- # إعداد متغيرات البيئة لتفادي مشاكل الكتابة في مجلدات محظورة
12
- os.environ["XDG_CONFIG_HOME"] = "/tmp"
13
- os.environ["HF_HOME"] = "/tmp"
14
- os.environ["TRANSFORMERS_CACHE"] = "/tmp"
 
 
 
 
15
 
16
- # إعداد مفتاح GPT
17
- openai.api_key = os.getenv("OPENAI_API_KEY")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
18
 
19
- # إعداد صفحة Streamlit
20
- st.set_page_config(page_title="Health Matrix AI Command Center", layout="wide")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
21
 
22
- # تحميل ملف CSS من نفس مجلد هذا الملف
23
- current_dir = pathlib.Path(__file__).resolve().parent
24
- css_file = current_dir / "style.css"
25
- if css_file.exists():
26
- with open(css_file, encoding="utf-8") as f:
27
- st.markdown(f"<style>{f.read()}</style>", unsafe_allow_html=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
28
 
29
- # إضافة الخلفية المتحركة
30
- st.markdown(
 
 
 
 
 
 
 
 
 
31
  """
32
- <div class="background">
33
- <div class="circle"></div>
34
- <div class="circle"></div>
35
- <div class="circle"></div>
36
- <div class="circle"></div>
37
- <div class="circle"></div>
38
- <div class="circle"></div>
39
- </div>
40
- """,
41
- unsafe_allow_html=True,
42
- )
 
43
 
44
- # عرض شعار Health Matrix في الزاوية العلوية اليسرى باستخدام الصورة المحلية
45
- logo_path = current_dir / "logo.jpg"
46
- if logo_path.exists():
47
- logo_url = str(logo_path.as_posix())
48
- st.markdown(
49
- f"""
50
- <div style="position:absolute; top:1rem; left:1rem; z-index:2;">
51
- <img src="file://{logo_url}" width="140" alt="Health Matrix Logo"/>
52
- </div>
53
- """,
54
- unsafe_allow_html=True,
 
 
 
 
 
 
 
 
55
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
56
 
57
- # --------------------
58
- # محتوى الصفحة الرئيسية (Landing Page)
59
 
60
- # عنوان وتعريف قصير
61
- st.markdown(
 
62
  """
63
- <div style="margin-top:5rem; text-align:center;">
64
- <h1 style="font-size:3rem; font-weight:600; margin-bottom:0.5rem; color:#36ba01;">Welcome to the AI‑Powered Command Center</h1>
65
- <h2 style="font-size:1.8rem; font-weight:500; margin-top:0; color:#004c97;">by Health Matrix</h2>
66
- <p style="max-width:700px; margin:0 auto; font-size:1.1rem; line-height:1.5; color:#a9bcd4;">
67
- This smart assistant leverages AI to automate decisions, schedule actions, and provide real‑time updates – all while you relax.
68
- </p>
69
- </div>
70
- """,
71
- unsafe_allow_html=True,
72
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
73
 
74
- # يمكن لاحقاً عرض الخط الزمني بعد اكتمال تنفيذ الوكيل
75
 
76
- # تخصيص الأسماء الافتراضية للموظفين والشفتات
77
- employee_ids_default = [850]
78
- shift_data = """
79
- ShiftID,Department,RequiredSkill,RequiredCert,ShiftTime
80
- S101,ICU,ICU,ACLS,2025-06-04 07:00
81
- """
82
- df_shifts_default = pd.read_csv(StringIO(shift_data))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
83
 
84
- def is_eligible(row, shift):
85
- """Check if employee's job role matches shift requirement."""
 
 
 
86
  return row.get("JobRole", "").strip().lower() == shift["RequiredSkill"].strip().lower()
87
 
88
- def gpt_decide(shift, eligible_df):
89
- """Call GPT model to decide assignment or notification.
90
 
91
- The function returns a dict with keys action and employee, defaulting to skip if any error occurs
92
- or the API key isn't configured.
 
 
 
93
  """
94
- # If API key is not set, skip decision automatically
95
- if not openai.api_key:
96
  return {"action": "skip"}
97
- # Build prompt listing eligible employees
98
  emp_list = (
99
  eligible_df[["fullName", "phoneNumber", "organizationPath"]].to_dict(orient="records")
100
- if not eligible_df.empty
101
- else []
102
  )
103
  prompt = f"""
104
  أنت مساعد ذكي مسؤول عن إدارة المناوبات الطبية.
@@ -109,7 +370,7 @@ def gpt_decide(shift, eligible_df):
109
  - وقت الشفت: {shift['ShiftTime']}
110
 
111
  الموظفون المؤهلون بناءً على الوظيفة:
112
- {emp_list if emp_list else "لا يوجد موظفون مؤهلون"}
113
 
114
  اختر أحدهم باستخدام التنسيق التالي:
115
  {{"action": "assign", "employee": "الاسم"}}
@@ -134,8 +395,9 @@ def gpt_decide(shift, eligible_df):
134
  st.error(f"❌ GPT Error: {e}")
135
  return {"action": "skip"}
136
 
137
- def render_timeline(events):
138
- """Return HTML string rendering a list of timeline events."""
 
139
  html = '<div class="timeline">'
140
  for event in events:
141
  icon = event.get("icon", "")
@@ -153,15 +415,22 @@ def render_timeline(events):
153
  html += "</div>"
154
  return html
155
 
156
- def run_agent(employee_ids, df_shifts):
157
- """Execute the AI agent workflow and return events, summary, reasoning."""
158
- events = []
159
- shift_assignment_results = []
160
- reasoning_rows = []
 
 
 
 
 
 
 
 
161
 
162
  # Step 1: Fetch employees
163
  events.append({"icon": "🔍", "title": "Fetching employee data", "desc": "Loading employee information from UKG API..."})
164
- # Attempt to fetch employee data
165
  df_employees = fetch_employees(employee_ids)
166
  if df_employees.empty:
167
  events.append({
@@ -169,20 +438,16 @@ def run_agent(employee_ids, df_shifts):
169
  "title": "No Employee Data",
170
  "desc": "No employees were returned or credentials are invalid."
171
  })
172
- # even if no employees, proceed to next steps with empty DataFrame
173
  else:
174
  events.append({
175
  "icon": "✅",
176
  "title": "Employee Data Loaded",
177
  "desc": f"{len(df_employees)} employee(s) loaded successfully."
178
  })
179
- # timeline update deferred until end
180
 
181
  # Step 2: Evaluate each shift
182
  events.append({"icon": "📋", "title": "Evaluating Shifts", "desc": "Matching employees to shift requirements..."})
183
- # Loop through shifts
184
  for _, shift in df_shifts.iterrows():
185
- # Determine eligible employees based on JobRole
186
  eligible = df_employees[
187
  df_employees.apply(lambda r: is_eligible(r, shift), axis=1)
188
  ] if not df_employees.empty else pd.DataFrame()
@@ -212,60 +477,130 @@ def run_agent(employee_ids, df_shifts):
212
  "desc": "No eligible employees available or decision skipped."
213
  })
214
  shift_assignment_results.append(("❌ No eligible", shift["ShiftID"], "⚠️ Skipped"))
215
- # timeline update deferred until end
216
- # Add reasoning for each employee
217
  for _, emp_row in df_employees.iterrows():
218
  role_match = shift["RequiredSkill"].strip().lower() == emp_row.get("JobRole", "").strip().lower()
219
  cert_ok = True
220
  avail_ok = True
221
  ot_ok = True
222
  status = "✅ Eligible" if all([role_match, cert_ok, avail_ok, ot_ok]) else "❌ Not Eligible"
223
- reasoning_rows.append(
224
- {
225
- "Employee": emp_row.get("fullName", ""),
226
- "ShiftID": shift["ShiftID"],
227
- "Eligible": status,
228
- "Reasoning": " | ".join(
229
- [
230
- "✅ Role Match" if role_match else "❌ Role",
231
- "✅ Cert" if cert_ok else "❌ Cert",
232
- "✅ Avail" if avail_ok else "❌ Avail",
233
- "✅ OT" if ot_ok else "❌ OT",
234
- ]
235
- ),
236
- }
237
- )
238
  events.append({"icon": "📊", "title": "Summary Ready", "desc": "The AI has finished processing the shifts."})
239
  return events, shift_assignment_results, reasoning_rows
240
 
241
- # زر رئيسي لتشغيل الوكيل الذكي موضوع في منتصف الصفحة
242
- col_left, col_center, col_right = st.columns([2, 3, 2])
243
- with col_center:
244
- start_clicked = st.button("▶️ Start AI Agent", key="start_agent", help="Let the AI handle the work")
245
- if start_clicked:
246
- # تنفيذ الوكيل وعرض النتائج على شكل خط زمني
247
- events, shift_assignment_results, reasoning_rows = run_agent(employee_ids_default, df_shifts_default)
248
- # عرض الملخص والشرح بعد الانتهاء
249
- # عرض الخط الزمني بعد اكتمال تشغيل الوكيل
250
- st.subheader("🕒 Timeline of Actions")
251
- st.markdown(render_timeline(events), unsafe_allow_html=True)
252
-
253
- st.subheader("📊 Shift Fulfillment Summary")
254
- if shift_assignment_results:
255
- st.dataframe(pd.DataFrame(shift_assignment_results, columns=["Employee", "ShiftID", "Status"]))
256
- else:
257
- st.write("No assignments to display.")
258
- st.subheader("📋 Reasoning Behind Selections")
259
- if reasoning_rows:
260
- st.dataframe(pd.DataFrame(reasoning_rows))
261
- else:
262
- st.write("No reasoning data available.")
263
 
264
- # تذييل الصفحة
265
- st.markdown(
266
- """
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
267
  ---
268
  © 2025 Health Matrix Corp – Empowering Digital Health Transformation
269
  Contact: [info@healthmatrixcorp.com](mailto:info@healthmatrixcorp.com)
270
- """
271
- )
 
 
 
 
 
 
1
+ """Streamlit application for Health Matrix AI Command Center.
2
+
3
+ This standalone script merges the functionality of three separate files—
4
+ the Streamlit front‑end, custom CSS styling, and UKG API helpers—into a
5
+ single file. It provides a landing page that introduces the AI agent and
6
+ allows users to run scheduling logic with an attractive timeline of actions.
7
+
8
+ The CSS styling is embedded directly within this script to avoid external
9
+ dependencies. Helper functions for interacting with the UKG (Kronos) API are
10
+ included here as well, avoiding the need for a separate `schedule.py` module.
11
+
12
+ If your environment does not provide valid UKG authentication or an
13
+ OpenAI API key, the application will still run—failing gracefully and
14
+ displaying warnings instead of crashing.
15
+
16
+ Usage:
17
+ streamlit run merged_app.py
18
+ """
19
+
20
  import os
21
+ import json
22
  import pathlib
23
+ from typing import List, Optional, Iterable, Dict, Any
 
24
  from io import StringIO
 
25
 
26
+ import pandas as pd
27
+ import requests
28
+ import streamlit as st
29
 
30
+ # Optional: import OpenAI if available. If not installed, the application
31
+ # will still run but GPT decisions will always skip.
32
+ try:
33
+ import openai # type: ignore
34
+ HAS_OPENAI = True
35
+ except ImportError:
36
+ openai = None # type: ignore
37
+ HAS_OPENAI = False
38
 
39
+ # -----------------------------------------------------------------------------
40
+ # Embedded CSS from `style (6).css`
41
+ # The original styling file defined the layout, floating circles, button
42
+ # appearance, and timeline card aesthetics. Embedding it here avoids the
43
+ # overhead of reading an external file and ensures all styles load together.
44
+ _EMBEDDED_CSS = """
45
+ /* === General Layout === */
46
+ html, body {
47
+ margin: 0;
48
+ padding: 0;
49
+ height: 100%;
50
+ /* Use the primary font family suggested by the brief */
51
+ font-family: 'Segoe UI', 'Open Sans', sans-serif;
52
+ /* Dark, elegant backdrop inspired by premium tech sites */
53
+ background: linear-gradient(145deg, #002d5b 0%, #001a33 100%);
54
+ overflow: hidden;
55
+ color: #f5faff;
56
+ }
57
 
58
+ /* === Floating Animated Circles === */
59
+ .background {
60
+ position: fixed;
61
+ inset: 0;
62
+ overflow: hidden;
63
+ z-index: 0;
64
+ }
65
+ @keyframes floatAround {
66
+ 0% { transform: translate(0, 0) scale(1); }
67
+ 25% { transform: translate(50px, -30px) scale(1.2); }
68
+ 50% { transform: translate(-40px, 60px) scale(1.1); }
69
+ 75% { transform: translate(30px, -50px) scale(1.3); }
70
+ 100% { transform: translate(0, 0) scale(1); }
71
+ }
72
+ .circle {
73
+ position: absolute;
74
+ width: 320px;
75
+ height: 320px;
76
+ background: radial-gradient(circle, #004c97, #36ba01);
77
+ border-radius: 50%;
78
+ opacity: 0.12;
79
+ animation: floatAround 12s ease-in-out infinite;
80
+ filter: blur(20px);
81
+ }
82
+ .circle:nth-child(1) { top: 10%; left: 20%; animation-delay: 0s; }
83
+ .circle:nth-child(2) { top: 60%; left: 70%; animation-delay: 4s; }
84
+ .circle:nth-child(3) { top: 80%; left: 30%; animation-delay: 8s; }
85
+ .circle:nth-child(4) { top: 40%; left: 50%; animation-delay: 2s; }
86
+ .circle:nth-child(5) { top: 70%; left: 10%; animation-delay: 6s; }
87
+ .circle:nth-child(6) { top: 20%; left: 80%; animation-delay: 1s; }
88
 
89
+ /* === Override default Streamlit button styling === */
90
+ .stButton > button {
91
+ background: linear-gradient(90deg, #004c97, #36BA01);
92
+ color: white;
93
+ border: none;
94
+ border-radius: 50px;
95
+ padding: 1rem 2rem;
96
+ font-size: 1.2rem;
97
+ font-weight: 600;
98
+ cursor: pointer;
99
+ box-shadow: 0 0 25px rgba(54, 186, 1, 0.4);
100
+ transition: transform 0.3s ease-in-out;
101
+ animation: pulseAI 2.5s infinite;
102
+ }
103
+ .stButton > button:hover { transform: scale(1.05); }
104
+ @keyframes pulseAI {
105
+ 0% { transform: scale(1); }
106
+ 50% { transform: scale(1.03); }
107
+ 100% { transform: scale(1); }
108
+ }
109
+
110
+ /* === Timeline styles === */
111
+ .timeline {
112
+ margin-top: 2rem;
113
+ z-index: 2;
114
+ }
115
+ .timeline-card {
116
+ display: flex;
117
+ align-items: flex-start;
118
+ background: rgba(255, 255, 255, 0.08);
119
+ border-left: 6px solid #36ba01;
120
+ border-radius: 12px;
121
+ padding: 1.2rem;
122
+ margin-bottom: 1rem;
123
+ backdrop-filter: blur(6px);
124
+ box-shadow: 0 6px 12px rgba(0, 0, 0, 0.15);
125
+ }
126
+ .timeline-card .icon {
127
+ font-size: 1.6rem;
128
+ line-height: 1;
129
+ margin-right: 1rem;
130
+ color: #36ba01;
131
+ }
132
+ .timeline-card h4 {
133
+ margin: 0;
134
+ font-size: 1.1rem;
135
+ color: #e5f0fb;
136
+ }
137
+ .timeline-card p {
138
+ margin: 0.3rem 0 0;
139
+ font-size: 0.9rem;
140
+ color: #a9bcd4;
141
+ }
142
+ """
143
 
144
+ # -----------------------------------------------------------------------------
145
+ # UKG API helper functions (merged from schedule (3).py)
146
+
147
+ def _get_auth_header() -> Dict[str, str]:
148
+ """Construct the HTTP headers required for UKG API requests.
149
+
150
+ The UKG API uses both an app key and a bearer token for authorization.
151
+ These values are read from environment variables ``UKG_APP_KEY`` and
152
+ ``UKG_AUTH_TOKEN``. If either variable is missing, a Streamlit warning is
153
+ displayed. The function returns a dictionary of headers for use with
154
+ ``requests``.
155
  """
156
+ app_key = os.environ.get("UKG_APP_KEY")
157
+ token = os.environ.get("UKG_AUTH_TOKEN")
158
+ if not app_key or not token:
159
+ st.warning(
160
+ "UKG authentication variables (UKG_APP_KEY and/or UKG_AUTH_TOKEN) "
161
+ "are not set. API calls may fail."
162
+ )
163
+ return {
164
+ "Content-Type": "application/json",
165
+ "appkey": app_key or "",
166
+ "Authorization": f"Bearer {token}" if token else "",
167
+ }
168
 
169
+
170
+ def fetch_open_shifts(
171
+ start_date: str = "2000-01-01",
172
+ end_date: str = "3000-01-01",
173
+ location_ids: Optional[Iterable[str]] = None,
174
+ ) -> pd.DataFrame:
175
+ """Retrieve open shift instances from the UKG demo API.
176
+
177
+ This helper sends a POST request with the given date range and optional
178
+ location identifiers. If the API returns data, the function constructs
179
+ a DataFrame with selected fields. On failure, an empty DataFrame is
180
+ returned and an error is logged in the Streamlit interface.
181
+ """
182
+ if location_ids is None:
183
+ location_ids = ["2401", "2402", "2953", "2955", "2927", "2928", "2401", "2955"]
184
+
185
+ url = (
186
+ "https://partnerdemo-019.cfn.mykronos.com/api/v1/"
187
+ "scheduling/schedule/multi_read"
188
  )
189
+ headers = _get_auth_header()
190
+ payload = {
191
+ "select": ["OPENSHIFTS"],
192
+ "where": {
193
+ "locations": {
194
+ "dateRange": {
195
+ "startDate": start_date,
196
+ "endDate": end_date,
197
+ },
198
+ "includeEmployeeTransfer": False,
199
+ "locations": {"ids": list(location_ids)},
200
+ }
201
+ },
202
+ }
203
+ try:
204
+ response = requests.post(url, headers=headers, json=payload)
205
+ response.raise_for_status()
206
+ data = response.json()
207
+ open_shifts = data.get("openShifts", [])
208
+ rows: List[Dict[str, Any]] = []
209
+ for shift in open_shifts:
210
+ rows.append(
211
+ {
212
+ "ID": shift.get("id"),
213
+ "Start": shift.get("startDateTime"),
214
+ "End": shift.get("endDateTime"),
215
+ "Label": shift.get("label"),
216
+ "Org Job": shift.get("segments", [{}])[0]
217
+ .get("orgJobRef", {})
218
+ .get("qualifier", "")
219
+ if shift.get("segments")
220
+ else "",
221
+ "Posted": shift.get("posted"),
222
+ "Self Serviced": shift.get("selfServiced"),
223
+ "Locked": shift.get("locked"),
224
+ }
225
+ )
226
+ return pd.DataFrame(rows)
227
+ except Exception as e:
228
+ st.error(f"❌ UKG Open Shifts API call failed: {e}")
229
+ return pd.DataFrame()
230
+
231
 
232
+ def fetch_location_data(date: str = "2025-07-13", query: str = "Medsurg") -> pd.DataFrame:
233
+ """Retrieve location information from the UKG demo API.
234
 
235
+ This helper composes a POST request containing a search query and
236
+ returns location attributes in a DataFrame. If the call fails,
237
+ an empty DataFrame is returned and an error is displayed.
238
  """
239
+ url = (
240
+ "https://partnerdemo-019.cfn.mykronos.com/api/v1/"
241
+ "commons/locations/multi_read"
242
+ )
243
+ headers = _get_auth_header()
244
+ payload = {
245
+ "multiReadOptions": {"includeOrgPathDetails": True},
246
+ "where": {
247
+ "query": {"context": "ORG", "date": date, "q": query}
248
+ },
249
+ }
250
+ try:
251
+ response = requests.post(url, headers=headers, json=payload)
252
+ response.raise_for_status()
253
+ data = response.json()
254
+ rows = []
255
+ for item in data if isinstance(data, list) else data.get("locations", []):
256
+ rows.append(
257
+ {
258
+ "Node ID": item.get("nodeId", ""),
259
+ "Name": item.get("name", ""),
260
+ "Full Name": item.get("fullName", ""),
261
+ "Description": item.get("description", ""),
262
+ "Org Path": item.get("orgPath", ""),
263
+ "Persistent ID": item.get("persistentId", ""),
264
+ }
265
+ )
266
+ return pd.DataFrame(rows)
267
+ except Exception as e:
268
+ st.error(f"❌ UKG Location API call failed: {e}")
269
+ return pd.DataFrame()
270
 
 
271
 
272
+ def fetch_employees(employee_ids: Iterable[int]) -> pd.DataFrame:
273
+ """Fetch employee details from the UKG demo API.
274
+
275
+ For each identifier provided, this function calls the persons endpoint
276
+ and extracts person number, full name, phone number and organizational
277
+ path. It derives a ``JobRole`` from the final segment of the path.
278
+ If no records are found, a DataFrame with expected columns is returned
279
+ so downstream logic does not encounter missing keys.
280
+
281
+ Parameters
282
+ ----------
283
+ employee_ids : Iterable[int]
284
+ Identifiers of employees to retrieve.
285
+
286
+ Returns
287
+ -------
288
+ pandas.DataFrame
289
+ A DataFrame of employee details. Empty if no employees could be
290
+ retrieved.
291
+ """
292
+ base_url = "https://partnerdemo-019.cfn.mykronos.com/api/v1/commons/persons/"
293
+ headers = _get_auth_header()
294
+
295
+ def fetch_employee_data(emp_id: int) -> Optional[Dict[str, Any]]:
296
+ try:
297
+ resp = requests.get(f"{base_url}{emp_id}", headers=headers)
298
+ if resp.status_code == 200:
299
+ data = resp.json()
300
+ person_info = data.get("personInformation", {}).get("person", {})
301
+ person_number = person_info.get("personNumber")
302
+ full_name = person_info.get("fullName", "")
303
+ # Extract organization path from primary labor accounts
304
+ org_path = ""
305
+ primary_accounts = (
306
+ data.get("jobAssignment", {}).get("primaryLaborAccounts", [])
307
+ )
308
+ if primary_accounts:
309
+ org_path = primary_accounts[0].get("organizationPath", "")
310
+ # Extract first phone number if present
311
+ phone = ""
312
+ phones = data.get("personInformation", {}).get("telephoneNumbers", [])
313
+ if phones:
314
+ phone = phones[0].get("phoneNumber", "")
315
+ return {
316
+ "personNumber": person_number,
317
+ "organizationPath": org_path,
318
+ "phoneNumber": phone,
319
+ "fullName": full_name,
320
+ }
321
+ else:
322
+ st.warning(f"⚠️ Could not fetch employee {emp_id}: {resp.status_code}")
323
+ except Exception as e:
324
+ st.error(f"❌ Error fetching employee {emp_id}: {e}")
325
+ return None
326
+
327
+ records: List[Dict[str, Any]] = []
328
+ for emp_id in employee_ids:
329
+ data = fetch_employee_data(emp_id)
330
+ if data:
331
+ records.append(data)
332
+ # Build DataFrame with guaranteed columns
333
+ df_employees = pd.DataFrame(records)
334
+ for col in ["personNumber", "organizationPath", "phoneNumber", "fullName"]:
335
+ if col not in df_employees.columns:
336
+ df_employees[col] = []
337
+ def derive_role(path: Any) -> str:
338
+ if isinstance(path, str) and path:
339
+ return path.split("/")[-1]
340
+ return ""
341
+ df_employees["JobRole"] = df_employees["organizationPath"].apply(derive_role)
342
+ return df_employees
343
 
344
+ # -----------------------------------------------------------------------------
345
+ # GPT decision helper and timeline rendering
346
+
347
+ def is_eligible(row: pd.Series, shift: pd.Series) -> bool:
348
+ """Determine if a given employee record matches the required skill."""
349
  return row.get("JobRole", "").strip().lower() == shift["RequiredSkill"].strip().lower()
350
 
 
 
351
 
352
+ def gpt_decide(shift: pd.Series, eligible_df: pd.DataFrame) -> Dict[str, str]:
353
+ """Invoke OpenAI GPT model to decide assignment or notification.
354
+
355
+ If an API key is not configured or the model invocation fails, the
356
+ function defaults to returning ``{"action": "skip"}``.
357
  """
358
+ if not HAS_OPENAI or openai is None or not os.getenv("OPENAI_API_KEY"):
 
359
  return {"action": "skip"}
 
360
  emp_list = (
361
  eligible_df[["fullName", "phoneNumber", "organizationPath"]].to_dict(orient="records")
362
+ if not eligible_df.empty else []
 
363
  )
364
  prompt = f"""
365
  أنت مساعد ذكي مسؤول عن إدارة المناوبات الطبية.
 
370
  - وقت الشفت: {shift['ShiftTime']}
371
 
372
  الموظفون المؤهلون بناءً على الوظيفة:
373
+ {emp_list if emp_list else 'لا يوجد موظفون مؤهلون'}
374
 
375
  اختر أحدهم باستخدام التنسيق التالي:
376
  {{"action": "assign", "employee": "الاسم"}}
 
395
  st.error(f"❌ GPT Error: {e}")
396
  return {"action": "skip"}
397
 
398
+
399
+ def render_timeline(events: List[Dict[str, str]]) -> str:
400
+ """Convert a list of events into an HTML timeline."""
401
  html = '<div class="timeline">'
402
  for event in events:
403
  icon = event.get("icon", "")
 
415
  html += "</div>"
416
  return html
417
 
418
+
419
+ def run_agent(employee_ids: Iterable[int], df_shifts: pd.DataFrame):
420
+ """Execute the AI agent workflow and produce a timeline.
421
+
422
+ Steps:
423
+ 1. Fetch employees from UKG API.
424
+ 2. Evaluate each shift and determine assignments via GPT or simple logic.
425
+ 3. Generate reasoning rows for display.
426
+ Returns a tuple (events, shift_assignment_results, reasoning_rows).
427
+ """
428
+ events: List[Dict[str, str]] = []
429
+ shift_assignment_results: List[tuple] = []
430
+ reasoning_rows: List[Dict[str, Any]] = []
431
 
432
  # Step 1: Fetch employees
433
  events.append({"icon": "🔍", "title": "Fetching employee data", "desc": "Loading employee information from UKG API..."})
 
434
  df_employees = fetch_employees(employee_ids)
435
  if df_employees.empty:
436
  events.append({
 
438
  "title": "No Employee Data",
439
  "desc": "No employees were returned or credentials are invalid."
440
  })
 
441
  else:
442
  events.append({
443
  "icon": "✅",
444
  "title": "Employee Data Loaded",
445
  "desc": f"{len(df_employees)} employee(s) loaded successfully."
446
  })
 
447
 
448
  # Step 2: Evaluate each shift
449
  events.append({"icon": "📋", "title": "Evaluating Shifts", "desc": "Matching employees to shift requirements..."})
 
450
  for _, shift in df_shifts.iterrows():
 
451
  eligible = df_employees[
452
  df_employees.apply(lambda r: is_eligible(r, shift), axis=1)
453
  ] if not df_employees.empty else pd.DataFrame()
 
477
  "desc": "No eligible employees available or decision skipped."
478
  })
479
  shift_assignment_results.append(("❌ No eligible", shift["ShiftID"], "⚠️ Skipped"))
480
+ # Collect reasoning rows for each employee
 
481
  for _, emp_row in df_employees.iterrows():
482
  role_match = shift["RequiredSkill"].strip().lower() == emp_row.get("JobRole", "").strip().lower()
483
  cert_ok = True
484
  avail_ok = True
485
  ot_ok = True
486
  status = "✅ Eligible" if all([role_match, cert_ok, avail_ok, ot_ok]) else "❌ Not Eligible"
487
+ reasoning_rows.append({
488
+ "Employee": emp_row.get("fullName", ""),
489
+ "ShiftID": shift["ShiftID"],
490
+ "Eligible": status,
491
+ "Reasoning": " | ".join([
492
+ " Role Match" if role_match else "❌ Role",
493
+ "✅ Cert" if cert_ok else "❌ Cert",
494
+ "✅ Avail" if avail_ok else "❌ Avail",
495
+ "✅ OT" if ot_ok else "❌ OT",
496
+ ]),
497
+ })
 
 
 
 
498
  events.append({"icon": "📊", "title": "Summary Ready", "desc": "The AI has finished processing the shifts."})
499
  return events, shift_assignment_results, reasoning_rows
500
 
501
+ # -----------------------------------------------------------------------------
502
+ # Streamlit UI logic
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
503
 
504
+ def main() -> None:
505
+ """Entry point for the Streamlit application."""
506
+ # Configure page
507
+ st.set_page_config(page_title="Health Matrix AI Command Center", layout="wide")
508
+
509
+ # Set environment variables to avoid config warnings
510
+ os.environ.setdefault("XDG_CONFIG_HOME", "/tmp")
511
+ os.environ.setdefault("HF_HOME", "/tmp")
512
+ os.environ.setdefault("TRANSFORMERS_CACHE", "/tmp")
513
+
514
+ # Apply CSS styling
515
+ st.markdown(f"<style>{_EMBEDDED_CSS}</style>", unsafe_allow_html=True)
516
+
517
+ # Draw animated background circles
518
+ st.markdown(
519
+ """
520
+ <div class="background">
521
+ <div class="circle"></div>
522
+ <div class="circle"></div>
523
+ <div class="circle"></div>
524
+ <div class="circle"></div>
525
+ <div class="circle"></div>
526
+ <div class="circle"></div>
527
+ </div>
528
+ """,
529
+ unsafe_allow_html=True,
530
+ )
531
+
532
+ # Display the logo if available
533
+ current_dir = pathlib.Path(__file__).resolve().parent if 'pathlib' in globals() else None
534
+ # Fallback: attempt to load logo from the working directory
535
+ logo_candidates = [
536
+ os.path.join(os.getcwd(), "logo.jpg"),
537
+ os.path.join(os.path.dirname(__file__) if '__file__' in globals() else '.', "logo.jpg"),
538
+ ]
539
+ logo_url = None
540
+ for candidate in logo_candidates:
541
+ if os.path.exists(candidate):
542
+ logo_url = candidate
543
+ break
544
+ if logo_url:
545
+ st.markdown(
546
+ f"""
547
+ <div style="position:absolute; top:1rem; left:1rem; z-index:2;">
548
+ <img src="file://{logo_url}" width="140" alt="Health Matrix Logo"/>
549
+ </div>
550
+ """,
551
+ unsafe_allow_html=True,
552
+ )
553
+
554
+ # Intro header and subtitle
555
+ st.markdown(
556
+ """
557
+ <div style="margin-top:5rem; text-align:center;">
558
+ <h1 style="font-size:3rem; font-weight:600; margin-bottom:0.5rem; color:#36ba01;">Welcome to the AI‑Powered Command Center</h1>
559
+ <h2 style="font-size:1.8rem; font-weight:500; margin-top:0; color:#004c97;">by Health Matrix</h2>
560
+ <p style="max-width:700px; margin:0 auto; font-size:1.1rem; line-height:1.5; color:#a9bcd4;">
561
+ This smart assistant leverages AI to automate decisions, schedule actions, and provide real‑time updates – all while you relax.
562
+ </p>
563
+ </div>
564
+ """,
565
+ unsafe_allow_html=True,
566
+ )
567
+
568
+ # Default data for demonstration
569
+ employee_ids_default = [850]
570
+ shift_data = """ShiftID,Department,RequiredSkill,RequiredCert,ShiftTime\nS101,ICU,ICU,ACLS,2025-06-04 07:00"""
571
+ df_shifts_default = pd.read_csv(StringIO(shift_data))
572
+
573
+ # Center the AI button
574
+ col_left, col_center, col_right = st.columns([2, 3, 2])
575
+ with col_center:
576
+ if st.button("▶️ Start AI Agent", key="start_agent", help="Let the AI handle the work"):
577
+ events, shift_assignment_results, reasoning_rows = run_agent(employee_ids_default, df_shifts_default)
578
+ # Display timeline after running the agent
579
+ st.subheader("🕒 Timeline of Actions")
580
+ st.markdown(render_timeline(events), unsafe_allow_html=True)
581
+ # Show summary table
582
+ st.subheader("📊 Shift Fulfillment Summary")
583
+ if shift_assignment_results:
584
+ st.dataframe(pd.DataFrame(shift_assignment_results, columns=["Employee", "ShiftID", "Status"]))
585
+ else:
586
+ st.write("No assignments to display.")
587
+ # Show reasoning table
588
+ st.subheader("📋 Reasoning Behind Selections")
589
+ if reasoning_rows:
590
+ st.dataframe(pd.DataFrame(reasoning_rows))
591
+ else:
592
+ st.write("No reasoning data available.")
593
+
594
+ # Footer
595
+ st.markdown(
596
+ """
597
  ---
598
  © 2025 Health Matrix Corp – Empowering Digital Health Transformation
599
  Contact: [info@healthmatrixcorp.com](mailto:info@healthmatrixcorp.com)
600
+ """,
601
+ unsafe_allow_html=True,
602
+ )
603
+
604
+ # Execute when run as a script
605
+ if __name__ == "__main__":
606
+ main()