Spaces:
Sleeping
Sleeping
Create app.py
Browse files
app.py
ADDED
|
@@ -0,0 +1,567 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import json
|
| 3 |
+
import random
|
| 4 |
+
from datetime import datetime, timedelta
|
| 5 |
+
from typing import Dict, Any, List, Tuple
|
| 6 |
+
|
| 7 |
+
import gradio as gr
|
| 8 |
+
import pandas as pd
|
| 9 |
+
|
| 10 |
+
# ============================
|
| 11 |
+
# Branding
|
| 12 |
+
# ============================
|
| 13 |
+
PROCELEVATE_BLUE = "#0F2C59"
|
| 14 |
+
|
| 15 |
+
CUSTOM_CSS = f"""
|
| 16 |
+
/* Primary buttons */
|
| 17 |
+
.gr-button.gr-button-primary,
|
| 18 |
+
button.primary {{
|
| 19 |
+
background: {PROCELEVATE_BLUE} !important;
|
| 20 |
+
border-color: {PROCELEVATE_BLUE} !important;
|
| 21 |
+
color: white !important;
|
| 22 |
+
font-weight: 650 !important;
|
| 23 |
+
}}
|
| 24 |
+
.gr-button.gr-button-primary:hover,
|
| 25 |
+
button.primary:hover {{
|
| 26 |
+
filter: brightness(0.92);
|
| 27 |
+
}}
|
| 28 |
+
|
| 29 |
+
/* Tabs selected */
|
| 30 |
+
button[data-testid="tab-button"][aria-selected="true"] {{
|
| 31 |
+
border-bottom: 3px solid {PROCELEVATE_BLUE} !important;
|
| 32 |
+
color: {PROCELEVATE_BLUE} !important;
|
| 33 |
+
font-weight: 750 !important;
|
| 34 |
+
}}
|
| 35 |
+
|
| 36 |
+
/* Subtle modern rounding */
|
| 37 |
+
.block, .gr-box, .gr-panel {{
|
| 38 |
+
border-radius: 14px !important;
|
| 39 |
+
}}
|
| 40 |
+
"""
|
| 41 |
+
|
| 42 |
+
# ============================
|
| 43 |
+
# Settings / Paths
|
| 44 |
+
# ============================
|
| 45 |
+
DATA_DIR = "data"
|
| 46 |
+
OPS_FILE = os.path.join(DATA_DIR, "ops_events.json")
|
| 47 |
+
|
| 48 |
+
ADMIN_PIN = os.environ.get("ADMIN_PIN", "2580") # demo PIN
|
| 49 |
+
|
| 50 |
+
# ============================
|
| 51 |
+
# Demo: generate operational events
|
| 52 |
+
# ============================
|
| 53 |
+
DEPARTMENTS = ["Front Office", "Housekeeping", "F&B", "Maintenance", "Security"]
|
| 54 |
+
EVENT_TYPES = [
|
| 55 |
+
"Check-in delay",
|
| 56 |
+
"Self check-in success",
|
| 57 |
+
"Concierge question",
|
| 58 |
+
"Room service order",
|
| 59 |
+
"Housekeeping request",
|
| 60 |
+
"Towel request",
|
| 61 |
+
"Maintenance issue",
|
| 62 |
+
"Noise complaint",
|
| 63 |
+
"Wi-Fi complaint",
|
| 64 |
+
"Late checkout request",
|
| 65 |
+
"Breakfast query",
|
| 66 |
+
"Dinner menu query",
|
| 67 |
+
]
|
| 68 |
+
SENTIMENTS = ["Positive", "Neutral", "Negative"]
|
| 69 |
+
|
| 70 |
+
def ensure_data_dir():
|
| 71 |
+
os.makedirs(DATA_DIR, exist_ok=True)
|
| 72 |
+
|
| 73 |
+
def load_events() -> List[Dict[str, Any]]:
|
| 74 |
+
ensure_data_dir()
|
| 75 |
+
if not os.path.exists(OPS_FILE):
|
| 76 |
+
return []
|
| 77 |
+
try:
|
| 78 |
+
with open(OPS_FILE, "r", encoding="utf-8") as f:
|
| 79 |
+
data = json.load(f)
|
| 80 |
+
return data if isinstance(data, list) else []
|
| 81 |
+
except Exception:
|
| 82 |
+
return []
|
| 83 |
+
|
| 84 |
+
def save_events(events: List[Dict[str, Any]]):
|
| 85 |
+
ensure_data_dir()
|
| 86 |
+
with open(OPS_FILE, "w", encoding="utf-8") as f:
|
| 87 |
+
json.dump(events, f, ensure_ascii=False, indent=2)
|
| 88 |
+
|
| 89 |
+
def dt_now_str():
|
| 90 |
+
return datetime.now().strftime("%Y-%m-%d %H:%M")
|
| 91 |
+
|
| 92 |
+
def date_str(d: datetime):
|
| 93 |
+
return d.strftime("%Y-%m-%d")
|
| 94 |
+
|
| 95 |
+
def simulate_events(days: int = 7, seed: int = 42) -> List[Dict[str, Any]]:
|
| 96 |
+
random.seed(seed)
|
| 97 |
+
base = datetime.now().date()
|
| 98 |
+
|
| 99 |
+
events = []
|
| 100 |
+
for i in range(days):
|
| 101 |
+
d = base - timedelta(days=(days - 1 - i))
|
| 102 |
+
# Vary volumes by day (simulate peaks)
|
| 103 |
+
base_volume = random.randint(80, 140)
|
| 104 |
+
peak_multiplier = 1.15 if d.weekday() in [4, 5] else 1.0 # Fri/Sat peaks
|
| 105 |
+
volume = int(base_volume * peak_multiplier)
|
| 106 |
+
|
| 107 |
+
for _ in range(volume):
|
| 108 |
+
evt_type = random.choices(
|
| 109 |
+
EVENT_TYPES,
|
| 110 |
+
weights=[7, 10, 18, 7, 14, 12, 7, 5, 5, 5, 6, 8],
|
| 111 |
+
k=1
|
| 112 |
+
)[0]
|
| 113 |
+
|
| 114 |
+
dept = "Front Office"
|
| 115 |
+
if evt_type in ["Housekeeping request", "Towel request"]:
|
| 116 |
+
dept = "Housekeeping"
|
| 117 |
+
elif evt_type in ["Dinner menu query", "Breakfast query", "Room service order"]:
|
| 118 |
+
dept = "F&B"
|
| 119 |
+
elif evt_type in ["Maintenance issue", "Wi-Fi complaint"]:
|
| 120 |
+
dept = "Maintenance"
|
| 121 |
+
elif evt_type in ["Noise complaint"]:
|
| 122 |
+
dept = "Security"
|
| 123 |
+
|
| 124 |
+
sentiment = random.choices(SENTIMENTS, weights=[35, 45, 20], k=1)[0]
|
| 125 |
+
if evt_type in ["Noise complaint", "Wi-Fi complaint", "Maintenance issue", "Check-in delay"]:
|
| 126 |
+
sentiment = random.choices(SENTIMENTS, weights=[10, 35, 55], k=1)[0]
|
| 127 |
+
if evt_type in ["Self check-in success"]:
|
| 128 |
+
sentiment = random.choices(SENTIMENTS, weights=[70, 25, 5], k=1)[0]
|
| 129 |
+
|
| 130 |
+
# Simulated timestamps (spread within day)
|
| 131 |
+
hour = random.randint(6, 23)
|
| 132 |
+
minute = random.randint(0, 59)
|
| 133 |
+
ts = datetime(d.year, d.month, d.day, hour, minute)
|
| 134 |
+
|
| 135 |
+
# Extra attributes for some events
|
| 136 |
+
wait_mins = None
|
| 137 |
+
if evt_type == "Check-in delay":
|
| 138 |
+
wait_mins = random.randint(8, 25)
|
| 139 |
+
|
| 140 |
+
req_priority = None
|
| 141 |
+
if evt_type in ["Maintenance issue", "Noise complaint"]:
|
| 142 |
+
req_priority = random.choices(["Normal", "Urgent"], weights=[70, 30], k=1)[0]
|
| 143 |
+
|
| 144 |
+
events.append({
|
| 145 |
+
"timestamp": ts.strftime("%Y-%m-%d %H:%M"),
|
| 146 |
+
"date": ts.strftime("%Y-%m-%d"),
|
| 147 |
+
"department": dept,
|
| 148 |
+
"event_type": evt_type,
|
| 149 |
+
"sentiment": sentiment,
|
| 150 |
+
"wait_mins": wait_mins,
|
| 151 |
+
"priority": req_priority,
|
| 152 |
+
"channel": random.choice(["Front Desk", "Phone", "WhatsApp", "Web/App", "Concierge Agent"]),
|
| 153 |
+
})
|
| 154 |
+
|
| 155 |
+
return events
|
| 156 |
+
|
| 157 |
+
# ============================
|
| 158 |
+
# Analytics + Pulse generation
|
| 159 |
+
# ============================
|
| 160 |
+
def events_to_df(events: List[Dict[str, Any]]) -> pd.DataFrame:
|
| 161 |
+
if not events:
|
| 162 |
+
return pd.DataFrame(columns=["timestamp", "date", "department", "event_type", "sentiment", "wait_mins", "priority", "channel"])
|
| 163 |
+
df = pd.DataFrame(events)
|
| 164 |
+
return df
|
| 165 |
+
|
| 166 |
+
def compute_kpis(df: pd.DataFrame, target_date: str) -> Dict[str, Any]:
|
| 167 |
+
if df.empty:
|
| 168 |
+
return {
|
| 169 |
+
"target_date": target_date,
|
| 170 |
+
"total_events": 0,
|
| 171 |
+
"neg_sentiment_rate": 0.0,
|
| 172 |
+
"self_checkin_success": 0,
|
| 173 |
+
"checkin_delays": 0,
|
| 174 |
+
"avg_delay_mins": None,
|
| 175 |
+
"hk_requests": 0,
|
| 176 |
+
"wifi_complaints": 0,
|
| 177 |
+
"maintenance_issues": 0,
|
| 178 |
+
"dinner_queries": 0,
|
| 179 |
+
}
|
| 180 |
+
|
| 181 |
+
ddf = df[df["date"] == target_date].copy()
|
| 182 |
+
if ddf.empty:
|
| 183 |
+
return {
|
| 184 |
+
"target_date": target_date,
|
| 185 |
+
"total_events": 0,
|
| 186 |
+
"neg_sentiment_rate": 0.0,
|
| 187 |
+
"self_checkin_success": 0,
|
| 188 |
+
"checkin_delays": 0,
|
| 189 |
+
"avg_delay_mins": None,
|
| 190 |
+
"hk_requests": 0,
|
| 191 |
+
"wifi_complaints": 0,
|
| 192 |
+
"maintenance_issues": 0,
|
| 193 |
+
"dinner_queries": 0,
|
| 194 |
+
}
|
| 195 |
+
|
| 196 |
+
total = len(ddf)
|
| 197 |
+
neg_rate = (ddf["sentiment"].eq("Negative").sum() / total) if total else 0.0
|
| 198 |
+
|
| 199 |
+
delays = ddf[ddf["event_type"] == "Check-in delay"]
|
| 200 |
+
avg_delay = None
|
| 201 |
+
if not delays.empty and delays["wait_mins"].notna().any():
|
| 202 |
+
avg_delay = float(delays["wait_mins"].dropna().mean())
|
| 203 |
+
|
| 204 |
+
return {
|
| 205 |
+
"target_date": target_date,
|
| 206 |
+
"total_events": int(total),
|
| 207 |
+
"neg_sentiment_rate": float(neg_rate),
|
| 208 |
+
"self_checkin_success": int((ddf["event_type"] == "Self check-in success").sum()),
|
| 209 |
+
"checkin_delays": int((ddf["event_type"] == "Check-in delay").sum()),
|
| 210 |
+
"avg_delay_mins": avg_delay,
|
| 211 |
+
"hk_requests": int(ddf["event_type"].isin(["Housekeeping request", "Towel request"]).sum()),
|
| 212 |
+
"wifi_complaints": int((ddf["event_type"] == "Wi-Fi complaint").sum()),
|
| 213 |
+
"maintenance_issues": int((ddf["event_type"] == "Maintenance issue").sum()),
|
| 214 |
+
"dinner_queries": int((ddf["event_type"] == "Dinner menu query").sum()),
|
| 215 |
+
}
|
| 216 |
+
|
| 217 |
+
def compare_to_prev_day(df: pd.DataFrame, target_date: str) -> Dict[str, Any]:
|
| 218 |
+
t = datetime.strptime(target_date, "%Y-%m-%d").date()
|
| 219 |
+
prev = t - timedelta(days=1)
|
| 220 |
+
prev_date = prev.strftime("%Y-%m-%d")
|
| 221 |
+
|
| 222 |
+
k_today = compute_kpis(df, target_date)
|
| 223 |
+
k_prev = compute_kpis(df, prev_date)
|
| 224 |
+
|
| 225 |
+
def delta(a, b):
|
| 226 |
+
if a is None or b is None:
|
| 227 |
+
return None
|
| 228 |
+
return a - b
|
| 229 |
+
|
| 230 |
+
return {
|
| 231 |
+
"prev_date": prev_date,
|
| 232 |
+
"today": k_today,
|
| 233 |
+
"prev": k_prev,
|
| 234 |
+
"delta_total_events": delta(k_today["total_events"], k_prev["total_events"]),
|
| 235 |
+
"delta_neg_rate_pp": delta(k_today["neg_sentiment_rate"]*100, k_prev["neg_sentiment_rate"]*100),
|
| 236 |
+
"delta_checkin_delays": delta(k_today["checkin_delays"], k_prev["checkin_delays"]),
|
| 237 |
+
"delta_hk_requests": delta(k_today["hk_requests"], k_prev["hk_requests"]),
|
| 238 |
+
"delta_maintenance": delta(k_today["maintenance_issues"], k_prev["maintenance_issues"]),
|
| 239 |
+
"delta_wifi": delta(k_today["wifi_complaints"], k_prev["wifi_complaints"]),
|
| 240 |
+
"delta_dinner_queries": delta(k_today["dinner_queries"], k_prev["dinner_queries"]),
|
| 241 |
+
}
|
| 242 |
+
|
| 243 |
+
def build_alerts_and_actions(k: Dict[str, Any], comp: Dict[str, Any]) -> Tuple[pd.DataFrame, List[str], List[str]]:
|
| 244 |
+
alerts = []
|
| 245 |
+
actions = []
|
| 246 |
+
positives = []
|
| 247 |
+
|
| 248 |
+
# Thresholds (demo defaults)
|
| 249 |
+
neg_rate = k["neg_sentiment_rate"]
|
| 250 |
+
delays = k["checkin_delays"]
|
| 251 |
+
avg_delay = k["avg_delay_mins"]
|
| 252 |
+
hk = k["hk_requests"]
|
| 253 |
+
wifi = k["wifi_complaints"]
|
| 254 |
+
maint = k["maintenance_issues"]
|
| 255 |
+
dinner = k["dinner_queries"]
|
| 256 |
+
|
| 257 |
+
# Alerts
|
| 258 |
+
if neg_rate >= 0.30:
|
| 259 |
+
alerts.append(("RED", "Guest dissatisfaction spike", f"Negative sentiment rate is {neg_rate*100:.0f}% today."))
|
| 260 |
+
actions.append("GM to review top complaints today; run 10-min standup with FO/HK/F&B leads.")
|
| 261 |
+
elif neg_rate >= 0.22:
|
| 262 |
+
alerts.append(("AMBER", "Guest dissatisfaction rising", f"Negative sentiment rate is {neg_rate*100:.0f}% today."))
|
| 263 |
+
actions.append("Supervisor to spot-check service recovery for negative interactions.")
|
| 264 |
+
|
| 265 |
+
if delays >= 8:
|
| 266 |
+
details = f"{delays} check-in delay events today."
|
| 267 |
+
if avg_delay is not None:
|
| 268 |
+
details += f" Avg delay ~{avg_delay:.0f} mins."
|
| 269 |
+
alerts.append(("RED", "Front desk check-in delays", details))
|
| 270 |
+
actions.append("Add 1 staff during peak arrival window; use express/self-check flow for pre-arrivals.")
|
| 271 |
+
elif delays >= 4:
|
| 272 |
+
alerts.append(("AMBER", "Check-in delays observed", f"{delays} check-in delay events today."))
|
| 273 |
+
actions.append("Review arrival peaks; pre-assign rooms for early arrivals.")
|
| 274 |
+
|
| 275 |
+
if hk >= 25:
|
| 276 |
+
alerts.append(("AMBER", "High housekeeping load", f"{hk} housekeeping-related requests today."))
|
| 277 |
+
actions.append("Temporarily re-balance HK routes; pre-stage linens/towels for speed.")
|
| 278 |
+
if wifi >= 6:
|
| 279 |
+
alerts.append(("AMBER", "Wi-Fi issues", f"{wifi} Wi-Fi complaints today."))
|
| 280 |
+
actions.append("Check AP health in hotspot floors; proactive message with Wi-Fi steps to guests.")
|
| 281 |
+
if maint >= 6:
|
| 282 |
+
alerts.append(("AMBER", "Maintenance load high", f"{maint} maintenance issues today."))
|
| 283 |
+
actions.append("Prioritize urgent issues; schedule preventive checks during low occupancy hours.")
|
| 284 |
+
|
| 285 |
+
# Trend alerts vs previous day
|
| 286 |
+
if comp and comp.get("delta_checkin_delays") is not None and comp["delta_checkin_delays"] >= 4:
|
| 287 |
+
alerts.append(("AMBER", "Delays increased vs yesterday", f"Check-in delays up by {comp['delta_checkin_delays']} vs {comp['prev_date']}."))
|
| 288 |
+
if comp and comp.get("delta_hk_requests") is not None and comp["delta_hk_requests"] >= 8:
|
| 289 |
+
alerts.append(("AMBER", "HK requests increased vs yesterday", f"HK-related requests up by {comp['delta_hk_requests']} vs {comp['prev_date']}."))
|
| 290 |
+
|
| 291 |
+
# Positives
|
| 292 |
+
if k["self_checkin_success"] >= 15:
|
| 293 |
+
positives.append(f"Self check-in adoption is strong ({k['self_checkin_success']} successful self check-ins).")
|
| 294 |
+
if delays <= 2 and k["total_events"] > 0:
|
| 295 |
+
positives.append("Front desk flow looks stable today (low check-in delays).")
|
| 296 |
+
if maint == 0 and k["total_events"] > 0:
|
| 297 |
+
positives.append("No maintenance issues recorded today.")
|
| 298 |
+
if neg_rate <= 0.15 and k["total_events"] > 0:
|
| 299 |
+
positives.append("Guest sentiment is healthy today (low negative rate).")
|
| 300 |
+
|
| 301 |
+
# If no alerts, add a default positive note
|
| 302 |
+
if not alerts and k["total_events"] > 0:
|
| 303 |
+
positives.append("No major operational risks detected. Continue monitoring peak windows.")
|
| 304 |
+
|
| 305 |
+
alerts_df = pd.DataFrame(alerts, columns=["Severity", "Category", "Detail"]) if alerts else pd.DataFrame(columns=["Severity", "Category", "Detail"])
|
| 306 |
+
return alerts_df, actions, positives
|
| 307 |
+
|
| 308 |
+
def generate_pulse_text(k: Dict[str, Any], comp: Dict[str, Any], alerts_df: pd.DataFrame, actions: List[str], positives: List[str]) -> str:
|
| 309 |
+
td = k["target_date"]
|
| 310 |
+
prev = comp.get("prev_date") if comp else None
|
| 311 |
+
|
| 312 |
+
# Small deltas summary
|
| 313 |
+
delta_bits = []
|
| 314 |
+
if comp:
|
| 315 |
+
if comp.get("delta_total_events") is not None:
|
| 316 |
+
delta_bits.append(f"Total activity {'+' if comp['delta_total_events']>=0 else ''}{comp['delta_total_events']} vs {prev}")
|
| 317 |
+
if comp.get("delta_neg_rate_pp") is not None:
|
| 318 |
+
delta_bits.append(f"Neg. sentiment {'+' if comp['delta_neg_rate_pp']>=0 else ''}{comp['delta_neg_rate_pp']:.0f} pp vs {prev}")
|
| 319 |
+
if comp.get("delta_checkin_delays") is not None:
|
| 320 |
+
delta_bits.append(f"Check-in delays {'+' if comp['delta_checkin_delays']>=0 else ''}{comp['delta_checkin_delays']} vs {prev}")
|
| 321 |
+
|
| 322 |
+
delta_line = " | ".join(delta_bits) if delta_bits else "Trend comparison not available."
|
| 323 |
+
|
| 324 |
+
# Compose narrative
|
| 325 |
+
top_alerts = ""
|
| 326 |
+
if not alerts_df.empty:
|
| 327 |
+
# show up to 3 alerts in text
|
| 328 |
+
top = alerts_df.head(3).to_dict(orient="records")
|
| 329 |
+
lines = []
|
| 330 |
+
for a in top:
|
| 331 |
+
icon = "🔴" if a["Severity"] == "RED" else "🟠"
|
| 332 |
+
lines.append(f"{icon} **{a['Category']}** — {a['Detail']}")
|
| 333 |
+
top_alerts = "\n".join(lines)
|
| 334 |
+
else:
|
| 335 |
+
top_alerts = "🟢 No major operational risks detected."
|
| 336 |
+
|
| 337 |
+
# Actions list (up to 4)
|
| 338 |
+
action_lines = "\n".join([f"✅ {a}" for a in actions[:4]]) if actions else "✅ Maintain current staffing and monitor peaks."
|
| 339 |
+
|
| 340 |
+
# Positives (up to 3)
|
| 341 |
+
pos_lines = "\n".join([f"🟢 {p}" for p in positives[:3]]) if positives else "🟢 Stable day expected."
|
| 342 |
+
|
| 343 |
+
avg_delay_str = f"{k['avg_delay_mins']:.0f} mins" if k["avg_delay_mins"] is not None else "N/A"
|
| 344 |
+
|
| 345 |
+
pulse = f"""
|
| 346 |
+
## 📊 Hotel Operations Pulse — {td}
|
| 347 |
+
|
| 348 |
+
**Snapshot**
|
| 349 |
+
- Total operational signals captured: **{k['total_events']}**
|
| 350 |
+
- Negative sentiment rate: **{k['neg_sentiment_rate']*100:.0f}%**
|
| 351 |
+
- Check-in delays: **{k['checkin_delays']}** (avg delay: **{avg_delay_str}**)
|
| 352 |
+
- Housekeeping-related requests: **{k['hk_requests']}**
|
| 353 |
+
- Maintenance issues: **{k['maintenance_issues']}**
|
| 354 |
+
- Wi-Fi complaints: **{k['wifi_complaints']}**
|
| 355 |
+
- Dinner/menu queries: **{k['dinner_queries']}**
|
| 356 |
+
- Self check-in successes: **{k['self_checkin_success']}**
|
| 357 |
+
|
| 358 |
+
**Trend vs {prev if prev else 'previous day'}**
|
| 359 |
+
- {delta_line}
|
| 360 |
+
|
| 361 |
+
### 🚦 Key Alerts
|
| 362 |
+
{top_alerts}
|
| 363 |
+
|
| 364 |
+
### ✅ Recommended Actions (Manager / Supervisor)
|
| 365 |
+
{action_lines}
|
| 366 |
+
|
| 367 |
+
### 🌟 Positive Signals
|
| 368 |
+
{pos_lines}
|
| 369 |
+
|
| 370 |
+
**Note:** This is a demo pulse generated from sample operational signals. In production, this can connect to PMS / POS / housekeeping logs / guest feedback channels.
|
| 371 |
+
"""
|
| 372 |
+
return pulse.strip()
|
| 373 |
+
|
| 374 |
+
def kpis_table(k: Dict[str, Any], comp: Dict[str, Any]) -> pd.DataFrame:
|
| 375 |
+
def fmt_delta(x):
|
| 376 |
+
if x is None:
|
| 377 |
+
return ""
|
| 378 |
+
return f"{'+' if x>=0 else ''}{x}"
|
| 379 |
+
|
| 380 |
+
rows = [
|
| 381 |
+
("Total signals", k["total_events"], fmt_delta(comp.get("delta_total_events") if comp else None)),
|
| 382 |
+
("Negative sentiment (%)", round(k["neg_sentiment_rate"]*100), f"{fmt_delta(round(comp.get('delta_neg_rate_pp')))} pp" if comp and comp.get("delta_neg_rate_pp") is not None else ""),
|
| 383 |
+
("Check-in delays (#)", k["checkin_delays"], fmt_delta(comp.get("delta_checkin_delays") if comp else None)),
|
| 384 |
+
("Avg delay (mins)", (round(k["avg_delay_mins"]) if k["avg_delay_mins"] is not None else "N/A"), ""),
|
| 385 |
+
("HK requests (#)", k["hk_requests"], fmt_delta(comp.get("delta_hk_requests") if comp else None)),
|
| 386 |
+
("Maintenance issues (#)", k["maintenance_issues"], fmt_delta(comp.get("delta_maintenance") if comp else None)),
|
| 387 |
+
("Wi-Fi complaints (#)", k["wifi_complaints"], fmt_delta(comp.get("delta_wifi") if comp else None)),
|
| 388 |
+
("Dinner/menu queries (#)", k["dinner_queries"], fmt_delta(comp.get("delta_dinner_queries") if comp else None)),
|
| 389 |
+
("Self check-in successes (#)", k["self_checkin_success"], ""),
|
| 390 |
+
]
|
| 391 |
+
return pd.DataFrame(rows, columns=["Metric", "Today", "Δ vs Yesterday"])
|
| 392 |
+
|
| 393 |
+
# ============================
|
| 394 |
+
# Ops Assistant (simple NL routing)
|
| 395 |
+
# ============================
|
| 396 |
+
def ops_assistant_answer(question: str, k: Dict[str, Any], comp: Dict[str, Any], alerts_df: pd.DataFrame, actions: List[str]) -> str:
|
| 397 |
+
q = (question or "").strip().lower()
|
| 398 |
+
if not q:
|
| 399 |
+
return "Ask something like: “What needs attention today?” or “Any issues in housekeeping?”"
|
| 400 |
+
|
| 401 |
+
if "attention" in q or "focus" in q or "urgent" in q or "risk" in q:
|
| 402 |
+
if alerts_df.empty:
|
| 403 |
+
return "🟢 No major risks detected. Focus on peak arrival windows and keep service recovery readiness."
|
| 404 |
+
top = alerts_df.head(3).to_dict(orient="records")
|
| 405 |
+
lines = []
|
| 406 |
+
for a in top:
|
| 407 |
+
icon = "🔴" if a["Severity"] == "RED" else "🟠"
|
| 408 |
+
lines.append(f"{icon} {a['Category']}: {a['Detail']}")
|
| 409 |
+
return "Here are the top items needing attention:\n- " + "\n- ".join(lines)
|
| 410 |
+
|
| 411 |
+
if "housekeeping" in q or "towel" in q:
|
| 412 |
+
return f"Housekeeping load today: {k['hk_requests']} HK-related requests. " + (
|
| 413 |
+
"Recommendation: re-balance routes and pre-stage linens/towels during peak."
|
| 414 |
+
if k["hk_requests"] >= 20 else
|
| 415 |
+
"Load looks manageable; keep monitoring peak hours."
|
| 416 |
+
)
|
| 417 |
+
|
| 418 |
+
if "front" in q or "check-in" in q or "lobby" in q:
|
| 419 |
+
avg_delay = f"{k['avg_delay_mins']:.0f} mins" if k["avg_delay_mins"] is not None else "N/A"
|
| 420 |
+
return f"Front desk today: {k['checkin_delays']} check-in delay signals (avg: {avg_delay}). " + (
|
| 421 |
+
"Recommendation: add 1 staff during peak + push self-check pre-arrival."
|
| 422 |
+
if k["checkin_delays"] >= 4 else
|
| 423 |
+
"Flow looks stable; keep express check-in visible."
|
| 424 |
+
)
|
| 425 |
+
|
| 426 |
+
if "wifi" in q:
|
| 427 |
+
return f"Wi-Fi complaints today: {k['wifi_complaints']}. " + (
|
| 428 |
+
"Recommendation: check AP health + proactive guest message with Wi-Fi steps."
|
| 429 |
+
if k["wifi_complaints"] >= 4 else
|
| 430 |
+
"Low complaint volume; continue monitoring."
|
| 431 |
+
)
|
| 432 |
+
|
| 433 |
+
if "recommend" in q or "action" in q or "do next" in q:
|
| 434 |
+
if not actions:
|
| 435 |
+
return "Recommended actions: maintain staffing plan, monitor peaks, and review any negative feedback quickly."
|
| 436 |
+
return "Recommended actions:\n- " + "\n- ".join(actions[:5])
|
| 437 |
+
|
| 438 |
+
if "compare" in q or "yesterday" in q or "trend" in q:
|
| 439 |
+
if not comp:
|
| 440 |
+
return "Trend comparison not available."
|
| 441 |
+
msg = (
|
| 442 |
+
f"Compared to {comp['prev_date']}:\n"
|
| 443 |
+
f"- Total signals: {comp['delta_total_events']:+d}\n"
|
| 444 |
+
f"- Check-in delays: {comp['delta_checkin_delays']:+d}\n"
|
| 445 |
+
f"- HK requests: {comp['delta_hk_requests']:+d}\n"
|
| 446 |
+
f"- Maintenance issues: {comp['delta_maintenance']:+d}\n"
|
| 447 |
+
f"- Wi-Fi complaints: {comp['delta_wifi']:+d}\n"
|
| 448 |
+
)
|
| 449 |
+
if comp.get("delta_neg_rate_pp") is not None:
|
| 450 |
+
msg += f"- Negative sentiment: {comp['delta_neg_rate_pp']:+.0f} pp\n"
|
| 451 |
+
return msg
|
| 452 |
+
|
| 453 |
+
return "I can help with: risks, priorities, department issues (front desk/housekeeping/F&B/maintenance), trends vs yesterday, and recommended actions. Try: “What needs attention today?”"
|
| 454 |
+
|
| 455 |
+
# ============================
|
| 456 |
+
# UI Actions
|
| 457 |
+
# ============================
|
| 458 |
+
def refresh_pulse(selected_date: str) -> Tuple[str, pd.DataFrame, pd.DataFrame, str, Dict[str, Any]]:
|
| 459 |
+
events = load_events()
|
| 460 |
+
df = events_to_df(events)
|
| 461 |
+
|
| 462 |
+
if not selected_date:
|
| 463 |
+
# default to latest date in dataset
|
| 464 |
+
if df.empty:
|
| 465 |
+
selected_date = date_str(datetime.now())
|
| 466 |
+
else:
|
| 467 |
+
selected_date = sorted(df["date"].unique())[-1]
|
| 468 |
+
|
| 469 |
+
k = compute_kpis(df, selected_date)
|
| 470 |
+
comp = compare_to_prev_day(df, selected_date) if not df.empty else {}
|
| 471 |
+
alerts_df, actions, positives = build_alerts_and_actions(k, comp)
|
| 472 |
+
|
| 473 |
+
pulse_md = generate_pulse_text(k, comp, alerts_df, actions, positives)
|
| 474 |
+
kpi_df = kpis_table(k, comp)
|
| 475 |
+
|
| 476 |
+
quick_summary = (
|
| 477 |
+
f"Today: {k['total_events']} signals | Neg: {k['neg_sentiment_rate']*100:.0f}% | "
|
| 478 |
+
f"Delays: {k['checkin_delays']} | HK: {k['hk_requests']} | Maint: {k['maintenance_issues']}"
|
| 479 |
+
)
|
| 480 |
+
return pulse_md, kpi_df, alerts_df, quick_summary, {"k": k, "comp": comp, "alerts": alerts_df.to_dict(orient="records"), "actions": actions}
|
| 481 |
+
|
| 482 |
+
def answer_ops(question: str, state: Dict[str, Any]) -> str:
|
| 483 |
+
if not state or "k" not in state:
|
| 484 |
+
return "Please generate the pulse first."
|
| 485 |
+
k = state["k"]
|
| 486 |
+
comp = state.get("comp", {})
|
| 487 |
+
alerts_df = pd.DataFrame(state.get("alerts", []))
|
| 488 |
+
actions = state.get("actions", [])
|
| 489 |
+
return ops_assistant_answer(question, k, comp, alerts_df, actions)
|
| 490 |
+
|
| 491 |
+
def admin_unlock(pin: str):
|
| 492 |
+
if (pin or "").strip() == ADMIN_PIN:
|
| 493 |
+
return gr.update(visible=False), gr.update(visible=True), "✅ Admin access granted."
|
| 494 |
+
return gr.update(visible=True), gr.update(visible=False), "❌ Incorrect PIN."
|
| 495 |
+
|
| 496 |
+
def admin_generate(days: int, seed: int):
|
| 497 |
+
events = simulate_events(days=int(days), seed=int(seed))
|
| 498 |
+
save_events(events)
|
| 499 |
+
return f"✅ Generated {len(events)} demo operational events across last {days} day(s). Updated at {dt_now_str()}."
|
| 500 |
+
|
| 501 |
+
def admin_clear(pin: str):
|
| 502 |
+
if (pin or "").strip() != ADMIN_PIN:
|
| 503 |
+
return "❌ Incorrect PIN. Cannot clear data."
|
| 504 |
+
save_events([])
|
| 505 |
+
return f"✅ Cleared demo data at {dt_now_str()}."
|
| 506 |
+
|
| 507 |
+
# ============================
|
| 508 |
+
# Build UI
|
| 509 |
+
# ============================
|
| 510 |
+
with gr.Blocks(title="AI Hotel Operations Pulse (Prototype)", css=CUSTOM_CSS) as demo:
|
| 511 |
+
gr.Markdown(
|
| 512 |
+
"""
|
| 513 |
+
# 📌 AI Hotel Operations Pulse (Prototype)
|
| 514 |
+
A manager/owner-focused assistant that summarizes hotel health, flags risks, and recommends actions — **without reading long reports**.
|
| 515 |
+
|
| 516 |
+
**Outputs:** Daily Pulse • KPI Snapshot • Alerts • Recommended Actions • Ops Assistant Q&A
|
| 517 |
+
**Note:** Demo uses sample operational signals. In production, this can connect to PMS/POS/housekeeping logs/guest feedback systems.
|
| 518 |
+
"""
|
| 519 |
+
)
|
| 520 |
+
|
| 521 |
+
state = gr.State({})
|
| 522 |
+
|
| 523 |
+
with gr.Tab("Manager / Owner Pulse"):
|
| 524 |
+
with gr.Row():
|
| 525 |
+
selected_date = gr.Textbox(label="Pulse Date (YYYY-MM-DD)", placeholder="Leave blank to use latest available date")
|
| 526 |
+
btn = gr.Button("Generate Pulse", variant="primary")
|
| 527 |
+
|
| 528 |
+
quick = gr.Markdown("")
|
| 529 |
+
|
| 530 |
+
pulse_md = gr.Markdown("")
|
| 531 |
+
with gr.Row():
|
| 532 |
+
kpi_table_out = gr.Dataframe(label="KPI Snapshot", interactive=False, wrap=True)
|
| 533 |
+
alerts_out = gr.Dataframe(label="Alerts (Red/Amber)", interactive=False, wrap=True)
|
| 534 |
+
|
| 535 |
+
gr.Markdown("### 🧠 Ask the Ops Assistant")
|
| 536 |
+
q = gr.Textbox(label="Ask a manager-style question", placeholder="e.g., What needs my attention today? Any housekeeping issues? Compare vs yesterday.")
|
| 537 |
+
ask_btn = gr.Button("Ask", variant="primary")
|
| 538 |
+
a = gr.Textbox(label="Answer", lines=6, interactive=False)
|
| 539 |
+
|
| 540 |
+
btn.click(refresh_pulse, inputs=[selected_date], outputs=[pulse_md, kpi_table_out, alerts_out, quick, state])
|
| 541 |
+
ask_btn.click(answer_ops, inputs=[q, state], outputs=[a])
|
| 542 |
+
|
| 543 |
+
with gr.Tab("Admin (Demo Data)"):
|
| 544 |
+
gr.Markdown("### Admin access (PIN protected)")
|
| 545 |
+
pin_box = gr.Textbox(label="Enter Admin PIN", type="password", placeholder="PIN")
|
| 546 |
+
unlock_btn = gr.Button("Unlock Admin Tools", variant="primary")
|
| 547 |
+
unlock_status = gr.Markdown("")
|
| 548 |
+
|
| 549 |
+
admin_tools = gr.Column(visible=False)
|
| 550 |
+
with admin_tools:
|
| 551 |
+
gr.Markdown("Generate realistic demo operational signals.")
|
| 552 |
+
with gr.Row():
|
| 553 |
+
days = gr.Slider(3, 21, value=7, step=1, label="Days of demo data")
|
| 554 |
+
seed = gr.Slider(1, 999, value=42, step=1, label="Random seed (for repeatability)")
|
| 555 |
+
gen_btn = gr.Button("Generate / Refresh Demo Data", variant="primary")
|
| 556 |
+
gen_out = gr.Markdown("")
|
| 557 |
+
|
| 558 |
+
gr.Markdown("---")
|
| 559 |
+
clear_btn = gr.Button("Clear Demo Data (PIN required)")
|
| 560 |
+
clear_out = gr.Markdown("")
|
| 561 |
+
|
| 562 |
+
gen_btn.click(admin_generate, inputs=[days, seed], outputs=[gen_out])
|
| 563 |
+
clear_btn.click(admin_clear, inputs=[pin_box], outputs=[clear_out])
|
| 564 |
+
|
| 565 |
+
unlock_btn.click(admin_unlock, inputs=[pin_box], outputs=[pin_box, admin_tools, unlock_status])
|
| 566 |
+
|
| 567 |
+
demo.launch()
|