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 = '
'
for label, value, color in cards:
html += f"""
"""
html += "
"
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 = """
AI-Powered Coworking Space Pricing and Satisfaction Optimizer
n8n-automated decision system with interactive dashboards and AI assistance
This app uploads a merged coworking dataset, routes analysis through n8n workflows, and returns pricing, risk, and management insights.
"""
placeholder_kpis = """
Run the n8n automation after uploading the merged coworking file
Interactive charts, pricing recommendations, and alerts will populate automatically.
"""
pipeline_html = """
Project Goal
This app helps a coworking company decide where to raise, hold, or lower pricing
for desks, private offices, and meeting spaces while protecting member satisfaction and reducing retention risk.
Automation Flow
- Workflow 1 cleans and standardizes the merged dataset
- Workflow 2 generates pricing actions and management summaries
- Workflow 3 flags risky segments for review
"""
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)])