StayWiseAI / app.py
Jadjoueidi's picture
Update app.py
a3ebc4b verified
import os
import json
import requests
import pandas as pd
import gradio as gr
APP_NAME = "StayWise AI"
DATA_FILE = "synthetic_airbnb_project_data.csv"
N8N_WEBHOOK_URL = os.getenv("N8N_WEBHOOK_URL", "").strip()
def load_data():
return pd.read_csv(DATA_FILE)
df = load_data()
def safe_mean(series, default=0):
series = pd.to_numeric(series, errors="coerce").dropna()
if len(series) == 0:
return default
return float(series.mean())
def get_choices(column):
return sorted(df[column].dropna().astype(str).unique().tolist())
neighbourhood_groups = get_choices("neighbourhood_group")
neighbourhoods = get_choices("neighbourhood")
room_types = get_choices("room_type")
seasons = get_choices("season") if "season" in df.columns else ["Low Season", "Medium Season", "High Season"]
def call_n8n(payload):
if not N8N_WEBHOOK_URL:
return {
"status": "not_configured",
"insight": "n8n webhook is not configured yet.",
"next_step": "Add N8N_WEBHOOK_URL in Hugging Face Space secrets.",
"log": "Pipeline ran locally only."
}
try:
response = requests.post(N8N_WEBHOOK_URL, json=payload, timeout=15)
if response.status_code >= 200 and response.status_code < 300:
try:
data = response.json()
return {
"status": data.get("status", "success"),
"insight": data.get("insight", "n8n processed the recommendation."),
"next_step": data.get("next_step", "Review the generated automation output."),
"log": data.get("log", "Automation completed.")
}
except Exception:
return {
"status": "success",
"insight": "n8n received the data.",
"next_step": "Check your n8n workflow output.",
"log": response.text[:500]
}
return {
"status": "error",
"insight": f"n8n returned status code {response.status_code}.",
"next_step": "Check your webhook and Respond to Webhook node.",
"log": response.text[:500]
}
except Exception as e:
return {
"status": "error",
"insight": "The app could not reach n8n.",
"next_step": "Check that the n8n production webhook is active.",
"log": str(e)
}
def run_pipeline(
neighbourhood_group,
neighbourhood,
room_type,
price,
availability_365,
season,
local_event_score,
rating,
sentiment_score,
send_to_n8n
):
price = float(price)
availability_365 = float(availability_365)
local_event_score = float(local_event_score)
rating = float(rating)
sentiment_score = float(sentiment_score)
comparable = df.copy()
comparable = comparable[
(comparable["neighbourhood_group"].astype(str) == str(neighbourhood_group)) &
(comparable["room_type"].astype(str) == str(room_type))
]
local_comparable = comparable[comparable["neighbourhood"].astype(str) == str(neighbourhood)]
if len(local_comparable) >= 5:
comparable = local_comparable
if len(comparable) < 5:
comparable = df[df["room_type"].astype(str) == str(room_type)]
competitor_avg_price = safe_mean(comparable["price"], price)
avg_occupancy = safe_mean(comparable["occupancy_rate"], 0.5)
avg_demand = safe_mean(comparable["demand_score"], 50)
price_gap_pct = ((price - competitor_avg_price) / competitor_avg_price) * 100 if competitor_avg_price else 0
season_boost = {
"Low Season": -0.08,
"Medium Season": 0.00,
"High Season": 0.08,
"Low": -0.08,
"Medium": 0.00,
"High": 0.08,
"Peak": 0.12,
"Spring": 0.03,
"Summer": 0.08,
"Autumn": 0.00,
"Winter": -0.05
}.get(str(season), 0.00)
price_penalty = max(min(price_gap_pct / 100, 0.35), -0.35) * 0.30
event_boost = (local_event_score / 100) * 0.12
rating_boost = (rating - 4.0) * 0.06
sentiment_boost = sentiment_score * 0.08
occupancy = avg_occupancy + season_boost + event_boost + rating_boost + sentiment_boost - price_penalty
occupancy = max(0.05, min(0.95, occupancy))
booked_nights = round(occupancy * 30)
monthly_revenue = round(price * booked_nights, 2)
demand_score = (
0.45 * avg_demand +
0.25 * (occupancy * 100) +
0.15 * local_event_score +
0.10 * ((rating / 5) * 100) +
0.05 * ((sentiment_score + 1) / 2 * 100)
)
demand_score = round(max(0, min(100, demand_score)), 1)
if demand_score >= 70:
demand_level = "High"
demand_badge = "๐ŸŸข High"
elif demand_score >= 45:
demand_level = "Medium"
demand_badge = "๐ŸŸก Medium"
else:
demand_level = "Low"
demand_badge = "๐Ÿ”ด Low"
if price_gap_pct > 15 and demand_level != "High":
pricing_recommendation = "Consider lowering price"
suggested_price = round(competitor_avg_price * 1.05, 2)
insight = "The listing appears overpriced compared with similar properties."
next_step = f"Test a lower price around ${suggested_price} to improve occupancy."
recommendation_badge = "๐Ÿ”ป Price Reduction Suggested"
elif price_gap_pct < -10 and demand_level in ["Medium", "High"]:
pricing_recommendation = "Consider raising price"
suggested_price = round(min(competitor_avg_price * 0.98, price * 1.12), 2)
insight = "The listing appears underpriced relative to comparable demand."
next_step = f"Consider increasing the price toward ${suggested_price}."
recommendation_badge = "๐Ÿš€ Revenue Opportunity"
else:
pricing_recommendation = "Keep price stable"
suggested_price = round(price, 2)
insight = "The current price is aligned with comparable listings."
next_step = "Keep price stable and focus on visibility, reviews, and conversion."
recommendation_badge = "โœ… Stable Positioning"
opportunity_score = round(
demand_score * 0.45 +
occupancy * 100 * 0.25 +
rating / 5 * 100 * 0.15 +
((sentiment_score + 1) / 2 * 100) * 0.15,
2
)
payload = {
"app_name": APP_NAME,
"neighbourhood_group": neighbourhood_group,
"neighbourhood": neighbourhood,
"room_type": room_type,
"current_price": price,
"suggested_price": suggested_price,
"competitor_avg_price": round(competitor_avg_price, 2),
"price_vs_competitor_pct": round(price_gap_pct, 2),
"occupancy_estimate": round(occupancy, 3),
"booked_nights_month": booked_nights,
"monthly_revenue": monthly_revenue,
"demand_score": demand_score,
"demand_level": demand_level,
"opportunity_score": opportunity_score,
"pricing_recommendation": pricing_recommendation,
"insight": insight,
"next_step": next_step
}
if send_to_n8n:
n8n_response = call_n8n(payload)
else:
n8n_response = {
"status": "not_sent",
"insight": "n8n automation was not triggered.",
"next_step": "Tick the n8n checkbox to send this result to the workflow.",
"log": "Local pipeline only."
}
result_text = f"""
<div class="result-card">
<div class="section-label">Pipeline Result</div>
<h2>{recommendation_badge}</h2>
<div class="kpi-grid">
<div class="kpi-card">
<div class="kpi-title">Current Price</div>
<div class="kpi-value">${price:,.0f}</div>
</div>
<div class="kpi-card">
<div class="kpi-title">Suggested Price</div>
<div class="kpi-value">${suggested_price:,.0f}</div>
</div>
<div class="kpi-card">
<div class="kpi-title">Monthly Revenue</div>
<div class="kpi-value">${monthly_revenue:,.0f}</div>
</div>
<div class="kpi-card">
<div class="kpi-title">Demand</div>
<div class="kpi-value">{demand_badge}</div>
</div>
</div>
<h3>Key Metrics</h3>
<table class="metric-table">
<tr><td>Competitor average price</td><td>${competitor_avg_price:,.2f}</td></tr>
<tr><td>Price vs competitors</td><td>{price_gap_pct:.2f}%</td></tr>
<tr><td>Estimated occupancy</td><td>{occupancy * 100:.1f}%</td></tr>
<tr><td>Estimated booked nights / month</td><td>{booked_nights}</td></tr>
<tr><td>Demand score</td><td>{demand_score}/100</td></tr>
<tr><td>Opportunity score</td><td>{opportunity_score}/100</td></tr>
</table>
<h3>Business Insight</h3>
<p>{insight}</p>
<h3>Next Step</h3>
<p>{next_step}</p>
</div>
"""
automation_text = f"""
<div class="automation-card">
<div class="section-label">n8n Automation Output</div>
<h2>Automation Status: {n8n_response.get("status", "unknown")}</h2>
<table class="metric-table">
<tr><td>Insight</td><td>{n8n_response.get("insight", "No insight returned.")}</td></tr>
<tr><td>Next step</td><td>{n8n_response.get("next_step", "No next step returned.")}</td></tr>
<tr><td>Log</td><td>{n8n_response.get("log", "No log returned.")}</td></tr>
</table>
</div>
"""
cols = [
"id",
"name",
"neighbourhood_group",
"neighbourhood",
"room_type",
"price",
"occupancy_rate",
"monthly_revenue",
"demand_score",
"demand_level",
"pricing_recommendation"
]
available_cols = [c for c in cols if c in comparable.columns]
comparable_table = comparable[available_cols].head(10)
json_output = json.dumps(payload, indent=2)
return result_text, automation_text, comparable_table, json_output
custom_css = """
/* Main background */
body {
background: radial-gradient(circle at top left, #1e3a8a 0%, #0f172a 35%, #020617 100%) !important;
color: #f8fafc !important;
}
/* Main container */
.gradio-container {
max-width: 1250px !important;
margin: auto !important;
font-family: Inter, system-ui, -apple-system, BlinkMacSystemFont, sans-serif !important;
}
/* Header */
#hero {
background: linear-gradient(135deg, rgba(37, 99, 235, 0.95), rgba(14, 165, 233, 0.85));
border-radius: 28px;
padding: 38px 42px;
margin-bottom: 26px;
box-shadow: 0 25px 60px rgba(0, 0, 0, 0.35);
border: 1px solid rgba(255,255,255,0.16);
}
#hero h1 {
font-size: 48px;
margin: 0 0 8px 0;
color: white;
letter-spacing: -1px;
}
#hero p {
font-size: 18px;
margin: 6px 0 0 0;
color: #e0f2fe;
}
.hero-badge {
display: inline-block;
padding: 6px 12px;
background: rgba(255,255,255,0.16);
border: 1px solid rgba(255,255,255,0.22);
border-radius: 999px;
font-size: 13px;
margin-bottom: 14px;
color: white;
}
/* Panels */
.gr-block, .gr-form, .gr-box, .gr-panel {
border-radius: 20px !important;
}
.input-panel {
background: rgba(15, 23, 42, 0.82);
border: 1px solid rgba(148, 163, 184, 0.25);
border-radius: 24px;
padding: 20px;
box-shadow: 0 15px 45px rgba(0,0,0,0.25);
}
/* Buttons */
button.primary, .gr-button {
border-radius: 14px !important;
font-weight: 700 !important;
background: linear-gradient(90deg, #2563eb, #06b6d4) !important;
border: none !important;
color: white !important;
box-shadow: 0 10px 25px rgba(37, 99, 235, 0.32) !important;
}
button.primary:hover, .gr-button:hover {
transform: translateY(-1px);
filter: brightness(1.08);
}
/* Labels */
label, .wrap label {
color: #dbeafe !important;
font-weight: 600 !important;
}
/* Inputs */
input, textarea, select {
border-radius: 12px !important;
}
/* Slider */
input[type="range"] {
accent-color: #38bdf8 !important;
}
/* Output cards */
.result-card, .automation-card {
background: rgba(15, 23, 42, 0.88);
border: 1px solid rgba(148, 163, 184, 0.25);
border-radius: 26px;
padding: 26px;
margin-bottom: 22px;
box-shadow: 0 18px 50px rgba(0,0,0,0.28);
}
.result-card h2, .automation-card h2 {
margin-top: 8px;
color: #f8fafc;
font-size: 26px;
}
.result-card h3, .automation-card h3 {
margin-top: 24px;
color: #bae6fd;
font-size: 20px;
}
.result-card p, .automation-card p {
color: #e5e7eb;
line-height: 1.6;
}
.section-label {
display: inline-block;
background: rgba(56, 189, 248, 0.13);
border: 1px solid rgba(56, 189, 248, 0.35);
color: #7dd3fc;
padding: 7px 13px;
border-radius: 999px;
font-size: 13px;
font-weight: 700;
letter-spacing: 0.4px;
text-transform: uppercase;
}
/* KPI Cards */
.kpi-grid {
display: grid;
grid-template-columns: repeat(4, minmax(120px, 1fr));
gap: 14px;
margin: 22px 0;
}
.kpi-card {
background: linear-gradient(180deg, rgba(30, 41, 59, 0.95), rgba(15, 23, 42, 0.95));
border: 1px solid rgba(148, 163, 184, 0.25);
border-radius: 18px;
padding: 18px;
}
.kpi-title {
color: #94a3b8;
font-size: 13px;
margin-bottom: 8px;
}
.kpi-value {
color: #f8fafc;
font-size: 22px;
font-weight: 800;
}
/* Tables */
.metric-table {
width: 100%;
border-collapse: collapse;
overflow: hidden;
border-radius: 14px;
margin-top: 12px;
}
.metric-table td {
padding: 12px 14px;
border-bottom: 1px solid rgba(148, 163, 184, 0.18);
color: #e5e7eb;
}
.metric-table td:first-child {
color: #93c5fd;
font-weight: 650;
width: 45%;
}
.metric-table tr:last-child td {
border-bottom: none;
}
/* Dataframe and code areas */
.dataframe, .wrap, .contain {
border-radius: 18px !important;
}
/* Footer text */
.small-note {
color: #94a3b8;
font-size: 13px;
margin-top: -8px;
margin-bottom: 18px;
}
@media (max-width: 900px) {
.kpi-grid {
grid-template-columns: repeat(2, minmax(120px, 1fr));
}
#hero h1 {
font-size: 36px;
}
}
"""
with gr.Blocks(css=custom_css) as demo:
gr.HTML(
"""
<div id="hero">
<div class="hero-badge">Short-Term Rental Pricing Assistant</div>
<h1>๐Ÿ  StayWise AI</h1>
<p>AI-powered pricing and performance optimization for short-term rentals.</p>
<p class="small-note">Run the full pipeline, benchmark a listing against comparable properties, and return automation insights from n8n.</p>
</div>
"""
)
with gr.Row():
with gr.Column(scale=1):
gr.Markdown("## ๐Ÿ“Š Property Inputs")
neighbourhood_group = gr.Dropdown(
choices=neighbourhood_groups,
label="Neighbourhood Group",
value=neighbourhood_groups[0]
)
neighbourhood = gr.Dropdown(
choices=neighbourhoods,
label="Neighbourhood",
value=neighbourhoods[0]
)
room_type = gr.Dropdown(
choices=room_types,
label="Room Type",
value=room_types[0]
)
price = gr.Slider(
minimum=20,
maximum=1000,
value=150,
step=5,
label="Current Nightly Price ($)"
)
availability_365 = gr.Slider(
minimum=0,
maximum=365,
value=180,
step=1,
label="Availability per Year"
)
season = gr.Dropdown(
choices=seasons,
label="Season",
value=seasons[0]
)
local_event_score = gr.Slider(
minimum=0,
maximum=100,
value=50,
step=1,
label="Local Event Demand Score"
)
rating = gr.Slider(
minimum=1,
maximum=5,
value=4.4,
step=0.1,
label="Guest Rating"
)
sentiment_score = gr.Slider(
minimum=-1,
maximum=1,
value=0.2,
step=0.05,
label="Customer Sentiment Score"
)
send_to_n8n = gr.Checkbox(
label="Send output to n8n",
value=False
)
run_button = gr.Button("๐Ÿš€ Run Full Pipeline", variant="primary")
with gr.Column(scale=2):
result_output = gr.HTML()
automation_output = gr.HTML()
gr.Markdown("## ๐Ÿ“ˆ Comparable Listings")
comparable_output = gr.Dataframe()
gr.Markdown("## ๐Ÿงพ Pipeline Data Output")
json_output = gr.Code(language="json")
run_button.click(
fn=run_pipeline,
inputs=[
neighbourhood_group,
neighbourhood,
room_type,
price,
availability_365,
season,
local_event_score,
rating,
sentiment_score,
send_to_n8n
],
outputs=[
result_output,
automation_output,
comparable_output,
json_output
]
)
demo.launch()