MBG0903 commited on
Commit
22bb9fc
·
verified ·
1 Parent(s): b795dad

Create app.py

Browse files
Files changed (1) hide show
  1. app.py +567 -0
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()