Spaces:
Sleeping
Sleeping
| 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() |