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"""
{label}
{value}
""" 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 = """
ESCP
Business School

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

  1. Workflow 1 cleans and standardizes the merged dataset
  2. Workflow 2 generates pricing actions and management summaries
  3. 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)])