Spaces:
Sleeping
Sleeping
| import os | |
| import re | |
| import json | |
| from pathlib import Path | |
| import pandas as pd | |
| import gradio as gr | |
| import plotly.express as px | |
| import plotly.graph_objects as go | |
| import requests | |
| try: | |
| from huggingface_hub import InferenceClient | |
| except Exception: | |
| InferenceClient = None | |
| # ========================================================= | |
| # CONFIG | |
| # ========================================================= | |
| BASE_DIR = Path(__file__).resolve().parent | |
| WF1_URL = os.environ.get( | |
| "WF1_URL", | |
| "https://mojito3.app.n8n.cloud/webhook/hotel-data-preparation", | |
| ).strip() | |
| WF2_URL = os.environ.get( | |
| "WF2_URL", | |
| "https://mojito3.app.n8n.cloud/webhook/hotel-pricing-analysis", | |
| ).strip() | |
| WF3_URL = os.environ.get( | |
| "WF3_URL", | |
| "https://mojito3.app.n8n.cloud/webhook/hotel-risk-alerts", | |
| ).strip() | |
| HF_API_KEY = os.environ.get("HF_API_KEY", "").strip() | |
| MODEL_NAME = os.environ.get( | |
| "MODEL_NAME", | |
| "meta-llama/Llama-3.1-8B-Instruct", | |
| ).strip() | |
| LLM_ENABLED = bool(HF_API_KEY) and InferenceClient is not None | |
| llm_client = InferenceClient(api_key=HF_API_KEY) if LLM_ENABLED else None | |
| MAX_PREVIEW_ROWS = 15 | |
| IGNORE_SHEETS = {"data_dictionary", "sources", "source", "readme", "metadata"} | |
| CUSTOM_CSS = """ | |
| :root { | |
| --bg1: #18004f; | |
| --bg2: #2a0a89; | |
| --panel: rgba(255,255,255,0.88); | |
| --text: #20114f; | |
| --gold: #f3c544; | |
| --orange: #ff8b1a; | |
| --line: rgba(255,255,255,0.20); | |
| --shadow: 0 14px 38px rgba(11, 5, 43, 0.22); | |
| --radius: 22px; | |
| } | |
| html, body, .gradio-container { | |
| min-height: 100%; | |
| background: | |
| radial-gradient(circle at top left, rgba(82, 182, 255, 0.18), transparent 20%), | |
| linear-gradient(135deg, var(--bg1) 0%, var(--bg2) 55%, #12013a 100%); | |
| color: white; | |
| } | |
| .gradio-container { | |
| max-width: 1320px !important; | |
| margin: 0 auto !important; | |
| padding-top: 20px !important; | |
| padding-bottom: 32px !important; | |
| } | |
| /* tabs */ | |
| .gr-tab-nav { | |
| background: rgba(34, 9, 110, 0.70) !important; | |
| border: 1px solid rgba(255,255,255,0.14) !important; | |
| border-radius: 18px !important; | |
| padding: 10px 12px !important; | |
| } | |
| .gr-tab-nav button, | |
| .gr-tab-nav button span, | |
| .gr-tab-nav button p, | |
| .gr-tab-nav button div, | |
| .gr-tab-nav button label, | |
| button[aria-selected="false"], | |
| button[aria-selected="false"] * { | |
| color: #ffffff !important; | |
| opacity: 1 !important; | |
| font-weight: 800 !important; | |
| } | |
| .gr-tab-nav button { | |
| border-radius: 14px !important; | |
| transition: background 0.2s ease, color 0.2s ease !important; | |
| box-shadow: none !important; | |
| } | |
| .gr-tab-nav button:hover, | |
| button[aria-selected="false"]:hover { | |
| background: #000000 !important; | |
| background-color: #000000 !important; | |
| box-shadow: none !important; | |
| } | |
| .gr-tab-nav button:hover, | |
| .gr-tab-nav button:hover span, | |
| .gr-tab-nav button:hover p, | |
| .gr-tab-nav button:hover div, | |
| .gr-tab-nav button:hover label, | |
| button[aria-selected="false"]:hover, | |
| button[aria-selected="false"]:hover * { | |
| color: #ffffff !important; | |
| } | |
| .gr-tab-nav button.selected, | |
| button[aria-selected="true"] { | |
| background: transparent !important; | |
| border-bottom: 3px solid var(--orange) !important; | |
| box-shadow: none !important; | |
| } | |
| .gr-tab-nav button.selected *, | |
| button[aria-selected="true"] * { | |
| color: var(--orange) !important; | |
| } | |
| /* layout */ | |
| .app-shell { | |
| background: rgba(28, 8, 94, 0.58); | |
| border: 1px solid var(--line); | |
| border-radius: 28px; | |
| padding: 24px 26px 30px 26px; | |
| box-shadow: var(--shadow); | |
| backdrop-filter: blur(10px); | |
| } | |
| .hero-card { | |
| display: grid; | |
| grid-template-columns: 170px 1fr; | |
| gap: 22px; | |
| align-items: center; | |
| background: linear-gradient(135deg, rgba(67, 21, 181, 0.68), rgba(22, 4, 76, 0.72)); | |
| border: 1px solid rgba(255,255,255,0.14); | |
| border-radius: 24px; | |
| padding: 24px 28px; | |
| margin-bottom: 18px; | |
| } | |
| .logo-box { | |
| display: flex; | |
| flex-direction: column; | |
| align-items: flex-start; | |
| justify-content: center; | |
| gap: 2px; | |
| } | |
| .logo-mark { | |
| font-size: 56px; | |
| line-height: 1; | |
| font-weight: 900; | |
| color: white; | |
| } | |
| .logo-text { | |
| font-size: 17px; | |
| font-weight: 800; | |
| letter-spacing: 0.5px; | |
| color: white; | |
| } | |
| .logo-sub { | |
| font-size: 12px; | |
| letter-spacing: 1.4px; | |
| text-transform: uppercase; | |
| color: rgba(255,255,255,0.82); | |
| } | |
| .hero-title { | |
| font-size: 34px; | |
| line-height: 1.16; | |
| font-weight: 900; | |
| color: var(--gold); | |
| margin: 0 0 8px 0; | |
| } | |
| .hero-subtitle { | |
| font-size: 18px; | |
| color: rgba(255,255,255,0.95); | |
| margin: 0 0 6px 0; | |
| font-weight: 600; | |
| } | |
| .hero-note { | |
| font-size: 14px; | |
| color: rgba(255,255,255,0.82); | |
| margin: 0; | |
| } | |
| .panel-card { | |
| background: var(--panel); | |
| border: 1px solid rgba(255,255,255,0.45); | |
| border-radius: var(--radius); | |
| box-shadow: var(--shadow); | |
| padding: 32px !important; | |
| } | |
| .pipeline-panel { | |
| padding-left: 36px !important; | |
| padding-right: 36px !important; | |
| } | |
| .pipeline-html { | |
| color: var(--text); | |
| padding-left: 10px; | |
| padding-right: 10px; | |
| padding-bottom: 12px; | |
| line-height: 1.72; | |
| } | |
| .pipeline-html h3, | |
| .pipeline-html h4, | |
| .pipeline-html p, | |
| .pipeline-html li, | |
| .pipeline-html strong { | |
| color: var(--text); | |
| margin: 0; | |
| } | |
| .pipeline-html h3 { | |
| font-size: 20px; | |
| font-weight: 900; | |
| margin-bottom: 18px; | |
| } | |
| .pipeline-html h4 { | |
| font-size: 18px; | |
| font-weight: 900; | |
| margin-top: 24px; | |
| margin-bottom: 12px; | |
| } | |
| .pipeline-html p { | |
| font-size: 16px; | |
| margin-bottom: 20px; | |
| } | |
| .pipeline-html ol { | |
| margin: 0 0 28px 24px; | |
| padding: 0; | |
| } | |
| .pipeline-html li { | |
| font-size: 16px; | |
| margin-bottom: 12px; | |
| } | |
| .section-title { | |
| color: var(--text) !important; | |
| font-weight: 900 !important; | |
| } | |
| .section-title-white, | |
| .section-title-white * { | |
| color: white !important; | |
| font-weight: 900 !important; | |
| } | |
| label, .gr-form > div > label, .gr-box, .gr-panel { | |
| color: var(--text) !important; | |
| } | |
| .gr-button-primary { | |
| background: linear-gradient(135deg, #2b1d7d, #4d2ed2) !important; | |
| border: none !important; | |
| } | |
| .gradio-container .block { | |
| border-radius: 16px !important; | |
| } | |
| .ai-panel { | |
| padding: 30px !important; | |
| } | |
| .ai-panel .gr-markdown, | |
| .ai-panel .gr-markdown * { | |
| color: var(--text) !important; | |
| } | |
| /* keep dashboard summary text white */ | |
| .dashboard-white-text, | |
| .dashboard-white-text * { | |
| color: #ffffff !important; | |
| } | |
| .dashboard-white-text .gr-markdown, | |
| .dashboard-white-text .gr-markdown * { | |
| color: #ffffff !important; | |
| } | |
| .dashboard-white-text h1, | |
| .dashboard-white-text h2, | |
| .dashboard-white-text h3, | |
| .dashboard-white-text h4, | |
| .dashboard-white-text h5, | |
| .dashboard-white-text h6, | |
| .dashboard-white-text strong, | |
| .dashboard-white-text li, | |
| .dashboard-white-text p, | |
| .dashboard-white-text span, | |
| .dashboard-white-text ul, | |
| .dashboard-white-text ol { | |
| color: #ffffff !important; | |
| } | |
| .dashboard-white-text { | |
| padding-bottom: 22px !important; | |
| } | |
| .dashboard-white-text .gr-markdown { | |
| padding-right: 18px !important; | |
| line-height: 1.75 !important; | |
| } | |
| @media (max-width: 900px) { | |
| .hero-card { | |
| grid-template-columns: 1fr; | |
| text-align: center; | |
| } | |
| .logo-box { | |
| align-items: center; | |
| } | |
| .hero-title { | |
| font-size: 28px; | |
| } | |
| } | |
| """ | |
| # ========================================================= | |
| # HELPERS | |
| # ========================================================= | |
| def sanitize_value(v): | |
| if isinstance(v, pd.Timestamp): | |
| return v.isoformat() | |
| if pd.isna(v): | |
| return None | |
| if hasattr(v, "item"): | |
| try: | |
| return v.item() | |
| except Exception: | |
| pass | |
| return v | |
| def dataframe_to_records(df: pd.DataFrame): | |
| safe_df = df.copy() | |
| for col in safe_df.columns: | |
| safe_df[col] = safe_df[col].map(sanitize_value) | |
| return safe_df.to_dict(orient="records") | |
| def normalize_columns(columns): | |
| clean = [] | |
| for col in columns: | |
| c = str(col).strip().lower() | |
| c = re.sub(r"[^\w\s]", "", c) | |
| c = re.sub(r"\s+", "_", c) | |
| clean.append(c) | |
| return clean | |
| def pick_primary_sheet(file_path: str) -> pd.DataFrame: | |
| excel = pd.ExcelFile(file_path) | |
| sheet_names = excel.sheet_names | |
| valid_sheets = [s for s in sheet_names if s.strip().lower() not in IGNORE_SHEETS] | |
| chosen = valid_sheets[0] if valid_sheets else sheet_names[0] | |
| df = pd.read_excel(file_path, sheet_name=chosen) | |
| df.columns = normalize_columns(df.columns) | |
| return df | |
| def read_uploaded_excel(file_obj): | |
| if file_obj is None: | |
| return None | |
| path = file_obj.name if hasattr(file_obj, "name") else str(file_obj) | |
| return pick_primary_sheet(path) | |
| def post_to_n8n(url: str, payload: dict, timeout: int = 60): | |
| response = requests.post(url, json=payload, timeout=timeout) | |
| response.raise_for_status() | |
| return response.json() | |
| def fmt_num(x): | |
| if x is None or pd.isna(x): | |
| return "N/A" | |
| if isinstance(x, (int, float)): | |
| if abs(x) >= 1000: | |
| return f"{x:,.0f}" | |
| return f"{x:.2f}" | |
| return str(x) | |
| def fmt_pct(x): | |
| if x is None or pd.isna(x): | |
| return "N/A" | |
| return f"{x * 100:.1f}%" | |
| def safe_df_from_records(records): | |
| if not records: | |
| return pd.DataFrame() | |
| return pd.DataFrame(records) | |
| def build_kpi_cards(pricing_df: pd.DataFrame, risk_alerts_df: pd.DataFrame) -> str: | |
| pricing_df = pricing_df.copy() if pricing_df is not None else pd.DataFrame() | |
| risk_alerts_df = risk_alerts_df.copy() if risk_alerts_df is not None else pd.DataFrame() | |
| avg_price = pricing_df["avg_space_price"].mean() if "avg_space_price" in pricing_df.columns and not pricing_df.empty else None | |
| avg_util = pricing_df["avg_utilization"].mean() if "avg_utilization" in pricing_df.columns and not pricing_df.empty else None | |
| avg_cancel = pricing_df["avg_member_cancellation"].mean() if "avg_member_cancellation" in pricing_df.columns and not pricing_df.empty else None | |
| total_revenue = pricing_df["total_revenue"].sum() if "total_revenue" in pricing_df.columns and not pricing_df.empty else None | |
| raise_count = int((pricing_df["pricing_action"] == "Raise price").sum()) if "pricing_action" in pricing_df.columns and not pricing_df.empty else 0 | |
| alert_count = len(risk_alerts_df) | |
| cards = [ | |
| ("Locations/Segments", len(pricing_df), "#5f44cc"), | |
| ("Avg Space Price", fmt_num(avg_price), "#2fbf9f"), | |
| ("Avg Utilization", fmt_pct(avg_util), "#f3c544"), | |
| ("Avg Member Cancellation", fmt_pct(avg_cancel), "#e05b77"), | |
| ("Total Revenue", fmt_num(total_revenue), "#3ba0ff"), | |
| ("Raise Opportunities", raise_count, "#8a5cff"), | |
| ("Risk Alerts", alert_count, "#ff7a5c"), | |
| ] | |
| html = '<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(155px,1fr));gap:12px;">' | |
| for label, value, color in cards: | |
| html += f""" | |
| <div style=" | |
| background:rgba(255,255,255,0.86); | |
| border-radius:18px; | |
| padding:16px 14px; | |
| box-shadow:0 10px 28px rgba(15,6,57,0.12); | |
| border:1px solid rgba(255,255,255,0.7); | |
| border-top:4px solid {color}; | |
| text-align:center; | |
| "> | |
| <div style="font-size:11px;font-weight:900;letter-spacing:1px;text-transform:uppercase;color:#705fb0;margin-bottom:8px;">{label}</div> | |
| <div style="font-size:22px;font-weight:900;color:#24115e;">{value}</div> | |
| </div> | |
| """ | |
| html += "</div>" | |
| return html | |
| # ========================================================= | |
| # CHARTS | |
| # ========================================================= | |
| def empty_figure(title: str, message: str = "No data available yet") -> go.Figure: | |
| fig = go.Figure() | |
| fig.update_layout( | |
| title=title, | |
| template="plotly_white", | |
| paper_bgcolor="rgba(255,255,255,0.95)", | |
| plot_bgcolor="rgba(255,255,255,0.98)", | |
| height=420, | |
| annotations=[ | |
| dict( | |
| text=message, | |
| x=0.5, | |
| y=0.5, | |
| xref="paper", | |
| yref="paper", | |
| showarrow=False, | |
| font=dict(size=15, color="rgba(53,32,138,0.65)") | |
| ) | |
| ] | |
| ) | |
| return fig | |
| def chart_action_distribution(pricing_df: pd.DataFrame) -> go.Figure: | |
| if pricing_df is None or pricing_df.empty or "pricing_action" not in pricing_df.columns: | |
| return empty_figure("Pricing Action Distribution") | |
| chart_df = pricing_df["pricing_action"].value_counts().reset_index() | |
| chart_df.columns = ["pricing_action", "count"] | |
| fig = px.bar( | |
| chart_df, | |
| x="pricing_action", | |
| y="count", | |
| color="pricing_action", | |
| title="Pricing Action Distribution", | |
| color_discrete_sequence=["#5f44cc", "#2fbf9f", "#f3c544", "#e05b77", "#3ba0ff"], | |
| ) | |
| fig.update_layout(template="plotly_white", paper_bgcolor="rgba(255,255,255,0.95)", height=420, showlegend=False) | |
| return fig | |
| def chart_theme_counts(theme_counts: dict) -> go.Figure: | |
| if not theme_counts: | |
| return empty_figure("Top Complaint / Satisfaction Themes") | |
| df = pd.DataFrame({"theme": list(theme_counts.keys()), "count": list(theme_counts.values())}) | |
| df = df.sort_values("count", ascending=True).tail(10) | |
| fig = px.bar( | |
| df, | |
| x="count", | |
| y="theme", | |
| orientation="h", | |
| title="Top Complaint / Satisfaction Themes", | |
| color="count", | |
| color_continuous_scale=["#d8cdfa", "#5f44cc"], | |
| ) | |
| fig.update_layout(template="plotly_white", paper_bgcolor="rgba(255,255,255,0.95)", height=420) | |
| return fig | |
| def chart_avg_price_by_city(pricing_df: pd.DataFrame) -> go.Figure: | |
| if pricing_df is None or pricing_df.empty or "city" not in pricing_df.columns or "avg_space_price" not in pricing_df.columns: | |
| return empty_figure("Average Space Price by City") | |
| df = pricing_df.groupby("city", dropna=False)["avg_space_price"].mean().reset_index() | |
| fig = px.bar( | |
| df.sort_values("avg_space_price", ascending=False), | |
| x="city", | |
| y="avg_space_price", | |
| title="Average Space Price by City", | |
| color="avg_space_price", | |
| color_continuous_scale=["#d4d0ff", "#4320b5"], | |
| ) | |
| fig.update_layout(template="plotly_white", paper_bgcolor="rgba(255,255,255,0.95)", height=420) | |
| return fig | |
| def chart_avg_utilization_by_space(pricing_df: pd.DataFrame) -> go.Figure: | |
| if pricing_df is None or pricing_df.empty or "space_type" not in pricing_df.columns or "avg_utilization" not in pricing_df.columns: | |
| return empty_figure("Average Utilization by Space Type") | |
| df = pricing_df.groupby("space_type", dropna=False)["avg_utilization"].mean().reset_index() | |
| fig = px.bar( | |
| df.sort_values("avg_utilization", ascending=False), | |
| x="space_type", | |
| y="avg_utilization", | |
| title="Average Utilization by Space Type", | |
| color="avg_utilization", | |
| color_continuous_scale=["#d4fff2", "#2fbf9f"], | |
| ) | |
| fig.update_layout(template="plotly_white", paper_bgcolor="rgba(255,255,255,0.95)", height=420) | |
| fig.update_yaxes(tickformat=".0%") | |
| return fig | |
| def chart_revenue_by_city(pricing_df: pd.DataFrame) -> go.Figure: | |
| if pricing_df is None or pricing_df.empty or "city" not in pricing_df.columns or "total_revenue" not in pricing_df.columns: | |
| return empty_figure("Revenue by City") | |
| df = pricing_df.groupby("city", dropna=False)["total_revenue"].sum().reset_index() | |
| fig = px.bar( | |
| df.sort_values("total_revenue", ascending=False), | |
| x="city", | |
| y="total_revenue", | |
| title="Revenue by City", | |
| color="total_revenue", | |
| color_continuous_scale=["#f9ddb0", "#f3c544"], | |
| ) | |
| fig.update_layout(template="plotly_white", paper_bgcolor="rgba(255,255,255,0.95)", height=420) | |
| return fig | |
| def chart_alert_levels(risk_alerts_df: pd.DataFrame) -> go.Figure: | |
| if risk_alerts_df is None or risk_alerts_df.empty or "alert_level" not in risk_alerts_df.columns: | |
| return empty_figure("Alert Levels", "No risk alerts detected") | |
| df = risk_alerts_df["alert_level"].value_counts().reset_index() | |
| df.columns = ["alert_level", "count"] | |
| fig = px.bar( | |
| df, | |
| x="alert_level", | |
| y="count", | |
| color="alert_level", | |
| title="Risk Alert Levels", | |
| color_discrete_map={"High": "#e05b77", "Medium": "#f3c544", "Low": "#2fbf9f"}, | |
| ) | |
| fig.update_layout(template="plotly_white", paper_bgcolor="rgba(255,255,255,0.95)", height=420, showlegend=False) | |
| return fig | |
| # ========================================================= | |
| # N8N-DRIVEN PIPELINE | |
| # ========================================================= | |
| def build_execution_log(row_count, cleaned_count, pricing_count, alert_count): | |
| return f"""AUTOMATION PIPELINE COMPLETED | |
| Step 1 - Uploaded merged coworking dataset loaded | |
| Rows received from app: {row_count} | |
| Step 2 - n8n Workflow 1 completed | |
| Cleaned rows returned: {cleaned_count} | |
| Step 3 - n8n Workflow 2 completed | |
| Pricing decision rows returned: {pricing_count} | |
| Step 4 - n8n Workflow 3 completed | |
| Risk alerts returned: {alert_count} | |
| Status: | |
| - n8n automation: active | |
| - pricing engine: active | |
| - alerts engine: active | |
| - charts: ready | |
| - AI assistant context: ready | |
| """ | |
| def run_pipeline(merged_file): | |
| if merged_file is None: | |
| raise gr.Error("Please upload the merged coworking Excel file before running the analysis.") | |
| merged_df = read_uploaded_excel(merged_file) | |
| if merged_df is None or merged_df.empty: | |
| raise gr.Error("Could not read the uploaded Excel file.") | |
| merged_records = dataframe_to_records(merged_df) | |
| try: | |
| wf1_result = post_to_n8n(WF1_URL, {"merged_data": merged_records}) | |
| except Exception as e: | |
| raise gr.Error(f"Workflow 1 failed: {e}") | |
| cleaned_data = wf1_result.get("cleaned_data", []) | |
| if not isinstance(cleaned_data, list): | |
| raise gr.Error("Workflow 1 returned an invalid `cleaned_data` payload.") | |
| try: | |
| wf2_result = post_to_n8n(WF2_URL, {"merged_data": cleaned_data}) | |
| except Exception as e: | |
| raise gr.Error(f"Workflow 2 failed: {e}") | |
| pricing_decisions = wf2_result.get("pricing_decisions", []) | |
| management_summary = wf2_result.get("management_summary", "No management summary returned.") | |
| sentiment_counts = wf2_result.get("sentiment_counts", {}) | |
| theme_counts = wf2_result.get("theme_counts", {}) | |
| if not isinstance(pricing_decisions, list): | |
| raise gr.Error("Workflow 2 returned an invalid `pricing_decisions` payload.") | |
| try: | |
| wf3_result = post_to_n8n(WF3_URL, {"pricing_decisions": pricing_decisions}) | |
| except Exception as e: | |
| raise gr.Error(f"Workflow 3 failed: {e}") | |
| alerts_summary = wf3_result.get("alerts_summary", "No alerts summary returned.") | |
| risk_alerts = wf3_result.get("risk_alerts", []) | |
| alert_count = wf3_result.get("alert_count", len(risk_alerts) if isinstance(risk_alerts, list) else 0) | |
| pricing_df = safe_df_from_records(pricing_decisions) | |
| risk_alerts_df = safe_df_from_records(risk_alerts) | |
| dashboard_kpis = build_kpi_cards(pricing_df, risk_alerts_df) | |
| preview_df = merged_df.head(MAX_PREVIEW_ROWS).copy() | |
| coworking_summary_md = f""" | |
| ### Coworking Pricing Summary | |
| {management_summary} | |
| ### Automation Notes | |
| - Workflow 1 cleaned and standardized the uploaded merged dataset. | |
| - Workflow 2 generated pricing actions and chart-ready outputs. | |
| - Workflow 3 flagged risky coworking segments for management review. | |
| """ | |
| risk_summary_md = f""" | |
| ### Risk Monitoring Summary | |
| {alerts_summary} | |
| """ | |
| analysis_state = { | |
| "management_summary": management_summary, | |
| "sentiment_counts": sentiment_counts, | |
| "theme_counts": theme_counts, | |
| "pricing_decisions": pricing_decisions, | |
| "risk_alerts": risk_alerts, | |
| "alerts_summary": alerts_summary, | |
| } | |
| run_log = build_execution_log( | |
| row_count=len(merged_records), | |
| cleaned_count=len(cleaned_data), | |
| pricing_count=len(pricing_decisions), | |
| alert_count=alert_count, | |
| ) | |
| return ( | |
| run_log, | |
| preview_df, | |
| dashboard_kpis, | |
| coworking_summary_md, | |
| risk_summary_md, | |
| chart_action_distribution(pricing_df), | |
| chart_theme_counts(theme_counts), | |
| chart_avg_price_by_city(pricing_df), | |
| chart_avg_utilization_by_space(pricing_df), | |
| chart_revenue_by_city(pricing_df), | |
| chart_alert_levels(risk_alerts_df), | |
| pricing_df.head(20), | |
| risk_alerts_df.head(20), | |
| analysis_state, | |
| ) | |
| # ========================================================= | |
| # AI ASSISTANT | |
| # ========================================================= | |
| def fallback_ai_answer(question: str, analysis_state: dict) -> str: | |
| q = question.lower() | |
| pricing_decisions = analysis_state.get("pricing_decisions", []) | |
| risk_alerts = analysis_state.get("risk_alerts", []) | |
| theme_counts = analysis_state.get("theme_counts", {}) | |
| alerts_summary = analysis_state.get("alerts_summary", "") | |
| management_summary = analysis_state.get("management_summary", "") | |
| if not pricing_decisions: | |
| return "Please upload the merged coworking file and run the automation pipeline first." | |
| pricing_df = pd.DataFrame(pricing_decisions) | |
| if "complaint" in q or "problem" in q or "issue" in q or "theme" in q: | |
| if theme_counts: | |
| top_items = sorted(theme_counts.items(), key=lambda x: x[1], reverse=True)[:3] | |
| theme_text = ", ".join([f"{k} ({v})" for k, v in top_items]) | |
| return f"The main coworking experience themes are: {theme_text}. These themes are likely influencing pricing power and retention." | |
| return "No theme breakdown is currently available." | |
| if "raise" in q or "higher price" in q or "increase" in q or "pricing" in q: | |
| if "pricing_action" in pricing_df.columns: | |
| candidates = pricing_df[pricing_df["pricing_action"] == "Raise price"] | |
| if not candidates.empty: | |
| top = candidates.iloc[0] | |
| return ( | |
| f"The strongest current raise-price opportunity is {top.get('coworking_space_name', 'Unknown location')} " | |
| f"in {top.get('city', 'Unknown city')} for {top.get('space_type', 'Unknown space type')}. " | |
| f"Rationale: {top.get('rationale', 'No rationale returned.')}" | |
| ) | |
| return "No raise-price opportunity was returned by the automation." | |
| if "risk" in q or "alert" in q: | |
| if risk_alerts: | |
| first = risk_alerts[0] | |
| return ( | |
| f"{alerts_summary}\n\n" | |
| f"One flagged segment is {first.get('coworking_space_name', 'Unknown location')} in " | |
| f"{first.get('city', 'Unknown city')} for {first.get('space_type', 'Unknown space type')}. " | |
| f"Reason: {first.get('reasons', 'No reason returned.')}" | |
| ) | |
| return "No active risk alerts were returned by the automation." | |
| if "summary" in q or "overview" in q or "prioritize" in q: | |
| return management_summary or "No management summary is currently available." | |
| if "occupancy" in q or "utilization" in q: | |
| if "avg_utilization" in pricing_df.columns and not pricing_df.empty: | |
| avg_util = pricing_df["avg_utilization"].mean() | |
| return f"The average utilization across coworking segments is {fmt_pct(avg_util)}." | |
| return "Utilization data is not currently available." | |
| return ( | |
| "I can answer questions about pricing actions, coworking themes, risk alerts, utilization, and the overall management summary. " | |
| "Try asking: 'Where should prices be raised?' or 'What are the main complaint themes?'" | |
| ) | |
| def build_llm_prompt(question: str, analysis_state: dict) -> str: | |
| return f""" | |
| You are an AI assistant for a coworking space pricing and satisfaction dashboard. | |
| You must answer as a concise business analyst. | |
| Use coworking language only. Never refer to hotels, guests, or rooms. | |
| Management summary: | |
| {analysis_state.get("management_summary", "")} | |
| Alerts summary: | |
| {analysis_state.get("alerts_summary", "")} | |
| Theme counts: | |
| {json.dumps(analysis_state.get("theme_counts", {}), indent=2)} | |
| Pricing decisions sample: | |
| {json.dumps(analysis_state.get("pricing_decisions", [])[:5], indent=2)} | |
| Risk alerts sample: | |
| {json.dumps(analysis_state.get("risk_alerts", [])[:5], indent=2)} | |
| User question: | |
| {question} | |
| Instructions: | |
| - Answer directly. | |
| - Use coworking language only. | |
| - Mention pricing implications when relevant. | |
| - Keep it clear and business-focused. | |
| """ | |
| def ask_ai(question, history, analysis_state): | |
| if not question or not question.strip(): | |
| return history, "" | |
| history = history or [] | |
| if not analysis_state: | |
| answer = "Please upload the merged coworking file and run the automation pipeline first." | |
| elif LLM_ENABLED: | |
| try: | |
| prompt = build_llm_prompt(question, analysis_state) | |
| completion = llm_client.chat_completion( | |
| model=MODEL_NAME, | |
| messages=[ | |
| {"role": "system", "content": "You are a concise coworking pricing analyst."}, | |
| {"role": "user", "content": prompt}, | |
| ], | |
| max_tokens=350, | |
| temperature=0.2, | |
| ) | |
| if isinstance(completion, dict): | |
| answer = completion["choices"][0]["message"]["content"] | |
| else: | |
| answer = completion.choices[0].message.content | |
| except Exception as e: | |
| answer = f"LLM error: {e}\n\nFallback answer:\n{fallback_ai_answer(question, analysis_state)}" | |
| else: | |
| answer = fallback_ai_answer(question, analysis_state) | |
| history.append({"role": "user", "content": question}) | |
| history.append({"role": "assistant", "content": answer}) | |
| return history, "" | |
| # ========================================================= | |
| # UI | |
| # ========================================================= | |
| hero_html = """ | |
| <div class="app-shell"> | |
| <div class="hero-card"> | |
| <div class="logo-box"> | |
| <div class="logo-mark">✦</div> | |
| <div class="logo-text">ESCP</div> | |
| <div class="logo-sub">Business School</div> | |
| </div> | |
| <div> | |
| <h1 class="hero-title">AI-Powered Coworking Space Pricing and Satisfaction Optimizer</h1> | |
| <p class="hero-subtitle">n8n-automated decision system with interactive dashboards and AI assistance</p> | |
| <p class="hero-note">This app uploads a merged coworking dataset, routes analysis through n8n workflows, and returns pricing, risk, and management insights.</p> | |
| </div> | |
| </div> | |
| </div> | |
| """ | |
| placeholder_kpis = """ | |
| <div style="background:rgba(255,255,255,0.82);padding:18px;border-radius:18px;border:1px solid rgba(255,255,255,0.7);text-align:center;"> | |
| <div style="font-size:22px;font-weight:900;color:#24115e;">Run the n8n automation after uploading the merged coworking file</div> | |
| <div style="margin-top:8px;color:#6f5cb5;">Interactive charts, pricing recommendations, and alerts will populate automatically.</div> | |
| </div> | |
| """ | |
| pipeline_html = """ | |
| <div class="pipeline-html"> | |
| <h3>Project Goal</h3> | |
| <p> | |
| This app helps a coworking company decide where to <strong>raise, hold, or lower pricing</strong> | |
| for desks, private offices, and meeting spaces while protecting member satisfaction and reducing retention risk. | |
| </p> | |
| <h4>Automation Flow</h4> | |
| <ol> | |
| <li><strong>Workflow 1</strong> cleans and standardizes the merged dataset</li> | |
| <li><strong>Workflow 2</strong> generates pricing actions and management summaries</li> | |
| <li><strong>Workflow 3</strong> flags risky segments for review</li> | |
| </ol> | |
| </div> | |
| """ | |
| with gr.Blocks(title="AI Coworking Space Pricing Optimizer") as demo: | |
| analysis_state = gr.State({}) | |
| gr.HTML(hero_html) | |
| with gr.Tab("Pipeline Runner"): | |
| with gr.Group(elem_classes=["panel-card", "pipeline-panel"]): | |
| gr.HTML(pipeline_html) | |
| merged_file = gr.File( | |
| label="Upload merged coworking Excel file", | |
| file_types=[".xlsx"], | |
| ) | |
| run_button = gr.Button("Run n8n Automation Pipeline", variant="primary") | |
| run_log = gr.Textbox(label="Execution Log", lines=14, interactive=False) | |
| merged_preview = gr.Dataframe(label="Merged Data Preview", interactive=False) | |
| with gr.Tab("Dashboard"): | |
| kpi_html = gr.HTML(value=placeholder_kpis) | |
| with gr.Row(): | |
| coworking_summary_md = gr.Markdown( | |
| "Run the pipeline to generate the coworking pricing summary.", | |
| elem_classes=["dashboard-white-text"], | |
| ) | |
| risk_summary_md = gr.Markdown( | |
| "Run the pipeline to generate the risk summary.", | |
| elem_classes=["dashboard-white-text"], | |
| ) | |
| gr.Markdown("### Interactive Decision Dashboard", elem_classes=["section-title-white"]) | |
| with gr.Row(): | |
| action_chart = gr.Plot(label="Pricing Action Distribution") | |
| theme_chart = gr.Plot(label="Top Themes") | |
| with gr.Row(): | |
| price_city_chart = gr.Plot(label="Average Space Price by City") | |
| utilization_space_chart = gr.Plot(label="Average Utilization by Space Type") | |
| with gr.Row(): | |
| revenue_city_chart = gr.Plot(label="Revenue by City") | |
| alerts_level_chart = gr.Plot(label="Alert Levels") | |
| gr.Markdown("### Pricing Recommendations", elem_classes=["section-title-white"]) | |
| pricing_table = gr.Dataframe(label="Pricing Decisions", interactive=False) | |
| gr.Markdown("### Risk Alerts", elem_classes=["section-title-white"]) | |
| risk_alerts_table = gr.Dataframe(label="Risk Alerts", interactive=False) | |
| with gr.Tab('"AI" Dashboard'): | |
| with gr.Group(elem_classes=["panel-card", "ai-panel"]): | |
| gr.Markdown( | |
| """ | |
| ### Ask the Coworking Strategy Assistant | |
| Example questions: | |
| - Which locations can support higher pricing? | |
| - What service issues are hurting pricing power? | |
| - What should management prioritize first? | |
| - Summarize the risk situation. | |
| """, | |
| elem_classes=["section-title"], | |
| ) | |
| chatbot = gr.Chatbot(label="Conversation", height=430, value=[]) | |
| ai_input = gr.Textbox( | |
| label="Ask about the returned n8n results", | |
| placeholder="e.g. Which coworking segments should increase price?", | |
| lines=1, | |
| ) | |
| ai_input.submit( | |
| ask_ai, | |
| inputs=[ai_input, chatbot, analysis_state], | |
| outputs=[chatbot, ai_input], | |
| ) | |
| run_button.click( | |
| run_pipeline, | |
| inputs=[merged_file], | |
| outputs=[ | |
| run_log, | |
| merged_preview, | |
| kpi_html, | |
| coworking_summary_md, | |
| risk_summary_md, | |
| action_chart, | |
| theme_chart, | |
| price_city_chart, | |
| utilization_space_chart, | |
| revenue_city_chart, | |
| alerts_level_chart, | |
| pricing_table, | |
| risk_alerts_table, | |
| analysis_state, | |
| ], | |
| ) | |
| demo.launch(css=CUSTOM_CSS, allowed_paths=[str(BASE_DIR)]) |